From ba3330823bbb7439fa18b5d35fd7b238eb9b765b Mon Sep 17 00:00:00 2001 From: David Sass <10754765+sassdawe@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:03:58 +0200 Subject: [PATCH 001/182] Update installation commands for GitHub CLI Added '--source winget' option to install and upgrade commands. On machines where the `Store` source is enabled but the license to use the store is not granted specifying the source is required. This does not have any negative effect. --- docs/install_windows.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/install_windows.md b/docs/install_windows.md index 88ddc99b8d3..508afca3f16 100644 --- a/docs/install_windows.md +++ b/docs/install_windows.md @@ -11,13 +11,13 @@ The [GitHub CLI package](https://winget.run/pkg/GitHub/cli) is supported by Micr To install: ```pwsh -winget install --id GitHub.cli +winget install --id GitHub.cli --source winget ``` To upgrade: ```pwsh -winget upgrade --id GitHub.cli +winget upgrade --id GitHub.cli --source winget ``` > [!NOTE] From d0e6747d5d99b62444b069897fd5c4c9b5a342d3 Mon Sep 17 00:00:00 2001 From: timsu92 <33785401+timsu92@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:41:31 +0800 Subject: [PATCH 002/182] docs: fix SHA512 checksum for GPG key We missed one single character --- docs/install_linux.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install_linux.md b/docs/install_linux.md index bda751a7a82..99f5d82828a 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -17,7 +17,7 @@ > - `https://cli.github.com/packages/githubcli-archive-keyring.gpg` (Binary): > ``` > SHA256: 6084d5d7bd8e288441e0e94fc6275570895da18e6751f70f057485dc2d1a811b -> SHA512: ce6b9466dbd2a90b3227e177aa9b8187bd2405b1c29f91d78de83b9699dbbe2af35efd733bf53da622e7a38c59a7bc55539d63a3deaec9ff9c2bff8af626434 +> SHA512: ce6b9466dbd2a90b3227e177aa9b8187bd2405b1c29f91d78de83b9699dbbe2af35efd733bf53da622e7a38c59a7bc55539d63a3deae3c9ff9c2bff8af626434 > MD5: 23748c0965069fb1edae1b83c17890e1 > ``` > - `https://cli.github.com/packages/githubcli-archive-keyring.asc` (ASCII-armored): From e57fb436fa23106014e52774596a09ee834e2feb Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Mon, 30 Mar 2026 17:26:41 +0100 Subject: [PATCH 003/182] add skills command scaffold --- internal/skills/collisions.go | 55 ++ internal/skills/discovery/discovery.go | 769 ++++++++++++++++++ internal/skills/discovery/discovery_test.go | 109 +++ internal/skills/frontmatter/frontmatter.go | 148 ++++ .../skills/frontmatter/frontmatter_test.go | 178 ++++ internal/skills/gitclient/gitclient.go | 149 ++++ internal/skills/gitclient/gitclient_test.go | 49 ++ internal/skills/hosts/hosts.go | 175 ++++ internal/skills/hosts/hosts_test.go | 113 +++ internal/skills/installer/installer.go | 296 +++++++ internal/skills/lockfile/lockfile.go | 165 ++++ 11 files changed, 2206 insertions(+) create mode 100644 internal/skills/collisions.go create mode 100644 internal/skills/discovery/discovery.go create mode 100644 internal/skills/discovery/discovery_test.go create mode 100644 internal/skills/frontmatter/frontmatter.go create mode 100644 internal/skills/frontmatter/frontmatter_test.go create mode 100644 internal/skills/gitclient/gitclient.go create mode 100644 internal/skills/gitclient/gitclient_test.go create mode 100644 internal/skills/hosts/hosts.go create mode 100644 internal/skills/hosts/hosts_test.go create mode 100644 internal/skills/installer/installer.go create mode 100644 internal/skills/lockfile/lockfile.go diff --git a/internal/skills/collisions.go b/internal/skills/collisions.go new file mode 100644 index 00000000000..87e4705c965 --- /dev/null +++ b/internal/skills/collisions.go @@ -0,0 +1,55 @@ +package skills + +import ( + "fmt" + "sort" + "strings" + + "github.com/cli/cli/v2/internal/skills/discovery" +) + +// NameCollision represents a group of skills that share the same InstallName +// and would overwrite each other when installed to the same directory. +type NameCollision struct { + Name string // the conflicting install name (may include namespace prefix) + DisplayNames []string // display names of each conflicting skill +} + +// FindNameCollisions detects skills that share the same InstallName and returns a +// sorted slice of collisions. Callers decide how to present the conflict to +// the user (different flows need different error messages). +func FindNameCollisions(skills []discovery.Skill) []NameCollision { + byName := make(map[string][]discovery.Skill) + for _, s := range skills { + byName[s.InstallName()] = append(byName[s.InstallName()], s) + } + + var collisions []NameCollision + for name, group := range byName { + if len(group) <= 1 { + continue + } + names := make([]string, len(group)) + for i, s := range group { + names[i] = s.DisplayName() + } + collisions = append(collisions, NameCollision{Name: name, DisplayNames: names}) + } + + sort.Slice(collisions, func(i, j int) bool { + return collisions[i].Name < collisions[j].Name + }) + return collisions +} + +// FormatCollisions builds a human-readable string listing each collision, +// suitable for embedding in an error message. Each collision is formatted as +// "name: display1, display2" and collisions are separated by newlines with +// leading indentation. +func FormatCollisions(collisions []NameCollision) string { + lines := make([]string, len(collisions)) + for i, c := range collisions { + lines[i] = fmt.Sprintf("%s: %s", c.Name, strings.Join(c.DisplayNames, ", ")) + } + return strings.Join(lines, "\n ") +} diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go new file mode 100644 index 00000000000..e0e881088d2 --- /dev/null +++ b/internal/skills/discovery/discovery.go @@ -0,0 +1,769 @@ +package discovery + +import ( + "encoding/base64" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "strings" + "sync" + + "github.com/cli/cli/v2/internal/skills/frontmatter" +) + +// specNamePattern matches the strict agentskills.io name spec: +// 1-64 chars, lowercase alphanumeric + hyphens, no leading/trailing/consecutive hyphens. +var specNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) + +// safeNamePattern matches names that are safe for filesystem use during discovery. +// Allows letters (any case), numbers, hyphens, underscores, dots, and spaces. +// Must start with a letter or number. This matches copilot-agent-runtime's SKILL_NAME_REGEX. +var safeNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._\- ]*$`) + +// Skill represents a discovered skill in a repository. +type Skill struct { + Name string + Namespace string // author/scope prefix for namespaced skills + Description string + Path string // path within the repo, e.g. "skills/git-commit" + BlobSHA string // SHA of the SKILL.md blob + TreeSHA string // SHA of the skill directory tree + Convention string // which directory convention matched +} + +// DisplayName returns the skill name, prefixed with namespace if present +// to disambiguate skills from different authors in the same repository. +// Skills discovered via non-standard conventions (plugins, root) include +// a convention tag to distinguish them from identically-named skills in +// the standard skills/ directory. +func (s Skill) DisplayName() string { + name := s.Name + if s.Namespace != "" { + name = s.Namespace + "/" + name + } + switch s.Convention { + case "plugins": + return "[plugins] " + name + case "root": + return "[root] " + name + default: + return name + } +} + +// InstallName returns the relative path used for the install directory. +// For namespaced skills it returns "namespace/name" (creating a nested directory), +// otherwise it returns the plain name. Callers should use filepath.FromSlash +// when building OS-specific paths from this value. +func (s Skill) InstallName() string { + if s.Namespace != "" { + return s.Namespace + "/" + s.Name + } + return s.Name +} + +// ResolvedRef contains the resolved git reference and its SHA. +type ResolvedRef struct { + Ref string // tag name, branch name, or SHA + SHA string // commit SHA +} + +// RESTClient is the interface for making GitHub REST API calls. +// It mirrors the subset of api.Client used by discovery. +type RESTClient interface { + // REST performs a REST API call. + // hostname is the GitHub host (e.g. "github.com"). + // method is the HTTP method (e.g. "GET"). + // path is the API path (e.g. "repos/owner/repo/releases/latest"). + // body is the request body (nil for GET). + // data is the response data to unmarshal into. + REST(hostname string, method string, path string, body io.Reader, data interface{}) error +} + +type treeEntry struct { + Path string `json:"path"` + Mode string `json:"mode"` + Type string `json:"type"` + SHA string `json:"sha"` + Size int `json:"size"` +} + +// SkillFile represents a file within a skill directory. +type SkillFile struct { + Path string // relative path within the skill directory + SHA string // blob SHA for fetching content + Size int // file size in bytes +} + +type treeResponse struct { + SHA string `json:"sha"` + Tree []treeEntry `json:"tree"` + Truncated bool `json:"truncated"` +} + +type blobResponse struct { + SHA string `json:"sha"` + Content string `json:"content"` + Encoding string `json:"encoding"` +} + +type releaseResponse struct { + TagName string `json:"tag_name"` +} + +type repoResponse struct { + DefaultBranch string `json:"default_branch"` +} + +// ResolveRef determines the git ref to use for a given owner/repo. +// Priority: explicit version → latest release tag → default branch. +func ResolveRef(client RESTClient, host, owner, repo, version string) (*ResolvedRef, error) { + if version != "" { + return resolveExplicitRef(client, host, owner, repo, version) + } + ref, err := resolveLatestRelease(client, host, owner, repo) + if err == nil { + return ref, nil + } + return resolveDefaultBranch(client, host, owner, repo) +} + +// resolveExplicitRef resolves a user-supplied --pin value. It tries, in order: +// tag → commit SHA. Branches are deliberately excluded because they are mutable +// and pinning to one gives a false sense of reproducibility. +func resolveExplicitRef(client RESTClient, host, owner, repo, ref string) (*ResolvedRef, error) { + tagPath := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, ref) + var refResp struct { + Object struct { + SHA string `json:"sha"` + Type string `json:"type"` + } `json:"object"` + } + if err := client.REST(host, "GET", tagPath, nil, &refResp); err == nil { + sha := refResp.Object.SHA + if refResp.Object.Type == "tag" { + derefPath := fmt.Sprintf("repos/%s/%s/git/tags/%s", owner, repo, sha) + var tagResp struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` + } + if err := client.REST(host, "GET", derefPath, nil, &tagResp); err != nil { + return nil, fmt.Errorf("could not dereference annotated tag %q: %w", ref, err) + } + sha = tagResp.Object.SHA + } + return &ResolvedRef{Ref: ref, SHA: sha}, nil + } + + commitPath := fmt.Sprintf("repos/%s/%s/commits/%s", owner, repo, ref) + var commitResp struct { + SHA string `json:"sha"` + } + if err := client.REST(host, "GET", commitPath, nil, &commitResp); err == nil { + return &ResolvedRef{Ref: commitResp.SHA, SHA: commitResp.SHA}, nil + } + + return nil, fmt.Errorf("ref %q not found as tag or commit in %s/%s", ref, owner, repo) +} + +func resolveLatestRelease(client RESTClient, host, owner, repo string) (*ResolvedRef, error) { + apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", owner, repo) + var release releaseResponse + if err := client.REST(host, "GET", apiPath, nil, &release); err != nil { + return nil, fmt.Errorf("no releases found: %w", err) + } + if release.TagName == "" { + return nil, fmt.Errorf("latest release has no tag") + } + return resolveExplicitRef(client, host, owner, repo, release.TagName) +} + +func resolveDefaultBranch(client RESTClient, host, owner, repo string) (*ResolvedRef, error) { + apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) + var repoResp repoResponse + if err := client.REST(host, "GET", apiPath, nil, &repoResp); err != nil { + return nil, fmt.Errorf("could not determine default branch: %w", err) + } + branch := repoResp.DefaultBranch + if branch == "" { + branch = "main" + } + + refPath := fmt.Sprintf("repos/%s/%s/git/ref/heads/%s", owner, repo, branch) + var refResp struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` + } + if err := client.REST(host, "GET", refPath, nil, &refResp); err != nil { + return nil, fmt.Errorf("could not resolve branch %q: %w", branch, err) + } + + return &ResolvedRef{Ref: branch, SHA: refResp.Object.SHA}, nil +} + +// skillMatch represents a matched SKILL.md file and its convention. +type skillMatch struct { + entry treeEntry + name string + namespace string + skillDir string + convention string +} + +// MatchesSkillPath checks if a file path matches any known skill convention +// and returns the skill name. Returns empty string if the path doesn't match. +func MatchesSkillPath(filePath string) string { + m := matchSkillConventions(treeEntry{Path: filePath}) + if m == nil { + return "" + } + return m.name +} + +// matchSkillConventions checks if a blob path matches any known skill convention. +func matchSkillConventions(entry treeEntry) *skillMatch { + if path.Base(entry.Path) != "SKILL.md" { + return nil + } + + dir := path.Dir(entry.Path) + parentDir := path.Dir(dir) + skillName := path.Base(dir) + + if !ValidateName(skillName) { + return nil + } + + if parentDir == "skills" { + return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "skills"} + } + + grandparentDir := path.Dir(parentDir) + if grandparentDir == "skills" { + namespace := path.Base(parentDir) + if !ValidateName(namespace) { + return nil + } + return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "skills-namespaced"} + } + + if path.Base(parentDir) == "skills" && path.Dir(grandparentDir) == "plugins" { + namespace := path.Base(grandparentDir) + if !ValidateName(namespace) { + return nil + } + return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "plugins"} + } + + if parentDir == "." && skillName != "skills" && skillName != "plugins" && !strings.HasPrefix(skillName, ".") { + return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "root"} + } + + return nil +} + +// DiscoverSkills finds all skills in a repository at the given commit SHA. +func DiscoverSkills(client RESTClient, host, owner, repo, commitSHA string) ([]Skill, error) { + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, commitSHA) + var tree treeResponse + if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { + return nil, fmt.Errorf("could not fetch repository tree: %w", err) + } + + if tree.Truncated { + return nil, fmt.Errorf( + "repository tree for %s/%s is too large for full discovery\n"+ + " Use path-based install instead: gh skills install %s/%s skills/", + owner, repo, owner, repo, + ) + } + + treeSHAs := make(map[string]string) + for _, entry := range tree.Tree { + if entry.Type == "tree" { + treeSHAs[entry.Path] = entry.SHA + } + } + + seen := make(map[string]bool) + var matches []skillMatch + for _, entry := range tree.Tree { + if entry.Type != "blob" { + continue + } + m := matchSkillConventions(entry) + if m == nil { + continue + } + if seen[m.skillDir] { + continue + } + seen[m.skillDir] = true + matches = append(matches, *m) + } + + if len(matches) == 0 { + return nil, fmt.Errorf( + "no skills found in %s/%s\n"+ + " Expected skills in skills/*/SKILL.md, skills/{scope}/*/SKILL.md,\n"+ + " */SKILL.md, or plugins/*/skills/*/SKILL.md\n"+ + " This repository may be a curated list rather than a skills publisher", + owner, repo, + ) + } + + var skills []Skill + for _, m := range matches { + skills = append(skills, Skill{ + Name: m.name, + Namespace: m.namespace, + Path: m.skillDir, + BlobSHA: m.entry.SHA, + TreeSHA: treeSHAs[m.skillDir], + Convention: m.convention, + }) + } + + return skills, nil +} + +// FetchDescription fetches and parses the frontmatter description for a skill. +func FetchDescription(client RESTClient, host, owner, repo string, skill *Skill) string { + if skill.BlobSHA == "" { + return "" + } + content, err := FetchBlob(client, host, owner, repo, skill.BlobSHA) + if err != nil { + return "" + } + result, err := frontmatter.Parse(content) + if err != nil { + return "" + } + return result.Metadata.Description +} + +// FetchDescriptions fetches descriptions for a batch of skills. +func FetchDescriptions(client RESTClient, host, owner, repo string, skills []Skill) { + for i := range skills { + if skills[i].Description == "" { + skills[i].Description = FetchDescription(client, host, owner, repo, &skills[i]) + } + } +} + +// FetchDescriptionsConcurrent fetches descriptions with bounded concurrency. +func FetchDescriptionsConcurrent(client RESTClient, host, owner, repo string, skills []Skill, onProgress func(done, total int)) { + total := 0 + for _, s := range skills { + if s.Description == "" { + total++ + } + } + if total == 0 { + return + } + + const maxWorkers = 10 + sem := make(chan struct{}, maxWorkers) + var mu sync.Mutex + done := 0 + + var wg sync.WaitGroup + for i := range skills { + if skills[i].Description != "" { + continue + } + wg.Add(1) + go func(idx int) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + desc := FetchDescription(client, host, owner, repo, &skills[idx]) + + mu.Lock() + skills[idx].Description = desc + done++ + d := done + mu.Unlock() + if onProgress != nil { + onProgress(d, total) + } + }(i) + } + wg.Wait() +} + +// DiscoverSkillByPath looks up a single skill by its exact path in the repository. +func DiscoverSkillByPath(client RESTClient, host, owner, repo, commitSHA, skillPath string) (*Skill, error) { + skillPath = strings.TrimSuffix(skillPath, "/SKILL.md") + skillPath = strings.TrimSuffix(skillPath, "/") + + skillName := path.Base(skillPath) + if !ValidateName(skillName) { + return nil, fmt.Errorf("invalid skill name %q", skillName) + } + + parentPath := path.Dir(skillPath) + apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", owner, repo, parentPath, commitSHA) + + var contents []struct { + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + Type string `json:"type"` + } + if err := client.REST(host, "GET", apiPath, nil, &contents); err != nil { + return nil, fmt.Errorf("path %q not found in %s/%s: %w", parentPath, owner, repo, err) + } + + var treeSHA string + for _, entry := range contents { + if entry.Name == skillName && entry.Type == "dir" { + treeSHA = entry.SHA + break + } + } + if treeSHA == "" { + return nil, fmt.Errorf("skill directory %q not found in %s/%s", skillPath, owner, repo) + } + + skillTreePath := fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, treeSHA) + var skillTree treeResponse + if err := client.REST(host, "GET", skillTreePath, nil, &skillTree); err != nil { + return nil, fmt.Errorf("could not read skill directory: %w", err) + } + + var blobSHA string + for _, entry := range skillTree.Tree { + if entry.Path == "SKILL.md" && entry.Type == "blob" { + blobSHA = entry.SHA + break + } + } + if blobSHA == "" { + return nil, fmt.Errorf("no SKILL.md found in %s", skillPath) + } + + var namespace string + parts := strings.Split(skillPath, "/") + if len(parts) >= 3 && parts[0] == "skills" { + namespace = parts[1] + } + + skill := &Skill{ + Name: skillName, + Namespace: namespace, + Path: skillPath, + BlobSHA: blobSHA, + TreeSHA: treeSHA, + } + + skill.Description = FetchDescription(client, host, owner, repo, skill) + + return skill, nil +} + +// DiscoverSkillFiles returns all file paths belonging to a skill directory +// by fetching the skill's subtree directly using its tree SHA. +func DiscoverSkillFiles(client RESTClient, host, owner, repo, treeSHA, skillPath string) ([]treeEntry, error) { + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, treeSHA) + var tree treeResponse + if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { + return nil, fmt.Errorf("could not fetch skill tree: %w", err) + } + + if tree.Truncated { + // Recursive fetch was truncated — fall back to walking subtrees individually. + return walkTree(client, host, owner, repo, treeSHA, skillPath) + } + + var files []treeEntry + for _, entry := range tree.Tree { + if entry.Type == "blob" { + files = append(files, treeEntry{ + Path: skillPath + "/" + entry.Path, + SHA: entry.SHA, + Size: entry.Size, + }) + } + } + + return files, nil +} + +// ListSkillFiles returns all files in a skill directory as public SkillFile +// structs with paths relative to the skill root. +func ListSkillFiles(client RESTClient, host, owner, repo, treeSHA string) ([]SkillFile, error) { + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, treeSHA) + var tree treeResponse + if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { + return nil, fmt.Errorf("could not fetch skill tree: %w", err) + } + + if tree.Truncated { + // Fall back to non-recursive traversal when the tree is too large. + entries, err := walkTree(client, host, owner, repo, treeSHA, "") + if err != nil { + return nil, err + } + var files []SkillFile + for _, e := range entries { + // walkTree prefixes with "/{path}", trim the leading slash. + p := strings.TrimPrefix(e.Path, "/") + files = append(files, SkillFile{Path: p, SHA: e.SHA, Size: e.Size}) + } + return files, nil + } + + var files []SkillFile + for _, entry := range tree.Tree { + if entry.Type == "blob" { + files = append(files, SkillFile{ + Path: entry.Path, + SHA: entry.SHA, + Size: entry.Size, + }) + } + } + return files, nil +} + +// walkTree enumerates files by fetching each tree level individually, +// avoiding the truncation limit of the recursive tree API. +func walkTree(client RESTClient, host, owner, repo, sha, prefix string) ([]treeEntry, error) { + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, sha) + var tree treeResponse + if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { + return nil, fmt.Errorf("could not fetch tree %s: %w", prefix, err) + } + + var files []treeEntry + for _, entry := range tree.Tree { + entryPath := prefix + "/" + entry.Path + switch entry.Type { + case "blob": + files = append(files, treeEntry{Path: entryPath, SHA: entry.SHA, Size: entry.Size}) + case "tree": + sub, err := walkTree(client, host, owner, repo, entry.SHA, entryPath) + if err != nil { + return nil, err + } + files = append(files, sub...) + } + } + return files, nil +} + +// FetchBlob retrieves the content of a blob by SHA. +func FetchBlob(client RESTClient, host, owner, repo, sha string) (string, error) { + apiPath := fmt.Sprintf("repos/%s/%s/git/blobs/%s", owner, repo, sha) + var blob blobResponse + if err := client.REST(host, "GET", apiPath, nil, &blob); err != nil { + return "", fmt.Errorf("could not fetch blob: %w", err) + } + + if blob.Encoding != "base64" { + return "", fmt.Errorf("unexpected blob encoding: %s", blob.Encoding) + } + + // GitHub API returns base64 with embedded newlines; use the lenient + // RawStdEncoding decoder via a reader to handle them transparently. + decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(blob.Content))) + if err != nil { + return "", fmt.Errorf("could not decode blob content: %w", err) + } + + return string(decoded), nil +} + +// DiscoverLocalSkills finds skills in a local directory using the same +// conventions as remote discovery. +func DiscoverLocalSkills(dir string) ([]Skill, error) { + absDir, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("could not resolve path: %w", err) + } + + info, err := os.Stat(absDir) + if err != nil { + return nil, fmt.Errorf("could not access %s: %w", dir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("%s is not a directory", dir) + } + + if _, err := os.Stat(filepath.Join(absDir, "SKILL.md")); err == nil { + skill, err := localSkillFromDir(absDir) + if err != nil { + return nil, err + } + skill.Path = "." + return []Skill{*skill}, nil + } + + var skills []Skill + seen := make(map[string]bool) + + err = filepath.Walk(absDir, func(p string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + if info.IsDir() || info.Name() != "SKILL.md" { + return nil + } + + relPath, relErr := filepath.Rel(absDir, p) + if relErr != nil { + return relErr + } + relPath = filepath.ToSlash(relPath) + + entry := treeEntry{Path: relPath, Type: "blob"} + m := matchSkillConventions(entry) + if m == nil { + return nil + } + if seen[m.skillDir] { + return nil + } + seen[m.skillDir] = true + + skill, skillErr := localSkillFromDir(filepath.Join(absDir, filepath.FromSlash(m.skillDir))) + if skillErr != nil { + return nil //nolint:nilerr // intentionally skip files that aren't valid skills + } + skill.Path = m.skillDir + skill.Namespace = m.namespace + skill.Convention = m.convention + skills = append(skills, *skill) + return nil + }) + if err != nil { + return nil, fmt.Errorf("could not walk directory: %w", err) + } + + if len(skills) == 0 { + return nil, fmt.Errorf( + "no skills found in %s\n"+ + " Expected SKILL.md in the directory, or skills in skills/*/SKILL.md,\n"+ + " skills/{scope}/*/SKILL.md, */SKILL.md, or plugins/*/skills/*/SKILL.md", + dir, + ) + } + + return skills, nil +} + +func localSkillFromDir(dir string) (*Skill, error) { + skillFile := filepath.Join(dir, "SKILL.md") + data, err := os.ReadFile(skillFile) + if err != nil { + return nil, fmt.Errorf("could not read %s: %w", skillFile, err) + } + + name := filepath.Base(dir) + var description string + + result, parseErr := frontmatter.Parse(string(data)) + if parseErr == nil { + if result.Metadata.Name != "" { + name = result.Metadata.Name + } + description = result.Metadata.Description + } + + if !ValidateName(name) { + return nil, fmt.Errorf("invalid skill name %q in %s", name, dir) + } + + return &Skill{ + Name: name, + Description: description, + Path: filepath.Base(dir), + }, nil +} + +// ValidateName checks if a skill name is safe for use (filesystem-safe). +func ValidateName(name string) bool { + if len(name) == 0 || len(name) > 64 { + return false + } + if strings.Contains(name, "/") || strings.Contains(name, "..") { + return false + } + return safeNamePattern.MatchString(name) +} + +// IsSpecCompliant checks if a skill name matches the strict agentskills.io spec. +func IsSpecCompliant(name string) bool { + if len(name) == 0 || len(name) > 64 { + return false + } + if strings.Contains(name, "--") { + return false + } + return specNamePattern.MatchString(name) +} + +// verifyBatchSize controls how many repos are checked per code-search API call. +const verifyBatchSize = 8 + +type codeSearchResponse struct { + Items []codeSearchItem `json:"items"` +} + +type codeSearchItem struct { + Repository codeSearchRepo `json:"repository"` +} + +type codeSearchRepo struct { + FullName string `json:"full_name"` +} + +// VerifySkillRepos filters a list of repository names to only those that +// actually contain SKILL.md files. It uses the GitHub code search API with +// batched repo: qualifiers. +// +// If a verification call fails (e.g. rate limit), repos in that batch are +// kept rather than silently dropped — we fail open. +func VerifySkillRepos(client RESTClient, host string, repos []string) map[string]bool { + verified := make(map[string]bool) + + for i := 0; i < len(repos); i += verifyBatchSize { + end := i + verifyBatchSize + if end > len(repos) { + end = len(repos) + } + batch := repos[i:end] + + var queryParts []string + queryParts = append(queryParts, "filename:SKILL.md") + for _, r := range batch { + queryParts = append(queryParts, "repo:"+r) + } + query := strings.Join(queryParts, "+") + apiPath := fmt.Sprintf("search/code?q=%s&per_page=%d", query, verifyBatchSize*3) + + var resp codeSearchResponse + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + // Fail open: if we can't verify, assume all repos in the batch are valid + for _, r := range batch { + verified[r] = true + } + continue + } + + for _, item := range resp.Items { + verified[item.Repository.FullName] = true + } + } + + return verified +} diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go new file mode 100644 index 00000000000..b5fe2410df1 --- /dev/null +++ b/internal/skills/discovery/discovery_test.go @@ -0,0 +1,109 @@ +package discovery + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInstallName(t *testing.T) { + tests := []struct { + name string + skill Skill + wantName string + }{ + { + name: "plain skill", + skill: Skill{Name: "git-commit"}, + wantName: "git-commit", + }, + { + name: "namespaced skill", + skill: Skill{Name: "xlsx-pro", Namespace: "alice"}, + wantName: "alice/xlsx-pro", + }, + { + name: "plugin skill with namespace", + skill: Skill{Name: "code-review", Namespace: "bob", Convention: "plugins"}, + wantName: "bob/code-review", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, tt.skill.InstallName()) + }) + } +} + +func TestMatchSkillConventions_PluginNamespace(t *testing.T) { + entry := treeEntry{ + Path: "plugins/bob/skills/code-review/SKILL.md", + Type: "blob", + } + m := matchSkillConventions(entry) + assert.NotNil(t, m) + assert.Equal(t, "code-review", m.name) + assert.Equal(t, "bob", m.namespace) + assert.Equal(t, "plugins", m.convention) +} + +func TestMatchSkillConventions_NamespacedSkill(t *testing.T) { + entry := treeEntry{ + Path: "skills/alice/xlsx-pro/SKILL.md", + Type: "blob", + } + m := matchSkillConventions(entry) + assert.NotNil(t, m) + assert.Equal(t, "xlsx-pro", m.name) + assert.Equal(t, "alice", m.namespace) + assert.Equal(t, "skills-namespaced", m.convention) +} + +func TestMatchSkillConventions_RegularSkill(t *testing.T) { + entry := treeEntry{ + Path: "skills/git-commit/SKILL.md", + Type: "blob", + } + m := matchSkillConventions(entry) + assert.NotNil(t, m) + assert.Equal(t, "git-commit", m.name) + assert.Equal(t, "", m.namespace) + assert.Equal(t, "skills", m.convention) +} + +func TestDuplicatePluginSkills_DifferentAuthors(t *testing.T) { + // Simulates a repo with the same skill name under two different plugin authors. + // Previously this caused a collision error; now each gets a distinct namespace. + entries := []treeEntry{ + {Path: "plugins/author1/skills/azure-diag/SKILL.md", Type: "blob"}, + {Path: "plugins/author2/skills/azure-diag/SKILL.md", Type: "blob"}, + } + + seen := make(map[string]bool) + var matches []skillMatch + for _, e := range entries { + m := matchSkillConventions(e) + if m == nil || seen[m.skillDir] { + continue + } + seen[m.skillDir] = true + matches = append(matches, *m) + } + + assert.Len(t, matches, 2) + assert.Equal(t, "author1", matches[0].namespace) + assert.Equal(t, "author2", matches[1].namespace) + + // Build skills and verify they have different InstallNames + var skills []Skill + for _, m := range matches { + skills = append(skills, Skill{ + Name: m.name, + Namespace: m.namespace, + Convention: m.convention, + }) + } + assert.Equal(t, "author1/azure-diag", skills[0].InstallName()) + assert.Equal(t, "author2/azure-diag", skills[1].InstallName()) + assert.NotEqual(t, skills[0].InstallName(), skills[1].InstallName()) +} diff --git a/internal/skills/frontmatter/frontmatter.go b/internal/skills/frontmatter/frontmatter.go new file mode 100644 index 00000000000..03406888455 --- /dev/null +++ b/internal/skills/frontmatter/frontmatter.go @@ -0,0 +1,148 @@ +package frontmatter + +import ( + "bytes" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +const delimiter = "---" + +// Metadata represents the parsed YAML frontmatter of a SKILL.md file. +type Metadata struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + License string `yaml:"license,omitempty"` + Meta map[string]interface{} `yaml:"metadata,omitempty"` +} + +// ParseResult contains the parsed frontmatter and remaining body. +type ParseResult struct { + Metadata Metadata + Body string + RawYAML map[string]interface{} +} + +// Parse extracts YAML frontmatter from a SKILL.md file. +// Frontmatter is delimited by --- on its own lines. +func Parse(content string) (*ParseResult, error) { + trimmed := strings.TrimLeft(content, "\r\n") + if !strings.HasPrefix(trimmed, delimiter) { + return &ParseResult{Body: content}, nil + } + + rest := trimmed[len(delimiter):] + rest = strings.TrimLeft(rest, "\r\n") + endIdx := strings.Index(rest, "\n"+delimiter) + if endIdx == -1 { + return &ParseResult{Body: content}, nil + } + + yamlContent := rest[:endIdx] + body := rest[endIdx+len("\n"+delimiter):] + body = strings.TrimLeft(body, "\r\n") + + var rawYAML map[string]interface{} + if err := yaml.Unmarshal([]byte(yamlContent), &rawYAML); err != nil { + return nil, fmt.Errorf("invalid frontmatter YAML: %w", err) + } + + var meta Metadata + if err := yaml.Unmarshal([]byte(yamlContent), &meta); err != nil { + return nil, fmt.Errorf("invalid frontmatter YAML: %w", err) + } + + return &ParseResult{ + Metadata: meta, + Body: body, + RawYAML: rawYAML, + }, nil +} + +// InjectGitHubMetadata adds GitHub tracking metadata to the spec-defined +// "metadata" map in frontmatter. Keys are prefixed with "github-" to avoid +// collisions with other tools' metadata. +// pinnedRef is the user's explicit --pin value; empty string means unpinned. +// skillPath is the skill's source path in the repo (e.g. "skills/author/my-skill"). +func InjectGitHubMetadata(content string, owner, repo, ref, sha, treeSHA, pinnedRef, skillPath string) (string, error) { + result, err := Parse(content) + if err != nil { + return "", err + } + + if result.RawYAML == nil { + result.RawYAML = make(map[string]interface{}) + } + + meta, _ := result.RawYAML["metadata"].(map[string]interface{}) + if meta == nil { + meta = make(map[string]interface{}) + } + meta["github-owner"] = owner + meta["github-repo"] = repo + meta["github-ref"] = ref + meta["github-sha"] = sha + meta["github-tree-sha"] = treeSHA + meta["github-path"] = skillPath + if pinnedRef != "" { + meta["github-pinned"] = pinnedRef + } else { + delete(meta, "github-pinned") + } + result.RawYAML["metadata"] = meta + + return Serialize(result.RawYAML, result.Body) +} + +// InjectLocalMetadata adds local-source tracking metadata to frontmatter. +// sourcePath is the absolute path to the source skill directory. +func InjectLocalMetadata(content string, sourcePath string) (string, error) { + result, err := Parse(content) + if err != nil { + return "", err + } + + if result.RawYAML == nil { + result.RawYAML = make(map[string]interface{}) + } + + meta, _ := result.RawYAML["metadata"].(map[string]interface{}) + if meta == nil { + meta = make(map[string]interface{}) + } + delete(meta, "github-owner") + delete(meta, "github-repo") + delete(meta, "github-ref") + delete(meta, "github-sha") + delete(meta, "github-tree-sha") + delete(meta, "github-pinned") + delete(meta, "github-path") + meta["local-path"] = sourcePath + result.RawYAML["metadata"] = meta + + return Serialize(result.RawYAML, result.Body) +} + +// Serialize writes a frontmatter map and body back to a SKILL.md string. +func Serialize(frontmatter map[string]interface{}, body string) (string, error) { + var buf bytes.Buffer + + yamlBytes, err := yaml.Marshal(frontmatter) + if err != nil { + return "", fmt.Errorf("failed to serialize frontmatter: %w", err) + } + + buf.WriteString(delimiter + "\n") + buf.Write(yamlBytes) + buf.WriteString(delimiter + "\n") + if body != "" { + buf.WriteString(body) + if !strings.HasSuffix(body, "\n") { + buf.WriteString("\n") + } + } + + return buf.String(), nil +} diff --git a/internal/skills/frontmatter/frontmatter_test.go b/internal/skills/frontmatter/frontmatter_test.go new file mode 100644 index 00000000000..02bd1ee0e52 --- /dev/null +++ b/internal/skills/frontmatter/frontmatter_test.go @@ -0,0 +1,178 @@ +package frontmatter + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + content string + wantName string + wantDesc string + wantBody string + wantErr bool + }{ + { + name: "valid frontmatter", + content: "---\nname: test-skill\ndescription: A test skill\n---\n# Body\n", + wantName: "test-skill", + wantDesc: "A test skill", + wantBody: "# Body\n", + }, + { + name: "no frontmatter", + content: "# Just a markdown file\n", + wantBody: "# Just a markdown file\n", + }, + { + name: "invalid YAML", + content: "---\n: invalid yaml [[\n---\n", + wantErr: true, + }, + { + name: "no closing delimiter", + content: "---\nname: test\n", + wantBody: "---\nname: test\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Parse(tt.content) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantName, result.Metadata.Name) + assert.Equal(t, tt.wantDesc, result.Metadata.Description) + assert.Equal(t, tt.wantBody, result.Body) + }) + } +} + +func TestInjectGitHubMetadata(t *testing.T) { + tests := []struct { + name string + content string + owner string + repo string + ref string + sha string + treeSHA string + pinnedRef string + skillPath string + wantContains []string + wantNotContain []string + }{ + { + name: "injects metadata without pin", + content: "---\nname: my-skill\ndescription: desc\n---\n# Body\n", + owner: "owner", + repo: "repo", + ref: "v1.0.0", + sha: "abc123", + treeSHA: "tree456", + pinnedRef: "", + skillPath: "skills/my-skill", + wantContains: []string{ + "github-owner: owner", + "github-repo: repo", + "github-ref: v1.0.0", + "github-sha: abc123", + "github-tree-sha: tree456", + "github-path: skills/my-skill", + "# Body", + }, + wantNotContain: []string{ + "github-pinned", + }, + }, + { + name: "injects pinned ref", + content: "---\nname: my-skill\n---\n# Body\n", + owner: "owner", + repo: "repo", + ref: "v1.0.0", + sha: "abc", + treeSHA: "tree", + pinnedRef: "v1.0.0", + skillPath: "skills/my-skill", + wantContains: []string{ + "github-pinned: v1.0.0", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := InjectGitHubMetadata(tt.content, tt.owner, tt.repo, tt.ref, tt.sha, tt.treeSHA, tt.pinnedRef, tt.skillPath) + require.NoError(t, err) + for _, s := range tt.wantContains { + assert.Contains(t, got, s) + } + for _, s := range tt.wantNotContain { + assert.NotContains(t, got, s) + } + }) + } +} + +func TestInjectLocalMetadata(t *testing.T) { + content := "---\nname: my-skill\nmetadata:\n github-owner: old\n github-repo: old\n---\n# Body\n" + got, err := InjectLocalMetadata(content, "/home/user/skills/my-skill") + require.NoError(t, err) + + assert.Contains(t, got, "local-path: /home/user/skills/my-skill") + assert.NotContains(t, got, "github-owner") + assert.NotContains(t, got, "github-repo") +} + +func TestSerialize(t *testing.T) { + tests := []struct { + name string + frontmatter map[string]interface{} + body string + wantPrefix string + wantSuffix string + wantContains []string + }{ + { + name: "with body", + frontmatter: map[string]interface{}{"name": "test"}, + body: "# Body content", + wantPrefix: "---\n", + wantContains: []string{ + "name: test", + "# Body content", + }, + }, + { + name: "empty body", + frontmatter: map[string]interface{}{"name": "test"}, + body: "", + wantSuffix: "---\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Serialize(tt.frontmatter, tt.body) + require.NoError(t, err) + if tt.wantPrefix != "" { + assert.True(t, strings.HasPrefix(got, tt.wantPrefix)) + } + if tt.wantSuffix != "" { + assert.True(t, strings.HasSuffix(got, tt.wantSuffix)) + } + for _, s := range tt.wantContains { + assert.Contains(t, got, s) + } + }) + } +} diff --git a/internal/skills/gitclient/gitclient.go b/internal/skills/gitclient/gitclient.go new file mode 100644 index 00000000000..99735db9017 --- /dev/null +++ b/internal/skills/gitclient/gitclient.go @@ -0,0 +1,149 @@ +// Package gitclient provides a shared adapter from the cli/cli git.Client +// (via cmdutil.Factory) to the narrow interfaces used by skills commands. +package gitclient + +import ( + "context" + "os" + "strings" + + "github.com/cli/cli/v2/pkg/cmdutil" +) + +// RootResolver can resolve the git repository root directory. +type RootResolver interface { + ToplevelDir() (string, error) +} + +// RemoteResolver can resolve git remote URLs. +type RemoteResolver interface { + RemoteURL(name string) (string, error) +} + +// Client is the full git operations interface used by skills commands. +type Client interface { + RootResolver + RemoteResolver + GitDir(dir string) error + Remotes() ([]string, error) + CurrentBranch(dir string) (string, error) + IsIgnored(dir, path string) bool +} + +// FactoryClient adapts the cli/cli git.Client to the Client interface. +type FactoryClient struct { + F *cmdutil.Factory +} + +// ToplevelDir returns the root directory of the current git repository. +func (g *FactoryClient) ToplevelDir() (string, error) { + cmd, err := g.F.GitClient.Command(context.Background(), "rev-parse", "--show-toplevel") + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// RemoteURL returns the URL configured for the named git remote. +func (g *FactoryClient) RemoteURL(name string) (string, error) { + cmd, err := g.F.GitClient.Command(context.Background(), "remote", "get-url", name) + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// GitDir validates that the given directory is inside a git repository. +func (g *FactoryClient) GitDir(dir string) error { + cmd, err := g.F.GitClient.Command(context.Background(), "-C", dir, "rev-parse", "--git-dir") + if err != nil { + return err + } + _, err = cmd.Output() + return err +} + +// Remotes returns the list of configured git remote names. +func (g *FactoryClient) Remotes() ([]string, error) { + cmd, err := g.F.GitClient.Command(context.Background(), "remote") + if err != nil { + return nil, err + } + out, err := cmd.Output() + if err != nil { + return nil, err + } + return strings.Fields(string(out)), nil +} + +// CurrentBranch returns the current branch name, or "" if HEAD is detached. +func (g *FactoryClient) CurrentBranch(dir string) (string, error) { + cmd, err := g.F.GitClient.Command(context.Background(), "-C", dir, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", err + } + branch := strings.TrimSpace(string(out)) + if branch == "HEAD" { + return "", nil // detached HEAD + } + return branch, nil +} + +// IsIgnored reports whether the given path is git-ignored in the given directory. +func (g *FactoryClient) IsIgnored(dir, path string) bool { + cmd, err := g.F.GitClient.Command(context.Background(), "-C", dir, "check-ignore", "-q", path) + if err != nil { + return false + } + _, err = cmd.Output() + return err == nil +} + +// ResolveGitRoot returns the git repository root using the provided resolver, +// falling back to the current working directory on error. +func ResolveGitRoot(resolver RootResolver) string { + if resolver == nil { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" + } + root, err := resolver.ToplevelDir() + if err != nil { + if cwd, cwdErr := os.Getwd(); cwdErr == nil { + return cwd + } + return "" + } + return root +} + +// ResolveHomeDir returns the user's home directory, or "" on error. +func ResolveHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} + +// TruncateSHA returns the first 8 characters of a SHA, or the full string +// if it is shorter. +func TruncateSHA(sha string) string { + if len(sha) > 8 { + return sha[:8] + } + return sha +} diff --git a/internal/skills/gitclient/gitclient_test.go b/internal/skills/gitclient/gitclient_test.go new file mode 100644 index 00000000000..0b8a2cfffcf --- /dev/null +++ b/internal/skills/gitclient/gitclient_test.go @@ -0,0 +1,49 @@ +package gitclient + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +type mockResolver struct { + root string + err error +} + +func (m *mockResolver) ToplevelDir() (string, error) { + if m.err != nil { + return "", m.err + } + return m.root, nil +} + +func TestResolveGitRoot(t *testing.T) { + t.Run("returns root on success", func(t *testing.T) { + got := ResolveGitRoot(&mockResolver{root: "/my/repo"}) + assert.Equal(t, "/my/repo", got) + }) + + t.Run("falls back to cwd on error", func(t *testing.T) { + got := ResolveGitRoot(&mockResolver{err: fmt.Errorf("not a git repo")}) + assert.NotEmpty(t, got) // falls back to cwd + }) + + t.Run("nil resolver falls back to cwd", func(t *testing.T) { + got := ResolveGitRoot(nil) + assert.NotEmpty(t, got) // falls back to cwd + }) +} + +func TestResolveHomeDir(t *testing.T) { + got := ResolveHomeDir() + assert.NotEmpty(t, got) +} + +func TestTruncateSHA(t *testing.T) { + assert.Equal(t, "abcdef12", TruncateSHA("abcdef1234567890")) + assert.Equal(t, "short", TruncateSHA("short")) + assert.Equal(t, "12345678", TruncateSHA("12345678")) + assert.Equal(t, "", TruncateSHA("")) +} diff --git a/internal/skills/hosts/hosts.go b/internal/skills/hosts/hosts.go new file mode 100644 index 00000000000..bee20b0f092 --- /dev/null +++ b/internal/skills/hosts/hosts.go @@ -0,0 +1,175 @@ +package hosts + +import ( + "fmt" + "path/filepath" + + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/ghrepo" +) + +// Host represents an AI agent that can use skills. +type Host struct { + // ID is the canonical identifier for this host. + ID string + // Name is the human-readable display name. + Name string + // ProjectDir is the relative path within a project for skills. + ProjectDir string + // UserDir is the relative path within the user's home directory for skills. + UserDir string +} + +// Scope determines where skills are installed. +type Scope string + +const ( + ScopeProject Scope = "project" + ScopeUser Scope = "user" +) + +// Registry contains all known agent hosts. +var Registry = []Host{ + { + ID: "github-copilot", + Name: "GitHub Copilot", + ProjectDir: ".github/skills", + UserDir: ".copilot/skills", + }, + { + ID: "claude-code", + Name: "Claude Code", + ProjectDir: ".claude/skills", + UserDir: ".claude/skills", + }, + { + ID: "cursor", + Name: "Cursor", + ProjectDir: ".cursor/skills", + UserDir: ".cursor/skills", + }, + { + ID: "codex", + Name: "Codex", + ProjectDir: ".agents/skills", + UserDir: ".codex/skills", + }, + { + ID: "gemini", + Name: "Gemini CLI", + ProjectDir: ".agent/skills", + UserDir: ".gemini/skills", + }, + { + ID: "antigravity", + Name: "Antigravity", + ProjectDir: ".agent/skills", + UserDir: ".gemini/antigravity/skills", + }, +} + +// FindByID returns the host with the given ID, or an error if not found. +func FindByID(id string) (*Host, error) { + for i := range Registry { + if Registry[i].ID == id { + return &Registry[i], nil + } + } + return nil, fmt.Errorf("unknown host %q, valid hosts: %s", id, ValidHostIDs()) +} + +// ValidHostIDs returns a comma-separated list of valid host IDs. +func ValidHostIDs() string { + ids := "" + for i, h := range Registry { + if i > 0 { + ids += ", " + } + ids += h.ID + } + return ids +} + +// HostIDs returns the IDs of all known hosts as a slice. +func HostIDs() []string { + ids := make([]string, len(Registry)) + for i, h := range Registry { + ids[i] = h.ID + } + return ids +} + +// HostNames returns the display names of all hosts for prompting. +func HostNames() []string { + names := make([]string, len(Registry)) + for i, h := range Registry { + names[i] = h.Name + } + return names +} + +// UniqueProjectDirs returns the deduplicated set of project-scope skill +// directories from the Registry, preserving insertion order. +func UniqueProjectDirs() []string { + seen := map[string]bool{} + var dirs []string + for _, h := range Registry { + if !seen[h.ProjectDir] { + seen[h.ProjectDir] = true + dirs = append(dirs, h.ProjectDir) + } + } + return dirs +} + +// InstallDir resolves the absolute installation directory for a host and scope. +// For project scope, it uses the provided git root directory so that skills are +// installed at the top level regardless of which subdirectory the user is in. +// Returns an error when gitRoot is empty (not in a git repository). +// For user scope, it uses the home directory. +func (h *Host) InstallDir(scope Scope, gitRoot, homeDir string) (string, error) { + switch scope { + case ScopeProject: + if gitRoot == "" { + return "", fmt.Errorf("could not determine project root directory") + } + return filepath.Join(gitRoot, h.ProjectDir), nil + case ScopeUser: + if homeDir == "" { + return "", fmt.Errorf("could not determine home directory") + } + return filepath.Join(homeDir, h.UserDir), nil + default: + return "", fmt.Errorf("invalid scope %q", scope) + } +} + +// ScopeLabels returns the display labels for the scope selection prompt. +// If repoName is non-empty, it is included in the project-scope label +// for additional context. +func ScopeLabels(repoName string) []string { + projectLabel := "Project — install in current repository (recommended)" + if repoName != "" { + projectLabel = fmt.Sprintf("Project — %s (recommended)", repoName) + } + return []string{ + projectLabel, + "Global — install in home directory (available everywhere)", + } +} + +// RepoNameFromRemote extracts "owner/repo" from a git remote URL. +func RepoNameFromRemote(remote string) string { + if remote == "" { + return "" + } + u, err := git.ParseURL(remote) + if err != nil { + return "" + } + repo, err := ghrepo.FromURL(u) + if err != nil { + return "" + } + return ghrepo.FullName(repo) +} diff --git a/internal/skills/hosts/hosts_test.go b/internal/skills/hosts/hosts_test.go new file mode 100644 index 00000000000..78c2a3e9dc6 --- /dev/null +++ b/internal/skills/hosts/hosts_test.go @@ -0,0 +1,113 @@ +package hosts + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindByID(t *testing.T) { + host, err := FindByID("github-copilot") + require.NoError(t, err) + assert.Equal(t, "GitHub Copilot", host.Name) + assert.Equal(t, ".github/skills", host.ProjectDir) +} + +func TestFindByID_Invalid(t *testing.T) { + _, err := FindByID("nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown host") +} + +func TestValidHostIDs(t *testing.T) { + ids := ValidHostIDs() + assert.Contains(t, ids, "github-copilot") + assert.Contains(t, ids, "claude-code") + assert.Contains(t, ids, "cursor") +} + +func TestHostNames(t *testing.T) { + names := HostNames() + assert.Contains(t, names, "GitHub Copilot") + assert.Contains(t, names, "Claude Code") +} + +func TestInstallDir_Project(t *testing.T) { + host, _ := FindByID("github-copilot") + dir, err := host.InstallDir(ScopeProject, "/tmp/myrepo", "/home/user") + require.NoError(t, err) + assert.Equal(t, filepath.Join("/tmp/myrepo", ".github", "skills"), dir) +} + +func TestInstallDir_User(t *testing.T) { + host, _ := FindByID("github-copilot") + dir, err := host.InstallDir(ScopeUser, "/tmp/myrepo", "/home/user") + require.NoError(t, err) + assert.Equal(t, filepath.Join("/home/user", ".copilot", "skills"), dir) +} + +func TestInstallDir_NoGitRoot(t *testing.T) { + host, _ := FindByID("github-copilot") + _, err := host.InstallDir(ScopeProject, "", "/home/user") + assert.Error(t, err) +} + +func TestRepoNameFromRemote(t *testing.T) { + tests := []struct { + remote string + want string + }{ + {"https://github.com/owner/repo.git", "owner/repo"}, + {"https://github.com/owner/repo", "owner/repo"}, + {"git@github.com:owner/repo.git", "owner/repo"}, + {"git@github.com:owner/repo", "owner/repo"}, + {"ssh://git@github.com/owner/repo.git", "owner/repo"}, + {"ssh://git@github.com/owner/repo", "owner/repo"}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.remote, func(t *testing.T) { + assert.Equal(t, tt.want, RepoNameFromRemote(tt.remote)) + }) + } +} + +func TestUniqueProjectDirs(t *testing.T) { + dirs := UniqueProjectDirs() + + // Should contain all known project dirs + assert.Contains(t, dirs, ".github/skills") + assert.Contains(t, dirs, ".claude/skills") + assert.Contains(t, dirs, ".cursor/skills") + assert.Contains(t, dirs, ".agents/skills") + assert.Contains(t, dirs, ".agent/skills") + + // Should deduplicate — gemini and antigravity share .agent/skills + seen := map[string]int{} + for _, d := range dirs { + seen[d]++ + } + for dir, count := range seen { + assert.Equalf(t, 1, count, "directory %q appears %d times, expected 1", dir, count) + } +} + +func TestScopeLabels(t *testing.T) { + t.Run("without repo name", func(t *testing.T) { + labels := ScopeLabels("") + require.Len(t, labels, 2) + assert.Contains(t, labels[0], "Project") + assert.Contains(t, labels[0], "recommended") + assert.Contains(t, labels[1], "Global") + }) + + t.Run("with repo name", func(t *testing.T) { + labels := ScopeLabels("owner/repo") + require.Len(t, labels, 2) + assert.Contains(t, labels[0], "owner/repo") + assert.Contains(t, labels[0], "recommended") + assert.Contains(t, labels[1], "Global") + }) +} diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go new file mode 100644 index 00000000000..fa2854a7c95 --- /dev/null +++ b/internal/skills/installer/installer.go @@ -0,0 +1,296 @@ +package installer + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/hosts" + "github.com/cli/cli/v2/internal/skills/lockfile" +) + +// maxConcurrency limits parallel API requests to avoid rate limiting. +const maxConcurrency = 5 + +// Options configures an installation. +type Options struct { + Host string // GitHub API hostname + Owner string + Repo string + Ref string // resolved ref name + SHA string // resolved commit SHA + PinnedRef string // user-supplied --pin value (empty if unpinned) + Skills []discovery.Skill + AgentHost *hosts.Host + Scope hosts.Scope + Dir string // explicit target directory (overrides AgentHost+Scope) + GitRoot string // git repository root (for project scope) + HomeDir string // user home directory (for user scope) + Client discovery.RESTClient + OnProgress func(done, total int) // called after each skill is installed +} + +// Result tracks what was installed. +type Result struct { + Installed []string + Dir string + Warnings []string +} + +type skillResult struct { + name string + err error +} + +// Install fetches and writes skills to the target directory. +func Install(opts *Options) (*Result, error) { + targetDir := opts.Dir + if targetDir == "" { + var err error + targetDir, err = opts.AgentHost.InstallDir(opts.Scope, opts.GitRoot, opts.HomeDir) + if err != nil { + return nil, err + } + } + + if len(opts.Skills) == 1 { + skill := opts.Skills[0] + if opts.OnProgress != nil { + opts.OnProgress(0, 1) + } + if err := installSkill(opts, skill, targetDir); err != nil { + return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err) + } + var warnings []string + if err := lockfile.RecordInstall(skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil { + warnings = append(warnings, fmt.Sprintf("could not record install for %s: %v", skill.InstallName(), err)) + } + if opts.OnProgress != nil { + opts.OnProgress(1, 1) + } + return &Result{Installed: []string{skill.InstallName()}, Dir: targetDir, Warnings: warnings}, nil + } + + total := len(opts.Skills) + if opts.OnProgress != nil { + opts.OnProgress(0, total) + } + + sem := make(chan struct{}, maxConcurrency) + results := make([]skillResult, total) + var wg sync.WaitGroup + var mu sync.Mutex + done := 0 + + for i, skill := range opts.Skills { + wg.Add(1) + go func(idx int, s discovery.Skill) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + err := installSkill(opts, s, targetDir) + results[idx] = skillResult{name: s.InstallName(), err: err} + + if opts.OnProgress != nil { + mu.Lock() + done++ + d := done + mu.Unlock() + opts.OnProgress(d, total) + } + }(i, skill) + } + wg.Wait() + + var installed []string + var warnings []string + var firstErr error + for i, r := range results { + if r.err != nil { + if firstErr == nil { + firstErr = fmt.Errorf("failed to install skill %q: %w", r.name, r.err) + } + continue + } + installed = append(installed, r.name) + skill := opts.Skills[i] + if err := lockfile.RecordInstall(skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil { + warnings = append(warnings, fmt.Sprintf("could not record install for %s: %v", skill.InstallName(), err)) + } + } + + if firstErr != nil { + return &Result{Installed: installed, Dir: targetDir, Warnings: warnings}, firstErr + } + + return &Result{Installed: installed, Dir: targetDir, Warnings: warnings}, nil +} + +// LocalOptions configures a local directory installation. +type LocalOptions struct { + SourceDir string + Skills []discovery.Skill + AgentHost *hosts.Host + Scope hosts.Scope + Dir string + GitRoot string + HomeDir string +} + +// InstallLocal copies skills from a local directory to the target install location. +func InstallLocal(opts *LocalOptions) (*Result, error) { + targetDir := opts.Dir + if targetDir == "" { + var err error + targetDir, err = opts.AgentHost.InstallDir(opts.Scope, opts.GitRoot, opts.HomeDir) + if err != nil { + return nil, err + } + } + + var installed []string + for _, skill := range opts.Skills { + if err := installLocalSkill(opts.SourceDir, skill, targetDir); err != nil { + return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err) + } + installed = append(installed, skill.InstallName()) + } + + return &Result{Installed: installed, Dir: targetDir}, nil +} + +func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) error { + skillDir := filepath.Join(baseDir, filepath.FromSlash(skill.InstallName())) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return fmt.Errorf("could not create directory %s: %w", skillDir, err) + } + + srcDir := filepath.Join(sourceRoot, filepath.FromSlash(skill.Path)) + absSource, err := filepath.Abs(srcDir) + if err != nil { + return fmt.Errorf("could not resolve source path: %w", err) + } + + absSkillDir, err := filepath.Abs(skillDir) + if err != nil { + return fmt.Errorf("could not resolve target path: %w", err) + } + + return filepath.WalkDir(srcDir, func(p string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.Type()&os.ModeSymlink != 0 { + return nil + } + if d.IsDir() { + return nil + } + + relPath, err := filepath.Rel(srcDir, p) + if err != nil { + return err + } + + cleaned := filepath.Clean(relPath) + if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { + return nil + } + + destPath := filepath.Join(skillDir, cleaned) + + absDest, err := filepath.Abs(destPath) + if err != nil { + return fmt.Errorf("could not resolve destination path: %w", err) + } + if !strings.HasPrefix(absDest, absSkillDir+string(filepath.Separator)) && absDest != absSkillDir { + return nil + } + + if dir := filepath.Dir(destPath); dir != skillDir { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("could not create directory: %w", err) + } + } + + content, err := os.ReadFile(p) + if err != nil { + return fmt.Errorf("could not read %s: %w", p, err) + } + + if filepath.Base(relPath) == "SKILL.md" { + injected, injectErr := frontmatter.InjectLocalMetadata(string(content), absSource) + if injectErr != nil { + return fmt.Errorf("could not inject metadata: %w", injectErr) + } + content = []byte(injected) + } + + return os.WriteFile(destPath, content, 0o644) + }) +} + +func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { + skillDir := filepath.Join(baseDir, filepath.FromSlash(skill.InstallName())) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return fmt.Errorf("could not create directory %s: %w", skillDir, err) + } + + files, err := discovery.DiscoverSkillFiles(opts.Client, opts.Host, opts.Owner, opts.Repo, skill.TreeSHA, skill.Path) + if err != nil { + return fmt.Errorf("could not list skill files: %w", err) + } + + absSkillDir, err := filepath.Abs(skillDir) + if err != nil { + return fmt.Errorf("could not resolve skill directory path: %w", err) + } + + for _, file := range files { + content, err := discovery.FetchBlob(opts.Client, opts.Host, opts.Owner, opts.Repo, file.SHA) + if err != nil { + return fmt.Errorf("could not fetch %s: %w", file.Path, err) + } + + relPath := strings.TrimPrefix(file.Path, skill.Path+"/") + + cleaned := filepath.Clean(relPath) + if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { + continue + } + + destPath := filepath.Join(skillDir, cleaned) + + absDest, err := filepath.Abs(destPath) + if err != nil { + return fmt.Errorf("could not resolve destination path: %w", err) + } + if !strings.HasPrefix(absDest, absSkillDir+string(filepath.Separator)) && absDest != absSkillDir { + continue + } + + if dir := filepath.Dir(destPath); dir != skillDir { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("could not create directory: %w", err) + } + } + + if filepath.Base(relPath) == "SKILL.md" { + content, err = frontmatter.InjectGitHubMetadata(content, opts.Owner, opts.Repo, opts.Ref, file.SHA, skill.TreeSHA, opts.PinnedRef, skill.Path) + if err != nil { + return fmt.Errorf("could not inject metadata: %w", err) + } + } + + if err := os.WriteFile(destPath, []byte(content), 0o644); err != nil { + return fmt.Errorf("could not write %s: %w", destPath, err) + } + } + + return nil +} diff --git a/internal/skills/lockfile/lockfile.go b/internal/skills/lockfile/lockfile.go new file mode 100644 index 00000000000..4ceed787241 --- /dev/null +++ b/internal/skills/lockfile/lockfile.go @@ -0,0 +1,165 @@ +package lockfile + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +const ( + // lockVersion must match Vercel's CURRENT_LOCK_VERSION for interop. + lockVersion = 3 + agentsDir = ".agents" + lockFile = ".skill-lock.json" +) + +// Entry represents a single installed skill in the lock file. +type Entry struct { + Source string `json:"source"` + SourceType string `json:"sourceType"` + SourceURL string `json:"sourceUrl"` + SkillPath string `json:"skillPath,omitempty"` + SkillFolderHash string `json:"skillFolderHash"` + InstalledAt string `json:"installedAt"` + UpdatedAt string `json:"updatedAt"` + PinnedRef string `json:"pinnedRef,omitempty"` +} + +// File is the top-level structure of .skill-lock.json. +type File struct { + Version int `json:"version"` + Skills map[string]Entry `json:"skills"` + Dismissed map[string]bool `json:"dismissed,omitempty"` +} + +// Path returns the absolute path to the lock file. +func Path() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, agentsDir, lockFile), nil +} + +// Read loads the lock file, returning an empty file if it doesn't exist +// or if it's an incompatible version. +func Read() (*File, error) { + lockPath, err := Path() + if err != nil { + return newFile(), nil //nolint:nilerr // graceful: no home dir means fresh state + } + + data, err := os.ReadFile(lockPath) + if err != nil { + if os.IsNotExist(err) { + return newFile(), nil + } + return nil, fmt.Errorf("could not read lock file: %w", err) + } + + var f File + if err := json.Unmarshal(data, &f); err != nil { + return newFile(), nil //nolint:nilerr // graceful: corrupt file means fresh state + } + + if f.Version != lockVersion || f.Skills == nil { + return newFile(), nil + } + + return &f, nil +} + +// Write persists the lock file to disk. +func Write(f *File) error { + lockPath, err := Path() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + + return os.WriteFile(lockPath, data, 0o644) +} + +// RecordInstall adds or updates a skill entry in the lock file. +// It uses a file-based lock to prevent concurrent read-modify-write races +// when multiple install processes run simultaneously. +func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) error { + unlock := acquireLock() + defer unlock() + + f, err := Read() + if err != nil { + return err + } + + now := time.Now().UTC().Format(time.RFC3339) + + existing, exists := f.Skills[skillName] + installedAt := now + if exists { + installedAt = existing.InstalledAt + } + + f.Skills[skillName] = Entry{ + Source: owner + "/" + repo, + SourceType: "github", + SourceURL: "https://github.com/" + owner + "/" + repo + ".git", + SkillPath: skillPath, + SkillFolderHash: treeSHA, + InstalledAt: installedAt, + UpdatedAt: now, + PinnedRef: pinnedRef, + } + + return Write(f) +} + +func newFile() *File { + return &File{ + Version: lockVersion, + Skills: make(map[string]Entry), + } +} + +// acquireLock creates an exclusive lock file to serialize concurrent access. +// Returns an unlock function. If locking fails after retries, it proceeds +// unlocked rather than blocking the user indefinitely. +func acquireLock() (unlock func()) { + lockPath, pathErr := Path() + if pathErr != nil { + return func() {} + } + lkPath := lockPath + ".lk" + + // Ensure the parent directory exists (fresh machine may lack ~/.agents). + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return func() {} + } + + for i := 0; i < 30; i++ { + f, createErr := os.OpenFile(lkPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if createErr == nil { + f.Close() + return func() { os.Remove(lkPath) } + } + // Break stale locks older than 30s (e.g. from a crashed process). + if info, statErr := os.Stat(lkPath); statErr == nil && time.Since(info.ModTime()) > 30*time.Second { + os.Remove(lkPath) + continue + } + time.Sleep(100 * time.Millisecond) + } + + // Best-effort: proceed without lock. + return func() {} +} From 5d049cb8970ab8d5acb03efd0b48e5a744381f33 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Mon, 30 Mar 2026 17:28:54 +0100 Subject: [PATCH 004/182] register initial skills commands --- pkg/cmd/root/root.go | 2 ++ pkg/cmd/skills/skills.go | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 pkg/cmd/skills/skills.go diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index ed33f568ed3..262af1b78a0 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -38,6 +38,7 @@ import ( runCmd "github.com/cli/cli/v2/pkg/cmd/run" searchCmd "github.com/cli/cli/v2/pkg/cmd/search" secretCmd "github.com/cli/cli/v2/pkg/cmd/secret" + skillsCmd "github.com/cli/cli/v2/pkg/cmd/skills" sshKeyCmd "github.com/cli/cli/v2/pkg/cmd/ssh-key" statusCmd "github.com/cli/cli/v2/pkg/cmd/status" variableCmd "github.com/cli/cli/v2/pkg/cmd/variable" @@ -164,6 +165,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(repoCmd.NewCmdRepo(&repoResolvingCmdFactory)) cmd.AddCommand(rulesetCmd.NewCmdRuleset(&repoResolvingCmdFactory)) cmd.AddCommand(runCmd.NewCmdRun(&repoResolvingCmdFactory)) + cmd.AddCommand(skillsCmd.NewCmdSkills(f)) cmd.AddCommand(workflowCmd.NewCmdWorkflow(&repoResolvingCmdFactory)) cmd.AddCommand(labelCmd.NewCmdLabel(&repoResolvingCmdFactory)) cmd.AddCommand(cacheCmd.NewCmdCache(&repoResolvingCmdFactory)) diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go new file mode 100644 index 00000000000..e3f1c286f7b --- /dev/null +++ b/pkg/cmd/skills/skills.go @@ -0,0 +1,18 @@ +package skills + +import ( + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// NewCmdSkills returns the top-level "skills" command. +func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "skills ", + Short: "Install and manage agent skills", + Long: "Install and manage agent skills from GitHub repositories.", + GroupID: "core", + } + + return cmd +} From 758785b8f4af530849267b112274db455bcaab60 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Mon, 30 Mar 2026 17:32:23 +0100 Subject: [PATCH 005/182] improve test coverage/cleanup --- acceptance/acceptance_test.go | 10 +- acceptance/testdata/skills/.gitkeep | 0 .../skills/skills-install-alias.txtar | 3 + .../testdata/skills/skills-install-all.txtar | 5 + .../skills/skills-install-conflict.txtar | 8 + .../skills/skills-install-force.txtar | 11 + .../testdata/skills/skills-install-pin.txtar | 7 + .../skills/skills-install-scope.txtar | 9 + .../testdata/skills/skills-install.txtar | 10 + internal/skills/discovery/discovery.go | 8 +- internal/skills/installer/installer.go | 6 + internal/skills/lockfile/lockfile.go | 5 + pkg/cmd/skills/install/install.go | 993 ++++++++++++++++++ pkg/cmd/skills/install/install_test.go | 917 ++++++++++++++++ .../skills/install/install_windows_test.go | 63 ++ pkg/cmd/skills/skills.go | 3 + 16 files changed, 2055 insertions(+), 3 deletions(-) create mode 100644 acceptance/testdata/skills/.gitkeep create mode 100644 acceptance/testdata/skills/skills-install-alias.txtar create mode 100644 acceptance/testdata/skills/skills-install-all.txtar create mode 100644 acceptance/testdata/skills/skills-install-conflict.txtar create mode 100644 acceptance/testdata/skills/skills-install-force.txtar create mode 100644 acceptance/testdata/skills/skills-install-pin.txtar create mode 100644 acceptance/testdata/skills/skills-install-scope.txtar create mode 100644 acceptance/testdata/skills/skills-install.txtar create mode 100644 pkg/cmd/skills/install/install.go create mode 100644 pkg/cmd/skills/install/install_test.go create mode 100644 pkg/cmd/skills/install/install_windows_test.go diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 98642afafeb..7c3c6f6ce2e 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -14,9 +14,9 @@ import ( "math/rand" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghcmd" "github.com/cli/go-internal/testscript" - "github.com/MakeNowJust/heredoc" ) func ghMain() int { @@ -434,3 +434,11 @@ func (e *testScriptEnv) fromEnv() error { return nil } + +func TestSkills(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + testscript.Run(t, testScriptParamsFor(tsEnv, "skills")) +} diff --git a/acceptance/testdata/skills/.gitkeep b/acceptance/testdata/skills/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/acceptance/testdata/skills/skills-install-alias.txtar b/acceptance/testdata/skills/skills-install-alias.txtar new file mode 100644 index 00000000000..089474b3ace --- /dev/null +++ b/acceptance/testdata/skills/skills-install-alias.txtar @@ -0,0 +1,3 @@ +# Install with "add" alias +exec gh skills add github/awesome-copilot git-commit --scope user --force --agent github-copilot +stdout 'Installed git-commit' diff --git a/acceptance/testdata/skills/skills-install-all.txtar b/acceptance/testdata/skills/skills-install-all.txtar new file mode 100644 index 00000000000..6efd7747e57 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-all.txtar @@ -0,0 +1,5 @@ +# Install all skills from a repo with mixed conventions (skills/ + plugins/) +# This previously failed with "conflicting names" — now uses namespaced dirs +exec gh skills install github/awesome-copilot --all --scope user --force --agent github-copilot +stdout 'Installed' +! stderr 'conflicting names' diff --git a/acceptance/testdata/skills/skills-install-conflict.txtar b/acceptance/testdata/skills/skills-install-conflict.txtar new file mode 100644 index 00000000000..9e79e5a5e9e --- /dev/null +++ b/acceptance/testdata/skills/skills-install-conflict.txtar @@ -0,0 +1,8 @@ +# Install --all should handle skills with same name across conventions +# (skills/ and plugins/ directories) without collision errors +exec gh skills install github/awesome-copilot --all --force --dir $WORK/scope-test --agent github-copilot +stdout 'Installed' +! stderr 'conflicting names' + +# Verify skills were installed successfully +exists $WORK/scope-test/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install-force.txtar b/acceptance/testdata/skills/skills-install-force.txtar new file mode 100644 index 00000000000..5623fce84ce --- /dev/null +++ b/acceptance/testdata/skills/skills-install-force.txtar @@ -0,0 +1,11 @@ +# Install with --force should overwrite an existing skill without error +exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/force-test +stdout 'Installed git-commit' + +# Install again with --force — should succeed (overwrite) +exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/force-test +stdout 'Installed git-commit' + +# Without --force, non-interactive should fail when skill exists +! exec gh skills install github/awesome-copilot git-commit --dir $WORK/force-test +stderr 'already installed' diff --git a/acceptance/testdata/skills/skills-install-pin.txtar b/acceptance/testdata/skills/skills-install-pin.txtar new file mode 100644 index 00000000000..43d780e3e16 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-pin.txtar @@ -0,0 +1,7 @@ +# Install with --pin to a specific ref +exec gh skills install github/awesome-copilot git-commit --scope user --force --pin main +stdout 'Installed git-commit' + +# Install without --pin should resolve latest version +exec gh skills install github/awesome-copilot git-commit --scope user --force +stdout 'Installed git-commit' diff --git a/acceptance/testdata/skills/skills-install-scope.txtar b/acceptance/testdata/skills/skills-install-scope.txtar new file mode 100644 index 00000000000..9b8048ab5b9 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-scope.txtar @@ -0,0 +1,9 @@ +# Install with --scope project (default) inside a git repo +exec git init $WORK/myrepo +exec gh skills install github/awesome-copilot git-commit --scope project --force --agent github-copilot --dir $WORK/myrepo/.github/skills +stdout 'Installed git-commit' +exists $WORK/myrepo/.github/skills/git-commit/SKILL.md + +# Install with --scope user +exec gh skills install github/awesome-copilot git-commit --scope user --force --agent github-copilot +stdout 'Installed git-commit' diff --git a/acceptance/testdata/skills/skills-install.txtar b/acceptance/testdata/skills/skills-install.txtar new file mode 100644 index 00000000000..c04ced9d2da --- /dev/null +++ b/acceptance/testdata/skills/skills-install.txtar @@ -0,0 +1,10 @@ +# Install a single skill from a public repo +exec gh skills install github/awesome-copilot git-commit --scope user --force --agent github-copilot +stdout 'Installed git-commit' + +# Install with --dir to a custom directory +exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/custom-skills +stdout 'Installed git-commit' + +# Verify the skill was written to the custom directory +exists $WORK/custom-skills/git-commit/SKILL.md diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index e0e881088d2..fc234716a3d 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -573,8 +573,8 @@ func FetchBlob(client RESTClient, host, owner, repo, sha string) (string, error) return "", fmt.Errorf("unexpected blob encoding: %s", blob.Encoding) } - // GitHub API returns base64 with embedded newlines; use the lenient - // RawStdEncoding decoder via a reader to handle them transparently. + // GitHub API returns base64 with embedded newlines; use the StdEncoding + // decoder via a reader to handle them transparently. decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(blob.Content))) if err != nil { return "", fmt.Errorf("could not decode blob content: %w", err) @@ -615,6 +615,10 @@ func DiscoverLocalSkills(dir string) ([]Skill, error) { if walkErr != nil { return walkErr } + // Skip symlinks to avoid following links outside the source tree. + if info.Mode()&os.ModeSymlink != 0 { + return nil + } if info.IsDir() || info.Name() != "SKILL.md" { return nil } diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index fa2854a7c95..4c2a3925664 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -50,6 +50,9 @@ type skillResult struct { func Install(opts *Options) (*Result, error) { targetDir := opts.Dir if targetDir == "" { + if opts.AgentHost == nil { + return nil, fmt.Errorf("either Dir or AgentHost must be specified") + } var err error targetDir, err = opts.AgentHost.InstallDir(opts.Scope, opts.GitRoot, opts.HomeDir) if err != nil { @@ -146,6 +149,9 @@ type LocalOptions struct { func InstallLocal(opts *LocalOptions) (*Result, error) { targetDir := opts.Dir if targetDir == "" { + if opts.AgentHost == nil { + return nil, fmt.Errorf("either Dir or AgentHost must be specified") + } var err error targetDir, err = opts.AgentHost.InstallDir(opts.Scope, opts.GitRoot, opts.HomeDir) if err != nil { diff --git a/internal/skills/lockfile/lockfile.go b/internal/skills/lockfile/lockfile.go index 4ceed787241..ad5fd4d4be9 100644 --- a/internal/skills/lockfile/lockfile.go +++ b/internal/skills/lockfile/lockfile.go @@ -152,6 +152,11 @@ func acquireLock() (unlock func()) { f.Close() return func() { os.Remove(lkPath) } } + // Only retry when the lock file already exists (concurrent process). + // For other errors (permission denied, invalid path, etc.) give up immediately. + if !os.IsExist(createErr) { + return func() {} + } // Break stale locks older than 30s (e.g. from a crashed process). if info, statErr := os.Stat(lkPath); statErr == nil && time.Since(info.ModTime()) > 30*time.Second { os.Remove(lkPath) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go new file mode 100644 index 00000000000..38613800d85 --- /dev/null +++ b/pkg/cmd/skills/install/install.go @@ -0,0 +1,993 @@ +package install + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/gitclient" + "github.com/cli/cli/v2/internal/skills/hosts" + "github.com/cli/cli/v2/internal/skills/installer" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +const ( + // allSkillsKey is the persistent option label for selecting all skills. + allSkillsKey = "(all skills)" + + // maxSearchResults caps how many skills are shown per search page in + // interactive selection, keeping the prompt readable. + maxSearchResults = 30 +) + +// installOptions holds all dependencies and user-provided flags for the install command. +type installOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Prompter prompter.Prompter + GitClient installGitClient + + // Arguments + SkillSource string // owner/repo or local path + SkillName string // skill name, possibly with @version + + // Flags + Agent string // --agent flag + Scope string // --scope flag + ScopeChanged bool // true when --scope was explicitly set + Pin string // --pin flag + Dir string // --dir flag (overrides host+scope) + All bool // --all flag + Force bool // --force flag + + // Resolved at runtime + repo ghrepo.Interface // set when SkillSource is a GitHub repository + localPath string // set when SkillSource is a local directory + version string +} + +// installGitClient is the git interface needed by the install command. +type installGitClient interface { + gitclient.RootResolver + gitclient.RemoteResolver +} + +// NewCmdInstall creates the "skills install" command. +func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra.Command { + opts := &installOptions{ + IO: f.IOStreams, + Prompter: f.Prompter, + GitClient: &gitclient.FactoryClient{F: f}, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "install []", + Short: "Install agent skills from a GitHub repository", + Long: heredoc.Docf(` + Install agent skills from a GitHub repository or local directory into + your local environment. Skills are placed in a host-specific directory + at either project scope (inside the current git repository) or user + scope (in your home directory, available everywhere): + + Host Project User + GitHub Copilot .github/skills ~/.copilot/skills + Claude Code .claude/skills ~/.claude/skills + Cursor .cursor/skills ~/.cursor/skills + Codex .agents/skills ~/.codex/skills + Gemini CLI .agent/skills ~/.gemini/skills + Antigravity .agent/skills ~/.gemini/antigravity/skills + + Use %[1]s--agent%[1]s and %[1]s--scope%[1]s to control placement, or %[1]s--dir%[1]s for a + custom directory. The default scope is %[1]sproject%[1]s, and the default + agent is %[1]sgithub-copilot%[1]s (when running non-interactively). + + The first argument can be a GitHub repository in %[1]sOWNER/REPO%[1]s format + or a local directory path (e.g. %[1]s.%[1]s, %[1]s./my-skills%[1]s, %[1]s~/skills%[1]s). + For local directories, skills are auto-discovered using the same + conventions as remote repositories, and files are copied (not symlinked) + with local-path tracking metadata injected into frontmatter. + + Skills are discovered automatically using the %[1]sskills/*/SKILL.md%[1]s convention + defined by the Agent Skills specification. For more information on the specification, + see: https://agentskills.io/specification + + The skill argument can be a name, a namespaced name (%[1]sauthor/skill%[1]s), + or an exact path within the repository (%[1]sskills/author/skill%[1]s or + %[1]sskills/author/skill/SKILL.md%[1]s). + + Performance tip: when installing from a large repository with many + skills, providing an exact path instead of a skill name avoids a + full tree traversal of the repository, making the install significantly faster. + + When a skill name is provided without a version, the CLI resolves the + version in this order: + + 1. Latest tagged release in the repository + 2. Default branch HEAD + + To pin to a specific version, either append %[1]s@VERSION%[1]s to the skill + name or use the %[1]s--pin%[1]s flag. The version is resolved as a git tag or commit SHA. + + Installed skills have GitHub tracking metadata injected into their + frontmatter (%[1]sgithub-owner%[1]s, %[1]sgithub-repo%[1]s, %[1]sgithub-ref%[1]s, + %[1]sgithub-sha%[1]s, %[1]sgithub-tree-sha%[1]s, %[1]sgithub-path%[1]s). This + metadata identifies the source repository and enables %[1]sgh skills update%[1]s + to detect changes — the tree SHA serves as an ETag for staleness checks. + + When run interactively, the command prompts for any missing arguments. + When run non-interactively, %[1]srepository%[1]s is required, and either a + skill name or %[1]s--all%[1]s must be specified. + `, "`"), + Example: heredoc.Doc(` + # Interactive: choose repo, skill, and agent + $ gh skills install + + # Choose a skill from the repo interactively + $ gh skills install github/awesome-copilot + + # Install a specific skill + $ gh skills install github/awesome-copilot git-commit + + # Install a specific version + $ gh skills install github/awesome-copilot git-commit@v1.2.0 + + # Install all skills from a repo + $ gh skills install github/awesome-copilot --all + + # Install from a large namespaced repo by path (efficient, skips full discovery) + $ gh skills install github/awesome-copilot skills/monalisa/code-review + + # Install from a local directory (auto-discovers skills) + $ gh skills install ./my-skills-repo + + # Install from current directory + $ gh skills install . + + # Install a single local skill directory + $ gh skills install ./skills/git-commit + + # Install for Claude Code at user scope + $ gh skills install github/awesome-copilot git-commit --agent claude-code --scope user + + # Pin to a specific git ref + $ gh skills install github/awesome-copilot git-commit --pin v2.0.0 + `), + Aliases: []string{"add"}, + Args: cobra.MaximumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("must specify a repository to install from") + } + if len(args) >= 1 { + opts.SkillSource = args[0] + } + if len(args) >= 2 { + opts.SkillName = args[1] + } + opts.ScopeChanged = cmd.Flags().Changed("scope") + + // Resolve the source type early so installRun can branch directly. + if isLocalPath(opts.SkillSource) { + opts.localPath = opts.SkillSource + } + + if opts.Agent != "" { + if _, err := hosts.FindByID(opts.Agent); err != nil { + return cmdutil.FlagErrorf("invalid value for --agent: %s", err) + } + } + + if opts.Pin != "" && opts.SkillName != "" && strings.Contains(opts.SkillName, "@") { + return cmdutil.FlagErrorf("cannot use --pin with an inline @version in the skill name") + } + + if runF != nil { + return runF(opts) + } + return installRun(opts) + }, + } + + cmd.Flags().StringVar(&opts.Agent, "agent", "", fmt.Sprintf("target agent (%s)", hosts.ValidHostIDs())) + _ = cmd.RegisterFlagCompletionFunc("agent", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return hosts.HostIDs(), cobra.ShellCompDirectiveNoFileComp + }) + cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope") + cmd.Flags().StringVar(&opts.Pin, "pin", "", "pin to a specific git tag or commit SHA") + cmd.Flags().StringVar(&opts.Dir, "dir", "", "install to a custom directory (overrides --agent and --scope)") + cmd.Flags().BoolVar(&opts.All, "all", false, "install all skills from the repository") + cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "overwrite existing skills without prompting") + + return cmd +} + +func installRun(opts *installOptions) error { + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + + if opts.localPath != "" { + return runLocalInstall(opts) + } + + repo, source, err := resolveRepoArg(opts.SkillSource, canPrompt, opts.Prompter) + if err != nil { + return err + } + opts.repo = repo + opts.SkillSource = source + + parseSkillFromOpts(opts) + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + hostname := opts.repo.RepoHost() + + resolved, err := resolveVersion(opts, apiClient, hostname) + if err != nil { + return err + } + + var selectedSkills []discovery.Skill + + if isSkillPath(opts.SkillName) { + opts.IO.StartProgressIndicatorWithLabel("Looking up skill") + skill, err := discovery.DiscoverSkillByPath(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, opts.SkillName) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + selectedSkills = []discovery.Skill{*skill} + } else { + skills, err := discoverSkills(opts, apiClient, hostname, resolved) + if err != nil { + return err + } + + selectedSkills, err = selectSkillsWithSelector(opts, skills, canPrompt, skillSelector{ + matchByName: matchSkillByName, + sourceHint: ghrepo.FullName(opts.repo), + fetchDescriptions: func() { + opts.IO.StartProgressIndicatorWithLabel("Fetching skill info") + discovery.FetchDescriptionsConcurrent(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), skills, nil) + opts.IO.StopProgressIndicator() + }, + }) + if err != nil { + return err + } + } + + selectedHosts, err := resolveHosts(opts, canPrompt) + if err != nil { + return err + } + + scope, err := resolveScope(opts, canPrompt) + if err != nil { + return err + } + + gitRoot := gitclient.ResolveGitRoot(opts.GitClient) + homeDir := gitclient.ResolveHomeDir() + source = ghrepo.FullName(opts.repo) + + type hostPlan struct { + host *hosts.Host + skills []discovery.Skill + } + var plans []hostPlan + for _, host := range selectedHosts { + installSkills, err := checkOverwrite(opts, selectedSkills, host, scope, gitRoot, homeDir, canPrompt) + if err != nil { + return err + } + if len(installSkills) == 0 { + fmt.Fprintf(opts.IO.ErrOut, "No skills to install for %s.\n", host.Name) + continue + } + plans = append(plans, hostPlan{host: host, skills: installSkills}) + } + + for _, plan := range plans { + if len(plans) > 1 { + fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s...\n", plan.host.Name) + } + + result, err := installer.Install(&installer.Options{ + Host: hostname, + Owner: opts.repo.RepoOwner(), + Repo: opts.repo.RepoName(), + Ref: resolved.Ref, + SHA: resolved.SHA, + PinnedRef: opts.Pin, + Skills: plan.skills, + AgentHost: plan.host, + Scope: scope, + Dir: opts.Dir, + GitRoot: gitRoot, + HomeDir: homeDir, + Client: apiClient, + OnProgress: installProgress(opts.IO, len(plan.skills)), + }) + + if result != nil { + for _, w := range result.Warnings { + fmt.Fprintf(opts.IO.ErrOut, "%s %s\n", cs.WarningIcon(), w) + } + + for _, name := range result.Installed { + fmt.Fprintf(opts.IO.Out, "%s Installed %s (from %s@%s) in %s\n", + cs.SuccessIcon(), name, source, resolved.Ref, friendlyDir(result.Dir)) + } + + printFileTree(opts.IO.Out, cs, result.Dir, result.Installed) + printReviewHint(opts.IO.ErrOut, cs, source, result.Installed) + } + + if err != nil { + return err + } + } + + return nil +} + +// isLocalPath returns true if the argument looks like a local filesystem path +// rather than a GitHub owner/repo reference. +func isLocalPath(arg string) bool { + if arg == "" { + return false + } + sep := string(filepath.Separator) + if arg == "." || arg == ".." || + strings.HasPrefix(arg, "./") || strings.HasPrefix(arg, "../") || + strings.HasPrefix(arg, "."+sep) || strings.HasPrefix(arg, ".."+sep) { + return true + } + // filepath.IsAbs on Windows requires a drive letter, so "/tmp/foo" + // would not be recognized. Check explicitly for a leading "/" so that + // Unix-style absolute paths are never mistaken for owner/repo refs. + if filepath.IsAbs(arg) || arg[0] == '/' || strings.HasPrefix(arg, "~") { + return true + } + info, err := os.Stat(arg) + if err == nil && info.IsDir() { + return true + } + return false +} + +// runLocalInstall handles installation from a local directory path. +func runLocalInstall(opts *installOptions) error { + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + sourcePath := opts.localPath + if sourcePath == "~" { + if home, err := os.UserHomeDir(); err == nil { + sourcePath = home + } + } else if after, ok := strings.CutPrefix(sourcePath, "~/"); ok { + if home, err := os.UserHomeDir(); err == nil { + sourcePath = filepath.Join(home, after) + } + } + + absSource, err := filepath.Abs(sourcePath) + if err != nil { + return fmt.Errorf("could not resolve path: %w", err) + } + + opts.IO.StartProgressIndicatorWithLabel("Discovering skills") + skills, err := discovery.DiscoverLocalSkills(absSource) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if canPrompt { + fmt.Fprintf(opts.IO.ErrOut, "Found %d skill(s)\n", len(skills)) + } + + selectedSkills, err := selectSkillsWithSelector(opts, skills, canPrompt, skillSelector{ + matchByName: matchLocalSkillByName, + sourceHint: absSource, + }) + if err != nil { + return err + } + + selectedHosts, err := resolveHosts(opts, canPrompt) + if err != nil { + return err + } + + scope, err := resolveScope(opts, canPrompt) + if err != nil { + return err + } + + gitRoot := gitclient.ResolveGitRoot(opts.GitClient) + homeDir := gitclient.ResolveHomeDir() + + type hostPlan struct { + host *hosts.Host + skills []discovery.Skill + } + var plans []hostPlan + for _, host := range selectedHosts { + installSkills, err := checkOverwrite(opts, selectedSkills, host, scope, gitRoot, homeDir, canPrompt) + if err != nil { + return err + } + if len(installSkills) == 0 { + fmt.Fprintf(opts.IO.ErrOut, "No skills to install for %s.\n", host.Name) + continue + } + plans = append(plans, hostPlan{host: host, skills: installSkills}) + } + + for _, plan := range plans { + if len(plans) > 1 { + fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s...\n", plan.host.Name) + } + + result, err := installer.InstallLocal(&installer.LocalOptions{ + SourceDir: absSource, + Skills: plan.skills, + AgentHost: plan.host, + Scope: scope, + Dir: opts.Dir, + GitRoot: gitRoot, + HomeDir: homeDir, + }) + if err != nil { + return err + } + + for _, name := range result.Installed { + fmt.Fprintf(opts.IO.Out, "Installed %s (from %s) in %s\n", + name, opts.SkillSource, friendlyDir(result.Dir)) + } + + printFileTree(opts.IO.Out, cs, result.Dir, result.Installed) + printReviewHint(opts.IO.ErrOut, cs, "", result.Installed) + } + + return nil +} + +// isSkillPath returns true if the argument looks like a repo-relative path +// rather than a simple skill name. +func isSkillPath(name string) bool { + if name == "" { + return false + } + if name == "SKILL.md" || strings.HasSuffix(name, "/SKILL.md") { + return true + } + if strings.HasPrefix(name, "skills/") || strings.HasPrefix(name, "plugins/") { + return true + } + return false +} + +func resolveRepoArg(skillSource string, canPrompt bool, p prompter.Prompter) (ghrepo.Interface, string, error) { + if skillSource == "" { + if !canPrompt { + return nil, "", cmdutil.FlagErrorf("must specify a repository to install from") + } + repoInput, err := p.Input("Repository (owner/repo):", "") + if err != nil { + return nil, "", err + } + skillSource = strings.TrimSpace(repoInput) + if skillSource == "" { + return nil, "", fmt.Errorf("must specify a repository to install from") + } + } + repo, err := ghrepo.FromFullName(skillSource) + if err != nil { + return nil, "", cmdutil.FlagErrorf("invalid repository reference %q: expected OWNER/REPO, HOST/OWNER/REPO, or a full URL", skillSource) + } + return repo, skillSource, nil +} + +func parseSkillFromOpts(opts *installOptions) { + if opts.SkillName != "" { + if name, version, ok := cutLast(opts.SkillName, "@"); ok && name != "" { + opts.version = version + opts.SkillName = name + return + } + } + if opts.Pin != "" { + opts.version = opts.Pin + } +} + +// cutLast splits s around the last occurrence of sep, +// returning the text before and after sep, and whether sep was found. +func cutLast(s, sep string) (before, after string, found bool) { + if i := strings.LastIndex(s, sep); i >= 0 { + return s[:i], s[i+len(sep):], true + } + return s, "", false +} + +func resolveVersion(opts *installOptions, client discovery.RESTClient, hostname string) (*discovery.ResolvedRef, error) { + opts.IO.StartProgressIndicatorWithLabel("Resolving version") + resolved, err := discovery.ResolveRef(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), opts.version) + opts.IO.StopProgressIndicator() + if err != nil { + return nil, fmt.Errorf("could not resolve version: %w", err) + } + fmt.Fprintf(opts.IO.ErrOut, "Using ref %s (%s)\n", resolved.Ref, gitclient.TruncateSHA(resolved.SHA)) + return resolved, nil +} + +func discoverSkills(opts *installOptions, client discovery.RESTClient, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { + opts.IO.StartProgressIndicatorWithLabel("Discovering skills") + skills, err := discovery.DiscoverSkills(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA) + opts.IO.StopProgressIndicator() + if err != nil { + return nil, err + } + logConventions(opts.IO, skills) + for _, s := range skills { + if !discovery.IsSpecCompliant(s.Name) { + fmt.Fprintf(opts.IO.ErrOut, "Warning: skill %q does not follow the agentskills.io naming convention\n", s.DisplayName()) + } + } + sort.Slice(skills, func(i, j int) bool { + return skills[i].DisplayName() < skills[j].DisplayName() + }) + return skills, nil +} + +func logConventions(io *iostreams.IOStreams, skills []discovery.Skill) { + conventions := make(map[string]int) + for _, s := range skills { + conventions[s.Convention]++ + } + if n, ok := conventions["skills-namespaced"]; ok { + fmt.Fprintf(io.ErrOut, "Note: found %d namespaced skill(s) in skills/{author}/ directories\n", n) + } + if n, ok := conventions["plugins"]; ok { + fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) using the Claude Code plugins/ convention\n", n) + } + if n, ok := conventions["root"]; ok { + fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) at the repository root\n", n) + } +} + +// skillSelector holds the callbacks that differ between remote and local skill selection. +type skillSelector struct { + // matchByName resolves a skill name to matching skills. + matchByName func(opts *installOptions, skills []discovery.Skill) ([]discovery.Skill, error) + // sourceHint is shown in collision error guidance (e.g. "owner/repo" or "/path/to/skills"). + sourceHint string + // fetchDescriptions, if non-nil, is called before prompting to pre-populate descriptions. + fetchDescriptions func() +} + +func selectSkillsWithSelector(opts *installOptions, skills []discovery.Skill, canPrompt bool, sel skillSelector) ([]discovery.Skill, error) { + checkCollisions := func(ss []discovery.Skill) error { + return collisionError(ss, sel.sourceHint) + } + + if opts.All { + if err := checkCollisions(skills); err != nil { + return nil, err + } + return skills, nil + } + + if opts.SkillName != "" { + return sel.matchByName(opts, skills) + } + + if !canPrompt { + return nil, cmdutil.FlagErrorf("must specify a skill name or use --all when not running interactively") + } + + if sel.fetchDescriptions != nil { + sel.fetchDescriptions() + } + + tw := opts.IO.TerminalWidth() + descWidth := tw - 35 + if descWidth < 20 { + descWidth = 20 + } + + selected, err := opts.Prompter.MultiSelectWithSearch( + "Select skill(s) to install:", + "Filter skills", + nil, + []string{allSkillsKey}, + skillSearchFunc(skills, descWidth), + ) + if err != nil { + return nil, err + } + + if len(selected) == 0 { + return nil, fmt.Errorf("must select at least one skill") + } + + for _, s := range selected { + if s == allSkillsKey { + if err := checkCollisions(skills); err != nil { + return nil, err + } + return skills, nil + } + } + + result, err := matchSelectedSkills(skills, selected) + if err != nil { + return nil, err + } + return result, checkCollisions(result) +} + +func matchSkillByName(opts *installOptions, skills []discovery.Skill) ([]discovery.Skill, error) { + for _, s := range skills { + if s.DisplayName() == opts.SkillName { + return []discovery.Skill{s}, nil + } + } + + var matches []discovery.Skill + for _, s := range skills { + if s.Name == opts.SkillName { + matches = append(matches, s) + } + } + + switch len(matches) { + case 0: + return nil, fmt.Errorf("skill %q not found in %s", opts.SkillName, ghrepo.FullName(opts.repo)) + case 1: + return matches, nil + default: + names := make([]string, len(matches)) + for i, m := range matches { + names[i] = m.DisplayName() + } + return nil, fmt.Errorf( + "skill name %q is ambiguous — multiple matches found:\n %s\n Specify the full name (e.g. %s) to disambiguate", + opts.SkillName, strings.Join(names, "\n "), names[0], + ) + } +} + +func matchLocalSkillByName(opts *installOptions, skills []discovery.Skill) ([]discovery.Skill, error) { + for _, s := range skills { + if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName { + return []discovery.Skill{s}, nil + } + } + return nil, fmt.Errorf("skill %q not found in local directory", opts.SkillName) +} + +// skillSearchFunc returns a search function for MultiSelectWithSearch that +// filters skills by case-insensitive substring match on name and description. +func skillSearchFunc(skills []discovery.Skill, descWidth int) func(string) prompter.MultiSelectSearchResult { + return func(query string) prompter.MultiSelectSearchResult { + var matched []discovery.Skill + if query == "" { + matched = skills + } else { + q := strings.ToLower(query) + for _, s := range skills { + if strings.Contains(strings.ToLower(s.DisplayName()), q) || + strings.Contains(strings.ToLower(s.Description), q) { + matched = append(matched, s) + } + } + } + + more := 0 + if len(matched) > maxSearchResults { + more = len(matched) - maxSearchResults + matched = matched[:maxSearchResults] + } + + keys := make([]string, len(matched)) + labels := make([]string, len(matched)) + for i, s := range matched { + keys[i] = s.DisplayName() + if s.Description != "" { + labels[i] = fmt.Sprintf("%s — %s", s.DisplayName(), truncateDescription(s.Description, descWidth)) + } else { + labels[i] = s.DisplayName() + } + } + + return prompter.MultiSelectSearchResult{ + Keys: keys, + Labels: labels, + MoreResults: more, + } + } +} + +// matchSelectedSkills maps display names back to skill structs. +func matchSelectedSkills(skills []discovery.Skill, selected []string) ([]discovery.Skill, error) { + nameSet := make(map[string]struct{}, len(selected)) + for _, name := range selected { + nameSet[name] = struct{}{} + } + + var result []discovery.Skill + for _, s := range skills { + if _, ok := nameSet[s.DisplayName()]; ok { + result = append(result, s) + } + } + if len(result) == 0 { + return nil, fmt.Errorf("no matching skills found") + } + return result, nil +} + +// collisionError checks for name collisions and returns an error with +// guidance on how to install skills individually. +func collisionError(ss []discovery.Skill, sourceHint string) error { + collisions := skills.FindNameCollisions(ss) + if len(collisions) == 0 { + return nil + } + return errors.New(heredoc.Docf(` + cannot install skills with conflicting names — they would overwrite each other: + %s + Install these skills individually using the full name: + gh skills install %s namespace/skill-name + `, skills.FormatCollisions(collisions), sourceHint)) +} + +func resolveHosts(opts *installOptions, canPrompt bool) ([]*hosts.Host, error) { + if opts.Agent != "" { + h, err := hosts.FindByID(opts.Agent) + if err != nil { + return nil, err + } + return []*hosts.Host{h}, nil + } + + if !canPrompt { + h, err := hosts.FindByID("github-copilot") + if err != nil { + return nil, err + } + return []*hosts.Host{h}, nil + } + + fmt.Fprintln(opts.IO.ErrOut) + names := hosts.HostNames() + indices, err := opts.Prompter.MultiSelect("Select target agent(s):", []string{names[0]}, names) + if err != nil { + return nil, err + } + + if len(indices) == 0 { + return nil, fmt.Errorf("must select at least one target agent") + } + + selected := make([]*hosts.Host, len(indices)) + for i, idx := range indices { + selected[i] = &hosts.Registry[idx] + } + return selected, nil +} + +func resolveScope(opts *installOptions, canPrompt bool) (hosts.Scope, error) { + if opts.Dir != "" { + return hosts.Scope(opts.Scope), nil + } + + if opts.ScopeChanged || !canPrompt { + return hosts.Scope(opts.Scope), nil + } + + var repoName string + if remote, err := opts.GitClient.RemoteURL("origin"); err == nil { + repoName = hosts.RepoNameFromRemote(remote) + } + idx, err := opts.Prompter.Select("Installation scope:", "", hosts.ScopeLabels(repoName)) + if err != nil { + return "", err + } + if idx == 0 { + return hosts.ScopeProject, nil + } + return hosts.ScopeUser, nil +} + +func truncateDescription(s string, maxWidth int) string { + return text.Truncate(maxWidth, text.RemoveExcessiveWhitespace(s)) +} + +func checkOverwrite(opts *installOptions, skills []discovery.Skill, host *hosts.Host, scope hosts.Scope, gitRoot, homeDir string, canPrompt bool) ([]discovery.Skill, error) { + targetDir := opts.Dir + if targetDir == "" { + var err error + targetDir, err = host.InstallDir(scope, gitRoot, homeDir) + if err != nil { + return nil, err + } + } + + var existing, fresh []discovery.Skill + for _, s := range skills { + dir := filepath.Join(targetDir, filepath.FromSlash(s.InstallName())) + if _, err := os.Stat(dir); err == nil { + existing = append(existing, s) + } else { + fresh = append(fresh, s) + } + } + + if len(existing) == 0 { + return skills, nil + } + + if opts.Force { + return skills, nil + } + + if !canPrompt { + names := make([]string, len(existing)) + for i, s := range existing { + names[i] = s.DisplayName() + } + return nil, fmt.Errorf("skills already installed: %s (use --force to overwrite)", strings.Join(names, ", ")) + } + + var confirmed []discovery.Skill + for _, s := range existing { + prompt := existingSkillPrompt(targetDir, s) + ok, err := opts.Prompter.Confirm(prompt, false) + if err != nil { + return nil, err + } + if ok { + confirmed = append(confirmed, s) + } else { + fmt.Fprintf(opts.IO.ErrOut, "Skipping %s\n", s.DisplayName()) + } + } + + return append(fresh, confirmed...), nil +} + +func existingSkillPrompt(targetDir string, incoming discovery.Skill) string { + skillFile := filepath.Join(targetDir, filepath.FromSlash(incoming.InstallName()), "SKILL.md") + data, err := os.ReadFile(skillFile) + if err != nil { + return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) + } + + result, err := frontmatter.Parse(string(data)) + if err != nil || result.Metadata.Meta == nil { + return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) + } + + owner, _ := result.Metadata.Meta["github-owner"].(string) + repo, _ := result.Metadata.Meta["github-repo"].(string) + ref, _ := result.Metadata.Meta["github-ref"].(string) + + if owner != "" && repo != "" { + source := owner + "/" + repo + if ref != "" { + source += "@" + ref + } + return fmt.Sprintf("Skill %q already installed from %s. Overwrite?", incoming.DisplayName(), source) + } + + return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) +} + +func installProgress(io *iostreams.IOStreams, total int) func(done, total int) { + if total <= 1 { + return nil + } + return func(done, total int) { + if done == 0 { + io.StartProgressIndicator() + } else if done >= total { + io.StopProgressIndicator() + } + } +} + +func friendlyDir(dir string) string { + if cwd, err := os.Getwd(); err == nil { + if rel, err := filepath.Rel(cwd, dir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + if rel == "." { + return filepath.Base(dir) + } + return rel + } + } + if home, err := os.UserHomeDir(); err == nil && (dir == home || strings.HasPrefix(dir, home+string(filepath.Separator))) { + return "~" + dir[len(home):] + } + return dir +} + +// printFileTree renders a text tree of the on-disk contents of each skill directory. +func printFileTree(w io.Writer, cs *iostreams.ColorScheme, dir string, skillNames []string) { + if len(skillNames) == 0 { + return + } + fmt.Fprintln(w) + for _, name := range skillNames { + skillDir := filepath.Join(dir, filepath.FromSlash(name)) + fmt.Fprintf(w, " %s\n", cs.Bold(name+"/")) + printTreeDir(w, cs, skillDir, " ") + } +} + +func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) { + entries, err := os.ReadDir(dir) + if err != nil { + fmt.Fprintf(w, "%s%s\n", indent, cs.Gray("(could not read directory)")) + return + } + for i, entry := range entries { + isLast := i == len(entries)-1 + connector := "├── " + childIndent := "│ " + if isLast { + connector = "└── " + childIndent = " " + } + name := entry.Name() + if entry.IsDir() { + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), cs.Bold(name+"/")) + printTreeDir(w, cs, filepath.Join(dir, name), indent+cs.Gray(childIndent)) + } else { + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), name) + } + } +} + +// printReviewHint warns the user to review installed skills and suggests preview commands. +func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo string, skillNames []string) { + if len(skillNames) == 0 { + return + } + fmt.Fprintf(w, "\n%s Skills may contain prompt injections or malicious scripts.\n", cs.WarningIcon()) + if repo == "" { + fmt.Fprintln(w, " Review the installed files before use.") + return + } + fmt.Fprintln(w, " Review installed content before use:") + fmt.Fprintln(w) + for _, name := range skillNames { + fmt.Fprintf(w, " gh skills preview %s %s\n", repo, name) + } + fmt.Fprintln(w) +} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go new file mode 100644 index 00000000000..f53fd2267d9 --- /dev/null +++ b/pkg/cmd/skills/install/install_test.go @@ -0,0 +1,917 @@ +package install + +import ( + "encoding/base64" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/gitclient" + "github.com/cli/cli/v2/internal/skills/hosts" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockGitClient implements installGitClient for testing. +type mockGitClient struct { + root string + remote string + err error +} + +func (m *mockGitClient) ToplevelDir() (string, error) { + if m.err != nil { + return "", m.err + } + return m.root, nil +} + +func (m *mockGitClient) RemoteURL(_ string) (string, error) { + if m.err != nil { + return "", m.err + } + return m.remote, nil +} + +func TestNewCmdInstall_Help(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{}, + } + + cmd := NewCmdInstall(f, func(opts *installOptions) error { + return nil + }) + + assert.Equal(t, "install []", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + assert.NotEmpty(t, cmd.Example) +} + +func TestNewCmdInstall_Alias(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) + assert.Contains(t, cmd.Aliases, "add") +} + +func TestNewCmdInstall_Flags(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) + + flags := []string{"agent", "scope", "pin", "all", "dir", "force"} + for _, name := range flags { + assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag: --%s", name) + } +} + +func TestNewCmdInstall_MaxArgs(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) + + cmd.SetArgs([]string{"a", "b", "c"}) + err := cmd.Execute() + assert.Error(t, err) +} + +func TestResolveRepoArg(t *testing.T) { + tests := []struct { + input string + owner string + repo string + wantErr bool + }{ + {"github/awesome-copilot", "github", "awesome-copilot", false}, + {"owner/repo", "owner", "repo", false}, + {"a/b", "a", "b", false}, + {"https://github.com/owner/repo", "owner", "repo", false}, + {"https://github.com/owner/repo.git", "owner", "repo", false}, + {"invalid", "", "", true}, + {"", "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + repo, _, err := resolveRepoArg(tt.input, false, nil) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.owner, repo.RepoOwner()) + assert.Equal(t, tt.repo, repo.RepoName()) + }) + } +} + +func TestParseSkillFromOpts(t *testing.T) { + tests := []struct { + name string + skillName string + pin string + wantName string + wantVer string + }{ + { + name: "name with version", + skillName: "git-commit@v1.2.0", + wantName: "git-commit", + wantVer: "v1.2.0", + }, + { + name: "name without version", + skillName: "git-commit", + wantName: "git-commit", + wantVer: "", + }, + { + name: "inline version takes precedence over pin", + skillName: "git-commit@v1.0.0", + pin: "v2.0.0", + wantName: "git-commit", + wantVer: "v1.0.0", + }, + { + name: "pin flag alone", + skillName: "git-commit", + pin: "v3.0.0", + wantName: "git-commit", + wantVer: "v3.0.0", + }, + { + name: "empty", + skillName: "", + wantName: "", + wantVer: "", + }, + { + name: "@ at start is not version", + skillName: "@foo", + wantName: "@foo", + wantVer: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &installOptions{SkillName: tt.skillName, Pin: tt.pin} + parseSkillFromOpts(opts) + assert.Equal(t, tt.wantName, opts.SkillName) + assert.Equal(t, tt.wantVer, opts.version) + }) + } +} + +func TestInstallRun_NonInteractive_NoRepo(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + opts := &installOptions{ + IO: ios, + GitClient: &mockGitClient{root: "/tmp", remote: ""}, + } + + err := installRun(opts) + assert.Error(t, err) + assert.Equal(t, "must specify a repository to install from", err.Error()) +} + +func TestInstallRun_NonInteractive_NoSkill(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + opts := &installOptions{IO: ios, repo: ghrepo.New("o", "r")} + skills := []discovery.Skill{{Name: "test-skill", Path: "skills/test-skill"}} + _, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "must specify a skill name or use --all") +} + +func TestSelectSkills_All(t *testing.T) { + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{ + {Name: "a"}, + {Name: "b"}, + } + opts := &installOptions{All: true, IO: ios, repo: ghrepo.New("o", "r")} + got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) + require.NoError(t, err) + assert.Len(t, got, 2) +} + +func TestSelectSkills_ByName(t *testing.T) { + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{ + {Name: "alpha"}, + {Name: "beta"}, + } + opts := &installOptions{SkillName: "beta", IO: ios, repo: ghrepo.New("o", "r")} + got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) + require.NoError(t, err) + assert.Len(t, got, 1) + assert.Equal(t, "beta", got[0].Name) +} + +func TestSelectSkills_NotFound(t *testing.T) { + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{ + {Name: "alpha"}, + } + opts := &installOptions{SkillName: "nonexistent", IO: ios, repo: ghrepo.New("o", "r")} + _, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) + assert.Error(t, err) +} + +func TestSkillSearchFunc_EmptyQuery(t *testing.T) { + skills := []discovery.Skill{ + {Name: "alpha", Description: "first skill"}, + {Name: "beta", Description: "second skill"}, + } + fn := skillSearchFunc(skills, 40) + result := fn("") + assert.Nil(t, result.Err) + assert.Len(t, result.Keys, 2) + assert.Equal(t, "alpha", result.Keys[0]) + assert.Equal(t, "beta", result.Keys[1]) + assert.Equal(t, 0, result.MoreResults) +} + +func TestSkillSearchFunc_FilterByName(t *testing.T) { + skills := []discovery.Skill{ + {Name: "git-commit"}, + {Name: "code-review"}, + {Name: "git-push"}, + } + fn := skillSearchFunc(skills, 40) + result := fn("git") + assert.Nil(t, result.Err) + assert.Len(t, result.Keys, 2) + assert.Equal(t, "git-commit", result.Keys[0]) + assert.Equal(t, "git-push", result.Keys[1]) +} + +func TestSkillSearchFunc_FilterByDescription(t *testing.T) { + skills := []discovery.Skill{ + {Name: "alpha", Description: "handles authentication"}, + {Name: "beta", Description: "builds docker images"}, + } + fn := skillSearchFunc(skills, 40) + result := fn("docker") + assert.Nil(t, result.Err) + assert.Len(t, result.Keys, 1) + assert.Equal(t, "beta", result.Keys[0]) +} + +func TestSkillSearchFunc_CaseInsensitive(t *testing.T) { + skills := []discovery.Skill{ + {Name: "Git-Commit"}, + } + fn := skillSearchFunc(skills, 40) + result := fn("GIT") + assert.Nil(t, result.Err) + assert.Len(t, result.Keys, 1) +} + +func TestSkillSearchFunc_MoreResults(t *testing.T) { + skills := make([]discovery.Skill, 50) + for i := range skills { + skills[i] = discovery.Skill{Name: fmt.Sprintf("skill-%d", i)} + } + fn := skillSearchFunc(skills, 40) + result := fn("") + assert.Equal(t, maxSearchResults, len(result.Keys)) + assert.Equal(t, 50-maxSearchResults, result.MoreResults) +} + +func TestMatchSelectedSkills(t *testing.T) { + skills := []discovery.Skill{ + {Name: "alpha"}, + {Name: "beta"}, + {Name: "gamma"}, + } + got, err := matchSelectedSkills(skills, []string{"alpha", "gamma"}) + require.NoError(t, err) + assert.Len(t, got, 2) + assert.Equal(t, "alpha", got[0].Name) + assert.Equal(t, "gamma", got[1].Name) +} + +func TestMatchSelectedSkills_NoMatch(t *testing.T) { + skills := []discovery.Skill{{Name: "alpha"}} + _, err := matchSelectedSkills(skills, []string{"nonexistent"}) + assert.Error(t, err) +} + +func TestResolveHosts_ByFlag(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &installOptions{Agent: "claude-code", IO: ios} + hosts, err := resolveHosts(opts, false) + require.NoError(t, err) + assert.Len(t, hosts, 1) + assert.Equal(t, "claude-code", hosts[0].ID) +} + +func TestResolveHosts_InvalidFlag(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &installOptions{Agent: "nonexistent", IO: ios} + _, err := resolveHosts(opts, false) + assert.Error(t, err) +} + +func TestResolveHosts_DefaultNonInteractive(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &installOptions{IO: ios} + hosts, err := resolveHosts(opts, false) + require.NoError(t, err) + assert.Len(t, hosts, 1) + assert.Equal(t, "github-copilot", hosts[0].ID) +} + +func TestResolveHosts_MultiSelect(t *testing.T) { + ios, _, _, _ := iostreams.Test() + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(_ string, _ []string, _ []string) ([]int, error) { + return []int{0, 1}, nil + }, + } + opts := &installOptions{IO: ios, Prompter: pm} + hosts, err := resolveHosts(opts, true) + require.NoError(t, err) + assert.Len(t, hosts, 2) +} + +func TestResolveHosts_NoneSelected(t *testing.T) { + ios, _, _, _ := iostreams.Test() + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(_ string, _ []string, _ []string) ([]int, error) { + return []int{}, nil + }, + } + opts := &installOptions{IO: ios, Prompter: pm} + _, err := resolveHosts(opts, true) + assert.Error(t, err) +} + +func TestTruncateSHA(t *testing.T) { + assert.Equal(t, "abc123de", gitclient.TruncateSHA("abc123def456")) + assert.Equal(t, "short", gitclient.TruncateSHA("short")) +} + +func TestTruncateDescription(t *testing.T) { + tests := []struct { + name string + input string + maxWidth int + }{ + {"short stays short", "A short description", 60}, + {"newlines collapsed", "Line one.\nLine two.\nLine three.", 60}, + {"excessive whitespace", " lots of spaces ", 60}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateDescription(tt.input, tt.maxWidth) + assert.NotContains(t, got, "\n") + }) + } + + long := "Execute git commit with conventional commit message analysis and intelligent staging" + got := truncateDescription(long, 30) + assert.LessOrEqual(t, len(got), 33) // allow room for ellipsis +} + +func TestIsLocalPath(t *testing.T) { + tests := []struct { + arg string + want bool + }{ + {".", true}, + {"./skills", true}, + {"../other", true}, + {"/tmp/skills", true}, + {"~/skills", true}, + {"github/awesome-copilot", false}, + {"owner/repo", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.arg, func(t *testing.T) { + got := isLocalPath(tt.arg) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsSkillPath(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {"skills/test-skill", true}, + {"skills/author/skill", true}, + {"plugins/author/skills/skill", true}, + {"skills/author/skill/SKILL.md", true}, + {"git-commit", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isSkillPath(tt.name)) + }) + } +} + +func TestRunLocalInstall_NonInteractive(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "test-local") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + content := "---\nname: test-local\ndescription: A local skill\n---\n# Test\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + targetDir := t.TempDir() + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetColorEnabled(false) + + opts := &installOptions{ + IO: ios, + SkillSource: dir, + localPath: dir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + Dir: targetDir, + GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + } + + err := installRun(opts) + require.NoError(t, err) + + assert.Contains(t, stdout.String(), "Installed test-local") + + installed, err := os.ReadFile(filepath.Join(targetDir, "test-local", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(installed), "local-path") +} + +func TestRunLocalInstall_SingleSkillDir(t *testing.T) { + dir := t.TempDir() + content := "---\nname: direct-skill\ndescription: Direct\n---\n# Direct\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644)) + + targetDir := t.TempDir() + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetColorEnabled(false) + + opts := &installOptions{ + IO: ios, + SkillSource: dir, + localPath: dir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + Dir: targetDir, + GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + } + + err := installRun(opts) + require.NoError(t, err) + + assert.Contains(t, stdout.String(), "Installed direct-skill") +} + +func TestCollisionError(t *testing.T) { + t.Run("no collisions", func(t *testing.T) { + skills := []discovery.Skill{ + {Name: "a"}, + {Name: "b"}, + } + assert.NoError(t, collisionError(skills, "REPO")) + }) + + t.Run("no collisions with different namespaces", func(t *testing.T) { + skills := []discovery.Skill{ + {Name: "xlsx-pro", Namespace: "author1"}, + {Name: "xlsx-pro", Namespace: "author2"}, + } + assert.NoError(t, collisionError(skills, "REPO")) + }) + + t.Run("has collisions same name no namespace", func(t *testing.T) { + skills := []discovery.Skill{ + {Name: "xlsx-pro", Convention: "skills"}, + {Name: "xlsx-pro", Convention: "root"}, + } + err := collisionError(skills, "REPO") + assert.Error(t, err) + assert.Contains(t, err.Error(), "conflicting names") + assert.Contains(t, err.Error(), "gh skills install REPO") + }) + + t.Run("local source hint", func(t *testing.T) { + skills := []discovery.Skill{ + {Name: "xlsx-pro", Convention: "skills"}, + {Name: "xlsx-pro", Convention: "root"}, + } + err := collisionError(skills, "PATH") + assert.Error(t, err) + assert.Contains(t, err.Error(), "conflicting names") + assert.Contains(t, err.Error(), "gh skills install PATH") + }) +} + +func TestMatchSkillByName_Ambiguous(t *testing.T) { + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{ + {Name: "xlsx-pro", Namespace: "alice"}, + {Name: "xlsx-pro", Namespace: "bob"}, + } + opts := &installOptions{SkillName: "xlsx-pro", IO: ios, repo: ghrepo.New("o", "r")} + _, err := matchSkillByName(opts, skills) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ambiguous") +} + +func TestMatchSkillByName_NamespacedExact(t *testing.T) { + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{ + {Name: "xlsx-pro", Namespace: "alice"}, + {Name: "xlsx-pro", Namespace: "bob"}, + } + opts := &installOptions{SkillName: "bob/xlsx-pro", IO: ios, repo: ghrepo.New("o", "r")} + got, err := matchSkillByName(opts, skills) + require.NoError(t, err) + assert.Len(t, got, 1) + assert.Equal(t, "bob", got[0].Namespace) +} + +func TestFriendlyDir(t *testing.T) { + // Test home directory path + home, err := os.UserHomeDir() + require.NoError(t, err) + got := friendlyDir(filepath.Join(home, ".github", "skills")) + assert.True(t, strings.HasPrefix(got, "~"), "expected ~ prefix, got %q", got) +} + +func TestResolveScope_ExplicitFlag(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &installOptions{ + IO: ios, + Scope: "user", + ScopeChanged: true, + GitClient: &mockGitClient{root: "/tmp", remote: ""}, + } + scope, err := resolveScope(opts, true) + require.NoError(t, err) + assert.Equal(t, "user", string(scope)) +} + +func TestResolveScope_DirBypasses(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &installOptions{ + IO: ios, + Dir: "/tmp/custom", + Scope: "project", + GitClient: &mockGitClient{root: "/tmp", remote: ""}, + } + scope, err := resolveScope(opts, true) + require.NoError(t, err) + assert.Equal(t, "project", string(scope)) +} + +func TestCheckOverwrite_NoExisting(t *testing.T) { + ios, _, _, _ := iostreams.Test() + targetDir := t.TempDir() + skills := []discovery.Skill{{Name: "new-skill"}} + host := &hosts.Host{ID: "test", ProjectDir: "skills"} + opts := &installOptions{IO: ios, Dir: targetDir} + + got, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + require.NoError(t, err) + assert.Len(t, got, 1) +} + +func TestCheckOverwrite_ExistingWithForce(t *testing.T) { + targetDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "existing-skill"), 0o755)) + + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{{Name: "existing-skill"}} + host := &hosts.Host{ID: "test", ProjectDir: "skills"} + opts := &installOptions{IO: ios, Dir: targetDir, Force: true} + + got, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + require.NoError(t, err) + assert.Len(t, got, 1) +} + +func TestCheckOverwrite_ExistingNonInteractive(t *testing.T) { + targetDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "existing-skill"), 0o755)) + + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{{Name: "existing-skill"}} + host := &hosts.Host{ID: "test", ProjectDir: "skills"} + opts := &installOptions{IO: ios, Dir: targetDir} + + _, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already installed") +} + +func TestNewCmdInstall(t *testing.T) { + tests := []struct { + name string + input string + wantOpts installOptions + wantErr bool + }{ + { + name: "repo argument only", + input: "owner/repo", + wantOpts: installOptions{SkillSource: "owner/repo", Scope: "project"}, + }, + { + name: "repo and skill", + input: "owner/repo my-skill", + wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Scope: "project"}, + }, + { + name: "with all flags", + input: "owner/repo my-skill --agent github-copilot --scope user --pin v1.0.0 --force", + wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Agent: "github-copilot", Scope: "user", Pin: "v1.0.0", Force: true}, + }, + { + name: "all flag", + input: "owner/repo --all", + wantOpts: installOptions{SkillSource: "owner/repo", All: true, Scope: "project"}, + }, + { + name: "dir flag", + input: "owner/repo my-skill --dir /tmp/skills", + wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Dir: "/tmp/skills", Scope: "project"}, + }, + { + name: "too many args", + input: "a b c", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{}, + } + + var gotOpts *installOptions + cmd := NewCmdInstall(f, func(opts *installOptions) error { + gotOpts = opts + return nil + }) + + args, err := shlex.Split(tt.input) + require.NoError(t, err) + cmd.SetArgs(args) + cmd.SetIn(&strings.Reader{}) + cmd.SetOut(&strings.Builder{}) + cmd.SetErr(&strings.Builder{}) + + _, err = cmd.ExecuteC() + if tt.wantErr { + assert.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantOpts.SkillSource, gotOpts.SkillSource) + assert.Equal(t, tt.wantOpts.SkillName, gotOpts.SkillName) + assert.Equal(t, tt.wantOpts.Agent, gotOpts.Agent) + assert.Equal(t, tt.wantOpts.Scope, gotOpts.Scope) + assert.Equal(t, tt.wantOpts.Pin, gotOpts.Pin) + assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) + assert.Equal(t, tt.wantOpts.All, gotOpts.All) + assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) + }) + } +} + +func TestInstallRun_RemoteInstall(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + skillContent := "---\nname: test-skill\ndescription: A test\n---\n# Test\n" + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{"sha": "abc123", "tree": [{"path": "skills/test-skill", "type": "tree", "sha": "treeSHA"}, {"path": "skills/test-skill/SKILL.md", "type": "blob", "sha": "blobSHA"}]}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), + httpmock.StringResponse(`{"tree": [{"path": "SKILL.md", "type": "blob", "sha": "blobSHA", "size": 50}]}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSHA"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobSHA", "content": "%s", "encoding": "base64"}`, encodedContent)), + ) + + targetDir := t.TempDir() + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetColorEnabled(false) + + opts := &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + SkillSource: "owner/repo", + SkillName: "test-skill", + Agent: "github-copilot", + Scope: "project", + Dir: targetDir, + } + + defer reg.Verify(t) + err := installRun(opts) + require.NoError(t, err) + + assert.Contains(t, stdout.String(), "Installed test-skill") + + installed, readErr := os.ReadFile(filepath.Join(targetDir, "test-skill", "SKILL.md")) + require.NoError(t, readErr) + assert.Contains(t, string(installed), "github-owner: owner") + assert.Contains(t, string(installed), "github-repo: repo") +} + +func TestPrintFileTree(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "my-skill") + require.NoError(t, os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# test"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "scripts", "run.sh"), []byte("#!/bin/bash"), 0o644)) + + ios, _, stdout, _ := iostreams.Test() + cs := ios.ColorScheme() + + printFileTree(stdout, cs, dir, []string{"my-skill"}) + + out := stdout.String() + assert.Contains(t, out, "my-skill/") + assert.Contains(t, out, "SKILL.md") + assert.Contains(t, out, "scripts/") + assert.Contains(t, out, "run.sh") +} + +func TestPrintFileTree_Empty(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + cs := ios.ColorScheme() + + printFileTree(stdout, cs, t.TempDir(), nil) + assert.Empty(t, stdout.String()) +} + +func TestPrintTreeDir_Unreadable(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + cs := ios.ColorScheme() + + printTreeDir(stdout, cs, filepath.Join(t.TempDir(), "nonexistent"), " ") + assert.Contains(t, stdout.String(), "(could not read directory)") +} + +func TestPrintReviewHint_Remote(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + cs := ios.ColorScheme() + + printReviewHint(stderr, cs, "owner/repo", []string{"my-skill", "other-skill"}) + + out := stderr.String() + assert.Contains(t, out, "prompt injections or malicious scripts") + assert.Contains(t, out, "gh skills preview owner/repo my-skill") + assert.Contains(t, out, "gh skills preview owner/repo other-skill") +} + +func TestPrintReviewHint_Local(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + cs := ios.ColorScheme() + + printReviewHint(stderr, cs, "", []string{"my-skill"}) + + out := stderr.String() + assert.Contains(t, out, "prompt injections or malicious scripts") + assert.Contains(t, out, "Review the installed files before use.") + assert.NotContains(t, out, "gh skills preview") +} + +func TestPrintReviewHint_Empty(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + cs := ios.ColorScheme() + + printReviewHint(stderr, cs, "owner/repo", nil) + assert.Empty(t, stderr.String()) +} + +func TestSelectSkills_AllWithNamespacedSkills(t *testing.T) { + ios, _, _, _ := iostreams.Test() + skills := []discovery.Skill{ + {Name: "xlsx-pro", Namespace: "alice", Convention: "skills-namespaced"}, + {Name: "xlsx-pro", Namespace: "bob", Convention: "skills-namespaced"}, + {Name: "other-skill", Convention: "skills"}, + } + opts := &installOptions{All: true, IO: ios, repo: ghrepo.New("o", "r")} + got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) + require.NoError(t, err) + assert.Len(t, got, 3) +} + +func TestRunLocalInstall_NamespacedSkills(t *testing.T) { + dir := t.TempDir() + + // Create two skills with the same name under different namespaces + for _, ns := range []string{"alice", "bob"} { + skillDir := filepath.Join(dir, "skills", ns, "xlsx-pro") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + content := fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + } + + targetDir := t.TempDir() + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetColorEnabled(false) + + opts := &installOptions{ + IO: ios, + SkillSource: dir, + localPath: dir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + Dir: targetDir, + GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + } + + err := installRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "Installed alice/xlsx-pro") + assert.Contains(t, out, "Installed bob/xlsx-pro") + + // Both should be installed in separate directories + _, err = os.Stat(filepath.Join(targetDir, "alice", "xlsx-pro", "SKILL.md")) + assert.NoError(t, err, "alice/xlsx-pro should be installed") + _, err = os.Stat(filepath.Join(targetDir, "bob", "xlsx-pro", "SKILL.md")) + assert.NoError(t, err, "bob/xlsx-pro should be installed") +} + +func TestCheckOverwrite_NamespacedSkill(t *testing.T) { + ios, _, _, _ := iostreams.Test() + targetDir := t.TempDir() + + // Pre-create a namespaced skill directory + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "alice", "xlsx-pro"), 0o755)) + + skills := []discovery.Skill{ + {Name: "xlsx-pro", Namespace: "alice"}, + {Name: "xlsx-pro", Namespace: "bob"}, + } + host := &hosts.Host{ID: "test", ProjectDir: "skills"} + opts := &installOptions{IO: ios, Dir: targetDir, Force: true} + + got, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + require.NoError(t, err) + assert.Len(t, got, 2, "both skills should be installable (force mode)") +} diff --git a/pkg/cmd/skills/install/install_windows_test.go b/pkg/cmd/skills/install/install_windows_test.go new file mode 100644 index 00000000000..8a184fac497 --- /dev/null +++ b/pkg/cmd/skills/install/install_windows_test.go @@ -0,0 +1,63 @@ +//go:build windows + +package install + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsLocalPath_Windows(t *testing.T) { + tests := []struct { + name string + arg string + want bool + }{ + // Backslash-relative paths that only exist on Windows. + {`dot-backslash prefix`, `.\skills`, true}, + {`dotdot-backslash prefix`, `..\other`, true}, + {`drive-absolute path`, `C:\Users\me\skills`, true}, + {`drive-relative path`, `D:\projects`, true}, + {`UNC path`, `\\server\share\skills`, true}, + + // Forward-slash forms should still work on Windows. + {`dot-slash prefix`, `./skills`, true}, + {`dotdot-slash prefix`, `../other`, true}, + {`current dir`, `.`, true}, + {`absolute unix-style`, `/tmp/skills`, true}, + {`tilde prefix`, `~/skills`, true}, + + // owner/repo should never be treated as local. + {`owner-repo`, `github/awesome-copilot`, false}, + {`simple name`, `awesome-copilot`, false}, + {`empty string`, ``, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isLocalPath(tt.arg) + assert.Equal(t, tt.want, got, "isLocalPath(%q)", tt.arg) + }) + } +} + +func TestIsLocalPath_WindowsExistingDir(t *testing.T) { + // A directory that exists on disk should be detected as local even when + // its name looks like owner/repo (the os.Stat safety-net). + dir := t.TempDir() + nested := filepath.Join(dir, "owner", "repo") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + // Use a relative path that happens to contain a backslash separator. + rel, err := filepath.Rel(".", nested) + if err != nil { + // If we can't compute a relative path, just use the absolute one. + rel = nested + } + assert.True(t, isLocalPath(rel), "existing dir should be detected as local: %s", rel) +} diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index e3f1c286f7b..61afc12a4d8 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -1,6 +1,7 @@ package skills import ( + "github.com/cli/cli/v2/pkg/cmd/skills/install" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -14,5 +15,7 @@ func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { GroupID: "core", } + cmd.AddCommand(install.NewCmdInstall(f, nil)) + return cmd } From 40b2a784e3cddbf1419df4aa24b9ac4a6d44b518 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Mon, 30 Mar 2026 21:35:23 +0100 Subject: [PATCH 006/182] add core logic and improve test coverage --- .../skills/skills-install-alias.txtar | 3 - .../skills/skills-install-conflict.txtar | 8 - .../skills/skills-install-invalid-agent.txtar | 4 + .../skills/skills-install-invalid-repo.txtar | 3 + .../skills/skills-install-nested-files.txtar | 3 + .../skills-install-nonexistent-skill.txtar | 3 + .../skills/skills-install-scope.txtar | 12 +- .../testdata/skills/skills-install.txtar | 10 + .../skills-preview-noninteractive.txtar | 3 + .../testdata/skills/skills-preview.txtar | 9 + .../skills/skills-publish-dry-run.txtar | 33 + .../skills/skills-publish-lifecycle.txtar | 64 + .../skills/skills-search-noresults.txtar | 4 + .../testdata/skills/skills-search-page.txtar | 3 + .../testdata/skills/skills-search.txtar | 12 + .../skills/skills-update-noinstalled.txtar | 5 + .../testdata/skills/skills-update.txtar | 24 + git/client.go | 31 + internal/skills/{ => discovery}/collisions.go | 8 +- internal/skills/discovery/collisions_test.go | 62 + internal/skills/discovery/discovery.go | 147 +- internal/skills/discovery/discovery_test.go | 342 ++++- internal/skills/gitclient/gitclient.go | 149 -- internal/skills/gitclient/gitclient_test.go | 49 - internal/skills/hosts/hosts_test.go | 113 -- internal/skills/installer/installer.go | 55 +- internal/skills/installer/installer_test.go | 338 +++++ internal/skills/lockfile/lockfile.go | 42 +- internal/skills/lockfile/lockfile_test.go | 193 +++ .../{hosts/hosts.go => registry/registry.go} | 62 +- internal/skills/registry/registry_test.go | 153 ++ pkg/cmd/skills/install/install.go | 108 +- pkg/cmd/skills/install/install_test.go | 59 +- pkg/cmd/skills/preview/preview.go | 382 +++++ pkg/cmd/skills/preview/preview_test.go | 466 ++++++ pkg/cmd/skills/publish/publish.go | 1246 +++++++++++++++++ pkg/cmd/skills/publish/publish_test.go | 1059 ++++++++++++++ pkg/cmd/skills/search/search.go | 873 ++++++++++++ pkg/cmd/skills/search/search_test.go | 423 ++++++ pkg/cmd/skills/skills.go | 8 + pkg/cmd/skills/update/update.go | 560 ++++++++ pkg/cmd/skills/update/update_test.go | 391 ++++++ 42 files changed, 6849 insertions(+), 673 deletions(-) delete mode 100644 acceptance/testdata/skills/skills-install-alias.txtar delete mode 100644 acceptance/testdata/skills/skills-install-conflict.txtar create mode 100644 acceptance/testdata/skills/skills-install-invalid-agent.txtar create mode 100644 acceptance/testdata/skills/skills-install-invalid-repo.txtar create mode 100644 acceptance/testdata/skills/skills-install-nested-files.txtar create mode 100644 acceptance/testdata/skills/skills-install-nonexistent-skill.txtar create mode 100644 acceptance/testdata/skills/skills-preview-noninteractive.txtar create mode 100644 acceptance/testdata/skills/skills-preview.txtar create mode 100644 acceptance/testdata/skills/skills-publish-dry-run.txtar create mode 100644 acceptance/testdata/skills/skills-publish-lifecycle.txtar create mode 100644 acceptance/testdata/skills/skills-search-noresults.txtar create mode 100644 acceptance/testdata/skills/skills-search-page.txtar create mode 100644 acceptance/testdata/skills/skills-search.txtar create mode 100644 acceptance/testdata/skills/skills-update-noinstalled.txtar create mode 100644 acceptance/testdata/skills/skills-update.txtar rename internal/skills/{ => discovery}/collisions.go (89%) create mode 100644 internal/skills/discovery/collisions_test.go delete mode 100644 internal/skills/gitclient/gitclient.go delete mode 100644 internal/skills/gitclient/gitclient_test.go delete mode 100644 internal/skills/hosts/hosts_test.go create mode 100644 internal/skills/installer/installer_test.go create mode 100644 internal/skills/lockfile/lockfile_test.go rename internal/skills/{hosts/hosts.go => registry/registry.go} (72%) create mode 100644 internal/skills/registry/registry_test.go create mode 100644 pkg/cmd/skills/preview/preview.go create mode 100644 pkg/cmd/skills/preview/preview_test.go create mode 100644 pkg/cmd/skills/publish/publish.go create mode 100644 pkg/cmd/skills/publish/publish_test.go create mode 100644 pkg/cmd/skills/search/search.go create mode 100644 pkg/cmd/skills/search/search_test.go create mode 100644 pkg/cmd/skills/update/update.go create mode 100644 pkg/cmd/skills/update/update_test.go diff --git a/acceptance/testdata/skills/skills-install-alias.txtar b/acceptance/testdata/skills/skills-install-alias.txtar deleted file mode 100644 index 089474b3ace..00000000000 --- a/acceptance/testdata/skills/skills-install-alias.txtar +++ /dev/null @@ -1,3 +0,0 @@ -# Install with "add" alias -exec gh skills add github/awesome-copilot git-commit --scope user --force --agent github-copilot -stdout 'Installed git-commit' diff --git a/acceptance/testdata/skills/skills-install-conflict.txtar b/acceptance/testdata/skills/skills-install-conflict.txtar deleted file mode 100644 index 9e79e5a5e9e..00000000000 --- a/acceptance/testdata/skills/skills-install-conflict.txtar +++ /dev/null @@ -1,8 +0,0 @@ -# Install --all should handle skills with same name across conventions -# (skills/ and plugins/ directories) without collision errors -exec gh skills install github/awesome-copilot --all --force --dir $WORK/scope-test --agent github-copilot -stdout 'Installed' -! stderr 'conflicting names' - -# Verify skills were installed successfully -exists $WORK/scope-test/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install-invalid-agent.txtar b/acceptance/testdata/skills/skills-install-invalid-agent.txtar new file mode 100644 index 00000000000..23883524fa9 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-invalid-agent.txtar @@ -0,0 +1,4 @@ +# Invalid agent ID should error with valid options +! exec gh skills install github/awesome-copilot git-commit --agent bogus-agent --force +stderr 'unknown agent' +stderr 'github-copilot' diff --git a/acceptance/testdata/skills/skills-install-invalid-repo.txtar b/acceptance/testdata/skills/skills-install-invalid-repo.txtar new file mode 100644 index 00000000000..26ecbc718de --- /dev/null +++ b/acceptance/testdata/skills/skills-install-invalid-repo.txtar @@ -0,0 +1,3 @@ +# Nonexistent repo should error +! exec gh skills install nonexistent-owner-xyz/nonexistent-repo-abc --force --dir $WORK/tmp +stderr 'Not Found' diff --git a/acceptance/testdata/skills/skills-install-nested-files.txtar b/acceptance/testdata/skills/skills-install-nested-files.txtar new file mode 100644 index 00000000000..c5cf19e566f --- /dev/null +++ b/acceptance/testdata/skills/skills-install-nested-files.txtar @@ -0,0 +1,3 @@ +# Install a skill that has nested subdirectories and verify file tree +exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/nested-test +exists $WORK/nested-test/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar b/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar new file mode 100644 index 00000000000..23f72cee829 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar @@ -0,0 +1,3 @@ +# Installing a skill that doesn't exist in a valid repo should error +! exec gh skills install github/awesome-copilot nonexistent-skill-xyz --force --dir $WORK/tmp +stderr 'not found' diff --git a/acceptance/testdata/skills/skills-install-scope.txtar b/acceptance/testdata/skills/skills-install-scope.txtar index 9b8048ab5b9..da2df19ea8f 100644 --- a/acceptance/testdata/skills/skills-install-scope.txtar +++ b/acceptance/testdata/skills/skills-install-scope.txtar @@ -1,9 +1,9 @@ -# Install with --scope project (default) inside a git repo -exec git init $WORK/myrepo -exec gh skills install github/awesome-copilot git-commit --scope project --force --agent github-copilot --dir $WORK/myrepo/.github/skills -stdout 'Installed git-commit' +# Install with --scope project writes to the git repo's .github/skills/ +exec git init --initial-branch=main $WORK/myrepo +cd $WORK/myrepo +exec gh skills install github/awesome-copilot git-commit --scope project --force --agent github-copilot exists $WORK/myrepo/.github/skills/git-commit/SKILL.md -# Install with --scope user +# Install with --scope user writes to home directory exec gh skills install github/awesome-copilot git-commit --scope user --force --agent github-copilot -stdout 'Installed git-commit' +exists $HOME/.copilot/skills/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install.txtar b/acceptance/testdata/skills/skills-install.txtar index c04ced9d2da..183f930fdff 100644 --- a/acceptance/testdata/skills/skills-install.txtar +++ b/acceptance/testdata/skills/skills-install.txtar @@ -2,9 +2,19 @@ exec gh skills install github/awesome-copilot git-commit --scope user --force --agent github-copilot stdout 'Installed git-commit' +# Verify SKILL.md has frontmatter metadata injected +exists $HOME/.copilot/skills/git-commit/SKILL.md +grep 'github-owner' $HOME/.copilot/skills/git-commit/SKILL.md +grep 'github-repo' $HOME/.copilot/skills/git-commit/SKILL.md + +# Verify lockfile was written +exists $HOME/.agents/.skill-lock.json +grep 'git-commit' $HOME/.agents/.skill-lock.json + # Install with --dir to a custom directory exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/custom-skills stdout 'Installed git-commit' # Verify the skill was written to the custom directory exists $WORK/custom-skills/git-commit/SKILL.md +grep 'github-owner' $WORK/custom-skills/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-preview-noninteractive.txtar b/acceptance/testdata/skills/skills-preview-noninteractive.txtar new file mode 100644 index 00000000000..939df0ab6c3 --- /dev/null +++ b/acceptance/testdata/skills/skills-preview-noninteractive.txtar @@ -0,0 +1,3 @@ +# Preview with repo only and non-interactive should error +! exec gh skills preview github/awesome-copilot +stderr 'must specify a skill name' diff --git a/acceptance/testdata/skills/skills-preview.txtar b/acceptance/testdata/skills/skills-preview.txtar new file mode 100644 index 00000000000..3834c340c0d --- /dev/null +++ b/acceptance/testdata/skills/skills-preview.txtar @@ -0,0 +1,9 @@ +# Preview renders skill content and file tree +exec gh skills preview github/awesome-copilot git-commit +stdout 'SKILL.md' +# Verify actual content is rendered, not just the filename +stdout 'git-commit/' + +# Preview a skill that doesn't exist should error +! exec gh skills preview github/awesome-copilot nonexistent-skill-xyz +stderr 'not found' diff --git a/acceptance/testdata/skills/skills-publish-dry-run.txtar b/acceptance/testdata/skills/skills-publish-dry-run.txtar new file mode 100644 index 00000000000..39c0f234d4b --- /dev/null +++ b/acceptance/testdata/skills/skills-publish-dry-run.txtar @@ -0,0 +1,33 @@ +# Publish dry-run from a directory with no skills/ should fail gracefully +! exec gh skills publish --dry-run $WORK +stderr 'no skills/ directory found' + +# Publish dry-run against a valid skill directory should succeed +exec gh skills publish --dry-run $WORK/test-repo +stdout 'hello-world' + +# Validate alias should work identically +exec gh skills validate --dry-run $WORK/test-repo +stdout 'hello-world' + +# Publish dry-run with --tag +exec gh skills publish --dry-run --tag v1.0.0 $WORK/test-repo +stdout 'hello-world' + +# Publish dry-run with --fix +exec gh skills publish --dry-run --fix $WORK/test-repo +stdout 'hello-world' + +-- test-repo/skills/hello-world/SKILL.md -- +--- +name: hello-world +description: A test skill that greets the user. +--- + +# Hello World + +Greet the user warmly. + +-- test-repo/skills/hello-world/scripts/setup.sh -- +#!/bin/bash +echo "Hello from the hello-world skill!" diff --git a/acceptance/testdata/skills/skills-publish-lifecycle.txtar b/acceptance/testdata/skills/skills-publish-lifecycle.txtar new file mode 100644 index 00000000000..0e8a03a1d05 --- /dev/null +++ b/acceptance/testdata/skills/skills-publish-lifecycle.txtar @@ -0,0 +1,64 @@ +# Full publish lifecycle: create repo, publish, install from it, clean up + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a private repo for testing +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --private --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING +cd $SCRIPT_NAME-$RANDOM_STRING + +# Add a test skill +mkdir skills/hello-world/scripts +cp $WORK/skill.md skills/hello-world/SKILL.md +cp $WORK/setup.sh skills/hello-world/scripts/setup.sh +exec git add -A +exec git commit -m 'Add test skill' +exec git push origin main + +# Publish with a tag +exec gh skills publish --tag v0.1.0 + +# Verify the release was created on GitHub +exec gh release view v0.1.0 +stdout 'v0.1.0' + +# Install from our test repo +exec gh skills install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force +stdout 'Installed hello-world' + +# Verify installed files exist with correct metadata +exists $HOME/.copilot/skills/hello-world/SKILL.md +exists $HOME/.copilot/skills/hello-world/scripts/setup.sh +grep 'github-owner' $HOME/.copilot/skills/hello-world/SKILL.md + +# Install with --pin +exec gh skills install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force --pin v0.1.0 +stdout 'Installed hello-world' + +# Preview from our test repo +exec gh skills preview $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world +stdout 'Hello World' + +# Update dry-run should find installed skill +exec gh skills update --dry-run --all +stderr 'up to date' + +-- skill.md -- +--- +name: hello-world +description: A test skill that greets the user. +--- + +# Hello World + +Greet the user warmly and offer to run the setup script. + +-- setup.sh -- +#!/bin/bash +echo "Hello from the hello-world skill!" +echo "Setting up environment..." +echo "Done." diff --git a/acceptance/testdata/skills/skills-search-noresults.txtar b/acceptance/testdata/skills/skills-search-noresults.txtar new file mode 100644 index 00000000000..31f8293f0dd --- /dev/null +++ b/acceptance/testdata/skills/skills-search-noresults.txtar @@ -0,0 +1,4 @@ +# Search for something unlikely to exist returns empty stdout +# (NoResultsError is silent in non-TTY — exits 0 with no output) +exec gh skills search zzzznonexistenttotallyfakeskillxyz123 +! stdout . diff --git a/acceptance/testdata/skills/skills-search-page.txtar b/acceptance/testdata/skills/skills-search-page.txtar new file mode 100644 index 00000000000..71bc6f1de5f --- /dev/null +++ b/acceptance/testdata/skills/skills-search-page.txtar @@ -0,0 +1,3 @@ +# Pagination returns results on page 2 +exec gh skills search copilot --page 2 +stdout 'copilot' diff --git a/acceptance/testdata/skills/skills-search.txtar b/acceptance/testdata/skills/skills-search.txtar new file mode 100644 index 00000000000..eb4759a41c5 --- /dev/null +++ b/acceptance/testdata/skills/skills-search.txtar @@ -0,0 +1,12 @@ +# Search for skills matching a query +exec gh skills search copilot +stdout 'copilot' + +# Search with JSON output +exec gh skills search copilot --json skillName,repo --limit 1 +stdout '"skillName"' +stdout '"repo"' + +# Search with a short query should error +! exec gh skills search a +stderr 'at least' diff --git a/acceptance/testdata/skills/skills-update-noinstalled.txtar b/acceptance/testdata/skills/skills-update-noinstalled.txtar new file mode 100644 index 00000000000..7f24291bac5 --- /dev/null +++ b/acceptance/testdata/skills/skills-update-noinstalled.txtar @@ -0,0 +1,5 @@ +# Update with no installed skills should report appropriately +exec gh skills update --dry-run --all --dir $WORK/empty-dir +stderr 'No installed skills found' + +-- empty-dir/.gitkeep -- diff --git a/acceptance/testdata/skills/skills-update.txtar b/acceptance/testdata/skills/skills-update.txtar new file mode 100644 index 00000000000..7041c84b49f --- /dev/null +++ b/acceptance/testdata/skills/skills-update.txtar @@ -0,0 +1,24 @@ +# Dry-run update should find the installed skill and report status +exec gh skills update --dry-run --all --dir $WORK/skills-dir +stderr 'update' +stdout 'git-commit' + +# Force update should re-download and rewrite files +exec gh skills update --force --all --dir $WORK/skills-dir +stdout 'Updated' + +# Verify the SKILL.md was rewritten with real content (not our placeholder) +grep 'github-owner' $WORK/skills-dir/git-commit/SKILL.md +! grep 'Test skill content' $WORK/skills-dir/git-commit/SKILL.md + +-- skills-dir/git-commit/SKILL.md -- +--- +name: git-commit +description: Git commit helper +metadata: + github-owner: github + github-repo: awesome-copilot + github-tree-sha: 0000000000000000000000000000000000000000 + github-path: skills/git-commit +--- +Test skill content diff --git a/git/client.go b/git/client.go index 5f547c99c41..22c4eff16c3 100644 --- a/git/client.go +++ b/git/client.go @@ -713,6 +713,37 @@ func (c *Client) IsLocalGitRepo(ctx context.Context) (bool, error) { return true, nil } +// RemoteURL returns the fetch URL configured for the named remote. +func (c *Client) RemoteURL(ctx context.Context, name string) (string, error) { + cmd, err := c.Command(ctx, "remote", "get-url", name) + if err != nil { + return "", err + } + out, err := cmd.Output() + if err != nil { + return "", err + } + return firstLine(out), nil +} + +// IsIgnored reports whether the given path is ignored by .gitignore rules. +func (c *Client) IsIgnored(ctx context.Context, path string) bool { + cmd, err := c.Command(ctx, "check-ignore", "-q", path) + if err != nil { + return false + } + _, err = cmd.Output() + return err == nil +} + +// ShortSHA returns the first 8 characters of a SHA hash for display purposes. +func ShortSHA(sha string) string { + if len(sha) > 8 { + return sha[:8] + } + return sha +} + func (c *Client) UnsetRemoteResolution(ctx context.Context, name string) error { args := []string{"config", "--unset", fmt.Sprintf("remote.%s.gh-resolved", name)} cmd, err := c.Command(ctx, args...) diff --git a/internal/skills/collisions.go b/internal/skills/discovery/collisions.go similarity index 89% rename from internal/skills/collisions.go rename to internal/skills/discovery/collisions.go index 87e4705c965..38bf9b26b25 100644 --- a/internal/skills/collisions.go +++ b/internal/skills/discovery/collisions.go @@ -1,11 +1,9 @@ -package skills +package discovery import ( "fmt" "sort" "strings" - - "github.com/cli/cli/v2/internal/skills/discovery" ) // NameCollision represents a group of skills that share the same InstallName @@ -18,8 +16,8 @@ type NameCollision struct { // FindNameCollisions detects skills that share the same InstallName and returns a // sorted slice of collisions. Callers decide how to present the conflict to // the user (different flows need different error messages). -func FindNameCollisions(skills []discovery.Skill) []NameCollision { - byName := make(map[string][]discovery.Skill) +func FindNameCollisions(skills []Skill) []NameCollision { + byName := make(map[string][]Skill) for _, s := range skills { byName[s.InstallName()] = append(byName[s.InstallName()], s) } diff --git a/internal/skills/discovery/collisions_test.go b/internal/skills/discovery/collisions_test.go new file mode 100644 index 00000000000..b499c497a07 --- /dev/null +++ b/internal/skills/discovery/collisions_test.go @@ -0,0 +1,62 @@ +package discovery + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFindNameCollisions(t *testing.T) { + tests := []struct { + name string + skills []Skill + want []NameCollision + }{ + { + name: "no collisions", + skills: []Skill{ + {Name: "code-review", Path: "skills/code-review"}, + {Name: "issue-triage", Path: "skills/issue-triage"}, + }, + want: nil, + }, + { + name: "single collision", + skills: []Skill{ + {Name: "pr-summary", Path: "skills/pr-summary"}, + {Name: "pr-summary", Path: "skills/monalisa/pr-summary"}, + }, + want: []NameCollision{ + {Name: "pr-summary", DisplayNames: []string{"pr-summary", "pr-summary"}}, + }, + }, + { + name: "collisions sorted by name", + skills: []Skill{ + {Name: "octocat-lint", Path: "skills/octocat-lint"}, + {Name: "octocat-lint", Path: "skills/hubot/octocat-lint"}, + {Name: "code-review", Path: "skills/code-review"}, + {Name: "code-review", Path: "skills/monalisa/code-review"}, + }, + want: []NameCollision{ + {Name: "code-review", DisplayNames: []string{"code-review", "code-review"}}, + {Name: "octocat-lint", DisplayNames: []string{"octocat-lint", "octocat-lint"}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FindNameCollisions(tt.skills) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFormatCollisions(t *testing.T) { + collisions := []NameCollision{ + {Name: "pr-summary", DisplayNames: []string{"skills/pr-summary", "plugins/hubot/pr-summary"}}, + {Name: "code-review", DisplayNames: []string{"skills/code-review", "skills/monalisa/code-review"}}, + } + got := FormatCollisions(collisions) + assert.Equal(t, "pr-summary: skills/pr-summary, plugins/hubot/pr-summary\n code-review: skills/code-review, skills/monalisa/code-review", got) +} diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index fc234716a3d..05a531bc9dd 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -11,6 +11,7 @@ import ( "strings" "sync" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/skills/frontmatter" ) @@ -71,18 +72,6 @@ type ResolvedRef struct { SHA string // commit SHA } -// RESTClient is the interface for making GitHub REST API calls. -// It mirrors the subset of api.Client used by discovery. -type RESTClient interface { - // REST performs a REST API call. - // hostname is the GitHub host (e.g. "github.com"). - // method is the HTTP method (e.g. "GET"). - // path is the API path (e.g. "repos/owner/repo/releases/latest"). - // body is the request body (nil for GET). - // data is the response data to unmarshal into. - REST(hostname string, method string, path string, body io.Reader, data interface{}) error -} - type treeEntry struct { Path string `json:"path"` Mode string `json:"mode"` @@ -120,7 +109,7 @@ type repoResponse struct { // ResolveRef determines the git ref to use for a given owner/repo. // Priority: explicit version → latest release tag → default branch. -func ResolveRef(client RESTClient, host, owner, repo, version string) (*ResolvedRef, error) { +func ResolveRef(client *api.Client, host, owner, repo, version string) (*ResolvedRef, error) { if version != "" { return resolveExplicitRef(client, host, owner, repo, version) } @@ -134,7 +123,7 @@ func ResolveRef(client RESTClient, host, owner, repo, version string) (*Resolved // resolveExplicitRef resolves a user-supplied --pin value. It tries, in order: // tag → commit SHA. Branches are deliberately excluded because they are mutable // and pinning to one gives a false sense of reproducibility. -func resolveExplicitRef(client RESTClient, host, owner, repo, ref string) (*ResolvedRef, error) { +func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*ResolvedRef, error) { tagPath := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, ref) var refResp struct { Object struct { @@ -170,7 +159,7 @@ func resolveExplicitRef(client RESTClient, host, owner, repo, ref string) (*Reso return nil, fmt.Errorf("ref %q not found as tag or commit in %s/%s", ref, owner, repo) } -func resolveLatestRelease(client RESTClient, host, owner, repo string) (*ResolvedRef, error) { +func resolveLatestRelease(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", owner, repo) var release releaseResponse if err := client.REST(host, "GET", apiPath, nil, &release); err != nil { @@ -182,7 +171,7 @@ func resolveLatestRelease(client RESTClient, host, owner, repo string) (*Resolve return resolveExplicitRef(client, host, owner, repo, release.TagName) } -func resolveDefaultBranch(client RESTClient, host, owner, repo string) (*ResolvedRef, error) { +func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) var repoResp repoResponse if err := client.REST(host, "GET", apiPath, nil, &repoResp); err != nil { @@ -235,7 +224,7 @@ func matchSkillConventions(entry treeEntry) *skillMatch { parentDir := path.Dir(dir) skillName := path.Base(dir) - if !ValidateName(skillName) { + if !validateName(skillName) { return nil } @@ -246,7 +235,7 @@ func matchSkillConventions(entry treeEntry) *skillMatch { grandparentDir := path.Dir(parentDir) if grandparentDir == "skills" { namespace := path.Base(parentDir) - if !ValidateName(namespace) { + if !validateName(namespace) { return nil } return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "skills-namespaced"} @@ -254,7 +243,7 @@ func matchSkillConventions(entry treeEntry) *skillMatch { if path.Base(parentDir) == "skills" && path.Dir(grandparentDir) == "plugins" { namespace := path.Base(grandparentDir) - if !ValidateName(namespace) { + if !validateName(namespace) { return nil } return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "plugins"} @@ -268,7 +257,7 @@ func matchSkillConventions(entry treeEntry) *skillMatch { } // DiscoverSkills finds all skills in a repository at the given commit SHA. -func DiscoverSkills(client RESTClient, host, owner, repo, commitSHA string) ([]Skill, error) { +func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([]Skill, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, commitSHA) var tree treeResponse if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { @@ -332,8 +321,8 @@ func DiscoverSkills(client RESTClient, host, owner, repo, commitSHA string) ([]S return skills, nil } -// FetchDescription fetches and parses the frontmatter description for a skill. -func FetchDescription(client RESTClient, host, owner, repo string, skill *Skill) string { +// fetchDescription fetches and parses the frontmatter description for a skill. +func fetchDescription(client *api.Client, host, owner, repo string, skill *Skill) string { if skill.BlobSHA == "" { return "" } @@ -348,17 +337,8 @@ func FetchDescription(client RESTClient, host, owner, repo string, skill *Skill) return result.Metadata.Description } -// FetchDescriptions fetches descriptions for a batch of skills. -func FetchDescriptions(client RESTClient, host, owner, repo string, skills []Skill) { - for i := range skills { - if skills[i].Description == "" { - skills[i].Description = FetchDescription(client, host, owner, repo, &skills[i]) - } - } -} - // FetchDescriptionsConcurrent fetches descriptions with bounded concurrency. -func FetchDescriptionsConcurrent(client RESTClient, host, owner, repo string, skills []Skill, onProgress func(done, total int)) { +func FetchDescriptionsConcurrent(client *api.Client, host, owner, repo string, skills []Skill, onProgress func(done, total int)) { total := 0 for _, s := range skills { if s.Description == "" { @@ -385,7 +365,7 @@ func FetchDescriptionsConcurrent(client RESTClient, host, owner, repo string, sk sem <- struct{}{} defer func() { <-sem }() - desc := FetchDescription(client, host, owner, repo, &skills[idx]) + desc := fetchDescription(client, host, owner, repo, &skills[idx]) mu.Lock() skills[idx].Description = desc @@ -401,12 +381,12 @@ func FetchDescriptionsConcurrent(client RESTClient, host, owner, repo string, sk } // DiscoverSkillByPath looks up a single skill by its exact path in the repository. -func DiscoverSkillByPath(client RESTClient, host, owner, repo, commitSHA, skillPath string) (*Skill, error) { +func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skillPath string) (*Skill, error) { skillPath = strings.TrimSuffix(skillPath, "/SKILL.md") skillPath = strings.TrimSuffix(skillPath, "/") skillName := path.Base(skillPath) - if !ValidateName(skillName) { + if !validateName(skillName) { return nil, fmt.Errorf("invalid skill name %q", skillName) } @@ -465,14 +445,14 @@ func DiscoverSkillByPath(client RESTClient, host, owner, repo, commitSHA, skillP TreeSHA: treeSHA, } - skill.Description = FetchDescription(client, host, owner, repo, skill) + skill.Description = fetchDescription(client, host, owner, repo, skill) return skill, nil } // DiscoverSkillFiles returns all file paths belonging to a skill directory // by fetching the skill's subtree directly using its tree SHA. -func DiscoverSkillFiles(client RESTClient, host, owner, repo, treeSHA, skillPath string) ([]treeEntry, error) { +func DiscoverSkillFiles(client *api.Client, host, owner, repo, treeSHA, skillPath string) ([]SkillFile, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, treeSHA) var tree treeResponse if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { @@ -484,10 +464,10 @@ func DiscoverSkillFiles(client RESTClient, host, owner, repo, treeSHA, skillPath return walkTree(client, host, owner, repo, treeSHA, skillPath) } - var files []treeEntry + var files []SkillFile for _, entry := range tree.Tree { if entry.Type == "blob" { - files = append(files, treeEntry{ + files = append(files, SkillFile{ Path: skillPath + "/" + entry.Path, SHA: entry.SHA, Size: entry.Size, @@ -500,7 +480,7 @@ func DiscoverSkillFiles(client RESTClient, host, owner, repo, treeSHA, skillPath // ListSkillFiles returns all files in a skill directory as public SkillFile // structs with paths relative to the skill root. -func ListSkillFiles(client RESTClient, host, owner, repo, treeSHA string) ([]SkillFile, error) { +func ListSkillFiles(client *api.Client, host, owner, repo, treeSHA string) ([]SkillFile, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, treeSHA) var tree treeResponse if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { @@ -509,17 +489,7 @@ func ListSkillFiles(client RESTClient, host, owner, repo, treeSHA string) ([]Ski if tree.Truncated { // Fall back to non-recursive traversal when the tree is too large. - entries, err := walkTree(client, host, owner, repo, treeSHA, "") - if err != nil { - return nil, err - } - var files []SkillFile - for _, e := range entries { - // walkTree prefixes with "/{path}", trim the leading slash. - p := strings.TrimPrefix(e.Path, "/") - files = append(files, SkillFile{Path: p, SHA: e.SHA, Size: e.Size}) - } - return files, nil + return walkTree(client, host, owner, repo, treeSHA, "") } var files []SkillFile @@ -537,19 +507,22 @@ func ListSkillFiles(client RESTClient, host, owner, repo, treeSHA string) ([]Ski // walkTree enumerates files by fetching each tree level individually, // avoiding the truncation limit of the recursive tree API. -func walkTree(client RESTClient, host, owner, repo, sha, prefix string) ([]treeEntry, error) { +func walkTree(client *api.Client, host, owner, repo, sha, prefix string) ([]SkillFile, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, sha) var tree treeResponse if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { return nil, fmt.Errorf("could not fetch tree %s: %w", prefix, err) } - var files []treeEntry + var files []SkillFile for _, entry := range tree.Tree { - entryPath := prefix + "/" + entry.Path + entryPath := entry.Path + if prefix != "" { + entryPath = prefix + "/" + entry.Path + } switch entry.Type { case "blob": - files = append(files, treeEntry{Path: entryPath, SHA: entry.SHA, Size: entry.Size}) + files = append(files, SkillFile{Path: entryPath, SHA: entry.SHA, Size: entry.Size}) case "tree": sub, err := walkTree(client, host, owner, repo, entry.SHA, entryPath) if err != nil { @@ -562,7 +535,7 @@ func walkTree(client RESTClient, host, owner, repo, sha, prefix string) ([]treeE } // FetchBlob retrieves the content of a blob by SHA. -func FetchBlob(client RESTClient, host, owner, repo, sha string) (string, error) { +func FetchBlob(client *api.Client, host, owner, repo, sha string) (string, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/blobs/%s", owner, repo, sha) var blob blobResponse if err := client.REST(host, "GET", apiPath, nil, &blob); err != nil { @@ -683,7 +656,7 @@ func localSkillFromDir(dir string) (*Skill, error) { description = result.Metadata.Description } - if !ValidateName(name) { + if !validateName(name) { return nil, fmt.Errorf("invalid skill name %q in %s", name, dir) } @@ -694,8 +667,8 @@ func localSkillFromDir(dir string) (*Skill, error) { }, nil } -// ValidateName checks if a skill name is safe for use (filesystem-safe). -func ValidateName(name string) bool { +// validateName checks if a skill name is safe for use (filesystem-safe). +func validateName(name string) bool { if len(name) == 0 || len(name) > 64 { return false } @@ -715,59 +688,3 @@ func IsSpecCompliant(name string) bool { } return specNamePattern.MatchString(name) } - -// verifyBatchSize controls how many repos are checked per code-search API call. -const verifyBatchSize = 8 - -type codeSearchResponse struct { - Items []codeSearchItem `json:"items"` -} - -type codeSearchItem struct { - Repository codeSearchRepo `json:"repository"` -} - -type codeSearchRepo struct { - FullName string `json:"full_name"` -} - -// VerifySkillRepos filters a list of repository names to only those that -// actually contain SKILL.md files. It uses the GitHub code search API with -// batched repo: qualifiers. -// -// If a verification call fails (e.g. rate limit), repos in that batch are -// kept rather than silently dropped — we fail open. -func VerifySkillRepos(client RESTClient, host string, repos []string) map[string]bool { - verified := make(map[string]bool) - - for i := 0; i < len(repos); i += verifyBatchSize { - end := i + verifyBatchSize - if end > len(repos) { - end = len(repos) - } - batch := repos[i:end] - - var queryParts []string - queryParts = append(queryParts, "filename:SKILL.md") - for _, r := range batch { - queryParts = append(queryParts, "repo:"+r) - } - query := strings.Join(queryParts, "+") - apiPath := fmt.Sprintf("search/code?q=%s&per_page=%d", query, verifyBatchSize*3) - - var resp codeSearchResponse - if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { - // Fail open: if we can't verify, assume all repos in the batch are valid - for _, r := range batch { - verified[r] = true - } - continue - } - - for _, item := range resp.Items { - verified[item.Repository.FullName] = true - } - } - - return verified -} diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index b5fe2410df1..5368ad23add 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -1,9 +1,13 @@ package discovery import ( + "net/http" "testing" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestInstallName(t *testing.T) { @@ -14,18 +18,18 @@ func TestInstallName(t *testing.T) { }{ { name: "plain skill", - skill: Skill{Name: "git-commit"}, - wantName: "git-commit", + skill: Skill{Name: "code-review"}, + wantName: "code-review", }, { name: "namespaced skill", - skill: Skill{Name: "xlsx-pro", Namespace: "alice"}, - wantName: "alice/xlsx-pro", + skill: Skill{Name: "issue-triage", Namespace: "monalisa"}, + wantName: "monalisa/issue-triage", }, { name: "plugin skill with namespace", - skill: Skill{Name: "code-review", Namespace: "bob", Convention: "plugins"}, - wantName: "bob/code-review", + skill: Skill{Name: "pr-summary", Namespace: "hubot", Convention: "plugins"}, + wantName: "hubot/pr-summary", }, } for _, tt := range tests { @@ -35,48 +39,60 @@ func TestInstallName(t *testing.T) { } } -func TestMatchSkillConventions_PluginNamespace(t *testing.T) { - entry := treeEntry{ - Path: "plugins/bob/skills/code-review/SKILL.md", - Type: "blob", - } - m := matchSkillConventions(entry) - assert.NotNil(t, m) - assert.Equal(t, "code-review", m.name) - assert.Equal(t, "bob", m.namespace) - assert.Equal(t, "plugins", m.convention) -} - -func TestMatchSkillConventions_NamespacedSkill(t *testing.T) { - entry := treeEntry{ - Path: "skills/alice/xlsx-pro/SKILL.md", - Type: "blob", +func TestMatchSkillConventions(t *testing.T) { + tests := []struct { + name string + path string + wantNil bool + wantName string + wantNamespace string + wantConvention string + }{ + { + name: "plugin namespace", + path: "plugins/hubot/skills/pr-summary/SKILL.md", + wantName: "pr-summary", + wantNamespace: "hubot", + wantConvention: "plugins", + }, + { + name: "namespaced skill", + path: "skills/monalisa/issue-triage/SKILL.md", + wantName: "issue-triage", + wantNamespace: "monalisa", + wantConvention: "skills-namespaced", + }, + { + name: "regular skill", + path: "skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "skills", + }, + { + name: "non-SKILL.md file", + path: "skills/code-review/README.md", + wantNil: true, + }, } - m := matchSkillConventions(entry) - assert.NotNil(t, m) - assert.Equal(t, "xlsx-pro", m.name) - assert.Equal(t, "alice", m.namespace) - assert.Equal(t, "skills-namespaced", m.convention) -} - -func TestMatchSkillConventions_RegularSkill(t *testing.T) { - entry := treeEntry{ - Path: "skills/git-commit/SKILL.md", - Type: "blob", + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := matchSkillConventions(treeEntry{Path: tt.path, Type: "blob"}) + if tt.wantNil { + assert.Nil(t, m) + return + } + require.NotNil(t, m) + assert.Equal(t, tt.wantName, m.name) + assert.Equal(t, tt.wantNamespace, m.namespace) + assert.Equal(t, tt.wantConvention, m.convention) + }) } - m := matchSkillConventions(entry) - assert.NotNil(t, m) - assert.Equal(t, "git-commit", m.name) - assert.Equal(t, "", m.namespace) - assert.Equal(t, "skills", m.convention) } func TestDuplicatePluginSkills_DifferentAuthors(t *testing.T) { - // Simulates a repo with the same skill name under two different plugin authors. - // Previously this caused a collision error; now each gets a distinct namespace. entries := []treeEntry{ - {Path: "plugins/author1/skills/azure-diag/SKILL.md", Type: "blob"}, - {Path: "plugins/author2/skills/azure-diag/SKILL.md", Type: "blob"}, + {Path: "plugins/monalisa/skills/code-review/SKILL.md", Type: "blob"}, + {Path: "plugins/hubot/skills/code-review/SKILL.md", Type: "blob"}, } seen := make(map[string]bool) @@ -90,20 +106,236 @@ func TestDuplicatePluginSkills_DifferentAuthors(t *testing.T) { matches = append(matches, *m) } - assert.Len(t, matches, 2) - assert.Equal(t, "author1", matches[0].namespace) - assert.Equal(t, "author2", matches[1].namespace) + require.Len(t, matches, 2) + assert.Equal(t, "monalisa", matches[0].namespace) + assert.Equal(t, "hubot", matches[1].namespace) + assert.NotEqual(t, + Skill{Name: matches[0].name, Namespace: matches[0].namespace}.InstallName(), + Skill{Name: matches[1].name, Namespace: matches[1].namespace}.InstallName(), + ) +} + +func TestValidateName(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {name: "empty", input: "", want: false}, + {name: "too long", input: string(make([]byte, 65)), want: false}, + {name: "max length", input: "a" + string(make([]byte, 63)), want: false}, // 64 'a's would be valid but []byte gives null bytes + {name: "contains slash", input: "foo/bar", want: false}, + {name: "contains dotdot", input: "foo..bar", want: false}, + {name: "starts with dot", input: ".hidden", want: false}, + {name: "simple name", input: "code-review", want: true}, + {name: "with dots and underscores", input: "octocat_helper.v2", want: true}, + {name: "uppercase allowed", input: "Octocat", want: true}, + {name: "single char", input: "a", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, validateName(tt.input)) + }) + } +} + +func TestIsSpecCompliant(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {name: "empty", input: "", want: false}, + {name: "consecutive hyphens", input: "code--review", want: false}, + {name: "uppercase rejected", input: "Octocat", want: false}, + {name: "starts with hyphen", input: "-octocat", want: false}, + {name: "ends with hyphen", input: "octocat-", want: false}, + {name: "valid lowercase with hyphens", input: "issue-triage", want: true}, + {name: "valid single char", input: "a", want: true}, + {name: "valid with numbers", input: "copilot4", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsSpecCompliant(tt.input)) + }) + } +} + +func TestResolveRef(t *testing.T) { + tests := []struct { + name string + version string + stubs func(*httpmock.Registry) + wantRef string + wantSHA string + wantErr string + }{ + { + name: "explicit version resolves lightweight tag", + version: "v1.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "abc123", "type": "commit"}, + })) + }, + wantRef: "v1.0", + wantSHA: "abc123", + }, + { + name: "explicit version resolves annotated tag", + version: "v2.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v2.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "tag-obj-sha", "type": "tag"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/tags/tag-obj-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "real-commit-sha"}, + })) + }, + wantRef: "v2.0", + wantSHA: "real-commit-sha", + }, + { + name: "explicit version falls back to commit SHA", + version: "deadbeef", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/deadbeef"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/commits/deadbeef"), + httpmock.JSONResponse(map[string]interface{}{"sha": "deadbeef"})) + }, + wantRef: "deadbeef", + wantSHA: "deadbeef", + }, + { + name: "explicit version not found anywhere", + version: "nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/commits/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + }, + wantErr: `ref "nonexistent" not found as tag or commit in monalisa/octocat-skills`, + }, + { + name: "no version uses latest release", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.JSONResponse(map[string]interface{}{"tag_name": "v3.0"})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "release-sha", "type": "commit"}, + })) + }, + wantRef: "v3.0", + wantSHA: "release-sha", + }, + { + name: "no version falls back to default branch when no releases", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "branch-sha"}, + })) + }, + wantRef: "main", + wantSHA: "branch-sha", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + ref, err := ResolveRef(client, "github.com", "monalisa", "octocat-skills", tt.version) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantRef, ref.Ref) + assert.Equal(t, tt.wantSHA, ref.SHA) + }) + } +} + +func TestFetchBlob(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantErr string + want string + }{ + { + name: "decodes base64 content", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/abc"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc", "encoding": "base64", "content": "SGVsbG8gV29ybGQ=", + })) + }, + want: "Hello World", + }, + { + name: "rejects non-base64 encoding", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/abc"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc", "encoding": "utf-8", "content": "raw", + })) + }, + wantErr: "unexpected blob encoding: utf-8", + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/abc"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch blob", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) - // Build skills and verify they have different InstallNames - var skills []Skill - for _, m := range matches { - skills = append(skills, Skill{ - Name: m.name, - Namespace: m.namespace, - Convention: m.convention, + got, err := FetchBlob(client, "github.com", "monalisa", "octocat-skills", "abc") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) }) } - assert.Equal(t, "author1/azure-diag", skills[0].InstallName()) - assert.Equal(t, "author2/azure-diag", skills[1].InstallName()) - assert.NotEqual(t, skills[0].InstallName(), skills[1].InstallName()) } diff --git a/internal/skills/gitclient/gitclient.go b/internal/skills/gitclient/gitclient.go deleted file mode 100644 index 99735db9017..00000000000 --- a/internal/skills/gitclient/gitclient.go +++ /dev/null @@ -1,149 +0,0 @@ -// Package gitclient provides a shared adapter from the cli/cli git.Client -// (via cmdutil.Factory) to the narrow interfaces used by skills commands. -package gitclient - -import ( - "context" - "os" - "strings" - - "github.com/cli/cli/v2/pkg/cmdutil" -) - -// RootResolver can resolve the git repository root directory. -type RootResolver interface { - ToplevelDir() (string, error) -} - -// RemoteResolver can resolve git remote URLs. -type RemoteResolver interface { - RemoteURL(name string) (string, error) -} - -// Client is the full git operations interface used by skills commands. -type Client interface { - RootResolver - RemoteResolver - GitDir(dir string) error - Remotes() ([]string, error) - CurrentBranch(dir string) (string, error) - IsIgnored(dir, path string) bool -} - -// FactoryClient adapts the cli/cli git.Client to the Client interface. -type FactoryClient struct { - F *cmdutil.Factory -} - -// ToplevelDir returns the root directory of the current git repository. -func (g *FactoryClient) ToplevelDir() (string, error) { - cmd, err := g.F.GitClient.Command(context.Background(), "rev-parse", "--show-toplevel") - if err != nil { - return "", err - } - out, err := cmd.Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(out)), nil -} - -// RemoteURL returns the URL configured for the named git remote. -func (g *FactoryClient) RemoteURL(name string) (string, error) { - cmd, err := g.F.GitClient.Command(context.Background(), "remote", "get-url", name) - if err != nil { - return "", err - } - out, err := cmd.Output() - if err != nil { - return "", err - } - return strings.TrimSpace(string(out)), nil -} - -// GitDir validates that the given directory is inside a git repository. -func (g *FactoryClient) GitDir(dir string) error { - cmd, err := g.F.GitClient.Command(context.Background(), "-C", dir, "rev-parse", "--git-dir") - if err != nil { - return err - } - _, err = cmd.Output() - return err -} - -// Remotes returns the list of configured git remote names. -func (g *FactoryClient) Remotes() ([]string, error) { - cmd, err := g.F.GitClient.Command(context.Background(), "remote") - if err != nil { - return nil, err - } - out, err := cmd.Output() - if err != nil { - return nil, err - } - return strings.Fields(string(out)), nil -} - -// CurrentBranch returns the current branch name, or "" if HEAD is detached. -func (g *FactoryClient) CurrentBranch(dir string) (string, error) { - cmd, err := g.F.GitClient.Command(context.Background(), "-C", dir, "rev-parse", "--abbrev-ref", "HEAD") - if err != nil { - return "", err - } - out, err := cmd.Output() - if err != nil { - return "", err - } - branch := strings.TrimSpace(string(out)) - if branch == "HEAD" { - return "", nil // detached HEAD - } - return branch, nil -} - -// IsIgnored reports whether the given path is git-ignored in the given directory. -func (g *FactoryClient) IsIgnored(dir, path string) bool { - cmd, err := g.F.GitClient.Command(context.Background(), "-C", dir, "check-ignore", "-q", path) - if err != nil { - return false - } - _, err = cmd.Output() - return err == nil -} - -// ResolveGitRoot returns the git repository root using the provided resolver, -// falling back to the current working directory on error. -func ResolveGitRoot(resolver RootResolver) string { - if resolver == nil { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - return "" - } - root, err := resolver.ToplevelDir() - if err != nil { - if cwd, cwdErr := os.Getwd(); cwdErr == nil { - return cwd - } - return "" - } - return root -} - -// ResolveHomeDir returns the user's home directory, or "" on error. -func ResolveHomeDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return home -} - -// TruncateSHA returns the first 8 characters of a SHA, or the full string -// if it is shorter. -func TruncateSHA(sha string) string { - if len(sha) > 8 { - return sha[:8] - } - return sha -} diff --git a/internal/skills/gitclient/gitclient_test.go b/internal/skills/gitclient/gitclient_test.go deleted file mode 100644 index 0b8a2cfffcf..00000000000 --- a/internal/skills/gitclient/gitclient_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package gitclient - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" -) - -type mockResolver struct { - root string - err error -} - -func (m *mockResolver) ToplevelDir() (string, error) { - if m.err != nil { - return "", m.err - } - return m.root, nil -} - -func TestResolveGitRoot(t *testing.T) { - t.Run("returns root on success", func(t *testing.T) { - got := ResolveGitRoot(&mockResolver{root: "/my/repo"}) - assert.Equal(t, "/my/repo", got) - }) - - t.Run("falls back to cwd on error", func(t *testing.T) { - got := ResolveGitRoot(&mockResolver{err: fmt.Errorf("not a git repo")}) - assert.NotEmpty(t, got) // falls back to cwd - }) - - t.Run("nil resolver falls back to cwd", func(t *testing.T) { - got := ResolveGitRoot(nil) - assert.NotEmpty(t, got) // falls back to cwd - }) -} - -func TestResolveHomeDir(t *testing.T) { - got := ResolveHomeDir() - assert.NotEmpty(t, got) -} - -func TestTruncateSHA(t *testing.T) { - assert.Equal(t, "abcdef12", TruncateSHA("abcdef1234567890")) - assert.Equal(t, "short", TruncateSHA("short")) - assert.Equal(t, "12345678", TruncateSHA("12345678")) - assert.Equal(t, "", TruncateSHA("")) -} diff --git a/internal/skills/hosts/hosts_test.go b/internal/skills/hosts/hosts_test.go deleted file mode 100644 index 78c2a3e9dc6..00000000000 --- a/internal/skills/hosts/hosts_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package hosts - -import ( - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestFindByID(t *testing.T) { - host, err := FindByID("github-copilot") - require.NoError(t, err) - assert.Equal(t, "GitHub Copilot", host.Name) - assert.Equal(t, ".github/skills", host.ProjectDir) -} - -func TestFindByID_Invalid(t *testing.T) { - _, err := FindByID("nonexistent") - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown host") -} - -func TestValidHostIDs(t *testing.T) { - ids := ValidHostIDs() - assert.Contains(t, ids, "github-copilot") - assert.Contains(t, ids, "claude-code") - assert.Contains(t, ids, "cursor") -} - -func TestHostNames(t *testing.T) { - names := HostNames() - assert.Contains(t, names, "GitHub Copilot") - assert.Contains(t, names, "Claude Code") -} - -func TestInstallDir_Project(t *testing.T) { - host, _ := FindByID("github-copilot") - dir, err := host.InstallDir(ScopeProject, "/tmp/myrepo", "/home/user") - require.NoError(t, err) - assert.Equal(t, filepath.Join("/tmp/myrepo", ".github", "skills"), dir) -} - -func TestInstallDir_User(t *testing.T) { - host, _ := FindByID("github-copilot") - dir, err := host.InstallDir(ScopeUser, "/tmp/myrepo", "/home/user") - require.NoError(t, err) - assert.Equal(t, filepath.Join("/home/user", ".copilot", "skills"), dir) -} - -func TestInstallDir_NoGitRoot(t *testing.T) { - host, _ := FindByID("github-copilot") - _, err := host.InstallDir(ScopeProject, "", "/home/user") - assert.Error(t, err) -} - -func TestRepoNameFromRemote(t *testing.T) { - tests := []struct { - remote string - want string - }{ - {"https://github.com/owner/repo.git", "owner/repo"}, - {"https://github.com/owner/repo", "owner/repo"}, - {"git@github.com:owner/repo.git", "owner/repo"}, - {"git@github.com:owner/repo", "owner/repo"}, - {"ssh://git@github.com/owner/repo.git", "owner/repo"}, - {"ssh://git@github.com/owner/repo", "owner/repo"}, - {"", ""}, - } - for _, tt := range tests { - t.Run(tt.remote, func(t *testing.T) { - assert.Equal(t, tt.want, RepoNameFromRemote(tt.remote)) - }) - } -} - -func TestUniqueProjectDirs(t *testing.T) { - dirs := UniqueProjectDirs() - - // Should contain all known project dirs - assert.Contains(t, dirs, ".github/skills") - assert.Contains(t, dirs, ".claude/skills") - assert.Contains(t, dirs, ".cursor/skills") - assert.Contains(t, dirs, ".agents/skills") - assert.Contains(t, dirs, ".agent/skills") - - // Should deduplicate — gemini and antigravity share .agent/skills - seen := map[string]int{} - for _, d := range dirs { - seen[d]++ - } - for dir, count := range seen { - assert.Equalf(t, 1, count, "directory %q appears %d times, expected 1", dir, count) - } -} - -func TestScopeLabels(t *testing.T) { - t.Run("without repo name", func(t *testing.T) { - labels := ScopeLabels("") - require.Len(t, labels, 2) - assert.Contains(t, labels[0], "Project") - assert.Contains(t, labels[0], "recommended") - assert.Contains(t, labels[1], "Global") - }) - - t.Run("with repo name", func(t *testing.T) { - labels := ScopeLabels("owner/repo") - require.Len(t, labels, 2) - assert.Contains(t, labels[0], "owner/repo") - assert.Contains(t, labels[0], "recommended") - assert.Contains(t, labels[1], "Global") - }) -} diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index 4c2a3925664..ed2db507433 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -1,16 +1,19 @@ package installer import ( + "errors" "fmt" "os" "path/filepath" "strings" "sync" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" - "github.com/cli/cli/v2/internal/skills/hosts" "github.com/cli/cli/v2/internal/skills/lockfile" + "github.com/cli/cli/v2/internal/skills/registry" ) // maxConcurrency limits parallel API requests to avoid rate limiting. @@ -25,12 +28,12 @@ type Options struct { SHA string // resolved commit SHA PinnedRef string // user-supplied --pin value (empty if unpinned) Skills []discovery.Skill - AgentHost *hosts.Host - Scope hosts.Scope + AgentHost *registry.AgentHost + Scope registry.Scope Dir string // explicit target directory (overrides AgentHost+Scope) GitRoot string // git repository root (for project scope) HomeDir string // user home directory (for user scope) - Client discovery.RESTClient + Client *api.Client OnProgress func(done, total int) // called after each skill is installed } @@ -138,8 +141,8 @@ func Install(opts *Options) (*Result, error) { type LocalOptions struct { SourceDir string Skills []discovery.Skill - AgentHost *hosts.Host - Scope hosts.Scope + AgentHost *registry.AgentHost + Scope registry.Scope Dir string GitRoot string HomeDir string @@ -182,7 +185,7 @@ func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) return fmt.Errorf("could not resolve source path: %w", err) } - absSkillDir, err := filepath.Abs(skillDir) + safeSkillDir, err := safepaths.ParseAbsolute(skillDir) if err != nil { return fmt.Errorf("could not resolve target path: %w", err) } @@ -203,20 +206,17 @@ func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) return err } - cleaned := filepath.Clean(relPath) - if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { - return nil - } - - destPath := filepath.Join(skillDir, cleaned) - - absDest, err := filepath.Abs(destPath) + // Defensive: filepath.WalkDir cannot produce traversal paths, but we + // guard against it in case the walk input is ever changed. + safeDest, err := safeSkillDir.Join(relPath) if err != nil { + var traversalErr safepaths.PathTraversalError + if errors.As(err, &traversalErr) { + return nil + } return fmt.Errorf("could not resolve destination path: %w", err) } - if !strings.HasPrefix(absDest, absSkillDir+string(filepath.Separator)) && absDest != absSkillDir { - return nil - } + destPath := safeDest.String() if dir := filepath.Dir(destPath); dir != skillDir { if err := os.MkdirAll(dir, 0o755); err != nil { @@ -252,7 +252,7 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { return fmt.Errorf("could not list skill files: %w", err) } - absSkillDir, err := filepath.Abs(skillDir) + safeSkillDir, err := safepaths.ParseAbsolute(skillDir) if err != nil { return fmt.Errorf("could not resolve skill directory path: %w", err) } @@ -265,20 +265,15 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { relPath := strings.TrimPrefix(file.Path, skill.Path+"/") - cleaned := filepath.Clean(relPath) - if cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { - continue - } - - destPath := filepath.Join(skillDir, cleaned) - - absDest, err := filepath.Abs(destPath) + safeDest, err := safeSkillDir.Join(relPath) if err != nil { + var traversalErr safepaths.PathTraversalError + if errors.As(err, &traversalErr) { + continue + } return fmt.Errorf("could not resolve destination path: %w", err) } - if !strings.HasPrefix(absDest, absSkillDir+string(filepath.Separator)) && absDest != absSkillDir { - continue - } + destPath := safeDest.String() if dir := filepath.Dir(destPath); dir != skillDir { if err := os.MkdirAll(dir, 0o755); err != nil { diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go new file mode 100644 index 00000000000..2f6e09ca859 --- /dev/null +++ b/internal/skills/installer/installer_test.go @@ -0,0 +1,338 @@ +package installer + +import ( + "encoding/base64" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInstallLocalSkill(t *testing.T) { + tests := []struct { + name string + skill discovery.Skill + setup func(t *testing.T, srcDir string) + verify func(t *testing.T, destDir string) + }{ + { + name: "copies files", + skill: discovery.Skill{Name: "code-review", Path: "skills/code-review"}, + setup: func(t *testing.T, srcDir string) { + t.Helper() + skillSrc := filepath.Join(srcDir, "skills", "code-review") + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# Code Review"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "prompt.txt"), []byte("review this PR"), 0o644)) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "code-review", "prompt.txt")) + require.NoError(t, err) + assert.Equal(t, "review this PR", string(content)) + + _, err = os.Stat(filepath.Join(destDir, "code-review", "SKILL.md")) + assert.NoError(t, err) + }, + }, + { + name: "nested directories", + skill: discovery.Skill{Name: "issue-triage", Path: "skills/issue-triage"}, + setup: func(t *testing.T, srcDir string) { + t.Helper() + deep := filepath.Join(srcDir, "skills", "issue-triage", "prompts", "templates") + require.NoError(t, os.MkdirAll(deep, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(deep, "bug.txt"), []byte("triage bug"), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(srcDir, "skills", "issue-triage", "SKILL.md"), []byte("# Issue Triage"), 0o644)) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "issue-triage", "prompts", "templates", "bug.txt")) + require.NoError(t, err) + assert.Equal(t, "triage bug", string(content)) + }, + }, + { + name: "skips symlinks", + skill: discovery.Skill{Name: "pr-summary", Path: "skills/pr-summary"}, + setup: func(t *testing.T, srcDir string) { + t.Helper() + skillSrc := filepath.Join(srcDir, "skills", "pr-summary") + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# PR Summary"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "prompt.txt"), []byte("summarize"), 0o644)) + os.Symlink(filepath.Join(skillSrc, "prompt.txt"), filepath.Join(skillSrc, "link.txt")) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(destDir, "pr-summary", "prompt.txt")) + assert.NoError(t, err) + _, err = os.Stat(filepath.Join(destDir, "pr-summary", "link.txt")) + assert.True(t, os.IsNotExist(err)) + }, + }, + { + name: "injects metadata into SKILL.md", + skill: discovery.Skill{Name: "copilot-helper", Path: "skills/copilot-helper"}, + setup: func(t *testing.T, srcDir string) { + t.Helper() + skillSrc := filepath.Join(srcDir, "skills", "copilot-helper") + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# Copilot Helper\nAssists with tasks"), 0o644)) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "copilot-helper", "SKILL.md")) + require.NoError(t, err) + assert.True(t, strings.Contains(string(content), "local-path"), + "expected SKILL.md to contain local-path metadata, got: %s", string(content)) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srcDir := t.TempDir() + destDir := t.TempDir() + tt.setup(t, srcDir) + + err := installLocalSkill(srcDir, tt.skill, destDir) + require.NoError(t, err) + tt.verify(t, destDir) + }) + } +} + +func TestInstallSkill(t *testing.T) { + tests := []struct { + name string + skill discovery.Skill + stubs func(*httpmock.Registry) + verify func(t *testing.T, destDir string) + }{ + { + name: "installs files from remote", + skill: discovery.Skill{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree123"}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "skill-sha", "size": 10}, + {"path": "prompt.txt", "type": "blob", "sha": "prompt-sha", "size": 5}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/skill-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "skill-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("# Code Review")), + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/prompt-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "prompt-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("review this PR")), + })) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "code-review", "prompt.txt")) + require.NoError(t, err) + assert.Equal(t, "review this PR", string(content)) + + _, err = os.Stat(filepath.Join(destDir, "code-review", "SKILL.md")) + assert.NoError(t, err) + }, + }, + { + name: "injects metadata into SKILL.md", + skill: discovery.Skill{Name: "pr-summary", Path: "skills/pr-summary", TreeSHA: "tree456"}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree456"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree456", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "md-sha", "size": 20}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/md-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "md-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("# PR Summary\nSummarize pull requests")), + })) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(destDir, "pr-summary", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-owner: monalisa") + assert.Contains(t, string(content), "github-repo: octocat-skills") + }, + }, + { + name: "skips path traversal from malicious tree", + skill: discovery.Skill{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree123"}, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "safe-sha", "size": 10}, + {"path": "../../etc/passwd", "type": "blob", "sha": "evil-sha", "size": 100}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/safe-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "safe-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("# Safe Skill")), + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/evil-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "evil-sha", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("malicious content")), + })) + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(destDir, "code-review", "SKILL.md")) + assert.NoError(t, err) + + _, err = os.Stat(filepath.Join(destDir, "..", "etc", "passwd")) + assert.True(t, os.IsNotExist(err), "traversal path should not be written") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + destDir := t.TempDir() + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + opts := &Options{ + Host: "github.com", + Owner: "monalisa", + Repo: "octocat-skills", + Ref: "v1.0", + SHA: "commit123", + Client: client, + } + + err := installSkill(opts, tt.skill, destDir) + require.NoError(t, err) + tt.verify(t, destDir) + }) + } +} + +func stubTreeAndBlob(reg *httpmock.Registry, treeSHA string) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/octocat-skills/git/trees/%s", treeSHA)), + httpmock.JSONResponse(map[string]interface{}{ + "sha": treeSHA, "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": treeSHA + "-blob", "size": 10}, + }, + })) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/octocat-skills/git/blobs/%s-blob", treeSHA)), + httpmock.JSONResponse(map[string]interface{}{ + "sha": treeSHA + "-blob", "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte("# Skill")), + })) +} + +func TestInstall(t *testing.T) { + tests := []struct { + name string + skills []discovery.Skill + stubs func(*httpmock.Registry) + wantInstalled []string + wantErr string + }{ + { + name: "single skill", + skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, + }, + stubs: func(reg *httpmock.Registry) { stubTreeAndBlob(reg, "tree-cr") }, + wantInstalled: []string{"code-review"}, + }, + { + name: "multiple skills concurrently", + skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, + {Name: "issue-triage", Path: "skills/issue-triage", TreeSHA: "tree-it"}, + }, + stubs: func(reg *httpmock.Registry) { + stubTreeAndBlob(reg, "tree-cr") + stubTreeAndBlob(reg, "tree-it") + }, + wantInstalled: []string{"code-review", "issue-triage"}, + }, + { + name: "no dir or agent host", + skills: []discovery.Skill{{Name: "code-review"}}, + stubs: func(reg *httpmock.Registry) {}, + wantErr: "either Dir or AgentHost must be specified", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + destDir := t.TempDir() + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + opts := &Options{ + Host: "github.com", + Owner: "monalisa", + Repo: "octocat-skills", + Ref: "v1.0", + SHA: "commit123", + Client: client, + Skills: tt.skills, + Dir: destDir, + } + if tt.wantErr != "" { + opts.Dir = "" + } + + result, err := Install(opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.ElementsMatch(t, tt.wantInstalled, result.Installed) + assert.Equal(t, destDir, result.Dir) + + homeDir, _ := os.UserHomeDir() + lockPath := filepath.Join(homeDir, ".agents", ".skill-lock.json") + lockData, err := os.ReadFile(lockPath) + require.NoError(t, err, "lockfile should have been written") + for _, name := range tt.wantInstalled { + assert.Contains(t, string(lockData), name) + } + }) + } +} diff --git a/internal/skills/lockfile/lockfile.go b/internal/skills/lockfile/lockfile.go index ad5fd4d4be9..5761d24cfe2 100644 --- a/internal/skills/lockfile/lockfile.go +++ b/internal/skills/lockfile/lockfile.go @@ -15,8 +15,8 @@ const ( lockFile = ".skill-lock.json" ) -// Entry represents a single installed skill in the lock file. -type Entry struct { +// entry represents a single installed skill in the lock file. +type entry struct { Source string `json:"source"` SourceType string `json:"sourceType"` SourceURL string `json:"sourceUrl"` @@ -27,15 +27,15 @@ type Entry struct { PinnedRef string `json:"pinnedRef,omitempty"` } -// File is the top-level structure of .skill-lock.json. -type File struct { +// file is the top-level structure of .skill-lock.json. +type file struct { Version int `json:"version"` - Skills map[string]Entry `json:"skills"` + Skills map[string]entry `json:"skills"` Dismissed map[string]bool `json:"dismissed,omitempty"` } -// Path returns the absolute path to the lock file. -func Path() (string, error) { +// lockfilePath returns the absolute path to the lock file. +func lockfilePath() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err @@ -43,10 +43,10 @@ func Path() (string, error) { return filepath.Join(home, agentsDir, lockFile), nil } -// Read loads the lock file, returning an empty file if it doesn't exist +// read loads the lock file, returning an empty file if it doesn't exist // or if it's an incompatible version. -func Read() (*File, error) { - lockPath, err := Path() +func read() (*file, error) { + lockPath, err := lockfilePath() if err != nil { return newFile(), nil //nolint:nilerr // graceful: no home dir means fresh state } @@ -59,7 +59,7 @@ func Read() (*File, error) { return nil, fmt.Errorf("could not read lock file: %w", err) } - var f File + var f file if err := json.Unmarshal(data, &f); err != nil { return newFile(), nil //nolint:nilerr // graceful: corrupt file means fresh state } @@ -71,9 +71,9 @@ func Read() (*File, error) { return &f, nil } -// Write persists the lock file to disk. -func Write(f *File) error { - lockPath, err := Path() +// write persists the lock file to disk. +func write(f *file) error { + lockPath, err := lockfilePath() if err != nil { return err } @@ -97,7 +97,7 @@ func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) unlock := acquireLock() defer unlock() - f, err := Read() + f, err := read() if err != nil { return err } @@ -110,7 +110,7 @@ func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) installedAt = existing.InstalledAt } - f.Skills[skillName] = Entry{ + f.Skills[skillName] = entry{ Source: owner + "/" + repo, SourceType: "github", SourceURL: "https://github.com/" + owner + "/" + repo + ".git", @@ -121,13 +121,13 @@ func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) PinnedRef: pinnedRef, } - return Write(f) + return write(f) } -func newFile() *File { - return &File{ +func newFile() *file { + return &file{ Version: lockVersion, - Skills: make(map[string]Entry), + Skills: make(map[string]entry), } } @@ -135,7 +135,7 @@ func newFile() *File { // Returns an unlock function. If locking fails after retries, it proceeds // unlocked rather than blocking the user indefinitely. func acquireLock() (unlock func()) { - lockPath, pathErr := Path() + lockPath, pathErr := lockfilePath() if pathErr != nil { return func() {} } diff --git a/internal/skills/lockfile/lockfile_test.go b/internal/skills/lockfile/lockfile_test.go new file mode 100644 index 00000000000..b53d6aafc3e --- /dev/null +++ b/internal/skills/lockfile/lockfile_test.go @@ -0,0 +1,193 @@ +package lockfile + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupHome redirects HOME to a temp dir and returns the expected lockfile path. +func setupHome(t *testing.T) string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + return filepath.Join(home, agentsDir, lockFile) +} + +func TestRecordInstall(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) // optional pre-existing state + skill string + owner string + repo string + skillPath string + treeSHA string + pinnedRef string + verify func(t *testing.T, lockPath string) + }{ + { + name: "fresh install creates lockfile", + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readLockfile(t, lockPath) + require.Contains(t, f.Skills, "code-review") + e := f.Skills["code-review"] + assert.Equal(t, "monalisa/octocat-skills", e.Source) + assert.Equal(t, "github", e.SourceType) + assert.Equal(t, "https://github.com/monalisa/octocat-skills.git", e.SourceURL) + assert.Equal(t, "skills/code-review/SKILL.md", e.SkillPath) + assert.Equal(t, "abc123", e.SkillFolderHash) + assert.NotEmpty(t, e.InstalledAt) + assert.NotEmpty(t, e.UpdatedAt) + assert.Empty(t, e.PinnedRef) + }, + }, + { + name: "install with pinned ref", + skill: "pr-summary", + owner: "hubot", + repo: "skills-repo", + skillPath: "skills/pr-summary/SKILL.md", + treeSHA: "def456", + pinnedRef: "v1.0.0", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readLockfile(t, lockPath) + assert.Equal(t, "v1.0.0", f.Skills["pr-summary"].PinnedRef) + }, + }, + { + name: "update preserves InstalledAt and updates treeSHA", + setup: func(t *testing.T) { + t.Helper() + require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "old-sha", "")) + }, + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "new-sha", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readLockfile(t, lockPath) + e := f.Skills["code-review"] + assert.Equal(t, "new-sha", e.SkillFolderHash, "treeSHA should be updated") + // InstalledAt should be preserved (not empty proves it wasn't clobbered) + assert.NotEmpty(t, e.InstalledAt, "InstalledAt should be preserved from first install") + }, + }, + { + name: "multiple skills coexist", + setup: func(t *testing.T) { + t.Helper() + require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "sha1", "")) + }, + skill: "issue-triage", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/issue-triage/SKILL.md", + treeSHA: "sha2", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readLockfile(t, lockPath) + assert.Contains(t, f.Skills, "code-review") + assert.Contains(t, f.Skills, "issue-triage") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lockPath := setupHome(t) + if tt.setup != nil { + tt.setup(t) + } + + err := RecordInstall(tt.skill, tt.owner, tt.repo, tt.skillPath, tt.treeSHA, tt.pinnedRef) + require.NoError(t, err) + tt.verify(t, lockPath) + }) + } +} + +func TestRead(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, lockPath string) + wantSkill bool + }{ + { + name: "missing file returns fresh state", + setup: func(t *testing.T, lockPath string) {}, + }, + { + name: "corrupt JSON returns fresh state", + setup: func(t *testing.T, lockPath string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + require.NoError(t, os.WriteFile(lockPath, []byte("{invalid json"), 0o644)) + }, + }, + { + name: "wrong version returns fresh state", + setup: func(t *testing.T, lockPath string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + data, _ := json.Marshal(file{Version: 999, Skills: map[string]entry{"x": {}}}) + require.NoError(t, os.WriteFile(lockPath, data, 0o644)) + }, + }, + { + name: "valid lockfile", + setup: func(t *testing.T, lockPath string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + f := &file{ + Version: lockVersion, + Skills: map[string]entry{ + "code-review": {Source: "monalisa/octocat-skills", SourceType: "github"}, + }, + } + data, err := json.MarshalIndent(f, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(lockPath, data, 0o644)) + }, + wantSkill: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lockPath := setupHome(t) + tt.setup(t, lockPath) + + loaded, err := read() + require.NoError(t, err) + assert.Equal(t, lockVersion, loaded.Version) + + if tt.wantSkill { + assert.Contains(t, loaded.Skills, "code-review") + } else { + assert.Empty(t, loaded.Skills) + } + }) + } +} + +// readLockfile is a test helper that reads and parses the lockfile from disk. +func readLockfile(t *testing.T, path string) *file { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err, "lockfile should exist at %s", path) + var f file + require.NoError(t, json.Unmarshal(data, &f)) + return &f +} diff --git a/internal/skills/hosts/hosts.go b/internal/skills/registry/registry.go similarity index 72% rename from internal/skills/hosts/hosts.go rename to internal/skills/registry/registry.go index bee20b0f092..ecaaaa48de4 100644 --- a/internal/skills/hosts/hosts.go +++ b/internal/skills/registry/registry.go @@ -1,16 +1,17 @@ -package hosts +package registry import ( "fmt" "path/filepath" + "strings" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" ) -// Host represents an AI agent that can use skills. -type Host struct { - // ID is the canonical identifier for this host. +// AgentHost represents an AI agent that can use skills. +type AgentHost struct { + // ID is the canonical identifier for this agent host. ID string // Name is the human-readable display name. Name string @@ -28,8 +29,8 @@ const ( ScopeUser Scope = "user" ) -// Registry contains all known agent hosts. -var Registry = []Host{ +// Agents contains all known agent hosts. +var Agents = []AgentHost{ { ID: "github-copilot", Name: "GitHub Copilot", @@ -68,52 +69,45 @@ var Registry = []Host{ }, } -// FindByID returns the host with the given ID, or an error if not found. -func FindByID(id string) (*Host, error) { - for i := range Registry { - if Registry[i].ID == id { - return &Registry[i], nil +// FindByID returns the agent host with the given ID, or an error if not found. +func FindByID(id string) (*AgentHost, error) { + for i := range Agents { + if Agents[i].ID == id { + return &Agents[i], nil } } - return nil, fmt.Errorf("unknown host %q, valid hosts: %s", id, ValidHostIDs()) + return nil, fmt.Errorf("unknown agent %q, valid agents: %s", id, ValidAgentIDs()) } -// ValidHostIDs returns a comma-separated list of valid host IDs. -func ValidHostIDs() string { - ids := "" - for i, h := range Registry { - if i > 0 { - ids += ", " - } - ids += h.ID - } - return ids +// ValidAgentIDs returns a comma-separated list of valid agent IDs. +func ValidAgentIDs() string { + return strings.Join(AgentIDs(), ", ") } -// HostIDs returns the IDs of all known hosts as a slice. -func HostIDs() []string { - ids := make([]string, len(Registry)) - for i, h := range Registry { +// AgentIDs returns the IDs of all known agents as a slice. +func AgentIDs() []string { + ids := make([]string, len(Agents)) + for i, h := range Agents { ids[i] = h.ID } return ids } -// HostNames returns the display names of all hosts for prompting. -func HostNames() []string { - names := make([]string, len(Registry)) - for i, h := range Registry { +// AgentNames returns the display names of all agents for prompting. +func AgentNames() []string { + names := make([]string, len(Agents)) + for i, h := range Agents { names[i] = h.Name } return names } // UniqueProjectDirs returns the deduplicated set of project-scope skill -// directories from the Registry, preserving insertion order. +// directories from the Agents list, preserving insertion order. func UniqueProjectDirs() []string { seen := map[string]bool{} var dirs []string - for _, h := range Registry { + for _, h := range Agents { if !seen[h.ProjectDir] { seen[h.ProjectDir] = true dirs = append(dirs, h.ProjectDir) @@ -122,12 +116,12 @@ func UniqueProjectDirs() []string { return dirs } -// InstallDir resolves the absolute installation directory for a host and scope. +// InstallDir resolves the absolute installation directory for an agent host and scope. // For project scope, it uses the provided git root directory so that skills are // installed at the top level regardless of which subdirectory the user is in. // Returns an error when gitRoot is empty (not in a git repository). // For user scope, it uses the home directory. -func (h *Host) InstallDir(scope Scope, gitRoot, homeDir string) (string, error) { +func (h *AgentHost) InstallDir(scope Scope, gitRoot, homeDir string) (string, error) { switch scope { case ScopeProject: if gitRoot == "" { diff --git a/internal/skills/registry/registry_test.go b/internal/skills/registry/registry_test.go new file mode 100644 index 00000000000..f37c35e960a --- /dev/null +++ b/internal/skills/registry/registry_test.go @@ -0,0 +1,153 @@ +package registry + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindByID(t *testing.T) { + tests := []struct { + name string + id string + wantName string + wantErr string + }{ + {name: "github-copilot", id: "github-copilot", wantName: "GitHub Copilot"}, + {name: "claude-code", id: "claude-code", wantName: "Claude Code"}, + {name: "cursor", id: "cursor", wantName: "Cursor"}, + {name: "codex", id: "codex", wantName: "Codex"}, + {name: "gemini", id: "gemini", wantName: "Gemini CLI"}, + {name: "antigravity", id: "antigravity", wantName: "Antigravity"}, + {name: "unknown agent", id: "nonexistent", wantErr: "unknown agent"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + host, err := FindByID(tt.id) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantName, host.Name) + }) + } +} + +func TestInstallDir(t *testing.T) { + host, err := FindByID("github-copilot") + require.NoError(t, err) + + tests := []struct { + name string + scope Scope + gitRoot string + homeDir string + wantDir string + wantErr bool + }{ + { + name: "project scope", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".github", "skills"), + }, + { + name: "user scope", + scope: ScopeUser, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/home/monalisa", ".copilot", "skills"), + }, + { + name: "project scope without git root", + scope: ScopeProject, + gitRoot: "", + homeDir: "/home/monalisa", + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir, err := host.InstallDir(tt.scope, tt.gitRoot, tt.homeDir) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantDir, dir) + }) + } +} + +func TestRepoNameFromRemote(t *testing.T) { + tests := []struct { + remote string + want string + }{ + {"https://github.com/monalisa/octocat-skills.git", "monalisa/octocat-skills"}, + {"https://github.com/monalisa/octocat-skills", "monalisa/octocat-skills"}, + {"git@github.com:monalisa/octocat-skills.git", "monalisa/octocat-skills"}, + {"git@github.com:monalisa/octocat-skills", "monalisa/octocat-skills"}, + {"ssh://git@github.com/monalisa/octocat-skills.git", "monalisa/octocat-skills"}, + {"ssh://git@github.com/monalisa/octocat-skills", "monalisa/octocat-skills"}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.remote, func(t *testing.T) { + assert.Equal(t, tt.want, RepoNameFromRemote(tt.remote)) + }) + } +} + +func TestUniqueProjectDirs(t *testing.T) { + dirs := UniqueProjectDirs() + require.NotEmpty(t, dirs) + + // Should deduplicate — e.g. gemini and antigravity share .agent/skills + seen := map[string]int{} + for _, d := range dirs { + seen[d]++ + } + for dir, count := range seen { + assert.Equalf(t, 1, count, "directory %q appears %d times, expected 1", dir, count) + } +} + +func TestScopeLabels(t *testing.T) { + tests := []struct { + name string + repoName string + wantFirst []string + wantSecond []string + }{ + { + name: "without repo name", + repoName: "", + wantFirst: []string{"Project", "recommended"}, + wantSecond: []string{"Global"}, + }, + { + name: "with repo name", + repoName: "monalisa/octocat-skills", + wantFirst: []string{"monalisa/octocat-skills", "recommended"}, + wantSecond: []string{"Global"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + labels := ScopeLabels(tt.repoName) + require.Len(t, labels, 2) + for _, s := range tt.wantFirst { + assert.Contains(t, labels[0], s) + } + for _, s := range tt.wantSecond { + assert.Contains(t, labels[1], s) + } + }) + } +} diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 38613800d85..eac2e4a00bf 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -1,6 +1,7 @@ package install import ( + "context" "errors" "fmt" "io" @@ -12,14 +13,14 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" - "github.com/cli/cli/v2/internal/skills" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" - "github.com/cli/cli/v2/internal/skills/gitclient" - "github.com/cli/cli/v2/internal/skills/hosts" "github.com/cli/cli/v2/internal/skills/installer" + "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -40,7 +41,8 @@ type installOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) Prompter prompter.Prompter - GitClient installGitClient + GitClient *git.Client + Remotes func() (ghContext.Remotes, error) // Arguments SkillSource string // owner/repo or local path @@ -61,18 +63,13 @@ type installOptions struct { version string } -// installGitClient is the git interface needed by the install command. -type installGitClient interface { - gitclient.RootResolver - gitclient.RemoteResolver -} - // NewCmdInstall creates the "skills install" command. func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra.Command { opts := &installOptions{ IO: f.IOStreams, Prompter: f.Prompter, - GitClient: &gitclient.FactoryClient{F: f}, + GitClient: f.GitClient, + Remotes: f.Remotes, HttpClient: f.HttpClient, } @@ -188,7 +185,7 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. } if opts.Agent != "" { - if _, err := hosts.FindByID(opts.Agent); err != nil { + if _, err := registry.FindByID(opts.Agent); err != nil { return cmdutil.FlagErrorf("invalid value for --agent: %s", err) } } @@ -204,9 +201,9 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. }, } - cmd.Flags().StringVar(&opts.Agent, "agent", "", fmt.Sprintf("target agent (%s)", hosts.ValidHostIDs())) + cmd.Flags().StringVar(&opts.Agent, "agent", "", fmt.Sprintf("target agent (%s)", registry.ValidAgentIDs())) _ = cmd.RegisterFlagCompletionFunc("agent", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return hosts.HostIDs(), cobra.ShellCompDirectiveNoFileComp + return registry.AgentIDs(), cobra.ShellCompDirectiveNoFileComp }) cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope") cmd.Flags().StringVar(&opts.Pin, "pin", "", "pin to a specific git tag or commit SHA") @@ -287,12 +284,12 @@ func installRun(opts *installOptions) error { return err } - gitRoot := gitclient.ResolveGitRoot(opts.GitClient) - homeDir := gitclient.ResolveHomeDir() + gitRoot := resolveGitRoot(opts.GitClient) + homeDir := resolveHomeDir() source = ghrepo.FullName(opts.repo) type hostPlan struct { - host *hosts.Host + host *registry.AgentHost skills []discovery.Skill } var plans []hostPlan @@ -426,11 +423,11 @@ func runLocalInstall(opts *installOptions) error { return err } - gitRoot := gitclient.ResolveGitRoot(opts.GitClient) - homeDir := gitclient.ResolveHomeDir() + gitRoot := resolveGitRoot(opts.GitClient) + homeDir := resolveHomeDir() type hostPlan struct { - host *hosts.Host + host *registry.AgentHost skills []discovery.Skill } var plans []hostPlan @@ -534,18 +531,18 @@ func cutLast(s, sep string) (before, after string, found bool) { return s, "", false } -func resolveVersion(opts *installOptions, client discovery.RESTClient, hostname string) (*discovery.ResolvedRef, error) { +func resolveVersion(opts *installOptions, client *api.Client, hostname string) (*discovery.ResolvedRef, error) { opts.IO.StartProgressIndicatorWithLabel("Resolving version") resolved, err := discovery.ResolveRef(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), opts.version) opts.IO.StopProgressIndicator() if err != nil { return nil, fmt.Errorf("could not resolve version: %w", err) } - fmt.Fprintf(opts.IO.ErrOut, "Using ref %s (%s)\n", resolved.Ref, gitclient.TruncateSHA(resolved.SHA)) + fmt.Fprintf(opts.IO.ErrOut, "Using ref %s (%s)\n", resolved.Ref, git.ShortSHA(resolved.SHA)) return resolved, nil } -func discoverSkills(opts *installOptions, client discovery.RESTClient, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { +func discoverSkills(opts *installOptions, client *api.Client, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { opts.IO.StartProgressIndicatorWithLabel("Discovering skills") skills, err := discovery.DiscoverSkills(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA) opts.IO.StopProgressIndicator() @@ -755,7 +752,7 @@ func matchSelectedSkills(skills []discovery.Skill, selected []string) ([]discove // collisionError checks for name collisions and returns an error with // guidance on how to install skills individually. func collisionError(ss []discovery.Skill, sourceHint string) error { - collisions := skills.FindNameCollisions(ss) + collisions := discovery.FindNameCollisions(ss) if len(collisions) == 0 { return nil } @@ -764,28 +761,28 @@ func collisionError(ss []discovery.Skill, sourceHint string) error { %s Install these skills individually using the full name: gh skills install %s namespace/skill-name - `, skills.FormatCollisions(collisions), sourceHint)) + `, discovery.FormatCollisions(collisions), sourceHint)) } -func resolveHosts(opts *installOptions, canPrompt bool) ([]*hosts.Host, error) { +func resolveHosts(opts *installOptions, canPrompt bool) ([]*registry.AgentHost, error) { if opts.Agent != "" { - h, err := hosts.FindByID(opts.Agent) + h, err := registry.FindByID(opts.Agent) if err != nil { return nil, err } - return []*hosts.Host{h}, nil + return []*registry.AgentHost{h}, nil } if !canPrompt { - h, err := hosts.FindByID("github-copilot") + h, err := registry.FindByID("github-copilot") if err != nil { return nil, err } - return []*hosts.Host{h}, nil + return []*registry.AgentHost{h}, nil } fmt.Fprintln(opts.IO.ErrOut) - names := hosts.HostNames() + names := registry.AgentNames() indices, err := opts.Prompter.MultiSelect("Select target agent(s):", []string{names[0]}, names) if err != nil { return nil, err @@ -795,41 +792,43 @@ func resolveHosts(opts *installOptions, canPrompt bool) ([]*hosts.Host, error) { return nil, fmt.Errorf("must select at least one target agent") } - selected := make([]*hosts.Host, len(indices)) + selected := make([]*registry.AgentHost, len(indices)) for i, idx := range indices { - selected[i] = &hosts.Registry[idx] + selected[i] = ®istry.Agents[idx] } return selected, nil } -func resolveScope(opts *installOptions, canPrompt bool) (hosts.Scope, error) { +func resolveScope(opts *installOptions, canPrompt bool) (registry.Scope, error) { if opts.Dir != "" { - return hosts.Scope(opts.Scope), nil + return registry.Scope(opts.Scope), nil } if opts.ScopeChanged || !canPrompt { - return hosts.Scope(opts.Scope), nil + return registry.Scope(opts.Scope), nil } var repoName string - if remote, err := opts.GitClient.RemoteURL("origin"); err == nil { - repoName = hosts.RepoNameFromRemote(remote) + if opts.Remotes != nil { + if remotes, err := opts.Remotes(); err == nil && len(remotes) > 0 { + repoName = ghrepo.FullName(remotes[0].Repo) + } } - idx, err := opts.Prompter.Select("Installation scope:", "", hosts.ScopeLabels(repoName)) + idx, err := opts.Prompter.Select("Installation scope:", "", registry.ScopeLabels(repoName)) if err != nil { return "", err } if idx == 0 { - return hosts.ScopeProject, nil + return registry.ScopeProject, nil } - return hosts.ScopeUser, nil + return registry.ScopeUser, nil } func truncateDescription(s string, maxWidth int) string { return text.Truncate(maxWidth, text.RemoveExcessiveWhitespace(s)) } -func checkOverwrite(opts *installOptions, skills []discovery.Skill, host *hosts.Host, scope hosts.Scope, gitRoot, homeDir string, canPrompt bool) ([]discovery.Skill, error) { +func checkOverwrite(opts *installOptions, skills []discovery.Skill, host *registry.AgentHost, scope registry.Scope, gitRoot, homeDir string, canPrompt bool) ([]discovery.Skill, error) { targetDir := opts.Dir if targetDir == "" { var err error @@ -991,3 +990,28 @@ func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo string, skillN } fmt.Fprintln(w) } + +func resolveGitRoot(gc *git.Client) string { + if gc == nil { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" + } + root, err := gc.ToplevelDir(context.Background()) + if err != nil { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" + } + return root +} + +func resolveHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index f53fd2267d9..658815b630b 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -13,8 +13,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" - "github.com/cli/cli/v2/internal/skills/gitclient" - "github.com/cli/cli/v2/internal/skills/hosts" + "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -23,27 +22,6 @@ import ( "github.com/stretchr/testify/require" ) -// mockGitClient implements installGitClient for testing. -type mockGitClient struct { - root string - remote string - err error -} - -func (m *mockGitClient) ToplevelDir() (string, error) { - if m.err != nil { - return "", m.err - } - return m.root, nil -} - -func (m *mockGitClient) RemoteURL(_ string) (string, error) { - if m.err != nil { - return "", m.err - } - return m.remote, nil -} - func TestNewCmdInstall_Help(t *testing.T) { ios, _, _, _ := iostreams.Test() f := &cmdutil.Factory{ @@ -184,7 +162,7 @@ func TestInstallRun_NonInteractive_NoRepo(t *testing.T) { opts := &installOptions{ IO: ios, - GitClient: &mockGitClient{root: "/tmp", remote: ""}, + GitClient: &git.Client{RepoDir: t.TempDir()}, } err := installRun(opts) @@ -368,11 +346,6 @@ func TestResolveHosts_NoneSelected(t *testing.T) { assert.Error(t, err) } -func TestTruncateSHA(t *testing.T) { - assert.Equal(t, "abc123de", gitclient.TruncateSHA("abc123def456")) - assert.Equal(t, "short", gitclient.TruncateSHA("short")) -} - func TestTruncateDescription(t *testing.T) { tests := []struct { name string @@ -457,7 +430,7 @@ func TestRunLocalInstall_NonInteractive(t *testing.T) { Agent: "github-copilot", Scope: "project", Dir: targetDir, - GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + GitClient: &git.Client{RepoDir: t.TempDir()}, } err := installRun(opts) @@ -489,7 +462,7 @@ func TestRunLocalInstall_SingleSkillDir(t *testing.T) { Agent: "github-copilot", Scope: "project", Dir: targetDir, - GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + GitClient: &git.Client{RepoDir: t.TempDir()}, } err := installRun(opts) @@ -577,7 +550,7 @@ func TestResolveScope_ExplicitFlag(t *testing.T) { IO: ios, Scope: "user", ScopeChanged: true, - GitClient: &mockGitClient{root: "/tmp", remote: ""}, + GitClient: &git.Client{RepoDir: t.TempDir()}, } scope, err := resolveScope(opts, true) require.NoError(t, err) @@ -590,7 +563,7 @@ func TestResolveScope_DirBypasses(t *testing.T) { IO: ios, Dir: "/tmp/custom", Scope: "project", - GitClient: &mockGitClient{root: "/tmp", remote: ""}, + GitClient: &git.Client{RepoDir: t.TempDir()}, } scope, err := resolveScope(opts, true) require.NoError(t, err) @@ -601,10 +574,10 @@ func TestCheckOverwrite_NoExisting(t *testing.T) { ios, _, _, _ := iostreams.Test() targetDir := t.TempDir() skills := []discovery.Skill{{Name: "new-skill"}} - host := &hosts.Host{ID: "test", ProjectDir: "skills"} + host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} opts := &installOptions{IO: ios, Dir: targetDir} - got, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) require.NoError(t, err) assert.Len(t, got, 1) } @@ -615,10 +588,10 @@ func TestCheckOverwrite_ExistingWithForce(t *testing.T) { ios, _, _, _ := iostreams.Test() skills := []discovery.Skill{{Name: "existing-skill"}} - host := &hosts.Host{ID: "test", ProjectDir: "skills"} + host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} opts := &installOptions{IO: ios, Dir: targetDir, Force: true} - got, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) require.NoError(t, err) assert.Len(t, got, 1) } @@ -629,10 +602,10 @@ func TestCheckOverwrite_ExistingNonInteractive(t *testing.T) { ios, _, _, _ := iostreams.Test() skills := []discovery.Skill{{Name: "existing-skill"}} - host := &hosts.Host{ID: "test", ProjectDir: "skills"} + host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} opts := &installOptions{IO: ios, Dir: targetDir} - _, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + _, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) assert.Error(t, err) assert.Contains(t, err.Error(), "already installed") } @@ -755,7 +728,7 @@ func TestInstallRun_RemoteInstall(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "owner/repo", SkillName: "test-skill", Agent: "github-copilot", @@ -880,7 +853,7 @@ func TestRunLocalInstall_NamespacedSkills(t *testing.T) { Agent: "github-copilot", Scope: "project", Dir: targetDir, - GitClient: &mockGitClient{root: t.TempDir(), remote: ""}, + GitClient: &git.Client{RepoDir: t.TempDir()}, } err := installRun(opts) @@ -908,10 +881,10 @@ func TestCheckOverwrite_NamespacedSkill(t *testing.T) { {Name: "xlsx-pro", Namespace: "alice"}, {Name: "xlsx-pro", Namespace: "bob"}, } - host := &hosts.Host{ID: "test", ProjectDir: "skills"} + host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} opts := &installOptions{IO: ios, Dir: targetDir, Force: true} - got, err := checkOverwrite(opts, skills, host, hosts.ScopeProject, "/tmp", "/home", false) + got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) require.NoError(t, err) assert.Len(t, got, 2, "both skills should be installable (force mode)") } diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go new file mode 100644 index 00000000000..9541f423086 --- /dev/null +++ b/pkg/cmd/skills/preview/preview.go @@ -0,0 +1,382 @@ +package preview + +import ( + "fmt" + "io" + "net/http" + "sort" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/markdown" + "github.com/spf13/cobra" +) + +type previewOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Prompter prompter.Prompter + Executable func() string + + RepoArg string + SkillName string + + repo ghrepo.Interface +} + +// NewCmdPreview creates the "skills preview" command. +func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra.Command { + opts := &previewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Prompter: f.Prompter, + Executable: f.Executable, + } + + cmd := &cobra.Command{ + Use: "preview []", + Short: "Preview a skill from a GitHub repository", + Long: heredoc.Doc(` + Render a skill's SKILL.md content in the terminal. This fetches the + skill file from the repository and displays it using the configured + pager, without installing anything. + + A file tree is shown first, followed by the rendered SKILL.md content. + When running interactively and the skill contains additional files + (scripts, references, etc.), a file picker lets you browse them + individually. + + When run with only a repository argument, lists available skills and + prompts for selection. + `), + Example: heredoc.Doc(` + # Preview a specific skill + $ gh skills preview github/awesome-copilot code-review + + # Browse and preview interactively + $ gh skills preview github/awesome-copilot + `), + Aliases: []string{"show"}, + Args: cobra.RangeArgs(1, 2), + RunE: func(c *cobra.Command, args []string) error { + opts.RepoArg = args[0] + if len(args) == 2 { + opts.SkillName = args[1] + } + + repo, err := ghrepo.FromFullName(opts.RepoArg) + if err != nil { + return err + } + opts.repo = repo + + if runF != nil { + return runF(opts) + } + return previewRun(opts) + }, + } + + return cmd +} + +func previewRun(opts *previewOptions) error { + cs := opts.IO.ColorScheme() + + repo := opts.repo + owner := repo.RepoOwner() + repoName := repo.RepoName() + hostname := repo.RepoHost() + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Resolving %s/%s", owner, repoName)) + resolved, err := discovery.ResolveRef(apiClient, hostname, owner, repoName, "") + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("could not resolve version: %w", err) + } + + opts.IO.StartProgressIndicatorWithLabel("Discovering skills") + skills, err := discovery.DiscoverSkills(apiClient, hostname, owner, repoName, resolved.SHA) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + sort.Slice(skills, func(i, j int) bool { + return skills[i].DisplayName() < skills[j].DisplayName() + }) + + skill, err := selectSkill(opts, skills) + if err != nil { + return err + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching skill content") + var files []discovery.SkillFile + if skill.TreeSHA != "" { + files, err = discovery.ListSkillFiles(apiClient, hostname, owner, repoName, skill.TreeSHA) + if err != nil { + fmt.Fprintf(opts.IO.ErrOut, "warning: could not list skill files: %v\n", err) + files = nil + } + } + content, err := discovery.FetchBlob(apiClient, hostname, owner, repoName, skill.BlobSHA) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + parsed, parseErr := frontmatter.Parse(content) + if parseErr == nil { + content = parsed.Body + } + + rendered, err := markdown.Render(content, + markdown.WithTheme(opts.IO.TerminalTheme()), + markdown.WithWrap(opts.IO.TerminalWidth()), + markdown.WithoutIndentation()) + if err != nil { + rendered = content + } + + // Collect extra files (everything that isn't SKILL.md) + var extraFiles []discovery.SkillFile + for _, f := range files { + if f.Path != "SKILL.md" { + extraFiles = append(extraFiles, f) + } + } + + canPrompt := opts.IO.CanPrompt() + + // Non-interactive or skill has only SKILL.md: dump through pager + if !canPrompt || len(extraFiles) == 0 { + return renderAllFiles(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) + } + + // Interactive with multiple files: show tree, then file picker + return renderInteractive(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) +} + +// renderAllFiles dumps the tree, SKILL.md, and all extra files through the pager. +func renderAllFiles(opts *previewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, + files []discovery.SkillFile, rendered string, extraFiles []discovery.SkillFile, + apiClient *api.Client, hostname, owner, repo string) error { + + opts.IO.DetectTerminalTheme() + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "starting pager failed: %v\n", err) + } + defer opts.IO.StopPager() + + out := opts.IO.Out + + if len(files) > 0 { + fmt.Fprintf(out, "%s\n", cs.Bold(skill.DisplayName()+"/")) + renderFileTree(out, cs, files) + fmt.Fprintln(out) + } + + fmt.Fprintf(out, "%s\n\n", cs.Bold("── SKILL.md ──")) + fmt.Fprint(out, rendered) + + const maxFiles = 20 + const maxTotalBytes = 512 * 1024 + fetched := 0 + totalBytes := 0 + for _, f := range extraFiles { + if fetched >= maxFiles { + fmt.Fprintf(out, "\n%s\n", cs.Gray(fmt.Sprintf("(skipped remaining files — showing first %d)", maxFiles))) + break + } + if totalBytes+f.Size > maxTotalBytes && fetched > 0 { + fmt.Fprintf(out, "\n%s\n", cs.Gray("(skipped remaining files — size limit reached)")) + break + } + fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, f.SHA) + if fetchErr != nil { + fmt.Fprintf(out, "\n%s\n\n%s\n", cs.Bold("── "+f.Path+" ──"), cs.Gray("(could not fetch file)")) + continue + } + fetched++ + totalBytes += len(fileContent) + fmt.Fprintf(out, "\n%s\n\n", cs.Bold("── "+f.Path+" ──")) + fmt.Fprint(out, fileContent) + if !strings.HasSuffix(fileContent, "\n") { + fmt.Fprintln(out) + } + } + + return nil +} + +// renderInteractive shows the file tree, then a picker to browse individual files. +func renderInteractive(opts *previewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, + files []discovery.SkillFile, renderedSkillMD string, extraFiles []discovery.SkillFile, + apiClient *api.Client, hostname, owner, repo string) error { + + // Show the file tree to stderr so it persists above the prompt + fmt.Fprintf(opts.IO.ErrOut, "\n%s\n", cs.Bold(skill.DisplayName()+"/")) + renderFileTree(opts.IO.ErrOut, cs, files) + fmt.Fprintln(opts.IO.ErrOut) + + // Build choices: SKILL.md first, then extra files + choices := make([]string, 0, len(extraFiles)+1) + choices = append(choices, "SKILL.md") + for _, f := range extraFiles { + choices = append(choices, f.Path) + } + + // Save original stdout — StopPager closes IO.Out, so we need to + // restore a working writer before each StartPager call. + originalOut := opts.IO.Out + + for { + // Restore original Out before each pager cycle. StartPager replaces + // IO.Out with a pipe; StopPager closes that pipe but does not + // restore the original. The original writer remains valid. + opts.IO.Out = originalOut + + idx, err := opts.Prompter.Select("View a file (Esc to exit):", "", choices) + if err != nil { + return nil //nolint:nilerr // Prompter returns error on Esc/Ctrl-C; treat as graceful exit + } + + var content string + + if idx == 0 { + content = renderedSkillMD + } else { + selectedFile := extraFiles[idx-1] + + // Fetch on demand — don't hold blob data in memory + fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, selectedFile.SHA) + if fetchErr != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s could not fetch %s: %v\n", cs.Red("!"), selectedFile.Path, fetchErr) + continue + } + content = fileContent + if !strings.HasSuffix(content, "\n") { + content += "\n" + } + } + + if err := opts.IO.StartPager(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "starting pager failed: %v\n", err) + } + fmt.Fprint(opts.IO.Out, content) + opts.IO.StopPager() + } +} + +func selectSkill(opts *previewOptions, skills []discovery.Skill) (discovery.Skill, error) { + if opts.SkillName != "" { + for _, s := range skills { + if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName { + return s, nil + } + } + return discovery.Skill{}, fmt.Errorf("skill %q not found in %s", opts.SkillName, ghrepo.FullName(opts.repo)) + } + + if !opts.IO.CanPrompt() { + return discovery.Skill{}, fmt.Errorf("must specify a skill name when not running interactively") + } + + choices := make([]string, len(skills)) + for i, s := range skills { + choices[i] = s.DisplayName() + } + + idx, err := opts.Prompter.Select("Select a skill to preview:", "", choices) + if err != nil { + return discovery.Skill{}, err + } + + return skills[idx], nil +} + +// treeNode represents a file or directory in the tree for rendering. +type treeNode struct { + name string + children []*treeNode + isDir bool +} + +// renderFileTree prints a tree of skill files using box-drawing characters. +func renderFileTree(w io.Writer, cs *iostreams.ColorScheme, files []discovery.SkillFile) { + root := buildTree(files) + printTree(w, cs, root.children, "") +} + +// buildTree constructs a tree structure from flat file paths. +func buildTree(files []discovery.SkillFile) *treeNode { + root := &treeNode{isDir: true} + for _, f := range files { + parts := strings.Split(f.Path, "/") + current := root + for i, part := range parts { + isLast := i == len(parts)-1 + found := false + for _, child := range current.children { + if child.name == part { + current = child + found = true + break + } + } + if !found { + node := &treeNode{name: part, isDir: !isLast} + current.children = append(current.children, node) + current = node + } + } + } + sortTree(root) + return root +} + +func sortTree(node *treeNode) { + sort.Slice(node.children, func(i, j int) bool { + if node.children[i].isDir != node.children[j].isDir { + return node.children[i].isDir + } + return node.children[i].name < node.children[j].name + }) + for _, child := range node.children { + if child.isDir { + sortTree(child) + } + } +} + +func printTree(w io.Writer, cs *iostreams.ColorScheme, nodes []*treeNode, indent string) { + for i, node := range nodes { + isLast := i == len(nodes)-1 + connector := "├── " + childIndent := "│ " + if isLast { + connector = "└── " + childIndent = " " + } + if node.isDir { + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), cs.Bold(node.name+"/")) + printTree(w, cs, node.children, indent+cs.Gray(childIndent)) + } else { + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), node.name) + } + } +} diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go new file mode 100644 index 00000000000..b774558283c --- /dev/null +++ b/pkg/cmd/skills/preview/preview_test.go @@ -0,0 +1,466 @@ +package preview + +import ( + "encoding/base64" + "fmt" + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdPreview(t *testing.T) { + tests := []struct { + name string + input string + wantRepo string + wantSkillName string + wantErr bool + }{ + { + name: "repo and skill", + input: "github/awesome-copilot my-skill", + wantRepo: "github/awesome-copilot", + wantSkillName: "my-skill", + }, + { + name: "repo only", + input: "github/awesome-copilot", + wantRepo: "github/awesome-copilot", + }, + { + name: "no args", + input: "", + wantErr: true, + }, + { + name: "too many args", + input: "a b c", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Prompter: &prompter.PrompterMock{}, + } + + var gotOpts *previewOptions + cmd := NewCmdPreview(f, func(opts *previewOptions) error { + gotOpts = opts + return nil + }) + + args, _ := shlex.Split(tt.input) + cmd.SetArgs(args) + cmd.SetOut(&discardWriter{}) + cmd.SetErr(&discardWriter{}) + err := cmd.Execute() + + if tt.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.wantRepo, gotOpts.RepoArg) + assert.Equal(t, tt.wantSkillName, gotOpts.SkillName) + }) + } +} + +func TestNewCmdPreview_Alias(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}} + cmd := NewCmdPreview(f, func(_ *previewOptions) error { return nil }) + assert.Contains(t, cmd.Aliases, "show") +} + +func TestPreviewRun(t *testing.T) { + skillContent := "---\nname: my-skill\ndescription: A test skill\n---\n# My Skill\n\nThis is the skill content." + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + tests := []struct { + name string + opts *previewOptions + tty bool + httpStubs func(*httpmock.Registry) + wantStdout string + wantErr string + }{ + { + name: "preview specific skill", + tty: true, + opts: &previewOptions{ + repo: ghrepo.New("github", "awesome-copilot"), + SkillName: "my-skill", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills", "type": "tree", "sha": "tree1"}, + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob123", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/blobs/blob123"), + httpmock.StringResponse(`{"sha": "blob123", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + }, + wantStdout: "My Skill", + }, + { + name: "preview with display name match", + tty: true, + opts: &previewOptions{ + repo: ghrepo.New("owner", "repo"), + SkillName: "ns/my-skill", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills", "type": "tree", "sha": "tree1"}, + {"path": "skills/ns", "type": "tree", "sha": "tree-ns"}, + {"path": "skills/ns/my-skill", "type": "tree", "sha": "treeSHA2"}, + {"path": "skills/ns/my-skill/SKILL.md", "type": "blob", "sha": "blob456"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA2"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob456", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob456"), + httpmock.StringResponse(`{"sha": "blob456", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + }, + wantStdout: "My Skill", + }, + { + name: "skill not found", + tty: true, + opts: &previewOptions{ + repo: ghrepo.New("owner", "repo"), + SkillName: "nonexistent", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "tree2"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"} + ] + }`), + ) + }, + wantErr: `skill "nonexistent" not found in owner/repo`, + }, + { + name: "no skill name non-interactive errors", + tty: false, + opts: &previewOptions{ + repo: ghrepo.New("owner", "repo"), + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "tree2"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"} + ] + }`), + ) + }, + wantErr: "must specify a skill name when not running interactively", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + ios.SetStdinTTY(tt.tty) + tt.opts.IO = ios + + tt.opts.Prompter = &prompter.PrompterMock{} + + err := previewRun(tt.opts) + + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + }) + } +} + +func TestPreviewRun_Interactive(t *testing.T) { + skillContent := "# Selected Skill\n\nContent here." + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/alpha", "type": "tree", "sha": "tree-a"}, + {"path": "skills/alpha/SKILL.md", "type": "blob", "sha": "blob-a"}, + {"path": "skills/beta", "type": "tree", "sha": "tree-b"}, + {"path": "skills/beta/SKILL.md", "type": "blob", "sha": "blob-b"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/tree-b"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob-b", "size": 40} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob-b"), + httpmock.StringResponse(`{"sha": "blob-b", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + assert.Equal(t, "Select a skill to preview:", prompt) + assert.Equal(t, []string{"alpha", "beta"}, options) + return 1, nil // select "beta" + }, + } + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + repo: ghrepo.New("owner", "repo"), + } + + err := previewRun(opts) + assert.NoError(t, err) + assert.Contains(t, stdout.String(), "Selected Skill") +} + +func TestPreviewRun_ShowsFileTree(t *testing.T) { + skillContent := "---\nname: my-skill\ndescription: test\n---\n# My Skill\nBody." + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + scriptContent := "#!/bin/bash\necho hello" + encodedScript := base64.StdEncoding.EncodeToString([]byte(scriptContent)) + + makeReg := func() *httpmock.Registry { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"}, + {"path": "skills/my-skill/scripts", "type": "tree", "sha": "treeScripts"}, + {"path": "skills/my-skill/scripts/run.sh", "type": "blob", "sha": "blobScript"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50}, + {"path": "scripts", "type": "tree", "sha": "treeScripts"}, + {"path": "scripts/run.sh", "type": "blob", "sha": "blobScript", "size": 20} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSKILL"), + httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobScript"), + httpmock.StringResponse(`{"sha": "blobScript", "content": "`+encodedScript+`", "encoding": "base64"}`), + ) + return reg + } + + t.Run("interactive file picker", func(t *testing.T) { + reg := makeReg() + defer reg.Verify(t) + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetColorEnabled(false) + + selectCalls := 0 + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + selectCalls++ + if selectCalls == 1 { + // Options: ["SKILL.md", "scripts/run.sh"] + assert.Equal(t, "SKILL.md", options[0]) + assert.Equal(t, "scripts/run.sh", options[1]) + // Select "scripts/run.sh" + return 1, nil + } + // Simulate Esc/Ctrl-C to exit + return 0, fmt.Errorf("user cancelled") + }, + } + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + repo: ghrepo.New("owner", "repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + assert.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "echo hello") + assert.Equal(t, 2, selectCalls) + }) + + t.Run("non-interactive dumps all files", func(t *testing.T) { + reg := makeReg() + defer reg.Verify(t) + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + ios.SetColorEnabled(false) + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("owner", "repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + assert.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "my-skill/") + assert.Contains(t, out, "My Skill") + assert.Contains(t, out, "scripts/run.sh") + assert.Contains(t, out, "echo hello") + }) +} + +// discardWriter is a no-op writer for suppressing cobra output in tests. +type discardWriter struct{} + +func (d *discardWriter) Write(p []byte) (int, error) { return len(p), nil } diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go new file mode 100644 index 00000000000..9a920013159 --- /dev/null +++ b/pkg/cmd/skills/publish/publish.go @@ -0,0 +1,1246 @@ +package publish + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" + giturl "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +// publishOptions holds all dependencies and user-provided flags for the publish command. +type publishOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Config func() (gh.Config, error) + Prompter prompter.Prompter + GitClient *git.Client + + // Arguments + Dir string // directory to validate (default: cwd) + + // Flags + Fix bool // --fix flag: auto-fix issues where possible + Plugins bool // --plugins flag: generate .claude-plugin/ manifest + DryRun bool // --dry-run flag: validate only, don't publish + Tag string // --tag flag: release tag to create + + // Testing overrides + client *api.Client // injectable for tests; nil means use factory HttpClient + host string // API host (e.g. "github.com"); resolved from config in production +} + +// publishDiagnostic is a single validation finding. +type publishDiagnostic struct { + skill string // empty for repo-level issues + severity string // "error", "warning", "fixed", or "info" + message string +} + +// repoTopicsResponse is the response from the repo topics API. +type repoTopicsResponse struct { + Names []string `json:"names"` +} + +// tagEntry is a single tag from the tags list API. +type tagEntry struct { + Name string `json:"name"` +} + +// rulesetsResponse is a single ruleset from the rulesets API. +type rulesetsResponse struct { + ID int `json:"id"` + Name string `json:"name"` + Target string `json:"target"` + Enforcement string `json:"enforcement"` +} + +// securityAnalysis represents the security_and_analysis field from the repo API. +type securityAnalysis struct { + AdvancedSecurity *securityFeature `json:"advanced_security"` + SecretScanning *securityFeature `json:"secret_scanning"` + SecretScanningPushProtection *securityFeature `json:"secret_scanning_push_protection"` +} + +type securityFeature struct { + Status string `json:"status"` +} + +// repoSecurityResponse is the subset of repo API we need for security checks. +type repoSecurityResponse struct { + SecurityAndAnalysis *securityAnalysis `json:"security_and_analysis"` +} + +// NewCmdPublish creates the "skills publish" command. +func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra.Command { + opts := &publishOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Prompter: f.Prompter, + GitClient: f.GitClient, + } + + cmd := &cobra.Command{ + Use: "publish []", + Short: "Validate and publish skills to a GitHub repository", + Long: heredoc.Doc(` + Validate a local repository's skills against the Agent Skills specification + and publish them by creating a GitHub release. + + Validation checks include: + + - Skills follow the skills/*/SKILL.md directory convention + - Skill names match the strict agentskills.io naming rules + - Each skill name matches its directory name + - Required frontmatter fields (name, description) are present + - allowed-tools is a string, not an array + - Install metadata (metadata.github-*) is stripped if present + + After validation passes, publish will interactively guide you through: + + - Adding the "agent-skills" topic to the repository + - Choosing a version tag (semver recommended) + - Creating a GitHub release with auto-generated notes + + Use --dry-run to validate without publishing. + Use --tag to publish non-interactively with a specific tag. + Use --fix to automatically strip install metadata from committed files. + Use --plugins to generate a .claude-plugin/plugin.json manifest for + Claude Code plugin discovery. + `), + Example: heredoc.Doc(` + # Validate and publish interactively + $ gh skills publish + + # Publish with a specific tag (non-interactive) + $ gh skills publish --tag v1.0.0 + + # Validate only (no publish) + $ gh skills publish --dry-run + + # Validate and strip install metadata + $ gh skills publish --fix + + # Generate Claude Code plugin manifest + $ gh skills publish --plugins + `), + Aliases: []string{"validate"}, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 1 { + opts.Dir = args[0] + } + if runF != nil { + return runF(opts) + } + return publishRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.Fix, "fix", false, "Auto-fix issues where possible (e.g. strip install metadata)") + cmd.Flags().BoolVar(&opts.Plugins, "plugins", false, "Generate .claude-plugin/ manifest for Claude Code plugin discovery") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Validate without publishing") + cmd.Flags().StringVar(&opts.Tag, "tag", "", "Version tag for the release (e.g. v1.0.0)") + + return cmd +} + +func publishRun(opts *publishOptions) error { + dir := opts.Dir + if dir == "" { + var err error + dir, err = os.Getwd() + if err != nil { + return fmt.Errorf("could not determine working directory: %w", err) + } + } + + dir, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("could not resolve path: %w", err) + } + + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + + // Use injected client or create one from the factory HttpClient + client := opts.client + host := opts.host + + var diagnostics []publishDiagnostic + + // Check for skills directory + skillsDir := filepath.Join(dir, "skills") + info, err := os.Stat(skillsDir) + if err != nil || !info.IsDir() { + return fmt.Errorf("no skills/ directory found in %s; run this command from a repository root containing a skills/ directory", dir) + } + + // Discover skill directories + entries, err := os.ReadDir(skillsDir) + if err != nil { + return fmt.Errorf("could not read skills/ directory: %w", err) + } + + var skillDirs []string + for _, e := range entries { + if e.IsDir() { + skillDirs = append(skillDirs, e.Name()) + } + } + + if len(skillDirs) == 0 { + return fmt.Errorf("no skill directories found in %s/skills/", dir) + } + + for _, dirName := range skillDirs { + skillPath := filepath.Join(skillsDir, dirName, "SKILL.md") + content, err := os.ReadFile(skillPath) + if err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: "missing SKILL.md file", + }) + continue + } + + result, err := frontmatter.Parse(string(content)) + if err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: fmt.Sprintf("invalid frontmatter YAML: %s", err), + }) + continue + } + + // Validate name field exists + if result.Metadata.Name == "" { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: "missing required field: name", + }) + } else { + // Validate name matches directory + if result.Metadata.Name != dirName { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: fmt.Sprintf("name %q does not match directory name %q", result.Metadata.Name, dirName), + }) + } + + // Validate name is spec-compliant + if !discovery.IsSpecCompliant(result.Metadata.Name) { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: fmt.Sprintf("name %q does not follow agentskills.io naming convention (lowercase alphanumeric + hyphens)", result.Metadata.Name), + }) + } + } + + // Validate description field exists + if result.Metadata.Description == "" { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: "missing required field: description", + }) + } else if len(result.Metadata.Description) > 1024 { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "warning", + message: fmt.Sprintf("description is %d chars (recommended max: 1024)", len(result.Metadata.Description)), + }) + } + + // Validate allowed-tools is string, not array + if raw, ok := result.RawYAML["allowed-tools"]; ok { + if _, isSlice := raw.([]interface{}); isSlice { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: "allowed-tools must be a string (space-delimited), not an array", + }) + } + } + + // Check for install metadata that should be stripped + if meta, ok := result.RawYAML["metadata"].(map[string]interface{}); ok { + githubKeys := findGitHubMetadataKeys(meta) + if len(githubKeys) > 0 { + if opts.Fix { + fixed, fixErr := stripGitHubMetadata(string(content)) + if fixErr != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: fmt.Sprintf("could not strip install metadata: %s", fixErr), + }) + } else if writeErr := os.WriteFile(skillPath, []byte(fixed), 0o644); writeErr != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: fmt.Sprintf("could not write fixed SKILL.md: %s", writeErr), + }) + } else { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "fixed", + message: fmt.Sprintf("stripped install metadata: %s", strings.Join(githubKeys, ", ")), + }) + } + } else { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "error", + message: fmt.Sprintf("contains install metadata that must be stripped: %s (use --fix)", strings.Join(githubKeys, ", ")), + }) + } + } + } + + // Recommended: license field + if result.Metadata.License == "" { + if _, ok := result.RawYAML["license"]; !ok { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "warning", + message: "recommended field missing: license", + }) + } + } + + // Recommended: body length + bodyLines := strings.Count(result.Body, "\n") + 1 + if bodyLines > 500 { + diagnostics = append(diagnostics, publishDiagnostic{ + skill: dirName, + severity: "warning", + message: fmt.Sprintf("skill body is %d lines (recommended max: 500 for efficient context)", bodyLines), + }) + } + } + + // Check for installed skill directories that should be gitignored + installedDirDiags := checkInstalledSkillDirs(opts.GitClient, dir) + diagnostics = append(diagnostics, installedDirDiags...) + + // Remote repository checks (best-effort) + owner, repo := detectGitHubRemote(opts.GitClient) + hasTopic := false + var existingTags []tagEntry + if owner != "" && repo != "" { + // Create API client for remote checks if not already injected + if client == nil { + httpClient, httpErr := opts.HttpClient() + if httpErr == nil { + apiClient := api.NewClientFromHTTP(httpClient) + cfg, cfgErr := opts.Config() + if cfgErr == nil { + host, _ = cfg.Authentication().DefaultHost() + client = apiClient + } + } + } + + if client != nil { + // Security and ruleset checks (advisory, always shown) + securityDiags := checkSecuritySettings(client, host, owner, repo, skillsDir) + diagnostics = append(diagnostics, securityDiags...) + + rulesetDiags := checkTagProtection(client, host, owner, repo) + diagnostics = append(diagnostics, rulesetDiags...) + + // Check topic (needed for publish flow, not a blocking error) + hasTopic = repoHasTopic(client, host, owner, repo) + + // Fetch existing tags (needed for version suggestion) + existingTags = fetchTags(client, host, owner, repo) + } + } else { + diagnostics = append(diagnostics, detectMissingRepoDiagnostic(opts.GitClient, dir)...) + } + + // Render diagnostics + errors, warnings, fixes := 0, 0, 0 + for _, d := range diagnostics { + switch d.severity { + case "error": + errors++ + case "warning": + warnings++ + case "fixed": + fixes++ + } + } + + if canPrompt { + renderDiagnosticsTTY(opts, skillDirs, diagnostics, errors, warnings, fixes, owner, repo) + } else { + renderDiagnosticsPlain(opts, diagnostics, errors, warnings) + } + + // Generate Claude Code plugin manifest if requested + if opts.Plugins { + pluginDiags := generateClaudePlugin(dir, skillDirs, owner, repo) + for _, d := range pluginDiags { + switch d.severity { + case "error": + fmt.Fprintf(opts.IO.ErrOut, "%s %s\n", cs.FailureIcon(), d.message) + default: + fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.SuccessIcon(), d.message) + } + } + } + + if errors > 0 { + return fmt.Errorf("validation failed with %d error(s)", errors) + } + + // --- Publish flow --- + if opts.DryRun { + fmt.Fprintf(opts.IO.ErrOut, "\nDry run complete. Use without --dry-run to publish.\n") + return nil + } + + if owner == "" || repo == "" { + fmt.Fprintf(opts.IO.ErrOut, "\nValidation passed. Set up a GitHub remote to publish.\n") + return nil + } + + if !canPrompt && opts.Tag == "" { + fmt.Fprintf(opts.IO.ErrOut, "\nValidation passed. Use --tag to publish non-interactively.\n") + return nil + } + + if client == nil { + fmt.Fprintf(opts.IO.ErrOut, "\nValidation passed but could not create API client. Check your authentication configuration.\n") + return nil + } + + fmt.Fprintf(opts.IO.ErrOut, "\nPublishing to %s/%s...\n\n", owner, repo) + + return runPublishRelease(opts, client, host, owner, repo, dir, hasTopic, existingTags) +} + +// repoHasTopic checks whether the repo has the agent-skills topic. +func repoHasTopic(client *api.Client, host, owner, repo string) bool { + if client == nil { + return false + } + apiPath := fmt.Sprintf("repos/%s/%s/topics", owner, repo) + var resp repoTopicsResponse + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return false + } + for _, t := range resp.Names { + if t == "agent-skills" { + return true + } + } + return false +} + +// fetchTags returns the most recent tags from the repo. +func fetchTags(client *api.Client, host, owner, repo string) []tagEntry { + if client == nil { + return nil + } + apiPath := fmt.Sprintf("repos/%s/%s/tags?per_page=10", owner, repo) + var tags []tagEntry + if err := client.REST(host, "GET", apiPath, nil, &tags); err != nil { + return nil + } + return tags +} + +// runPublishRelease handles the interactive publish flow: topic, tag, release, immutability. +func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, repo, dir string, hasTopic bool, existingTags []tagEntry) error { + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + + // 1. Add topic if missing + if !hasTopic { + addTopic := true + if canPrompt { + var err error + addTopic, err = opts.Prompter.Confirm( + fmt.Sprintf("Add \"agent-skills\" topic to %s/%s? (required for discoverability)", owner, repo), true) + if err != nil { + return err + } + } + if addTopic { + if err := addAgentSkillsTopic(client, host, owner, repo); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Could not add topic: %v\n", cs.WarningIcon(), err) + fmt.Fprintf(opts.IO.ErrOut, " Add it manually: gh repo edit %s/%s --add-topic agent-skills\n", owner, repo) + } else { + fmt.Fprintf(opts.IO.Out, "%s Added \"agent-skills\" topic\n", cs.SuccessIcon()) + } + } + } + + // 2. Determine tag + tag := opts.Tag + if tag == "" { + suggested := "v1.0.0" + if len(existingTags) > 0 { + if next := suggestNextTag(existingTags[0].Name); next != "" { + suggested = next + } + } + + if canPrompt { + strategies := []string{ + fmt.Sprintf("Semver (recommended) — %s", suggested), + "Custom tag", + } + idx, err := opts.Prompter.Select("Tagging strategy:", "", strategies) + if err != nil { + return err + } + + if idx == 0 { + tag = suggested + edited, err := opts.Prompter.Input(fmt.Sprintf("Version tag [%s]:", suggested), suggested) + if err != nil { + return err + } + if edited != "" { + tag = edited + } + } else { + custom, err := opts.Prompter.Input("Tag:", "") + if err != nil { + return err + } + if custom == "" { + return fmt.Errorf("tag is required") + } + tag = custom + } + } else { + return fmt.Errorf("--tag is required for non-interactive publish") + } + } + + // Validate tag doesn't already exist + for _, t := range existingTags { + if t.Name == tag { + return fmt.Errorf("tag %s already exists — choose a different version", tag) + } + } + + // 3. Offer to enable immutable releases + immutableEnabled := checkImmutableReleases(client, host, owner, repo) + if !immutableEnabled && canPrompt { + enableImmutable, err := opts.Prompter.Confirm( + "Enable immutable releases? (prevents tampering with published releases)", true) + if err != nil { + return err + } + if enableImmutable { + if err := enableImmutableReleases(client, host, owner, repo); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Could not enable immutable releases: %v\n", cs.WarningIcon(), err) + fmt.Fprintf(opts.IO.ErrOut, " Enable manually in Settings → General → Releases\n") + } else { + fmt.Fprintf(opts.IO.Out, "%s Enabled immutable releases\n", cs.SuccessIcon()) + } + } + } + + // 4. Inform if not on default branch + var currentBranch string + if opts.GitClient != nil { + bc := *opts.GitClient + bc.RepoDir = dir + if b, err := bc.CurrentBranch(context.Background()); err == nil { + currentBranch = b + } + } + defaultBranch := detectDefaultBranch(client, host, owner, repo) + if currentBranch != "" && defaultBranch != "" && currentBranch != defaultBranch { + fmt.Fprintf(opts.IO.ErrOut, "%s Publishing from branch %q (default is %q)\n", cs.WarningIcon(), currentBranch, defaultBranch) + } + + // 5. Confirm and create release + if canPrompt { + confirmed, err := opts.Prompter.Confirm( + fmt.Sprintf("Create release %s with auto-generated notes?", tag), true) + if err != nil { + return err + } + if !confirmed { + fmt.Fprintf(opts.IO.ErrOut, "Publish cancelled.\n") + return nil + } + } + + // Create release via REST API + releaseBody := map[string]interface{}{ + "tag_name": tag, + "generate_release_notes": true, + } + if currentBranch != "" { + releaseBody["target_commitish"] = currentBranch + } + releaseJSON, err := json.Marshal(releaseBody) + if err != nil { + return fmt.Errorf("failed to serialize release request: %w", err) + } + + releasePath := fmt.Sprintf("repos/%s/%s/releases", owner, repo) + var releaseResp struct { + HTMLURL string `json:"html_url"` + } + if err := client.REST(host, "POST", releasePath, bytes.NewReader(releaseJSON), &releaseResp); err != nil { + return fmt.Errorf("failed to create release: %w", err) + } + + fmt.Fprintf(opts.IO.Out, "%s Published %s\n", cs.SuccessIcon(), tag) + fmt.Fprintf(opts.IO.Out, "%s Install with: gh skills install %s/%s\n", cs.SuccessIcon(), owner, repo) + fmt.Fprintf(opts.IO.Out, "%s Pin with: gh skills install %s/%s --pin %s\n", cs.SuccessIcon(), owner, repo, tag) + + return nil +} + +// detectDefaultBranch returns the default branch of the remote repo via the API. +func detectDefaultBranch(client *api.Client, host, owner, repo string) string { + if client == nil { + return "" + } + var result struct { + DefaultBranch string `json:"default_branch"` + } + if err := client.REST(host, "GET", fmt.Sprintf("repos/%s/%s", owner, repo), nil, &result); err != nil { + return "" + } + return result.DefaultBranch +} + +// addAgentSkillsTopic adds the "agent-skills" topic to the repo, preserving existing topics. +func addAgentSkillsTopic(client *api.Client, host, owner, repo string) error { + apiPath := fmt.Sprintf("repos/%s/%s/topics", owner, repo) + + // Fetch existing topics + var resp repoTopicsResponse + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return fmt.Errorf("could not fetch existing topics: %w", err) + } + + // Deduplicate: only add if not already present + for _, t := range resp.Names { + if t == "agent-skills" { + return nil + } + } + + topics := append(resp.Names, "agent-skills") + topicsJSON, err := json.Marshal(map[string][]string{"names": topics}) + if err != nil { + return fmt.Errorf("could not serialize topics: %w", err) + } + return client.REST(host, "PUT", apiPath, bytes.NewReader(topicsJSON), nil) +} + +// checkImmutableReleases checks if immutable releases are enabled for the repo. +func checkImmutableReleases(client *api.Client, host, owner, repo string) bool { + if client == nil { + return false + } + apiPath := fmt.Sprintf("repos/%s/%s/immutable-releases", owner, repo) + var resp struct { + Enabled bool `json:"enabled"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return false + } + return resp.Enabled +} + +// enableImmutableReleases enables immutable releases for the repo. +func enableImmutableReleases(client *api.Client, host, owner, repo string) error { + apiPath := fmt.Sprintf("repos/%s/%s/immutable-releases", owner, repo) + body := bytes.NewReader([]byte(`{"enabled":true}`)) + return client.REST(host, "PATCH", apiPath, body, nil) +} + +// checkTagProtection checks whether tag protection rulesets are enabled. +func checkTagProtection(client *api.Client, host, owner, repo string) []publishDiagnostic { + if client == nil { + return nil + } + apiPath := fmt.Sprintf("repos/%s/%s/rulesets", owner, repo) + var rulesets []rulesetsResponse + if err := client.REST(host, "GET", apiPath, nil, &rulesets); err != nil { + return nil + } + + for _, rs := range rulesets { + if rs.Target == "tag" && rs.Enforcement == "active" { + return nil + } + } + + return []publishDiagnostic{{ + severity: "warning", + message: "no active tag protection rulesets found — consider protecting tags to ensure immutable releases (Settings → Rules → Rulesets)", + }} +} + +// checkSecuritySettings checks whether recommended security features are enabled. +func checkSecuritySettings(client *api.Client, host, owner, repo, skillsDir string) []publishDiagnostic { + if client == nil { + return nil + } + apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) + var resp repoSecurityResponse + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return nil + } + + if resp.SecurityAndAnalysis == nil { + return nil + } + + var diagnostics []publishDiagnostic + sa := resp.SecurityAndAnalysis + + if sa.SecretScanning == nil || sa.SecretScanning.Status != "enabled" { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: "secret scanning is not enabled — recommended to prevent accidental credential exposure (gh repo edit --enable-secret-scanning)", + }) + } + + if sa.SecretScanningPushProtection == nil || sa.SecretScanningPushProtection.Status != "enabled" { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: "secret scanning push protection is not enabled — blocks pushes containing secrets (gh repo edit --enable-secret-scanning-push-protection)", + }) + } + + hasCode, hasManifests := detectCodeAndManifests(skillsDir) + + if hasCode { + alertsPath := fmt.Sprintf("repos/%s/%s/code-scanning/alerts?per_page=1&state=open", owner, repo) + if err := client.REST(host, "GET", alertsPath, nil, new([]interface{})); err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "info", + message: "skills include code files but code scanning does not appear to be configured (Settings → Code security → Code scanning)", + }) + } + } + + if hasManifests { + dependabotPath := fmt.Sprintf("repos/%s/%s/vulnerability-alerts", owner, repo) + if err := client.REST(host, "GET", dependabotPath, nil, nil); err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "info", + message: "skills include dependency manifests but Dependabot alerts do not appear to be enabled (Settings → Code security → Dependabot)", + }) + } + } + + return diagnostics +} + +// codeExtensions are file extensions that indicate code is present. +var codeExtensions = map[string]bool{ + ".go": true, ".py": true, ".js": true, ".ts": true, ".rb": true, + ".rs": true, ".java": true, ".cs": true, ".sh": true, ".bash": true, + ".zsh": true, ".ps1": true, ".swift": true, ".kt": true, ".c": true, + ".cpp": true, ".h": true, ".php": true, ".pl": true, ".lua": true, +} + +// manifestFiles are dependency manifest filenames. +var manifestFiles = map[string]bool{ + "package.json": true, "package-lock.json": true, "yarn.lock": true, + "go.mod": true, "go.sum": true, "Cargo.toml": true, "Cargo.lock": true, + "requirements.txt": true, "Pipfile": true, "Pipfile.lock": true, + "pyproject.toml": true, "poetry.lock": true, "Gemfile": true, + "Gemfile.lock": true, "pom.xml": true, "build.gradle": true, + "composer.json": true, "composer.lock": true, +} + +// detectCodeAndManifests walks the skills directory looking for code files +// and dependency manifests. +func detectCodeAndManifests(skillsDir string) (hasCode, hasManifests bool) { + _ = filepath.Walk(skillsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + ext := filepath.Ext(info.Name()) + if codeExtensions[ext] { + hasCode = true + } + if manifestFiles[info.Name()] { + hasManifests = true + } + if hasCode && hasManifests { + return filepath.SkipAll + } + return nil + }) + return +} + +// checkInstalledSkillDirs warns when agent host skill directories exist +// in the repo and are not gitignored. +func checkInstalledSkillDirs(gitClient *git.Client, repoDir string) []publishDiagnostic { + var diagnostics []publishDiagnostic + + for _, relPath := range registry.UniqueProjectDirs() { + absPath := filepath.Join(repoDir, relPath) + if _, err := os.Stat(absPath); os.IsNotExist(err) { + continue + } + + if gitClient != nil { + ic := *gitClient + ic.RepoDir = repoDir + if ic.IsIgnored(context.Background(), relPath) { + continue + } + } + + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: fmt.Sprintf( + "%s/ contains installed skills and should be added to .gitignore to avoid publishing other authors' content", + relPath), + }) + } + + return diagnostics +} + +// semverPattern matches v-prefixed semver tags (e.g. v1.2.3). +var semverPattern = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)$`) + +// suggestNextTag increments the patch version of a semver tag. +func suggestNextTag(latest string) string { + m := semverPattern.FindStringSubmatch(latest) + if m == nil { + return "" + } + + prefix := "" + if strings.HasPrefix(latest, "v") { + prefix = "v" + } + + major, minor := m[1], m[2] + patch := 0 + fmt.Sscanf(m[3], "%d", &patch) + + return fmt.Sprintf("%s%s.%s.%d", prefix, major, minor, patch+1) +} + +// detectGitHubRemote attempts to detect the GitHub owner/repo from git remotes. +func detectGitHubRemote(gitClient *git.Client) (owner, repo string) { + if gitClient == nil { + return "", "" + } + + // Try origin first + if url, err := gitClient.RemoteURL(context.Background(), "origin"); err == nil { + if o, r := parseGitHubURL(url); o != "" { + return o, r + } + } + + // Fall back to any remote that points to GitHub + remotes, err := gitClient.Remotes(context.Background()) + if err != nil { + return "", "" + } + for _, r := range remotes { + if r.Name == "origin" { + continue + } + if url, err := gitClient.RemoteURL(context.Background(), r.Name); err == nil { + if o, rp := parseGitHubURL(url); o != "" { + return o, rp + } + } + } + return "", "" +} + +// parseGitHubURL extracts owner/repo from a GitHub remote URL. +// Only GitHub.com URLs are recognized. +func parseGitHubURL(rawURL string) (owner, repo string) { + u, err := giturl.ParseURL(rawURL) + if err != nil { + return "", "" + } + r, err := ghrepo.FromURL(u) + if err != nil { + return "", "" + } + // Only match github.com — the default GitHub host. + host := strings.ToLower(r.RepoHost()) + if host != ghinstance.Default() { + return "", "" + } + return r.RepoOwner(), r.RepoName() +} + +// detectMissingRepoDiagnostic explains why remote checks were skipped. +func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDiagnostic { + if gitClient == nil { + return nil + } + + dc := *gitClient + dc.RepoDir = dir + if _, err := dc.GitDir(context.Background()); err != nil { + return []publishDiagnostic{{ + severity: "warning", + message: "not a git repository — initialize with: git init && gh repo create", + }} + } + + remotes, err := dc.Remotes(context.Background()) + if err != nil || len(remotes) == 0 { + return []publishDiagnostic{{ + severity: "warning", + message: "no git remote found — create a GitHub repository with: gh repo create", + }} + } + + var urls []string + for _, r := range remotes { + if url, err := dc.RemoteURL(context.Background(), r.Name); err == nil { + urls = append(urls, url) + } + } + return []publishDiagnostic{{ + severity: "warning", + message: fmt.Sprintf("remote %q is not a GitHub repository — skills must be hosted on GitHub for discovery", strings.Join(urls, ", ")), + }} +} + +func renderDiagnosticsTTY(opts *publishOptions, skillDirs []string, diagnostics []publishDiagnostic, errors, warnings, fixes int, owner, repo string) { + cs := opts.IO.ColorScheme() + + // Separate info messages from errors/warnings for cleaner output + var infos, issues []publishDiagnostic + for _, d := range diagnostics { + if d.severity == "info" { + infos = append(infos, d) + } else { + issues = append(issues, d) + } + } + + if len(issues) == 0 && fixes == 0 { + fmt.Fprintf(opts.IO.Out, "%s %d skill(s) validated successfully\n", cs.SuccessIcon(), len(skillDirs)) + } else { + for _, d := range issues { + var prefix string + switch d.severity { + case "error": + prefix = cs.FailureIcon() + case "warning": + prefix = cs.WarningIcon() + case "fixed": + prefix = cs.SuccessIcon() + default: + prefix = cs.FailureIcon() + } + if d.skill != "" { + fmt.Fprintf(opts.IO.Out, "%s %s: %s\n", prefix, cs.Bold(d.skill), d.message) + } else { + fmt.Fprintf(opts.IO.Out, "%s %s\n", prefix, d.message) + } + } + + fmt.Fprintln(opts.IO.Out) + if fixes > 0 { + fmt.Fprintf(opts.IO.Out, "Fixed %d issue(s)\n", fixes) + } + if errors > 0 { + fmt.Fprintf(opts.IO.Out, "%s, %s\n", + cs.Red(fmt.Sprintf("%d error(s)", errors)), + cs.Yellow(fmt.Sprintf("%d warning(s)", warnings))) + } else { + fmt.Fprintf(opts.IO.Out, "%s\n", cs.Yellow(fmt.Sprintf("%d warning(s)", warnings))) + } + } + + // Always show info messages + for _, d := range infos { + fmt.Fprintf(opts.IO.ErrOut, "\n%s\n", d.message) + } + + if errors == 0 { + if owner != "" && repo != "" { + fmt.Fprintf(opts.IO.ErrOut, "\n%s Repository: %s/%s\n", cs.Green("Ready to publish!"), owner, repo) + } else { + fmt.Fprintf(opts.IO.ErrOut, "\n%s Ensure the repository has the \"agent-skills\" topic.\n", cs.Green("Ready to publish!")) + } + } +} + +func renderDiagnosticsPlain(opts *publishOptions, diagnostics []publishDiagnostic, errors, warnings int) { + for _, d := range diagnostics { + if d.severity == "info" { + continue + } + fmt.Fprintf(opts.IO.Out, "%s\t%s\t%s\n", d.severity, d.skill, d.message) + } + if errors == 0 && warnings == 0 { + fmt.Fprintf(opts.IO.Out, "ok\n") + } +} + +// findGitHubMetadataKeys returns metadata keys with the "github-" prefix. +func findGitHubMetadataKeys(meta map[string]interface{}) []string { + var keys []string + for k := range meta { + if strings.HasPrefix(k, "github-") { + keys = append(keys, k) + } + } + sort.Strings(keys) + return keys +} + +// stripGitHubMetadata removes github-* keys from the metadata map and re-serializes. +func stripGitHubMetadata(content string) (string, error) { + result, err := frontmatter.Parse(content) + if err != nil { + return "", err + } + + meta, ok := result.RawYAML["metadata"].(map[string]interface{}) + if !ok { + return content, nil + } + + for k := range meta { + if strings.HasPrefix(k, "github-") { + delete(meta, k) + } + } + + if len(meta) == 0 { + delete(result.RawYAML, "metadata") + } else { + result.RawYAML["metadata"] = meta + } + + return frontmatter.Serialize(result.RawYAML, result.Body) +} + +// claudePluginJSON is the .claude-plugin/plugin.json structure. +type claudePluginJSON struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version,omitempty"` + Author *claudeAuthor `json:"author,omitempty"` + Homepage string `json:"homepage,omitempty"` + Repository string `json:"repository,omitempty"` + License string `json:"license,omitempty"` + Keywords []string `json:"keywords,omitempty"` +} + +type claudeAuthor struct { + Name string `json:"name"` +} + +// claudeMarketplaceJSON is the .claude-plugin/marketplace.json structure. +type claudeMarketplaceJSON struct { + Name string `json:"name"` + Owner claudeAuthor `json:"owner"` + Plugins []claudeMarketplacePlugin `json:"plugins"` +} + +type claudeMarketplacePlugin struct { + Name string `json:"name"` + Source string `json:"source"` + Description string `json:"description,omitempty"` +} + +// generateClaudePlugin creates .claude-plugin/plugin.json (and optionally +// marketplace.json for multi-skill repos). +func generateClaudePlugin(dir string, skillDirs []string, owner, repo string) []publishDiagnostic { + var diags []publishDiagnostic + + pluginDir := filepath.Join(dir, ".claude-plugin") + pluginPath := filepath.Join(pluginDir, "plugin.json") + + // Don't overwrite existing plugin.json + if _, err := os.Stat(pluginPath); err == nil { + diags = append(diags, publishDiagnostic{ + severity: "info", + message: ".claude-plugin/plugin.json already exists (skipped)", + }) + return diags + } + + pluginName := filepath.Base(dir) + if repo != "" { + pluginName = repo + } + + description := buildPluginDescription(dir, skillDirs) + + plugin := claudePluginJSON{ + Name: pluginName, + Description: description, + Version: "1.0.0", + Keywords: []string{"agent-skills"}, + } + + if owner != "" && repo != "" { + plugin.Repository = fmt.Sprintf("https://github.com/%s/%s", owner, repo) + plugin.Homepage = fmt.Sprintf("https://github.com/%s/%s", owner, repo) + plugin.Author = &claudeAuthor{Name: owner} + } + + // Collect license from any skill + for _, skillName := range skillDirs { + skillPath := filepath.Join(dir, "skills", skillName, "SKILL.md") + content, err := os.ReadFile(skillPath) + if err != nil { + continue + } + result, err := frontmatter.Parse(string(content)) + if err != nil { + continue + } + if result.Metadata.License != "" { + plugin.License = result.Metadata.License + break + } + } + + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + diags = append(diags, publishDiagnostic{ + severity: "error", + message: fmt.Sprintf("could not create .claude-plugin/: %v", err), + }) + return diags + } + + data, err := json.MarshalIndent(plugin, "", " ") + if err != nil { + diags = append(diags, publishDiagnostic{ + severity: "error", + message: fmt.Sprintf("could not serialize plugin.json: %v", err), + }) + return diags + } + + if err := os.WriteFile(pluginPath, append(data, '\n'), 0o644); err != nil { + diags = append(diags, publishDiagnostic{ + severity: "error", + message: fmt.Sprintf("could not write plugin.json: %v", err), + }) + return diags + } + + diags = append(diags, publishDiagnostic{ + severity: "info", + message: fmt.Sprintf("generated .claude-plugin/plugin.json for %q with %d skill(s)", pluginName, len(skillDirs)), + }) + + // Generate marketplace.json for multi-skill repos with a GitHub remote + if len(skillDirs) > 1 && owner != "" && repo != "" { + marketplacePath := filepath.Join(pluginDir, "marketplace.json") + if _, err := os.Stat(marketplacePath); err != nil { + mDiags := generateMarketplace(marketplacePath, pluginName, owner, skillDirs, dir) + diags = append(diags, mDiags...) + } + } + + return diags +} + +// generateMarketplace creates a marketplace.json for plugin marketplace discovery. +func generateMarketplace(path, pluginName, owner string, skillDirs []string, dir string) []publishDiagnostic { + desc := buildPluginDescription(dir, skillDirs) + plugins := []claudeMarketplacePlugin{{ + Name: pluginName, + Source: ".", + Description: desc, + }} + + marketplace := claudeMarketplaceJSON{ + Name: pluginName, + Owner: claudeAuthor{Name: owner}, + Plugins: plugins, + } + + data, err := json.MarshalIndent(marketplace, "", " ") + if err != nil { + return []publishDiagnostic{{ + severity: "error", + message: fmt.Sprintf("could not serialize marketplace.json: %v", err), + }} + } + + if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { + return []publishDiagnostic{{ + severity: "error", + message: fmt.Sprintf("could not write marketplace.json: %v", err), + }} + } + + return []publishDiagnostic{{ + severity: "info", + message: "generated .claude-plugin/marketplace.json for plugin marketplace discovery", + }} +} + +// buildPluginDescription creates a description from skill names and descriptions. +func buildPluginDescription(dir string, skillDirs []string) string { + if len(skillDirs) == 1 { + skillPath := filepath.Join(dir, "skills", skillDirs[0], "SKILL.md") + if content, err := os.ReadFile(skillPath); err == nil { + if result, err := frontmatter.Parse(string(content)); err == nil && result.Metadata.Description != "" { + return result.Metadata.Description + } + } + } + + var names []string + for _, name := range skillDirs { + names = append(names, name) + } + if len(names) <= 5 { + return fmt.Sprintf("Agent skills: %s", strings.Join(names, ", ")) + } + return fmt.Sprintf("Agent skills collection with %d skills", len(names)) +} diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go new file mode 100644 index 00000000000..56e3b1e0ade --- /dev/null +++ b/pkg/cmd/skills/publish/publish_test.go @@ -0,0 +1,1059 @@ +package publish + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/require" +) + +func testPublishGitClient(t *testing.T, remoteURLs map[string]string) *git.Client { + t.Helper() + dir := t.TempDir() + runGit := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+dir) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) + } + runGit("init", "--initial-branch=main") + runGit("config", "user.email", "monalisa@github.com") + runGit("config", "user.name", "Monalisa Octocat") + for name, url := range remoteURLs { + runGit("remote", "add", name, url) + } + return &git.Client{RepoDir: dir} +} + +func TestPublishCmd_Help(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := stubFactory(ios) + cmd := NewCmdPublish(&f, nil) + if cmd.Use == "" { + t.Error("publish command has no Use string") + } + if cmd.Short == "" { + t.Error("publish command has no Short description") + } +} + +func TestPublishCmd_Alias(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := stubFactory(ios) + cmd := NewCmdPublish(&f, nil) + found := false + for _, alias := range cmd.Aliases { + if alias == "validate" { + found = true + break + } + } + if !found { + t.Error("publish command should have 'validate' alias") + } +} + +func TestPublish_ValidSkill(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "git-commit") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: git-commit +description: A skill for writing good git commits +allowed-tools: git +license: MIT +--- +You are a git commit expert. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "v1.0.0"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + + opts := &publishOptions{ + IO: ios, + Dir: dir, + GitClient: testPublishGitClient(t, map[string]string{ + "origin": "https://github.com/test/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + + err := publishRun(opts) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "ok") { + t.Errorf("expected 'ok' output, got: %s", out) + } +} + +func TestPublish_MissingName(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "git-commit") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +description: A skill for writing good git commits +--- +Body text. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + } + + err := publishRun(opts) + if err == nil { + t.Fatal("expected error for missing name") + } + + out := stdout.String() + if !strings.Contains(out, "missing required field: name") { + t.Errorf("expected name error in output, got: %s", out) + } +} + +func TestPublish_NameMismatch(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "git-commit") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: wrong-name +description: A skill +--- +Body. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + } + + err := publishRun(opts) + if err == nil { + t.Fatal("expected error for name mismatch") + } + + out := stdout.String() + if !strings.Contains(out, "does not match directory name") { + t.Errorf("expected name mismatch error, got: %s", out) + } +} + +func TestPublish_NonSpecCompliantName(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "My_Skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: My_Skill +description: A skill with non-compliant name +--- +Body. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + } + + err := publishRun(opts) + if err == nil { + t.Fatal("expected error for non-spec-compliant name") + } + + out := stdout.String() + if !strings.Contains(out, "naming convention") { + t.Errorf("expected naming convention error, got: %s", out) + } +} + +func TestPublish_AllowedToolsArray(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "bad-tools") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: bad-tools +description: A skill with array allowed-tools +allowed-tools: + - git + - curl +--- +Body. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + } + + err := publishRun(opts) + if err == nil { + t.Fatal("expected error for array allowed-tools") + } + + out := stdout.String() + if !strings.Contains(out, "allowed-tools must be a string") { + t.Errorf("expected allowed-tools error, got: %s", out) + } +} + +func TestPublish_StripMetadata(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "test-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: test-skill +description: A test skill +metadata: + github-owner: someone + github-repo: something + github-ref: v1.0.0 + github-sha: abc123 + github-tree-sha: def456 +--- +Body. +` + skillPath := filepath.Join(skillDir, "SKILL.md") + if err := os.WriteFile(skillPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, _, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + Fix: true, + } + + err := publishRun(opts) + if err != nil { + t.Fatalf("expected no error with --fix, got: %v", err) + } + + fixed, err := os.ReadFile(skillPath) + if err != nil { + t.Fatal(err) + } + + fixedStr := string(fixed) + if strings.Contains(fixedStr, "github-owner") { + t.Errorf("expected github-owner to be stripped, got:\n%s", fixedStr) + } + if strings.Contains(fixedStr, "github-sha") { + t.Errorf("expected github-sha to be stripped, got:\n%s", fixedStr) + } + if strings.Contains(fixedStr, "metadata:") { + t.Errorf("expected empty metadata map to be removed, got:\n%s", fixedStr) + } +} + +func TestPublish_MetadataWithoutFix(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "test-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: test-skill +description: A test skill +metadata: + github-owner: someone + github-sha: abc123 +--- +Body. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + Fix: false, + } + + err := publishRun(opts) + if err == nil { + t.Fatal("expected error without --fix when metadata present") + } + + out := stdout.String() + if !strings.Contains(out, "install metadata") { + t.Errorf("expected install metadata error, got: %s", out) + } + if !strings.Contains(out, "--fix") { + t.Errorf("expected --fix suggestion, got: %s", out) + } +} + +func TestPublish_NoSkillsDir(t *testing.T) { + dir := t.TempDir() + ios, _, _, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + } + + err := publishRun(opts) + if err == nil { + t.Fatal("expected error for missing skills/ directory") + } + if !strings.Contains(err.Error(), "no skills/ directory") { + t.Errorf("expected 'no skills/ directory' error, got: %v", err) + } +} + +func TestPublish_MissingSKILLMD(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "empty-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + } + + err := publishRun(opts) + if err == nil { + t.Fatal("expected error for missing SKILL.md") + } + + out := stdout.String() + if !strings.Contains(out, "missing SKILL.md") { + t.Errorf("expected missing SKILL.md error, got: %s", out) + } +} + +func TestPublish_DryRun(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "good-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: good-skill +description: A good skill +license: MIT +--- +Body. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "v1.0.0"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/test/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + + opts := &publishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: testPublishGitClient(t, map[string]string{ + "origin": "https://github.com/test/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + + err := publishRun(opts) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + errOut := stderr.String() + if !strings.Contains(errOut, "Dry run complete") { + t.Errorf("stderr should confirm dry run, got: %s", errOut) + } +} + +func TestPublish_LicenseWarning(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "skills", "no-license") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + + content := `--- +name: no-license +description: A skill without license +--- +Body. +` + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + ios, _, stdout, _ := iostreams.Test() + + opts := &publishOptions{ + IO: ios, + Dir: dir, + } + + err := publishRun(opts) + if err != nil { + t.Fatalf("expected no error (warnings only), got: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "license") { + t.Errorf("expected license warning, got: %s", out) + } +} + +func TestSuggestNextTag(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"v1.0.0", "v1.0.1"}, + {"v2.3.4", "v2.3.5"}, + {"1.0.0", "1.0.1"}, + {"v0.0.9", "v0.0.10"}, + {"not-semver", ""}, + {"v1", ""}, + {"v1.0", ""}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := suggestNextTag(tt.input) + if got != tt.want { + t.Errorf("suggestNextTag(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestParseGitHubURL(t *testing.T) { + tests := []struct { + url string + wantOwner string + wantRepo string + }{ + {"git@github.com:github/gh-skills.git", "github", "gh-skills"}, + {"https://github.com/github/gh-skills.git", "github", "gh-skills"}, + {"https://github.com/github/gh-skills", "github", "gh-skills"}, + {"git@github.com:owner/repo.git", "owner", "repo"}, + {"https://gitlab.com/owner/repo.git", "", ""}, + {"not-a-url", "", ""}, + } + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + owner, repo := parseGitHubURL(tt.url) + if owner != tt.wantOwner || repo != tt.wantRepo { + t.Errorf("parseGitHubURL(%q) = (%q, %q), want (%q, %q)", tt.url, owner, repo, tt.wantOwner, tt.wantRepo) + } + }) + } +} + +func TestRepoHasTopic(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"golang", "agent-skills"}, + }), + ) + + if !repoHasTopic(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") { + t.Error("expected true when topic present") + } +} + +func TestRepoHasTopic_Missing(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"golang"}, + }), + ) + + if repoHasTopic(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") { + t.Error("expected false when topic missing") + } +} + +func TestFetchTags_NoTags(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + + tags := fetchTags(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") + if len(tags) != 0 { + t.Errorf("expected no tags, got %d", len(tags)) + } +} + +func TestFetchTags_WithTags(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "v1.2.3"}, + }), + ) + + tags := fetchTags(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") + if len(tags) != 1 { + t.Fatalf("expected 1 tag, got %d", len(tags)) + } + if tags[0].Name != "v1.2.3" { + t.Errorf("expected v1.2.3, got %s", tags[0].Name) + } +} + +func TestCheckTagProtection_Active(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "protect-tags", "target": "tag", "enforcement": "active"}, + }), + ) + + diags := checkTagProtection(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") + if len(diags) != 0 { + t.Errorf("expected no diagnostics when tag protection active, got: %v", diags) + } +} + +func TestCheckTagProtection_Missing(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "branch-protection", "target": "branch", "enforcement": "active"}, + }), + ) + + diags := checkTagProtection(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") + if len(diags) != 1 { + t.Fatalf("expected 1 diagnostic, got %d", len(diags)) + } + if !strings.Contains(diags[0].message, "tag protection") { + t.Errorf("expected tag protection warning, got: %s", diags[0].message) + } +} + +func TestCheckSecuritySettings_AllEnabled(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + + skillsDir := t.TempDir() + + diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) + if len(diags) != 0 { + t.Errorf("expected no diagnostics when all security enabled, got %d: %v", len(diags), diags) + } +} + +func TestCheckSecuritySettings_NoneEnabled(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "disabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "disabled"}, + }, + }), + ) + + skillsDir := t.TempDir() + + diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) + if len(diags) != 2 { + t.Errorf("expected 2 diagnostics (secret scanning + push protection), got %d: %v", len(diags), diags) + } + for _, d := range diags { + if d.severity != "warning" { + t.Errorf("secret scanning diagnostics should be warnings, got %q: %s", d.severity, d.message) + } + } +} + +func TestCheckSecuritySettings_WithCodeFiles(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/code-scanning/alerts"), + httpmock.StatusStringResponse(404, "not found"), + ) + + skillsDir := t.TempDir() + scriptDir := filepath.Join(skillsDir, "my-skill", "scripts") + if err := os.MkdirAll(scriptDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(scriptDir, "helper.sh"), []byte("#!/bin/bash"), 0o644); err != nil { + t.Fatal(err) + } + + diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) + hasCodeScanInfo := false + for _, d := range diags { + if strings.Contains(d.message, "code scanning") { + hasCodeScanInfo = true + if d.severity != "info" { + t.Errorf("code scanning suggestion should be info, got %q", d.severity) + } + } + } + if !hasCodeScanInfo { + t.Error("expected code scanning info when code files present") + } +} + +func TestCheckSecuritySettings_WithManifests(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/vulnerability-alerts"), + httpmock.StatusStringResponse(404, "not found"), + ) + + skillsDir := t.TempDir() + skillDir := filepath.Join(skillsDir, "my-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "package.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + + diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) + hasDependabotInfo := false + for _, d := range diags { + if strings.Contains(d.message, "Dependabot") { + hasDependabotInfo = true + if d.severity != "info" { + t.Errorf("Dependabot suggestion should be info, got %q", d.severity) + } + } + } + if !hasDependabotInfo { + t.Error("expected Dependabot info when manifest files present") + } +} + +func TestDetectCodeAndManifests(t *testing.T) { + dir := t.TempDir() + + hasCode, hasManifests := detectCodeAndManifests(dir) + if hasCode || hasManifests { + t.Error("empty dir should have no code or manifests") + } + + if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte("#!/bin/bash"), 0o644); err != nil { + t.Fatal(err) + } + hasCode, hasManifests = detectCodeAndManifests(dir) + if !hasCode { + t.Error("should detect .sh as code") + } + if hasManifests { + t.Error("should not detect manifests") + } + + if err := os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("flask"), 0o644); err != nil { + t.Fatal(err) + } + hasCode, hasManifests = detectCodeAndManifests(dir) + if !hasCode || !hasManifests { + t.Error("should detect both code and manifests") + } +} + +func TestCheckInstalledSkillDirs_NotPresent(t *testing.T) { + dir := t.TempDir() + diags := checkInstalledSkillDirs(nil, dir) + if len(diags) != 0 { + t.Errorf("expected no diagnostics for empty dir, got %d", len(diags)) + } +} + +func TestCheckInstalledSkillDirs_PresentNotIgnored(t *testing.T) { + gitClient := testPublishGitClient(t, nil) + dir := gitClient.RepoDir + + installedDir := filepath.Join(dir, ".github", "skills", "some-skill") + if err := os.MkdirAll(installedDir, 0o755); err != nil { + t.Fatal(err) + } + + diags := checkInstalledSkillDirs(gitClient, dir) + if len(diags) == 0 { + t.Fatal("expected warning for unignored .github/skills/") + } + if diags[0].severity != "warning" { + t.Errorf("expected warning, got %q", diags[0].severity) + } + if !strings.Contains(diags[0].message, ".gitignore") { + t.Errorf("expected .gitignore mention, got: %s", diags[0].message) + } +} + +func TestCheckInstalledSkillDirs_PresentAndIgnored(t *testing.T) { + gitClient := testPublishGitClient(t, nil) + dir := gitClient.RepoDir + + installedDir := filepath.Join(dir, ".github", "skills", "some-skill") + if err := os.MkdirAll(installedDir, 0o755); err != nil { + t.Fatal(err) + } + + // Add .gitignore so git check-ignore recognises the path. + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".github/skills\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+dir) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) + } + runGit("add", ".gitignore") + runGit("commit", "-m", "init") + + diags := checkInstalledSkillDirs(gitClient, dir) + if len(diags) != 0 { + t.Errorf("expected no diagnostics when gitignored, got %d: %v", len(diags), diags) + } +} + +func TestGenerateClaudePlugin(t *testing.T) { + dir := t.TempDir() + + for _, name := range []string{"git-commit", "code-review"} { + skillDir := filepath.Join(dir, "skills", name) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + content := fmt.Sprintf("---\nname: %s\ndescription: A %s skill\nlicense: MIT\n---\nBody.\n", name, name) + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + diags := generateClaudePlugin(dir, []string{"git-commit", "code-review"}, "testowner", "testrepo") + + var generated int + for _, d := range diags { + if d.severity == "error" { + t.Errorf("unexpected error: %s", d.message) + } + if d.severity == "info" && strings.Contains(d.message, "generated") { + generated++ + } + } + if generated != 2 { + t.Errorf("expected 2 generated files, got %d", generated) + } + + pluginData, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "plugin.json")) + if err != nil { + t.Fatalf("plugin.json not created: %v", err) + } + var plugin claudePluginJSON + if err := json.Unmarshal(pluginData, &plugin); err != nil { + t.Fatalf("invalid plugin.json: %v", err) + } + if plugin.Name != "testrepo" { + t.Errorf("plugin.Name = %q, want %q", plugin.Name, "testrepo") + } + if plugin.License != "MIT" { + t.Errorf("plugin.License = %q, want %q", plugin.License, "MIT") + } + if plugin.Repository != "https://github.com/testowner/testrepo" { + t.Errorf("plugin.Repository = %q", plugin.Repository) + } + + marketData, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "marketplace.json")) + if err != nil { + t.Fatalf("marketplace.json not created: %v", err) + } + var marketplace claudeMarketplaceJSON + if err := json.Unmarshal(marketData, &marketplace); err != nil { + t.Fatalf("invalid marketplace.json: %v", err) + } + if marketplace.Name != "testrepo" { + t.Errorf("marketplace.Name = %q, want %q", marketplace.Name, "testrepo") + } + if len(marketplace.Plugins) != 1 || marketplace.Plugins[0].Source != "." { + t.Errorf("marketplace.Plugins = %+v", marketplace.Plugins) + } +} + +func TestGenerateClaudePlugin_SkipsExisting(t *testing.T) { + dir := t.TempDir() + + skillDir := filepath.Join(dir, "skills", "my-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\ndescription: test\n---\nBody.\n"), 0o644); err != nil { + t.Fatal(err) + } + + pluginDir := filepath.Join(dir, ".claude-plugin") + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(`{"name":"existing"}`), 0o644); err != nil { + t.Fatal(err) + } + + diags := generateClaudePlugin(dir, []string{"my-skill"}, "owner", "repo") + + for _, d := range diags { + if d.severity == "error" { + t.Errorf("unexpected error: %s", d.message) + } + if strings.Contains(d.message, "generated") { + t.Error("should not regenerate existing plugin.json") + } + } +} + +func TestDetectGitHubRemote(t *testing.T) { + gitClient := testPublishGitClient(t, map[string]string{ + "origin": "https://github.com/myorg/myrepo.git", + }) + + owner, repo := detectGitHubRemote(gitClient) + if owner != "myorg" || repo != "myrepo" { + t.Errorf("expected myorg/myrepo, got %s/%s", owner, repo) + } +} + +func TestDetectGitHubRemote_Fallback(t *testing.T) { + gitClient := testPublishGitClient(t, map[string]string{ + "origin": "https://gitlab.com/foo/bar.git", + "upstream": "git@github.com:org/repo.git", + }) + + owner, repo := detectGitHubRemote(gitClient) + if owner != "org" || repo != "repo" { + t.Errorf("expected org/repo, got %s/%s", owner, repo) + } +} + +func TestDetectGitHubRemote_NoGitHub(t *testing.T) { + gitClient := testPublishGitClient(t, map[string]string{ + "origin": "https://gitlab.com/foo/bar.git", + }) + + owner, repo := detectGitHubRemote(gitClient) + if owner != "" || repo != "" { + t.Errorf("expected empty, got %s/%s", owner, repo) + } +} + +func TestPublishCmd_RunFHook(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := stubFactory(ios) + + var capturedOpts *publishOptions + cmd := NewCmdPublish(&f, func(opts *publishOptions) error { + capturedOpts = opts + return nil + }) + + cmd.SetArgs([]string{"./my-skills", "--dry-run", "--fix", "--tag", "v1.0.0"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if capturedOpts == nil { + t.Fatal("runF was not called") + } + if capturedOpts.Dir != "./my-skills" { + t.Errorf("Dir = %q, want %q", capturedOpts.Dir, "./my-skills") + } + if !capturedOpts.DryRun { + t.Error("expected DryRun to be true") + } + if !capturedOpts.Fix { + t.Error("expected Fix to be true") + } + if capturedOpts.Tag != "v1.0.0" { + t.Errorf("Tag = %q, want %q", capturedOpts.Tag, "v1.0.0") + } +} + +// stubFactory creates a minimal cmdutil.Factory for tests. +func stubFactory(ios *iostreams.IOStreams) cmdutil.Factory { + return cmdutil.Factory{ + IOStreams: ios, + } +} diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go new file mode 100644 index 00000000000..0d7e39043ff --- /dev/null +++ b/pkg/cmd/skills/search/search.go @@ -0,0 +1,873 @@ +package search + +import ( + "errors" + "fmt" + "math" + "net/http" + "net/url" + "os" + "os/exec" + "sort" + "strings" + "sync" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +const ( + defaultLimit = 15 + maxResults = 1000 // GitHub Code Search API hard limit + + // searchPageSize is the number of raw results to request from the + // GitHub Search API per call (max allowed). + searchPageSize = 100 +) + +// SkillSearchFields defines the set of fields available for --json output. +var SkillSearchFields = []string{ + "repo", + "skillName", + "description", + "stars", + "path", +} + +type searchOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Config func() (gh.Config, error) + Prompter prompter.Prompter + Executable string // path to the current gh binary for install subprocess + Exporter cmdutil.Exporter + + // User inputs + Query string + Owner string // optional: scope results to a specific GitHub owner + Page int + Limit int +} + +// NewCmdSearch creates the "skills search" command. +func NewCmdSearch(f *cmdutil.Factory, runF func(*searchOptions) error) *cobra.Command { + opts := &searchOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Prompter: f.Prompter, + Executable: f.Executable(), + } + + cmd := &cobra.Command{ + Use: "search ", + Short: "Search for skills across GitHub", + Long: heredoc.Doc(` + Search across all public GitHub repositories for skills matching a keyword. + + Uses the GitHub Code Search API to find SKILL.md files whose name or + description matches the query term. + + Results are ranked by relevance: skills whose name contains the query + term appear first. + + Use --owner to scope results to a specific GitHub user or organization. + + In interactive mode, you can select skills from the results to install + directly. + `), + Example: heredoc.Doc(` + # Search for skills related to terraform + $ gh skills search terraform + + # Search for skills from a specific owner + $ gh skills search terraform --owner hashicorp + + # View the second page of results + $ gh skills search terraform --page 2 + + # Limit results to 5 + $ gh skills search terraform --limit 5 + `), + Args: cmdutil.MinimumArgs(1, "cannot search: query argument required"), + RunE: func(c *cobra.Command, args []string) error { + opts.Query = strings.Join(args, " ") + + if len(strings.TrimSpace(opts.Query)) < 2 { + return cmdutil.FlagErrorf("search query must be at least 2 characters") + } + + if opts.Page < 1 { + return cmdutil.FlagErrorf("invalid page number: %d", opts.Page) + } + + if opts.Limit < 1 { + return cmdutil.FlagErrorf("invalid limit: %d", opts.Limit) + } + + opts.Owner = strings.TrimSpace(opts.Owner) + if opts.Owner != "" && !couldBeOwner(opts.Owner) { + return cmdutil.FlagErrorf("invalid owner %q: must be a valid GitHub username or organization", opts.Owner) + } + + if runF != nil { + return runF(opts) + } + return searchRun(opts) + }, + } + + cmd.Flags().IntVar(&opts.Page, "page", 1, "Page number of results to fetch") + cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum number of results per page") + cmd.Flags().StringVar(&opts.Owner, "owner", "", "Filter results to a specific GitHub user or organization") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, SkillSearchFields) + + return cmd +} + +// codeSearchResult represents the GitHub Code Search API response. +type codeSearchResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []codeSearchItem `json:"items"` +} + +// codeSearchItem represents a single code search hit. +type codeSearchItem struct { + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + Repository codeSearchRepository `json:"repository"` +} + +// codeSearchRepository is the repo info embedded in a code search hit. +type codeSearchRepository struct { + FullName string `json:"full_name"` +} + +// skillResult is a deduplicated search result. +type skillResult struct { + Repo string + Owner string // parsed from Repo + RepoName string // parsed from Repo + SkillName string + Description string + Path string // original file path (e.g. skills/terraform/SKILL.md) + BlobSHA string + Stars int // repository stargazer count +} + +// ExportData implements cmdutil.exportable for --json output. +func (s skillResult) ExportData(fields []string) map[string]interface{} { + data := map[string]interface{}{} + for _, f := range fields { + switch f { + case "repo": + data[f] = s.Repo + case "skillName": + data[f] = s.SkillName + case "description": + data[f] = s.Description + case "stars": + data[f] = s.Stars + case "path": + data[f] = s.Path + } + } + return data +} + +func searchRun(opts *searchOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + apiClient := api.NewClientFromHTTP(httpClient) + + cfg, err := opts.Config() + if err != nil { + return err + } + host, _ := cfg.Authentication().DefaultHost() + + opts.IO.StartProgressIndicatorWithLabel("Searching for skills") + + skills, err := searchByKeyword(apiClient, host, opts.Query, opts.Owner, opts.Page, opts.Limit) + if err != nil { + opts.IO.StopProgressIndicator() + return err + } + + if len(skills) == 0 { + opts.IO.StopProgressIndicator() + return noResults(opts, noResultsMessage(opts)) + } + + // Pre-rank before expensive enrichment, then truncate working set. + rankByRelevance(skills, opts.Query) + skills = truncateForProcessing(skills, opts.Page, opts.Limit) + + enrichSkills(apiClient, host, skills) + opts.IO.StopProgressIndicator() + + // Filter out noise and re-rank with enriched data (descriptions, stars). + skills = filterByRelevance(skills, opts.Query) + if len(skills) == 0 { + return noResults(opts, noResultsMessage(opts)) + } + rankByRelevance(skills, opts.Query) + + // Collapse duplicate skill names across repos, keeping up to 3 + // top-ranked instances of each. Prevents aggregator repos + // (which copy popular skills) from flooding results. + skills = deduplicateByName(skills) + + // Paginate to the requested page window. + var totalPages int + skills, totalPages = paginate(skills, opts.Page, opts.Limit) + if len(skills) == 0 { + msg := fmt.Sprintf("no skills found on page %d for query %q", opts.Page, opts.Query) + if opts.Owner != "" { + msg = fmt.Sprintf("no skills found on page %d for query %q from owner %q", opts.Page, opts.Query, opts.Owner) + } + return noResults(opts, msg) + } + + return renderResults(opts, skills, totalPages) +} + +// noResultsMessage returns an appropriate "no results" message. +func noResultsMessage(opts *searchOptions) string { + if opts.Owner != "" { + return fmt.Sprintf("no skills found matching %q from owner %q", opts.Query, opts.Owner) + } + return fmt.Sprintf("no skills found matching %q", opts.Query) +} + +// searchByKeyword runs parallel searches: content match, path match, owner +// match (for single-word queries), and (for multi-word queries) a hyphenated +// content match to catch skill names like "mcp-apps" when the user types +// "mcp apps". When owner is non-empty, all queries are scoped to that +// GitHub user/org via user: and the implicit owner search is skipped. +func searchByKeyword(client *api.Client, host, queryTerm, owner string, page, limit int) ([]skillResult, error) { + ownerScope := "" + if owner != "" { + ownerScope = " user:" + owner + } + + primaryQ := fmt.Sprintf("filename:SKILL.md %s%s", queryTerm, ownerScope) + pathTerm := strings.ReplaceAll(queryTerm, " ", "-") + pathQ := fmt.Sprintf("filename:SKILL.md path:%s%s", pathTerm, ownerScope) + + var ( + primaryItems []codeSearchItem + primaryErr error + pathResult *codeSearchResult + pathErr error + ownerResult *codeSearchResult + ownerErr error + hyphenResult *codeSearchResult + hyphenErr error + ) + + hasSpaces := strings.Contains(queryTerm, " ") + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + pathResult, pathErr = executeSearch(client, host, pathQ, 1, searchPageSize) + }() + + // When no explicit --owner is set and the query looks like it could be a + // GitHub username, fire an additional user: search to discover + // skills published by that org. Results compete on the same footing as + // everything else (no scoring boost). + if owner == "" && couldBeOwner(queryTerm) { + ownerQ := fmt.Sprintf("filename:SKILL.md user:%s", queryTerm) + wg.Add(1) + go func() { + defer wg.Done() + ownerResult, ownerErr = executeSearch(client, host, ownerQ, 1, searchPageSize) + }() + } + + // When the query has spaces (e.g. "mcp apps"), run an additional content + // search with the hyphenated form ("mcp-apps") so we don't miss skills + // whose names use hyphens as word separators. + if hasSpaces { + hyphenQ := fmt.Sprintf("filename:SKILL.md %s%s", pathTerm, ownerScope) + wg.Add(1) + go func() { + defer wg.Done() + hyphenResult, hyphenErr = executeSearch(client, host, hyphenQ, 1, searchPageSize) + }() + } + + // Primary content search runs on the main goroutine. + primaryItems, _, primaryErr = fetchPrimaryPages(client, host, primaryQ, page, limit) + wg.Wait() + + if primaryErr != nil { + return nil, primaryErr + } + + // Merge: path-matched → hyphen-matched → owner-matched → primary content. + var merged []codeSearchItem + + if pathErr == nil && pathResult != nil { + merged = append(merged, pathResult.Items...) + } + if hasSpaces && hyphenErr == nil && hyphenResult != nil { + merged = append(merged, hyphenResult.Items...) + } + if ownerErr == nil && ownerResult != nil { + merged = append(merged, ownerResult.Items...) + } + merged = append(merged, primaryItems...) + + return deduplicateResults(merged), nil +} + +// noResults returns an empty JSON array for exporters or a no-results error. +func noResults(opts *searchOptions, msg string) error { + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, []skillResult{}) + } + return cmdutil.NewNoResultsError(msg) +} + +// truncateForProcessing caps the working set before expensive enrichment. +// Each skill in the working set triggers a blob fetch (description) and +// potentially a repo fetch (stars), so keeping this small matters for +// performance. Pre-ranking ensures the best candidates are at the top. +func truncateForProcessing(skills []skillResult, page, limit int) []skillResult { + maxToProcess := page * limit * 3 + if maxToProcess < limit*3 { + maxToProcess = limit * 3 + } + if len(skills) > maxToProcess { + return skills[:maxToProcess] + } + return skills +} + +// enrichSkills fetches descriptions and star counts concurrently. +func enrichSkills(client *api.Client, host string, skills []skillResult) { + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + fetchDescriptions(client, host, skills) + }() + go func() { + defer wg.Done() + fetchRepoStars(client, host, skills) + }() + wg.Wait() +} + +// paginate slices results to the requested page window. +func paginate(skills []skillResult, page, limit int) ([]skillResult, int) { + total := len(skills) + totalPages := (total + limit - 1) / limit + start := (page - 1) * limit + if start >= total { + return nil, totalPages + } + end := start + limit + if end > total { + end = total + } + return skills[start:end], totalPages +} + +// deduplicateByName caps the number of results with the same skill name. +// Since results are pre-sorted by relevance score, the first occurrences +// are the best instances. This prevents aggregator repos (which copy +// popular skills verbatim) from flooding results while still showing +// a few alternative sources. +func deduplicateByName(skills []skillResult) []skillResult { + const maxPerName = 3 + counts := make(map[string]int) + var result []skillResult + for _, s := range skills { + key := strings.ToLower(s.SkillName) + if counts[key] >= maxPerName { + continue + } + counts[key]++ + result = append(result, s) + } + return result +} + +// renderResults handles all output modes: JSON, interactive picker, or table. +func renderResults(opts *searchOptions, skills []skillResult, totalPages int) error { + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, skills) + } + + cs := opts.IO.ColorScheme() + header := fmt.Sprintf("\n%s Showing %s matching %q", + cs.SuccessIcon(), + pluralize(len(skills), "skill"), + opts.Query, + ) + if totalPages > 1 { + header += fmt.Sprintf(" (page %d/%d)", opts.Page, totalPages) + } + + if opts.IO.CanPrompt() { + fmt.Fprintln(opts.IO.ErrOut, header) + if opts.Page < totalPages { + fmt.Fprintf(opts.IO.ErrOut, "Use --page %d for more results.\n", opts.Page+1) + } + return promptInstall(opts, skills) + } + + // Non-interactive mode: render table. + if opts.IO.IsStdoutTTY() { + fmt.Fprintln(opts.IO.Out, header) + fmt.Fprintln(opts.IO.Out) + } + + if err := renderTable(opts.IO, skills); err != nil { + return err + } + + if opts.IO.IsStdoutTTY() && opts.Page < totalPages { + fmt.Fprintf(opts.IO.ErrOut, "\nUse --page %d for more results.\n", opts.Page+1) + } + + return nil +} + +// renderTable outputs a formatted table of skill results. +func renderTable(io *iostreams.IOStreams, skills []skillResult) error { + isTTY := io.IsStdoutTTY() + tw := io.TerminalWidth() + descWidth := tw - 70 + if descWidth < 20 { + descWidth = 20 + } + + table := tableprinter.New(io, tableprinter.WithHeader("REPOSITORY", "SKILL", "DESCRIPTION", "STARS")) + for _, s := range skills { + table.AddField(s.Repo) + table.AddField(s.SkillName) + desc := s.Description + if isTTY { + desc = text.Truncate(descWidth, desc) + } + table.AddField(desc) + table.AddField(formatStars(s.Stars)) + table.EndRow() + } + return table.Render() +} + +// promptInstall shows a multi-select picker for the user to choose skills +// to install from the search results, then runs the install command for each. +func promptInstall(opts *searchOptions, skills []skillResult) error { + fmt.Fprintln(opts.IO.ErrOut) + + cs := opts.IO.ColorScheme() + + // Reserve space for the checkbox UI prefix ("[ ] ") and the description + // indent ("\n " = 7 chars), then use the remaining terminal width. + tw := opts.IO.TerminalWidth() + descWidth := tw - 11 + if descWidth < 30 { + descWidth = 30 + } + + options := make([]string, len(skills)) + for i, s := range skills { + starStr := "" + if s.Stars > 0 { + starStr = " " + cs.Gray("★ "+formatStars(s.Stars)) + } + descStr := "" + if s.Description != "" { + desc := collapseWhitespace(s.Description) + descStr = "\n " + cs.Gray(text.Truncate(descWidth, desc)) + } + options[i] = s.SkillName + " " + cs.Gray(s.Repo) + starStr + descStr + } + + indices, err := opts.Prompter.MultiSelect( + "Select skills to install (press Enter to skip):", + nil, + options, + ) + if err != nil { + return err + } + + if len(indices) == 0 { + return nil + } + + // Prompt for target agent host (once for all selected skills) + hostNames := registry.AgentNames() + hostIdx, err := opts.Prompter.Select("Select target agent:", "", hostNames) + if err != nil { + return err + } + host := registry.Agents[hostIdx] + + // Prompt for installation scope + scopeIdx, err := opts.Prompter.Select("Installation scope:", "", registry.ScopeLabels("")) + if err != nil { + return err + } + scope := string(registry.ScopeProject) + if scopeIdx == 1 { + scope = string(registry.ScopeUser) + } + + for _, idx := range indices { + s := skills[idx] + fmt.Fprintf(opts.IO.ErrOut, "\n%s Installing %s from %s...\n", + cs.Blue("::"), s.SkillName, s.Repo) + + //nolint:gosec // arguments are from user-selected search results, not arbitrary input + cmd := exec.Command(opts.Executable, "skills", "install", s.Repo, s.SkillName, + "--agent", host.ID, "--scope", scope) + cmd.Stdin = os.Stdin + cmd.Stdout = opts.IO.Out + cmd.Stderr = opts.IO.ErrOut + if err := cmd.Run(); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Failed to install %s from %s: %s\n", + cs.Red("!"), s.SkillName, s.Repo, err) + } + } + + return nil +} + +// relevanceScore computes a numeric ranking score for a search result. +// Higher scores rank first. Signals (in priority order): +// - Exact skill name match (10 000 points) +// - Partial skill name match (1 000 points) +// - Description contains query (100 points) +// - Repository stars (logarithmic bonus, up to ~700 points) +func relevanceScore(s skillResult, query string) int { + term := strings.ToLower(query) + termHyphen := strings.ReplaceAll(term, " ", "-") + score := 0 + + // Name match. Normalize spaces to hyphens since skill directory names + // use hyphens as word separators (e.g. query "mcp apps" → "mcp-apps"). + skillLower := strings.ToLower(s.SkillName) + if skillLower == term || skillLower == termHyphen { + score += 10_000 + } else if strings.Contains(skillLower, term) || strings.Contains(skillLower, termHyphen) { + score += 1_000 + } + + // Description match. + if strings.Contains(strings.ToLower(s.Description), term) { + score += 100 + } + + // Stars bonus: use log₁₀ scaling so popular repos rank higher without + // completely drowning out less-popular but more relevant results. + if s.Stars > 0 { + score += int(math.Log10(float64(s.Stars)) * 150) + } + + return score +} + +// filterByRelevance removes results that are not meaningfully related to +// the query. A result is kept if the query term appears in the skill name, +// the YAML description, or the repository owner or name. +func filterByRelevance(skills []skillResult, query string) []skillResult { + queryTerm := strings.ToLower(query) + termHyphen := strings.ReplaceAll(queryTerm, " ", "-") + + filtered := skills[:0] // reuse backing array + for _, s := range skills { + nameLower := strings.ToLower(s.SkillName) + descLower := strings.ToLower(s.Description) + ownerLower := strings.ToLower(s.Owner) + repoLower := strings.ToLower(s.RepoName) + + if strings.Contains(nameLower, queryTerm) || + strings.Contains(nameLower, termHyphen) || + strings.Contains(descLower, queryTerm) || + strings.Contains(ownerLower, queryTerm) || + strings.Contains(repoLower, queryTerm) { + filtered = append(filtered, s) + } + } + return filtered +} + +// rankByRelevance sorts results by multi-signal score, highest first. +func rankByRelevance(skills []skillResult, query string) { + sort.SliceStable(skills, func(i, j int) bool { + return relevanceScore(skills[i], query) > relevanceScore(skills[j], query) + }) +} + +// couldBeOwner returns true if s looks like a valid GitHub username/org. +// GitHub usernames: 1-39 chars, alphanumeric or hyphen, no leading/trailing hyphens. +func couldBeOwner(s string) bool { + if len(s) == 0 || len(s) > 39 { + return false + } + for i, c := range s { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': + continue + case c == '-': + if i == 0 || i == len(s)-1 { + return false + } + default: + return false + } + } + return true +} + +// isRateLimitError checks whether err is a GitHub API rate-limit response. +// Per GitHub docs, a rate limit is indicated by: +// - HTTP 429 (always a rate limit) +// - HTTP 403 with x-ratelimit-remaining: 0 (primary rate limit) +// - HTTP 403 with a retry-after header (secondary rate limit) +func isRateLimitError(err error) bool { + var httpErr api.HTTPError + if !errors.As(err, &httpErr) { + return false + } + if httpErr.StatusCode == 429 { + return true + } + if httpErr.StatusCode == 403 { + if httpErr.Headers.Get("x-ratelimit-remaining") == "0" { + return true + } + if httpErr.Headers.Get("retry-after") != "" { + return true + } + } + return false +} + +// rateLimitErrorMessage returns a user-friendly message for rate-limit errors. +const rateLimitErrorMessage = "GitHub API rate limit exceeded. Please wait a minute and try again." + +// executeSearch performs a single GitHub Code Search API call. +func executeSearch(client *api.Client, host, query string, page, pageSize int) (*codeSearchResult, error) { + apiPath := fmt.Sprintf("search/code?q=%s&per_page=%d&page=%d", + url.QueryEscape(query), pageSize, page) + var result codeSearchResult + err := client.REST(host, "GET", apiPath, nil, &result) + if err != nil && isRateLimitError(err) { + return nil, fmt.Errorf("%s", rateLimitErrorMessage) + } + return &result, err +} + +// fetchPrimaryPages fetches enough API pages from GitHub Code Search to +// cover the requested display page, accounting for filtering losses. +func fetchPrimaryPages(client *api.Client, host, query string, displayPage, displayLimit int) ([]codeSearchItem, int, error) { + // Over-fetch to account for deduplication + filtering losses. + // The Code Search API is rate-limited at 10 req/min, so we keep + // page fetching conservative. Two pages (200 results) provides a + // good buffer for typical filter rates while staying well within + // the rate-limit budget. + needed := displayPage * displayLimit * 3 + numPages := (needed + searchPageSize - 1) / searchPageSize + if numPages < 1 { + numPages = 1 + } + maxAPIPages := maxResults / searchPageSize + if numPages > maxAPIPages { + numPages = maxAPIPages + } + + var allItems []codeSearchItem + var totalCount int + for p := 1; p <= numPages; p++ { + result, err := executeSearch(client, host, query, p, searchPageSize) + if err != nil { + if p == 1 { + return nil, 0, err + } + break // partial results from earlier pages are OK + } + allItems = append(allItems, result.Items...) + totalCount = result.TotalCount + if len(result.Items) < searchPageSize { + break // no more results available + } + } + return allItems, totalCount, nil +} + +// deduplicateResults extracts unique (repo, skill name) pairs from code search hits. +func deduplicateResults(items []codeSearchItem) []skillResult { + seen := make(map[string]struct{}) + var results []skillResult + + for _, item := range items { + skillName := extractSkillName(item.Path) + if skillName == "" { + continue + } + key := item.Repository.FullName + "/" + skillName + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + + owner, repoName := splitRepo(item.Repository.FullName) + results = append(results, skillResult{ + Repo: item.Repository.FullName, + Owner: owner, + RepoName: repoName, + SkillName: skillName, + Path: item.Path, + BlobSHA: item.SHA, + }) + } + + return results +} + +// splitRepo splits "owner/repo" into its components. +func splitRepo(fullName string) (string, string) { + parts := strings.SplitN(fullName, "/", 2) + if len(parts) != 2 { + return fullName, "" + } + return parts[0], parts[1] +} + +// fetchDescriptions fetches SKILL.md frontmatter descriptions concurrently +// for all search results. Each result may come from a different repo. +func fetchDescriptions(client *api.Client, host string, skills []skillResult) { + const maxWorkers = 10 + sem := make(chan struct{}, maxWorkers) + var wg sync.WaitGroup + var mu sync.Mutex + + for i := range skills { + if skills[i].BlobSHA == "" { + continue + } + wg.Add(1) + go func(idx int) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + content, err := discovery.FetchBlob(client, host, skills[idx].Owner, skills[idx].RepoName, skills[idx].BlobSHA) + if err != nil { + return + } + result, err := frontmatter.Parse(content) + if err != nil { + return + } + + mu.Lock() + skills[idx].Description = result.Metadata.Description + mu.Unlock() + }(i) + } + wg.Wait() +} + +// extractSkillName derives the skill name from a SKILL.md path, but only if +// the path matches a known skill convention (skills/*, skills/scope/*, root-level, +// or plugins/*/skills/*). Returns empty string for non-conforming paths. +func extractSkillName(filePath string) string { + return discovery.MatchesSkillPath(filePath) +} + +func pluralize(count int, singular string) string { + if count == 1 { + return fmt.Sprintf("%d %s", count, singular) + } + return fmt.Sprintf("%d %ss", count, singular) +} + +// collapseWhitespace replaces runs of whitespace (newlines, tabs, etc.) +// with a single space. +func collapseWhitespace(s string) string { + fields := strings.Fields(s) + return strings.Join(fields, " ") +} + +// formatStars formats a star count for display (e.g. 1700 → "1.7k"). +func formatStars(n int) string { + if n >= 1000 { + return fmt.Sprintf("%.1fk", float64(n)/1000) + } + return fmt.Sprintf("%d", n) +} + +// repoInfo holds the subset of repository metadata we fetch for ranking. +type repoInfo struct { + StargazersCount int `json:"stargazers_count"` +} + +// fetchRepoStars fetches stargazer counts for each unique repository in +// the result set, using bounded concurrency. +func fetchRepoStars(client *api.Client, host string, skills []skillResult) { + const maxWorkers = 10 + sem := make(chan struct{}, maxWorkers) + var wg sync.WaitGroup + var mu sync.Mutex + + repoStars := make(map[string]int) + seen := make(map[string]bool) + + for _, s := range skills { + if seen[s.Repo] { + continue + } + seen[s.Repo] = true + + wg.Add(1) + go func(owner, repo, fullName string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) + var info repoInfo + if err := client.REST(host, "GET", apiPath, nil, &info); err != nil { + return + } + mu.Lock() + repoStars[fullName] = info.StargazersCount + mu.Unlock() + }(s.Owner, s.RepoName, s.Repo) + } + wg.Wait() + + for i := range skills { + if stars, ok := repoStars[skills[i].Repo]; ok { + skills[i].Stars = stars + } + } +} diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go new file mode 100644 index 00000000000..db266f46026 --- /dev/null +++ b/pkg/cmd/skills/search/search_test.go @@ -0,0 +1,423 @@ +package search + +import ( + "net/http" + "testing" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdSearch(t *testing.T) { + tests := []struct { + name string + args string + wantOpts searchOptions + wantErr string + }{ + { + name: "query argument", + args: "terraform", + wantOpts: searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + }, + { + name: "with page flag", + args: "terraform --page 3", + wantOpts: searchOptions{Query: "terraform", Page: 3, Limit: defaultLimit}, + }, + { + name: "with limit flag", + args: "terraform --limit 5", + wantOpts: searchOptions{Query: "terraform", Page: 1, Limit: 5}, + }, + { + name: "with limit short flag", + args: "terraform -L 10", + wantOpts: searchOptions{Query: "terraform", Page: 1, Limit: 10}, + }, + { + name: "with owner flag", + args: "terraform --owner hashicorp", + wantOpts: searchOptions{Query: "terraform", Owner: "hashicorp", Page: 1, Limit: defaultLimit}, + }, + { + name: "no arguments", + args: "", + wantErr: "cannot search: query argument required", + }, + { + name: "invalid page", + args: "terraform --page 0", + wantErr: "invalid page number: 0", + }, + { + name: "query too short", + args: "a", + wantErr: "search query must be at least 2 characters", + }, + { + name: "query too short single char", + args: "x", + wantErr: "search query must be at least 2 characters", + }, + { + name: "invalid limit zero", + args: "terraform --limit 0", + wantErr: "invalid limit: 0", + }, + { + name: "invalid limit negative", + args: "terraform --limit -1", + wantErr: "invalid limit: -1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + var gotOpts *searchOptions + cmd := NewCmdSearch(f, func(opts *searchOptions) error { + gotOpts = opts + return nil + }) + + argv := []string{} + if tt.args != "" { + for _, part := range splitOnSpaces(tt.args) { + if part != "" { + argv = append(argv, part) + } + } + } + cmd.SetArgs(argv) + cmd.SetOut(&discardWriter{}) + cmd.SetErr(&discardWriter{}) + + _, err := cmd.ExecuteC() + if tt.wantErr != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantOpts.Query, gotOpts.Query) + assert.Equal(t, tt.wantOpts.Owner, gotOpts.Owner) + assert.Equal(t, tt.wantOpts.Page, gotOpts.Page) + assert.Equal(t, tt.wantOpts.Limit, gotOpts.Limit) + }) + } +} + +func TestSearchRun(t *testing.T) { + const emptyCodeResponse = `{"total_count": 0, "incomplete_results": false, "items": []}` + + // stubKeywordSearch registers the HTTP stubs needed for a keyword search. + // searchByKeyword fires up to 3 concurrent search/code requests (path, + // owner, primary). Stubs are one-shot in httpmock, so we register one + // per request. + stubKeywordSearch := func(reg *httpmock.Registry, codeResponse string) { + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.StringResponse(codeResponse), + ) + } + } + + tests := []struct { + name string + opts *searchOptions + tty bool + httpStubs func(*httpmock.Registry) + wantStdout string + wantStderr string + wantErr string + }{ + { + name: "displays results in non-TTY", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) + }, + wantStdout: "github/awesome-skills\tterraform\t\t0\n", + }, + { + name: "deduplicates results", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 3, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}, {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}, {"name": "SKILL.md", "path": "skills/terraform-aws/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) + }, + wantStdout: "github/awesome-skills\tterraform\t\t0\ngithub/awesome-skills\tterraform-aws\t\t0\n", + }, + { + name: "no results", + tty: true, + opts: &searchOptions{Query: "nonexistent", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, emptyCodeResponse) + }, + wantErr: `no skills found matching "nonexistent"`, + }, + { + name: "nested skill path", + tty: false, + opts: &searchOptions{Query: "my-skill", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/author/my-skill/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) + }, + wantStdout: "org/repo\tmy-skill\t\t0\n", + }, + { + name: "ranks name-matching results first", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 3, "incomplete_results": false, "items": [ + {"name": "SKILL.md", "path": "skills/terraform-deploy/SKILL.md", "repository": {"full_name": "org/repo1"}}, + {"name": "SKILL.md", "path": "skills/terraform-plan/SKILL.md", "repository": {"full_name": "org/repo2"}}, + {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "org/repo3"}} + ]}`) + }, + // exact name match "terraform" first, then partial matches alphabetically by score + wantStdout: "org/repo3\tterraform\t\t0\norg/repo1\tterraform-deploy\t\t0\norg/repo2\tterraform-plan\t\t0\n", + }, + { + name: "caps total pages at 1000-result limit", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 5000, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) + }, + // In non-TTY mode, no header or pagination text is shown + wantStdout: "org/repo\tterraform\t\t0\n", + }, + { + name: "page beyond available results", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 999, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) + }, + wantErr: `no skills found on page 999 for query "terraform"`, + }, + { + name: "json output with selected fields", + tty: false, + opts: func() *searchOptions { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"repo", "skillName", "stars"}) + return &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit, Exporter: exporter} + }(), + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) + }, + wantStdout: "[{\"repo\":\"github/awesome-skills\",\"skillName\":\"terraform\",\"stars\":0}]\n", + }, + { + name: "json output empty results", + tty: false, + opts: func() *searchOptions { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"repo", "skillName"}) + return &searchOptions{Query: "nonexistent", Page: 1, Limit: defaultLimit, Exporter: exporter} + }(), + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, emptyCodeResponse) + }, + wantStdout: "[]\n", + }, + { + name: "rate limit error returns friendly message", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + // All search/code calls return 403 with x-ratelimit-remaining: 0 + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.WithHeader( + httpmock.StatusJSONResponse(403, map[string]string{"message": "API rate limit exceeded"}), + "x-ratelimit-remaining", "0", + ), + ) + } + }, + wantErr: rateLimitErrorMessage, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.Config = func() (gh.Config, error) { + return config.NewBlankConfig(), nil + } + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.tty) + ios.SetStderrTTY(tt.tty) + tt.opts.IO = ios + + defer reg.Verify(t) + err := searchRun(tt.opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} + +func TestDeduplicateResults(t *testing.T) { + items := []codeSearchItem{ + {Path: "skills/terraform/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/terraform/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/docker/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/terraform/SKILL.md", Repository: codeSearchRepository{FullName: "other/repo"}}, + } + + results := deduplicateResults(items) + + assert.Equal(t, 3, len(results)) + assert.Equal(t, "org/repo", results[0].Repo) + assert.Equal(t, "org", results[0].Owner) + assert.Equal(t, "repo", results[0].RepoName) + assert.Equal(t, "terraform", results[0].SkillName) + assert.Equal(t, "docker", results[1].SkillName) + assert.Equal(t, "other/repo", results[2].Repo) + assert.Equal(t, "other", results[2].Owner) + assert.Equal(t, "terraform", results[2].SkillName) +} + +func TestExtractSkillName(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"skills/terraform/SKILL.md", "terraform"}, + {"skills/author/my-skill/SKILL.md", "my-skill"}, + {"SKILL.md", ""}, + {"skills/docker/SKILL.md", "docker"}, + // Root-level convention + {"my-skill/SKILL.md", "my-skill"}, + // Plugins convention + {"plugins/openai/skills/chat/SKILL.md", "chat"}, + // Non-matching paths should be filtered out + {"random/nested/deep/SKILL.md", ""}, + {".hidden/SKILL.md", ""}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := extractSkillName(tt.path) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFilterByRelevance(t *testing.T) { + skills := []skillResult{ + {Repo: "org/repo1", Owner: "org", RepoName: "repo1", SkillName: "terraform"}, + {Repo: "org/repo2", Owner: "org", RepoName: "repo2", SkillName: "docker"}, + {Repo: "terraform-corp/tools", Owner: "terraform-corp", RepoName: "tools", SkillName: "linter"}, + {Repo: "acme/terraform-tools", Owner: "acme", RepoName: "terraform-tools", SkillName: "validator"}, + {Repo: "x/y", Owner: "x", RepoName: "y", SkillName: "unrelated", Description: "terraform integration"}, + {Repo: "x/z", Owner: "x", RepoName: "z", SkillName: "noise"}, + } + + filtered := filterByRelevance(skills, "terraform") + + // Should keep: name match (terraform), owner match (terraform-corp), + // repo name match (terraform-tools), description match (terraform integration). + // Should drop: docker, noise. + assert.Equal(t, 4, len(filtered)) + assert.Equal(t, "terraform", filtered[0].SkillName) + assert.Equal(t, "linter", filtered[1].SkillName) + assert.Equal(t, "validator", filtered[2].SkillName) + assert.Equal(t, "unrelated", filtered[3].SkillName) +} + +func TestRankByRelevance(t *testing.T) { + skills := []skillResult{ + {Repo: "org/repo1", Owner: "org", SkillName: "devops"}, + {Repo: "org/repo2", Owner: "org", SkillName: "terraform-plan"}, + {Repo: "org/repo3", Owner: "org", SkillName: "docker", Description: "Manages terraform docker containers"}, + {Repo: "org/repo4", Owner: "org", SkillName: "terraform"}, + } + + rankByRelevance(skills, "terraform") + + // Exact name match scores highest (10 000), then partial name (1 000), + // then description match (100), then body-only (0). + assert.Equal(t, "terraform", skills[0].SkillName) + assert.Equal(t, "terraform-plan", skills[1].SkillName) + assert.Equal(t, "docker", skills[2].SkillName) + assert.Equal(t, "devops", skills[3].SkillName) +} + +func TestRankByRelevanceStarsTiebreak(t *testing.T) { + skills := []skillResult{ + {Repo: "small/repo", Owner: "small", SkillName: "terraform", Stars: 10}, + {Repo: "big/repo", Owner: "big", SkillName: "terraform", Stars: 5000}, + } + + rankByRelevance(skills, "terraform") + + // Both have exact name match; big/repo wins on stars tiebreak + assert.Equal(t, "big/repo", skills[0].Repo) + assert.Equal(t, "small/repo", skills[1].Repo) +} + +func TestFormatStars(t *testing.T) { + assert.Equal(t, "0", formatStars(0)) + assert.Equal(t, "42", formatStars(42)) + assert.Equal(t, "999", formatStars(999)) + assert.Equal(t, "1.0k", formatStars(1000)) + assert.Equal(t, "1.7k", formatStars(1700)) + assert.Equal(t, "12.5k", formatStars(12500)) +} + +func splitOnSpaces(s string) []string { + var parts []string + current := "" + for _, c := range s { + if c == ' ' { + if current != "" { + parts = append(parts, current) + current = "" + } + } else { + current += string(c) + } + } + if current != "" { + parts = append(parts, current) + } + return parts +} + +type discardWriter struct{} + +func (d *discardWriter) Write(p []byte) (n int, err error) { + return len(p), nil +} diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index 61afc12a4d8..8a136731485 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -2,6 +2,10 @@ package skills import ( "github.com/cli/cli/v2/pkg/cmd/skills/install" + "github.com/cli/cli/v2/pkg/cmd/skills/preview" + "github.com/cli/cli/v2/pkg/cmd/skills/publish" + "github.com/cli/cli/v2/pkg/cmd/skills/search" + "github.com/cli/cli/v2/pkg/cmd/skills/update" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -16,6 +20,10 @@ func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { } cmd.AddCommand(install.NewCmdInstall(f, nil)) + cmd.AddCommand(preview.NewCmdPreview(f, nil)) + cmd.AddCommand(publish.NewCmdPublish(f, nil)) + cmd.AddCommand(search.NewCmdSearch(f, nil)) + cmd.AddCommand(update.NewCmdUpdate(f, nil)) return cmd } diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go new file mode 100644 index 00000000000..42995a315a7 --- /dev/null +++ b/pkg/cmd/skills/update/update.go @@ -0,0 +1,560 @@ +package update + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/installer" + "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +// updateOptions holds all dependencies and user-provided flags for the update command. +type updateOptions struct { + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Config func() (gh.Config, error) + Prompter prompter.Prompter + GitClient *git.Client + + // Arguments + Skills []string // optional: specific skills to update + + // Flags + All bool // --all flag (update without prompting) + Force bool // --force flag (re-download even if SHAs match) + DryRun bool // --dry-run flag (report only, no changes) + Dir string // --dir flag (scan a custom directory) +} + +// installedSkill represents a locally installed skill parsed from its SKILL.md frontmatter. +type installedSkill struct { + name string + owner string + repo string + treeSHA string // tree SHA at install time + pinned string // explicit pin value (empty = unpinned) + sourcePath string // original path in source repo (e.g. "skills/author/name") + dir string // local directory path + host *registry.AgentHost + scope registry.Scope +} + +// pendingUpdate describes a single skill that has an available update. +type pendingUpdate struct { + local installedSkill + newSHA string // new tree SHA from remote + resolved *discovery.ResolvedRef + skill discovery.Skill +} + +// NewCmdUpdate creates the "skills update" command. +func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Command { + opts := &updateOptions{ + IO: f.IOStreams, + Prompter: f.Prompter, + Config: f.Config, + GitClient: f.GitClient, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "update [...]", + Short: "Update installed skills to their latest versions", + Long: heredoc.Doc(` + Checks installed skills for available updates by comparing the local + tree SHA (from SKILL.md frontmatter) against the remote repository. + + Scans all known agent host directories (Copilot, Claude, Cursor, Codex, + Gemini, Antigravity) in both project and user scope automatically. + + Without arguments, checks all installed skills. With skill names, + checks only those specific skills. + + Pinned skills (installed with --pin) are skipped with a notice. + Use "gh skills install --pin " to change the pinned version. + + Skills without GitHub metadata (e.g. installed manually or by another + tool) are prompted for their source repository in interactive mode. + The update re-downloads the skill with metadata injected, so future + updates work automatically. + + With --force, re-downloads skills even when the remote version matches + the local tree SHA. This overwrites locally modified skill files with + their original content, but does not remove extra files added locally. + + In interactive mode, shows which skills have updates and asks for + confirmation before proceeding. With --all, updates without prompting. + With --dry-run, reports available updates without modifying any files. + `), + Example: heredoc.Doc(` + # Check and update all skills interactively + $ gh skills update + + # Update specific skills + $ gh skills update mcp-cli git-commit + + # Update all without prompting + $ gh skills update --all + + # Re-download all skills (restore locally modified files) + $ gh skills update --force --all + + # Check for updates without applying (read-only) + $ gh skills update --dry-run + `), + RunE: func(cmd *cobra.Command, args []string) error { + opts.Skills = args + if runF != nil { + return runF(opts) + } + return updateRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.All, "all", false, "Update all skills without prompting") + cmd.Flags().BoolVar(&opts.Force, "force", false, "Re-download even if already up to date") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Report available updates without modifying files") + cmd.Flags().StringVar(&opts.Dir, "dir", "", "Scan a custom directory for installed skills") + + return cmd +} + +func updateRun(opts *updateOptions) error { + cs := opts.IO.ColorScheme() + canPrompt := opts.IO.CanPrompt() + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + cfg, err := opts.Config() + if err != nil { + return err + } + hostname, _ := cfg.Authentication().DefaultHost() + + gitRoot := resolveGitRoot(opts.GitClient) + homeDir := resolveHomeDir() + + // Scan for installed skills + var installed []installedSkill + if opts.Dir != "" { + skills, scanErr := scanInstalledSkills(opts.Dir, nil, "") + if scanErr != nil { + return fmt.Errorf("could not scan directory: %w", scanErr) + } + installed = skills + } else { + installed = scanAllHosts(gitRoot, homeDir) + } + + if len(installed) == 0 { + fmt.Fprintf(opts.IO.ErrOut, "No installed skills found.\n") + return nil + } + + // Filter to requested skills if specified + if len(opts.Skills) > 0 { + requested := make(map[string]bool, len(opts.Skills)) + for _, name := range opts.Skills { + requested[name] = true + } + var filtered []installedSkill + for _, s := range installed { + if requested[s.name] { + filtered = append(filtered, s) + } + } + if len(filtered) == 0 { + return fmt.Errorf("none of the specified skills are installed") + } + installed = filtered + } + + // Prompt for metadata on skills missing it (before starting progress indicator) + var noMeta []string + // Track skills where the user provided a source repo interactively. + // Keyed by directory path to avoid collisions when the same skill name + // is installed across multiple hosts or scopes. + type promptedEntry struct { + name string + source string // "owner/repo" + } + prompted := make(map[string]promptedEntry) // dir → entry + for i := range installed { + s := &installed[i] + if s.owner != "" && s.repo != "" { + continue + } + if !canPrompt { + noMeta = append(noMeta, s.name) + continue + } + fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata\n", cs.WarningIcon(), s.name) + owner, repo, reason, ok, promptErr := promptForSkillOrigin(opts.Prompter, s.name) + if promptErr != nil { + return promptErr + } + if !ok { + if reason != "" { + fmt.Fprintf(opts.IO.ErrOut, " %s %s\n", cs.WarningIcon(), reason) + } + fmt.Fprintf(opts.IO.ErrOut, " Skipping %s\n", s.name) + continue + } + s.owner = owner + s.repo = repo + prompted[s.dir] = promptedEntry{name: s.name, source: owner + "/" + repo} + } + + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Checking %d installed skill(s) for updates", len(installed))) + + var updates []pendingUpdate + var pinned []installedSkill + + type repoKey struct{ owner, repo string } + repoSkills := make(map[repoKey][]discovery.Skill) + repoRefs := make(map[repoKey]*discovery.ResolvedRef) + repoErrors := make(map[repoKey]bool) + + for _, s := range installed { + if s.owner == "" || s.repo == "" { + continue + } + if s.pinned != "" { + pinned = append(pinned, s) + continue + } + + key := repoKey{s.owner, s.repo} + + if repoErrors[key] { + continue + } + + // Resolve ref and discover skills once per repo + if _, ok := repoRefs[key]; !ok { + resolved, resolveErr := discovery.ResolveRef(apiClient, hostname, s.owner, s.repo, "") + if resolveErr != nil { + repoErrors[key] = true + opts.IO.StopProgressIndicator() + fmt.Fprintf(opts.IO.ErrOut, "%s Skipping %s: could not resolve %s/%s: %v\n", cs.WarningIcon(), s.name, s.owner, s.repo, resolveErr) + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Checking %d installed skill(s) for updates", len(installed))) + continue + } + repoRefs[key] = resolved + + skills, discoverErr := discovery.DiscoverSkills(apiClient, hostname, s.owner, s.repo, resolved.SHA) + if discoverErr != nil { + repoErrors[key] = true + opts.IO.StopProgressIndicator() + fmt.Fprintf(opts.IO.ErrOut, "%s Skipping %s: %v\n", cs.WarningIcon(), s.name, discoverErr) + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Checking %d installed skill(s) for updates", len(installed))) + continue + } + repoSkills[key] = skills + } + + resolved := repoRefs[key] + for _, remote := range repoSkills[key] { + matched := false + if s.sourcePath != "" { + matched = remote.Path == s.sourcePath + } else { + matched = remote.InstallName() == s.name + } + if matched && (remote.TreeSHA != s.treeSHA || opts.Force) { + updates = append(updates, pendingUpdate{ + local: s, + newSHA: remote.TreeSHA, + resolved: resolved, + skill: remote, + }) + break + } + } + } + + opts.IO.StopProgressIndicator() + + // Warn about prompted skills that weren't found in the remote repo + for _, entry := range prompted { + parts := strings.SplitN(entry.source, "/", 2) + key := repoKey{parts[0], parts[1]} + skills, resolved := repoSkills[key] + if !resolved { + continue + } + found := false + for _, remote := range skills { + if remote.InstallName() == entry.name || remote.Name == entry.name { + found = true + break + } + } + if !found { + fmt.Fprintf(opts.IO.ErrOut, "%s Skill %s not found in %s\n", cs.WarningIcon(), entry.name, entry.source) + } + } + + for _, s := range pinned { + fmt.Fprintf(opts.IO.ErrOut, "%s %s is pinned to %s (skipped)\n", cs.Gray("⊘"), s.name, s.pinned) + } + for _, name := range noMeta { + fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata — reinstall to enable updates\n", cs.WarningIcon(), name) + } + + if len(updates) == 0 { + if opts.Force && opts.DryRun { + fmt.Fprintf(opts.IO.ErrOut, "All skills are up to date. Use --force without --dry-run to re-download anyway.\n") + } else { + fmt.Fprintf(opts.IO.ErrOut, "All skills are up to date.\n") + } + return nil + } + + fmt.Fprintf(opts.IO.ErrOut, "\n%d update(s) available:\n", len(updates)) + for _, u := range updates { + if u.local.treeSHA == u.newSHA { + fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s (reinstall) [%s]\n", + cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, + git.ShortSHA(u.newSHA), u.resolved.Ref) + } else { + fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s → %s [%s]\n", + cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, + cs.Gray(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA), + u.resolved.Ref) + } + } + fmt.Fprintln(opts.IO.ErrOut) + + if opts.DryRun { + return nil + } + + if !opts.All { + if !canPrompt { + return fmt.Errorf("updates available; re-run with --all to apply, or run interactively to confirm") + } + confirmed, confirmErr := opts.Prompter.Confirm(fmt.Sprintf("Update %d skill(s)?", len(updates)), true) + if confirmErr != nil { + return confirmErr + } + if !confirmed { + fmt.Fprintf(opts.IO.ErrOut, "Update cancelled.\n") + return nil + } + } + + var failed bool + for _, u := range updates { + installOpts := &installer.Options{ + Host: hostname, + Owner: u.local.owner, + Repo: u.local.repo, + Ref: u.resolved.Ref, + SHA: u.resolved.SHA, + Skills: []discovery.Skill{u.skill}, + AgentHost: u.local.host, + Scope: u.local.scope, + GitRoot: gitRoot, + HomeDir: homeDir, + Client: apiClient, + } + // When updating skills from a custom --dir, host is nil. + // Use the skill's install root as the target. For namespaced + // skills (name contains "/"), the dir is two levels below the + // root instead of one. + if u.local.host == nil { + base := filepath.Dir(u.local.dir) + if strings.Contains(u.local.name, "/") { + base = filepath.Dir(base) + } + installOpts.Dir = base + } + _, installErr := installer.Install(installOpts) + if installErr != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Failed to update %s: %v\n", cs.FailureIcon(), u.local.name, installErr) + failed = true + continue + } + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s Updated %s\n", cs.SuccessIcon(), u.local.name) + } else { + fmt.Fprintf(opts.IO.Out, "Updated %s\n", u.local.name) + } + } + + if failed { + return cmdutil.SilentError + } + + return nil +} + +// scanAllHosts walks every known host directory (project + user scope) and +// collects installed skills. Skills are deduplicated by directory path. +func scanAllHosts(gitRoot, homeDir string) []installedSkill { + seen := make(map[string]bool) + var all []installedSkill + + for i := range registry.Agents { + host := ®istry.Agents[i] + for _, scope := range []registry.Scope{registry.ScopeProject, registry.ScopeUser} { + dir, err := host.InstallDir(scope, gitRoot, homeDir) + if err != nil { + continue + } + skills, err := scanInstalledSkills(dir, host, scope) + if err != nil { + continue + } + for _, s := range skills { + if seen[s.dir] { + continue + } + seen[s.dir] = true + all = append(all, s) + } + } + } + + return all +} + +// scanInstalledSkills reads all SKILL.md files in a skills directory and +// extracts GitHub metadata from their frontmatter. It handles both flat +// layouts ({dir}/{name}/SKILL.md) and namespaced layouts +// ({dir}/{namespace}/{name}/SKILL.md). +func scanInstalledSkills(skillsDir string, host *registry.AgentHost, scope registry.Scope) ([]installedSkill, error) { + entries, err := os.ReadDir(skillsDir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("could not read skills directory: %w", err) + } + + var skills []installedSkill + for _, e := range entries { + if !e.IsDir() { + continue + } + + // Flat layout: {dir}/{name}/SKILL.md + skillFile := filepath.Join(skillsDir, e.Name(), "SKILL.md") + if data, readErr := os.ReadFile(skillFile); readErr == nil { + if s, ok := parseInstalledSkill(data, e.Name(), filepath.Join(skillsDir, e.Name()), host, scope); ok { + skills = append(skills, s) + continue + } + } + + // Namespaced layout: {dir}/{namespace}/{name}/SKILL.md + subEntries, subErr := os.ReadDir(filepath.Join(skillsDir, e.Name())) + if subErr != nil { + continue + } + for _, sub := range subEntries { + if !sub.IsDir() { + continue + } + subSkillFile := filepath.Join(skillsDir, e.Name(), sub.Name(), "SKILL.md") + if data, readErr := os.ReadFile(subSkillFile); readErr == nil { + installName := e.Name() + "/" + sub.Name() + if s, ok := parseInstalledSkill(data, installName, filepath.Join(skillsDir, e.Name(), sub.Name()), host, scope); ok { + skills = append(skills, s) + } + } + } + } + + return skills, nil +} + +// parseInstalledSkill parses a SKILL.md file and returns an installedSkill. +func parseInstalledSkill(data []byte, name, dir string, host *registry.AgentHost, scope registry.Scope) (installedSkill, bool) { + result, err := frontmatter.Parse(string(data)) + if err != nil { + return installedSkill{}, false + } + + s := installedSkill{ + name: name, + dir: dir, + host: host, + scope: scope, + } + + if result.Metadata.Meta != nil { + s.owner, _ = result.Metadata.Meta["github-owner"].(string) + s.repo, _ = result.Metadata.Meta["github-repo"].(string) + s.treeSHA, _ = result.Metadata.Meta["github-tree-sha"].(string) + s.pinned, _ = result.Metadata.Meta["github-pinned"].(string) + s.sourcePath, _ = result.Metadata.Meta["github-path"].(string) + } + + return s, true +} + +// promptForSkillOrigin asks the user for the source repository of a skill +// that has no GitHub metadata. +func promptForSkillOrigin(p prompter.Prompter, skillName string) (owner, repo, reason string, ok bool, err error) { + input, err := p.Input( + fmt.Sprintf("Repository for %s (owner/repo):", skillName), "") + if err != nil { + return "", "", "", false, err + } + input = strings.TrimSpace(input) + if input == "" { + return "", "", "", false, nil + } + r, err := ghrepo.FromFullName(input) + if err != nil { + //nolint:nilerr // intentionally converting parse error into a user-facing validation message + return "", "", fmt.Sprintf("invalid repository %q: expected owner/repo", input), false, nil + } + return r.RepoOwner(), r.RepoName(), "", true, nil +} + +func resolveGitRoot(gc *git.Client) string { + if gc == nil { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" + } + root, err := gc.ToplevelDir(context.Background()) + if err != nil { + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" + } + return root +} + +func resolveHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go new file mode 100644 index 00000000000..735536b0d7a --- /dev/null +++ b/pkg/cmd/skills/update/update_test.go @@ -0,0 +1,391 @@ +package update + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/prompter" + + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdUpdate_Help(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{}, + } + + cmd := NewCmdUpdate(f, func(opts *updateOptions) error { + return nil + }) + + assert.Equal(t, "update [...]", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + assert.NotEmpty(t, cmd.Example) +} + +func TestNewCmdUpdate_Flags(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdUpdate(f, func(_ *updateOptions) error { return nil }) + + flags := []string{"all", "force", "dry-run", "dir"} + for _, name := range flags { + assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag: --%s", name) + } +} + +func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + + var gotOpts *updateOptions + cmd := NewCmdUpdate(f, func(opts *updateOptions) error { + gotOpts = opts + return nil + }) + + args, _ := shlex.Split("mcp-cli git-commit --all --force") + cmd.SetArgs(args) + cmd.SetOut(os.Stdout) + cmd.SetErr(os.Stderr) + err := cmd.Execute() + require.NoError(t, err) + assert.Equal(t, []string{"mcp-cli", "git-commit"}, gotOpts.Skills) + assert.True(t, gotOpts.All) + assert.True(t, gotOpts.Force) +} + +func TestScanInstalledSkills(t *testing.T) { + dir := t.TempDir() + + skillDir := filepath.Join(dir, "git-commit") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + content := "---\nname: git-commit\ndescription: Git commit helper\nmetadata:\n github-owner: github\n github-repo: awesome-copilot\n github-tree-sha: abc123\n github-path: skills/git-commit\n---\nBody content\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + noMetaDir := filepath.Join(dir, "unknown-skill") + require.NoError(t, os.MkdirAll(noMetaDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(noMetaDir, "SKILL.md"), []byte("---\nname: unknown-skill\n---\nNo metadata here\n"), 0o644)) + + pinnedDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(pinnedDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pinnedDir, "SKILL.md"), []byte("---\nname: pinned-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: def456\n github-pinned: v1.0.0\n---\nPinned content\n"), 0o644)) + + skills, err := scanInstalledSkills(dir, nil, "") + require.NoError(t, err) + assert.Len(t, skills, 3) + + byName := make(map[string]installedSkill) + for _, s := range skills { + byName[s.name] = s + } + + gc := byName["git-commit"] + assert.Equal(t, "github", gc.owner) + assert.Equal(t, "awesome-copilot", gc.repo) + assert.Equal(t, "abc123", gc.treeSHA) + assert.Equal(t, "skills/git-commit", gc.sourcePath) + assert.Empty(t, gc.pinned) + + us := byName["unknown-skill"] + assert.Empty(t, us.owner) + assert.Empty(t, us.repo) + + ps := byName["pinned-skill"] + assert.Equal(t, "v1.0.0", ps.pinned) +} + +func TestScanInstalledSkills_NonExistentDir(t *testing.T) { + skills, err := scanInstalledSkills("/nonexistent/path", nil, "") + require.NoError(t, err) + assert.Nil(t, skills) +} + +func TestScanInstalledSkills_CorruptedYAML(t *testing.T) { + dir := t.TempDir() + skillDir := filepath.Join(dir, "corrupt") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nnot: valid: yaml: [broken\n---\nbody\n"), 0o644)) + + skills, err := scanInstalledSkills(dir, nil, "") + require.NoError(t, err) + assert.Len(t, skills, 0) +} + +func TestPromptForSkillOrigin_Valid(t *testing.T) { + pm := &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "github/awesome-copilot", nil + }, + } + owner, repo, _, ok, err := promptForSkillOrigin(pm, "test-skill") + require.NoError(t, err) + assert.True(t, ok) + assert.Equal(t, "github", owner) + assert.Equal(t, "awesome-copilot", repo) +} + +func TestPromptForSkillOrigin_Empty(t *testing.T) { + pm := &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "", nil + }, + } + _, _, _, ok, err := promptForSkillOrigin(pm, "test-skill") + require.NoError(t, err) + assert.False(t, ok) +} + +func TestPromptForSkillOrigin_Invalid(t *testing.T) { + pm := &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "just-a-name", nil + }, + } + _, _, reason, ok, err := promptForSkillOrigin(pm, "test-skill") + require.NoError(t, err) + assert.False(t, ok) + assert.Contains(t, reason, "invalid repository") +} + +func TestUpdateRun_NoInstalledSkills(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(false) + + dir := t.TempDir() + + reg := &httpmock.Registry{} + opts := &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + + defer reg.Verify(t) + err := updateRun(opts) + require.NoError(t, err) + assert.Contains(t, stderr.String(), "No installed skills found.") +} + +func TestUpdateRun_SpecificSkillNotInstalled(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + + dir := t.TempDir() + skillDir := filepath.Join(dir, "existing-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: existing-skill\nmetadata:\n github-owner: owner\n github-repo: repo\n github-tree-sha: abc\n---\n"), 0o644)) + + reg := &httpmock.Registry{} + opts := &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + Skills: []string{"nonexistent"}, + } + + defer reg.Verify(t) + err := updateRun(opts) + assert.EqualError(t, err, "none of the specified skills are installed") +} + +func TestUpdateRun_PinnedSkillsSkipped(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + dir := t.TempDir() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: pinned-skill\nmetadata:\n github-owner: owner\n github-repo: repo\n github-tree-sha: abc123\n github-pinned: v1.0.0\n---\n"), 0o644)) + + reg := &httpmock.Registry{} + opts := &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + + defer reg.Verify(t) + err := updateRun(opts) + require.NoError(t, err) + assert.Contains(t, stderr.String(), "pinned-skill is pinned to v1.0.0 (skipped)") + assert.Contains(t, stderr.String(), "All skills are up to date.") +} + +func TestUpdateRun_NoMetaSkipsNonInteractive(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + dir := t.TempDir() + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: manual-skill\n---\nNo metadata\n"), 0o644)) + + reg := &httpmock.Registry{} + opts := &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + + defer reg.Verify(t) + err := updateRun(opts) + require.NoError(t, err) + assert.Contains(t, stderr.String(), "manual-skill has no GitHub metadata") +} + +func TestUpdateRun_AllUpToDate(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(false) + + dir := t.TempDir() + skillDir := filepath.Join(dir, "my-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: abc123def456\n github-path: skills/my-skill\n---\n"), 0o644)) + + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/octo/skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commitsha123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/octo/skills/git/trees/commitsha123")), + httpmock.StringResponse(`{"sha": "commitsha123", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha1"}, {"path": "skills/my-skill", "type": "tree", "sha": "abc123def456"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`), + ) + + opts := &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + + defer reg.Verify(t) + err := updateRun(opts) + require.NoError(t, err) + assert.Contains(t, stderr.String(), "All skills are up to date.") +} + +func TestUpdateRun_DryRun(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + dir := t.TempDir() + skillDir := filepath.Join(dir, "my-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: oldsha123\n github-path: skills/my-skill\n---\n"), 0o644)) + + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/octo/skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/octo/skills/git/trees/newcommit456"), + httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/my-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), + ) + + opts := &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + DryRun: true, + } + + defer reg.Verify(t) + err := updateRun(opts) + require.NoError(t, err) + assert.Contains(t, stderr.String(), "1 update(s) available:") + assert.Contains(t, stdout.String(), "my-skill") + assert.Contains(t, stdout.String(), "octo/skills") +} + +func TestUpdateRun_NonInteractiveNoAll(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + dir := t.TempDir() + skillDir := filepath.Join(dir, "my-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: oldsha123\n github-path: skills/my-skill\n---\n"), 0o644)) + + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "repos/octo/skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/octo/skills/git/trees/newcommit456"), + httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/my-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), + ) + + opts := &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + + defer reg.Verify(t) + err := updateRun(opts) + assert.EqualError(t, err, "updates available; re-run with --all to apply, or run interactively to confirm") +} From 8ea84d0dee4f247a82912fc16e74eab29f9da091 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:37:47 -0600 Subject: [PATCH 007/182] Expand test coverage and fix invariants/bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the three primary discovery entry points with httpmock-based tests. DiscoverSkills: happy path, truncated tree, no skills, API error, dedup. DiscoverSkillByPath: path resolution, namespaces, invalid name, missing directory, missing SKILL.md. DiscoverLocalSkills: convention matching, root skill, no skills, nonexistent directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test InstallLocal public API instead of private installLocalSkill Replace tests that called installLocalSkill directly with tests through InstallLocal. Adds coverage for AgentHost+Scope resolution path, multiple skills, and missing Dir/AgentHost error. Fixes symlink test to require.NoError on os.Symlink. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test partial failure in concurrent Install Add test where one of two skills fails (500 on tree fetch). Asserts that result.Installed contains the successful skill and err wraps the failed skill name. Fixes test loop to not clear Dir for partial failure cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Refactor update tests to table-driven pattern Consolidate 16 individual test functions into 3 standalone + 3 table tests matching cli/cli conventions. Fix ArgsPassedToOptions to use iostreams.Test() instead of os.Stdout/os.Stderr. Use GitHub-branded test data. No coverage lost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add update execution test that verifies SKILL.md is rewritten All prior update tests used DryRun or hit early exits. New test exercises the full fetch-and-rewrite path: stale treeSHA triggers re-download, SKILL.md is overwritten with new content and metadata. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Use heredoc.Doc for multiline SKILL.md strings in update tests Replace escaped newline strings with heredoc.Doc backtick literals for readability, matching cli/cli conventions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add interactive update path tests Cover confirm-and-apply, confirm-cancelled, and no-metadata prompt paths in TestUpdateRun. These interactive branches were previously untested since all prior tests used non-TTY or DryRun. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test no-metadata prompt enrichment through full update path Add test where a skill with no GitHub metadata is prompted for origin, user provides owner/repo, skill gets enriched and proceeds through version resolution and file rewrite. Covers lines 222-224 in update.go. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Replace deprecated cs.Gray with cs.Muted Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test namespaced skill update with --dir base resolution Cover the filepath.Dir double-up path for namespaced skills (name contains '/') when using --dir. Verifies the install base is resolved correctly so the update writes to the right directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test install failure during update reports error and preserves file Cover the path where version resolution succeeds but blob fetch fails during the actual install. Verifies stderr error message, SilentError return, and that the original SKILL.md is not modified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Dedupe resolveGitRoot/resolveHomeDir into installer, rename scanAllHosts Move ResolveGitRoot and ResolveHomeDir to the installer package to eliminate duplication between install and update commands. Fix ResolveGitRoot to check RepoDir before calling ToplevelDir. Rename scanAllHosts to scanAllAgents to match registry naming. Add test exercising scanAllAgents via updateRun without --dir. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Use heredoc.Doc for multiline YAML strings across all test files Convert 13 escaped-newline frontmatter strings to heredoc.Doc for readability. Applies to discovery, frontmatter, install, update, publish, and preview test files. Preserves edge-case test strings and fmt.Sprintf interpolations as-is. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Use git.Client.Copy() instead of struct copy to avoid mutex copy Fixes go vet 'copies lock value' warnings in publish command where *git.Client was copied by value to set a different RepoDir. Rename terse variable names (bc/ic/dc) to branchGit/ignoreGit/dirGit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Rewrite publish tests: table-driven through publishRun Consolidate 35 test functions into 2: TestNewCmdPublish (4 cases for CLI arg parsing) and TestPublishRun (22 cases exercising all behavior through the command's run function). No individual helper function tests — every codepath tested through publishRun scenarios. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Remove .gitkeep from acceptance/testdata/skills Delete the placeholder .gitkeep file from acceptance/testdata/skills. The directory no longer needs a placeholder file to be tracked in the repository. Rename testPublishGitClient to newTestGitClient Rename the test helper function testPublishGitClient to newTestGitClient in pkg/cmd/skills/publish/publish_test.go and update all call sites accordingly. This is a purely refactor/name-change with no behavioral changes to tests. Fix Windows CI: set USERPROFILE alongside HOME in tests os.UserHomeDir() uses USERPROFILE on Windows, not HOME. All tests that redirect HOME for lockfile isolation now also set USERPROFILE to the same temp directory. Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Use range-over-int in acquireLock retry loop Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test lock acquisition edge cases through RecordInstall Make lockRetries and lockRetryInterval configurable (package-level vars) so tests can avoid the 3s retry wait. Add two RecordInstall cases: - Stale lock (>30s old) is broken and install succeeds - Fresh lock exhausts retries, proceeds best-effort without lock Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Rename test helpers for lockfile tests Rename setupHome to setupTestHome and readLockfile to readTestLockfile in internal/skills/lockfile tests, and update all call sites and comments accordingly. This is a refactor-only change to clarify test helper names with no behavior change. Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Test read() degradation through RecordInstall, delete TestRead Move corrupt JSON and wrong version cases into TestRecordInstall table. RecordInstall calls read() internally, so these exercise the same degradation paths through the public API. Verifies the lockfile is rewritten with correct version and new data after recovery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix InstalledAt preservation test to actually prove preservation Move the update-preserves-InstalledAt case out of the table into a standalone subtest that reads InstalledAt between two RecordInstall calls and asserts exact equality. The table version only checked NotEmpty which couldn't detect if InstalledAt was overwritten. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Merge duplicate plugin test into TestMatchSkillConventions table The standalone TestDuplicatePluginSkills_DifferentAuthors re-implemented dedup logic that belongs in DiscoverSkills. Replace with a table case that tests convention matching only. Dedup is already covered by TestDiscoverSkills. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix broken validateName max-length test case Replace make([]byte, N) (which produces null bytes) with strings.Repeat to actually test the 64-character boundary. Add positive test for valid 64-char name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Replace name-matching hack with createDir field in TestDiscoverLocalSkills Use a struct field instead of comparing tt.name to control whether the test directory is created. Prevents silent breakage if someone renames the test case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Improve collisions tests: table-driven FormatCollisions, exercise DisplayName Convert TestFormatCollisions to table test with nil-input case. Update single collision case to use different conventions (plugins vs skills) so DisplayName() logic is actually exercised in the assertion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add tests for MatchesSkillPath, DiscoverSkillFiles, ListSkillFiles, FetchDescriptionsConcurrent Also cover previously untested branches: root convention matching, annotated tag dereference failure, empty tag_name/default_branch fallbacks, recursive walkTree with subtrees, and skill directory deduplication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test full GitHub key stripping in InjectLocalMetadata Add all 7 github-* keys to the input metadata and assert all are absent after injection. Previously only tested github-owner and github-repo removal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test Serialize trailing-newline addition for body without newline Add case where body doesn't end in newline and assert the output has one appended. Previously this branch was uncovered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test InjectGitHubMetadata with no existing frontmatter Add case where content has no --- delimiters, exercising the RawYAML == nil branch that creates frontmatter from scratch. Also fix test data to use GitHub-branded names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Convert TestInjectLocalMetadata to table-driven with no-metadata case Add case for content with no frontmatter, exercising the meta == nil branch. Aligns with table-driven pattern used throughout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Replace name-matching hack with useAgentHost field in TestInstallLocal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add tests for ResolveGitRoot Cover RepoDir shortcut, nil client fallback, and empty RepoDir fallback. Skip ResolveHomeDir — it's a thin os.UserHomeDir wrapper with no logic to test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Test OnProgress callback in both single and multi-skill Install paths Cover the progress reporting branches in Install for both the single-skill fast path (len==1) and the concurrent multi-skill path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Cover missing InstallDir error branches and malformed URL in registry Add user-scope-without-homeDir and invalid-scope cases to TestInstallDir. Add malformed URL case to TestRepoNameFromRemote. Coverage 80.5% → 87.8%. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Rewrite install tests: table-driven through installRun and runLocalInstall Consolidate 48 individual test functions into 6: TestNewCmdInstall (10 cases for CLI parsing), TestInstallRun (21 cases for remote install flow), TestRunLocalInstall (10 cases for local install flow), plus TestIsLocalPath, TestIsSkillPath, TestFriendlyDir for pure input classification. Delete zero-value Help test. All behavior tested through public functions instead of calling internal helpers directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix data race in OnProgress test with atomic counter The OnProgress callback was appending to a shared slice from concurrent goroutines. Replace with sync/atomic counter to avoid the race. Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Add interactive install tests for skill selection, scope, host, and overwrite Exercise the interactive TTY paths in installRun: MultiSelectWithSearch for skill selection, Select for scope prompt, MultiSelect for host selection, and Confirm for overwrite declined. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Exercise skillSearchFunc fully through interactive mock Update the interactive skill selection test to use 31 skills (exceeding maxSearchResults cap), include a skill without a description, and have the mock call searchFunc with both empty and filtered queries. Verifies the MoreResults count, label formatting, and truncation branches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fill remaining install coverage gaps Add local path detection cases to TestNewCmdInstall. Add interactive repo prompt, user scope selection, overwrite without metadata, and single exact match cases to TestInstallRun. Add bare tilde expansion to TestRunLocalInstall. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Move HOME/USERPROFILE setenv to test loops, remove per-case duplication Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add isTTY field to install test tables, centralize TTY setup Move TTY configuration from individual opts funcs into the test loops. Each table case declares isTTY: true/false and the loop sets all three streams accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Remove INSTALL_TARGET env var hack from install test Metadata injection is already proven by installer package tests. This test only needs to verify installRun orchestrates correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add ScopeChanged: true to all install tests with explicit Scope Ensures tests simulate the same state cobra produces when --scope is explicitly provided, preventing silent codepath divergence if the default scope behavior changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix assert.Error → require.Error in TestNewCmdSearch Prevents nil panic on err.Error() if the command unexpectedly returns nil. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Improve preview test quality and coverage - Fix assert.Error/assert.NoError → require.Error/require.NoError to prevent nil panics in TestNewCmdPreview and TestPreviewRun - Add renderAllFiles edge case tests: maxFiles cap (20 files), maxBytes cap (512KB), and FetchBlob error fallback message - Replace custom discardWriter with io.Discard - Use GitHub-branded names (monalisa) in new tests Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Add search test coverage: rate limits, owner scope, blob enrichment - Add HTTP 429 and 403+Retry-After rate limit test cases - Add owner-scoped no-results test (exercises noResultsMessage branch) - Add blob description enrichment test (exercises fetchDescriptions path) - Replace custom splitOnSpaces with strings.Fields - Replace custom discardWriter with io.Discard Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Remove low-value alias test for preview command The test only asserts a string literal matches another string literal. Alias presence is already visible in the command definition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Replace local pluralize with text.Pluralize The internal/text package already provides this function via go-gh. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Inline collapseWhitespace — just strings.Fields + Join Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Doc: suggest using go-humanize for star formatting Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> Return cmdutil.CancelError on user cancellation in publish and update Both commands returned nil (success exit) when the user declined confirmation. The core CLI pattern is to return CancelError so the process exits with a non-zero status. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Add interactive publish prompt tests and isTTY field Cover all prompt branches in runPublishRelease: - Topic confirm + semver tag selection + final confirm (happy path) - Custom tag input path (select idx=1) - Final confirm declined (CancelError) - Immutable releases prompt (enable via PATCH) Add isTTY field to test table struct for centralized TTY setup, matching the pattern used in install tests. Add auto-confirm prompters to existing TTY tests that now need them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Remove duplicate giturl import alias in publish The git package was imported twice — once as 'git' and again as 'giturl'. Use git.ParseURL directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Fix data race in search enrichment fetchDescriptions and fetchRepoStars run concurrently but both wrote to fields of the same skillResult slice elements, triggering the race detector. Refactor both functions to return index-keyed maps instead of mutating the slice directly. enrichSkills merges the maps into the slice after both goroutines complete. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> refactor: remove Claude plugin branding, align with Open Plugin Spec Replace all 'Claude plugin' references with generic 'plugin' terminology to align with the vendor-neutral Open Plugin Spec (https://github.com/vercel-labs/open-plugin-spec). Changes: - Rename .claude-plugin/ to .plugin/ (spec §5.1 vendor-neutral manifest) - Rename claudePluginJSON/claudeAuthor types to pluginJSON/pluginAuthor - Rename claudeMarketplaceJSON to marketplaceJSON - Rename generateClaudePlugin to generatePlugin - Remove 'Claude Code' from plugin-related comments, help text, and flags - Update install.go plugins/ convention message Factual host references (Claude Code as an agent name, .claude/skills directories) are intentionally preserved — those are product names, not plugin branding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Remove --plugins flag from publish command Remove the --plugins flag and all associated plugin generation code from the publish flow. This was scope creep — the publish command should focus on validating and publishing skills, not generating plugin manifests. Removed: - --plugins flag and Plugins option field - generatePlugin, generateMarketplace, buildPluginDescription functions - pluginJSON, marketplaceJSON, marketplacePlugin types - Related tests and help text The install command's ability to discover and pluck skills from plugin- structured repositories (plugins/ convention) is preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> don't fall back on default branch if you can't fetch latest release improve search algo by using square rot instead of log for stars, and reduce weight for exact name match add support for --unpin flag when updating a skill --- acceptance/testdata/skills/.gitkeep | 0 internal/skills/discovery/collisions_test.go | 34 +- internal/skills/discovery/discovery_test.go | 654 ++++- .../skills/frontmatter/frontmatter_test.go | 114 +- internal/skills/installer/installer.go | 28 + internal/skills/installer/installer_test.go | 206 +- internal/skills/lockfile/lockfile.go | 9 +- internal/skills/lockfile/lockfile_test.go | 191 +- internal/skills/registry/registry_test.go | 15 + pkg/cmd/skills/install/install.go | 44 +- pkg/cmd/skills/install/install_test.go | 2419 +++++++++++------ pkg/cmd/skills/preview/preview.go | 12 +- pkg/cmd/skills/preview/preview_test.go | 221 +- pkg/cmd/skills/publish/publish.go | 236 +- pkg/cmd/skills/publish/publish_test.go | 2220 ++++++++------- pkg/cmd/skills/search/search.go | 71 +- pkg/cmd/skills/search/search_test.go | 111 +- pkg/cmd/skills/update/update.go | 52 +- pkg/cmd/skills/update/update_test.go | 1328 +++++++-- 19 files changed, 5390 insertions(+), 2575 deletions(-) delete mode 100644 acceptance/testdata/skills/.gitkeep diff --git a/acceptance/testdata/skills/.gitkeep b/acceptance/testdata/skills/.gitkeep deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/internal/skills/discovery/collisions_test.go b/internal/skills/discovery/collisions_test.go index b499c497a07..fff5199ba7b 100644 --- a/internal/skills/discovery/collisions_test.go +++ b/internal/skills/discovery/collisions_test.go @@ -21,13 +21,13 @@ func TestFindNameCollisions(t *testing.T) { want: nil, }, { - name: "single collision", + name: "single collision with different conventions", skills: []Skill{ {Name: "pr-summary", Path: "skills/pr-summary"}, - {Name: "pr-summary", Path: "skills/monalisa/pr-summary"}, + {Name: "pr-summary", Path: "plugins/hubot/skills/pr-summary", Convention: "plugins"}, }, want: []NameCollision{ - {Name: "pr-summary", DisplayNames: []string{"pr-summary", "pr-summary"}}, + {Name: "pr-summary", DisplayNames: []string{"pr-summary", "[plugins] pr-summary"}}, }, }, { @@ -53,10 +53,28 @@ func TestFindNameCollisions(t *testing.T) { } func TestFormatCollisions(t *testing.T) { - collisions := []NameCollision{ - {Name: "pr-summary", DisplayNames: []string{"skills/pr-summary", "plugins/hubot/pr-summary"}}, - {Name: "code-review", DisplayNames: []string{"skills/code-review", "skills/monalisa/code-review"}}, + tests := []struct { + name string + collisions []NameCollision + want string + }{ + { + name: "formats multiple collisions", + collisions: []NameCollision{ + {Name: "pr-summary", DisplayNames: []string{"skills/pr-summary", "plugins/hubot/pr-summary"}}, + {Name: "code-review", DisplayNames: []string{"skills/code-review", "skills/monalisa/code-review"}}, + }, + want: "pr-summary: skills/pr-summary, plugins/hubot/pr-summary\n code-review: skills/code-review, skills/monalisa/code-review", + }, + { + name: "nil input returns empty string", + collisions: nil, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, FormatCollisions(tt.collisions)) + }) } - got := FormatCollisions(collisions) - assert.Equal(t, "pr-summary: skills/pr-summary, plugins/hubot/pr-summary\n code-review: skills/code-review, skills/monalisa/code-review", got) } diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 5368ad23add..9740525303d 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -2,8 +2,12 @@ package discovery import ( "net/http" + "os" + "path/filepath" + "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" @@ -73,6 +77,29 @@ func TestMatchSkillConventions(t *testing.T) { path: "skills/code-review/README.md", wantNil: true, }, + { + name: "plugin skill from different author", + path: "plugins/monalisa/skills/code-review/SKILL.md", + wantName: "code-review", + wantNamespace: "monalisa", + wantConvention: "plugins", + }, + { + name: "root convention single-skill repo", + path: "code-review/SKILL.md", + wantName: "code-review", + wantConvention: "root", + }, + { + name: "root convention excludes skills dir", + path: "skills/SKILL.md", + wantNil: true, + }, + { + name: "root convention excludes dot-prefixed", + path: ".hidden/SKILL.md", + wantNil: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -89,32 +116,6 @@ func TestMatchSkillConventions(t *testing.T) { } } -func TestDuplicatePluginSkills_DifferentAuthors(t *testing.T) { - entries := []treeEntry{ - {Path: "plugins/monalisa/skills/code-review/SKILL.md", Type: "blob"}, - {Path: "plugins/hubot/skills/code-review/SKILL.md", Type: "blob"}, - } - - seen := make(map[string]bool) - var matches []skillMatch - for _, e := range entries { - m := matchSkillConventions(e) - if m == nil || seen[m.skillDir] { - continue - } - seen[m.skillDir] = true - matches = append(matches, *m) - } - - require.Len(t, matches, 2) - assert.Equal(t, "monalisa", matches[0].namespace) - assert.Equal(t, "hubot", matches[1].namespace) - assert.NotEqual(t, - Skill{Name: matches[0].name, Namespace: matches[0].namespace}.InstallName(), - Skill{Name: matches[1].name, Namespace: matches[1].namespace}.InstallName(), - ) -} - func TestValidateName(t *testing.T) { tests := []struct { name string @@ -122,8 +123,8 @@ func TestValidateName(t *testing.T) { want bool }{ {name: "empty", input: "", want: false}, - {name: "too long", input: string(make([]byte, 65)), want: false}, - {name: "max length", input: "a" + string(make([]byte, 63)), want: false}, // 64 'a's would be valid but []byte gives null bytes + {name: "too long", input: strings.Repeat("a", 65), want: false}, + {name: "max length is valid", input: strings.Repeat("a", 64), want: true}, {name: "contains slash", input: "foo/bar", want: false}, {name: "contains dotdot", input: "foo..bar", want: false}, {name: "starts with dot", input: ".hidden", want: false}, @@ -261,6 +262,57 @@ func TestResolveRef(t *testing.T) { wantRef: "main", wantSHA: "branch-sha", }, + { + name: "annotated tag dereference failure", + version: "v4.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v4.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "tag-obj-sha", "type": "tag"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/tags/tag-obj-sha"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not dereference annotated tag", + }, + { + name: "empty tag_name in latest release falls back to default branch", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.JSONResponse(map[string]interface{}{"tag_name": ""})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "fallback-sha"}, + })) + }, + wantRef: "main", + wantSHA: "fallback-sha", + }, + { + name: "empty default_branch falls back to main", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": ""})) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "main-sha"}, + })) + }, + wantRef: "main", + wantSHA: "main-sha", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -339,3 +391,549 @@ func TestFetchBlob(t *testing.T) { }) } } + +func TestDiscoverSkills(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantSkills []string + wantErr string + }{ + { + name: "discovers skills from tree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/code-review", "type": "tree", "sha": "tree-sha-1"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "skills/issue-triage", "type": "tree", "sha": "tree-sha-2"}, + {"path": "skills/issue-triage/SKILL.md", "type": "blob", "sha": "blob-2"}, + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantSkills: []string{"code-review", "issue-triage"}, + }, + { + name: "truncated tree returns error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": true, "tree": []map[string]interface{}{}, + })) + }, + wantErr: "too large", + }, + { + name: "no skills found", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantErr: "no skills found", + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch repository tree", + }, + { + name: "deduplicates skills from same directory", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/code-review", "type": "tree", "sha": "tree-sha"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-2"}, + }, + })) + }, + wantSkills: []string{"code-review"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + skills, err := DiscoverSkills(client, "github.com", "monalisa", "octocat-skills", "abc123") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.Equal(t, tt.wantSkills, names) + }) + } +} + +func TestDiscoverSkillByPath(t *testing.T) { + tests := []struct { + name string + skillPath string + stubs func(*httpmock.Registry) + wantName string + wantNS string + wantErr string + }{ + { + name: "discovers skill by path", + skillPath: "skills/code-review", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "code-review", + }, + { + name: "namespaced path sets namespace", + skillPath: "skills/monalisa/issue-triage", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills/monalisa"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "issue-triage", "path": "skills/monalisa/issue-triage", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "issue-triage", + wantNS: "monalisa", + }, + { + name: "strips trailing SKILL.md from path", + skillPath: "skills/code-review/SKILL.md", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "code-review", + }, + { + name: "invalid skill name", + skillPath: "skills/.hidden-skill", + wantErr: "invalid skill name", + }, + { + name: "skill directory not found", + skillPath: "skills/nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "other-skill", "path": "skills/other-skill", "sha": "tree-sha", "type": "dir"}, + })) + }, + wantErr: "skill directory", + }, + { + name: "no SKILL.md in directory", + skillPath: "skills/code-review", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantErr: "no SKILL.md found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) + } + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + skill, err := DiscoverSkillByPath(client, "github.com", "monalisa", "octocat-skills", "abc123", tt.skillPath) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantName, skill.Name) + assert.Equal(t, tt.wantNS, skill.Namespace) + }) + } +} + +func TestDiscoverLocalSkills(t *testing.T) { + tests := []struct { + name string + createDir bool + setup func(t *testing.T, dir string) + wantSkills []string + wantErr string + }{ + { + name: "discovers skills in skills/ directory", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + for _, name := range []string{"code-review", "issue-triage"} { + skillDir := filepath.Join(dir, "skills", name) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + wantSkills: []string{"code-review", "issue-triage"}, + }, + { + name: "single skill at root", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: root-skill + --- + # Root + `)), 0o644)) + }, + wantSkills: []string{"root-skill"}, + }, + { + name: "no skills found", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Not a skill"), 0o644)) + }, + wantErr: "no skills found", + }, + { + name: "nonexistent directory", + setup: func(t *testing.T, dir string) {}, + wantErr: "could not access", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "repo") + if tt.createDir { + require.NoError(t, os.MkdirAll(dir, 0o755)) + } + tt.setup(t, dir) + + skills, err := DiscoverLocalSkills(dir) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.ElementsMatch(t, tt.wantSkills, names) + }) + } +} + +func TestMatchesSkillPath(t *testing.T) { + tests := []struct { + name string + path string + wantName string + }{ + {name: "skills convention", path: "skills/code-review/SKILL.md", wantName: "code-review"}, + {name: "namespaced convention", path: "skills/monalisa/issue-triage/SKILL.md", wantName: "issue-triage"}, + {name: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary"}, + {name: "non-skill file", path: "README.md", wantName: ""}, + {name: "non-SKILL.md in skill dir", path: "skills/code-review/prompt.txt", wantName: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, MatchesSkillPath(tt.path)) + }) + } +} + +func TestDiscoverSkillFiles(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantPaths []string + wantErr string + }{ + { + name: "returns files with skill path prefix", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "scripts/setup.sh", "type": "blob", "sha": "sha2", "size": 50}, + {"path": "scripts", "type": "tree", "sha": "treesub"}, + }, + })) + }, + wantPaths: []string{"skills/code-review/SKILL.md", "skills/code-review/scripts/setup.sh"}, + }, + { + name: "truncated tree falls back to walkTree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": true, "tree": []map[string]interface{}{}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + }, + })) + }, + wantPaths: []string{"skills/code-review/SKILL.md"}, + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch skill tree", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + files, err := DiscoverSkillFiles(client, "github.com", "monalisa", "octocat-skills", "tree123", "skills/code-review") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var paths []string + for _, f := range files { + paths = append(paths, f.Path) + } + assert.Equal(t, tt.wantPaths, paths) + }) + } +} + +func TestListSkillFiles(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + wantPaths []string + wantErr string + }{ + { + name: "returns relative paths", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "prompt.txt", "type": "blob", "sha": "sha2", "size": 20}, + }, + })) + }, + wantPaths: []string{"SKILL.md", "prompt.txt"}, + }, + { + name: "truncated tree falls back to walkTree with nested subtree", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", "truncated": true, "tree": []map[string]interface{}{}, + })) + // walkTree fetches the top-level tree non-recursively + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree123", + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "sha1", "size": 10}, + {"path": "scripts", "type": "tree", "sha": "subtree1"}, + }, + })) + // walkTree recurses into the "scripts" subtree + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/subtree1"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "subtree1", + "tree": []map[string]interface{}{ + {"path": "setup.sh", "type": "blob", "sha": "sha2", "size": 50}, + }, + })) + }, + wantPaths: []string{"SKILL.md", "scripts/setup.sh"}, + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree123"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "could not fetch skill tree", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + files, err := ListSkillFiles(client, "github.com", "monalisa", "octocat-skills", "tree123") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var paths []string + for _, f := range files { + paths = append(paths, f.Path) + } + assert.Equal(t, tt.wantPaths, paths) + }) + } +} + +func TestFetchDescriptionsConcurrent(t *testing.T) { + tests := []struct { + name string + skills []Skill + stubs func(*httpmock.Registry) + wantDescs []string + }{ + { + name: "fetches descriptions for skills without one", + skills: []Skill{ + {Name: "code-review", BlobSHA: "blob1"}, + {Name: "issue-triage", Description: "already set"}, + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob1"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob1", "encoding": "base64", + "content": "LS0tCm5hbWU6IGNvZGUtcmV2aWV3CmRlc2NyaXB0aW9uOiBSZXZpZXdzIFBScwotLS0KIyBUZXN0", + })) + }, + wantDescs: []string{"Reviews PRs", "already set"}, + }, + { + name: "no-op when all descriptions set", + skills: []Skill{ + {Name: "code-review", Description: "set"}, + }, + stubs: func(reg *httpmock.Registry) {}, + wantDescs: []string{"set"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + FetchDescriptionsConcurrent(client, "github.com", "monalisa", "octocat-skills", tt.skills, nil) + var descs []string + for _, s := range tt.skills { + descs = append(descs, s.Description) + } + assert.Equal(t, tt.wantDescs, descs) + }) + } +} diff --git a/internal/skills/frontmatter/frontmatter_test.go b/internal/skills/frontmatter/frontmatter_test.go index 02bd1ee0e52..51fe09133b1 100644 --- a/internal/skills/frontmatter/frontmatter_test.go +++ b/internal/skills/frontmatter/frontmatter_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -18,8 +19,14 @@ func TestParse(t *testing.T) { wantErr bool }{ { - name: "valid frontmatter", - content: "---\nname: test-skill\ndescription: A test skill\n---\n# Body\n", + name: "valid frontmatter", + content: heredoc.Doc(` + --- + name: test-skill + description: A test skill + --- + # Body + `), wantName: "test-skill", wantDesc: "A test skill", wantBody: "# Body\n", @@ -71,18 +78,24 @@ func TestInjectGitHubMetadata(t *testing.T) { wantNotContain []string }{ { - name: "injects metadata without pin", - content: "---\nname: my-skill\ndescription: desc\n---\n# Body\n", - owner: "owner", - repo: "repo", + name: "injects metadata without pin", + content: heredoc.Doc(` + --- + name: my-skill + description: desc + --- + # Body + `), + owner: "monalisa", + repo: "octocat-skills", ref: "v1.0.0", sha: "abc123", treeSHA: "tree456", pinnedRef: "", skillPath: "skills/my-skill", wantContains: []string{ - "github-owner: owner", - "github-repo: repo", + "github-owner: monalisa", + "github-repo: octocat-skills", "github-ref: v1.0.0", "github-sha: abc123", "github-tree-sha: tree456", @@ -94,10 +107,15 @@ func TestInjectGitHubMetadata(t *testing.T) { }, }, { - name: "injects pinned ref", - content: "---\nname: my-skill\n---\n# Body\n", - owner: "owner", - repo: "repo", + name: "injects pinned ref", + content: heredoc.Doc(` + --- + name: my-skill + --- + # Body + `), + owner: "monalisa", + repo: "octocat-skills", ref: "v1.0.0", sha: "abc", treeSHA: "tree", @@ -107,6 +125,22 @@ func TestInjectGitHubMetadata(t *testing.T) { "github-pinned: v1.0.0", }, }, + { + name: "injects metadata into content with no frontmatter", + content: "# Body only\n", + owner: "monalisa", + repo: "octocat-skills", + ref: "v1.0.0", + sha: "abc123", + treeSHA: "tree456", + pinnedRef: "", + skillPath: "skills/my-skill", + wantContains: []string{ + "github-owner: monalisa", + "github-repo: octocat-skills", + "# Body only", + }, + }, } for _, tt := range tests { @@ -124,13 +158,49 @@ func TestInjectGitHubMetadata(t *testing.T) { } func TestInjectLocalMetadata(t *testing.T) { - content := "---\nname: my-skill\nmetadata:\n github-owner: old\n github-repo: old\n---\n# Body\n" - got, err := InjectLocalMetadata(content, "/home/user/skills/my-skill") - require.NoError(t, err) - - assert.Contains(t, got, "local-path: /home/user/skills/my-skill") - assert.NotContains(t, got, "github-owner") - assert.NotContains(t, got, "github-repo") + tests := []struct { + name string + content string + wantContains []string + wantNotContain []string + }{ + { + name: "strips all github keys and injects local-path", + content: heredoc.Doc(` + --- + name: my-skill + metadata: + github-owner: old + github-repo: old + github-ref: v1.0.0 + github-sha: abc123 + github-tree-sha: tree456 + github-pinned: v1.0.0 + github-path: skills/my-skill + --- + # Body + `), + wantContains: []string{"local-path: /home/monalisa/skills/my-skill"}, + wantNotContain: []string{"github-owner", "github-repo", "github-ref", "github-sha", "github-tree-sha", "github-pinned", "github-path"}, + }, + { + name: "injects into content with no existing metadata", + content: "# Body only\n", + wantContains: []string{"local-path: /home/monalisa/skills/my-skill"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := InjectLocalMetadata(tt.content, "/home/monalisa/skills/my-skill") + require.NoError(t, err) + for _, s := range tt.wantContains { + assert.Contains(t, got, s) + } + for _, s := range tt.wantNotContain { + assert.NotContains(t, got, s) + } + }) + } } func TestSerialize(t *testing.T) { @@ -158,6 +228,12 @@ func TestSerialize(t *testing.T) { body: "", wantSuffix: "---\n", }, + { + name: "body without trailing newline gets one added", + frontmatter: map[string]interface{}{"name": "test"}, + body: "# No trailing newline", + wantSuffix: "# No trailing newline\n", + }, } for _, tt := range tests { diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index ed2db507433..8ae3da28fbb 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -1,6 +1,7 @@ package installer import ( + "context" "errors" "fmt" "os" @@ -9,6 +10,7 @@ import ( "sync" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" @@ -295,3 +297,29 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { return nil } + +// ResolveGitRoot returns the git repository root using the provided client, +// falling back to the current working directory on error. +func ResolveGitRoot(gc *git.Client) string { + if gc != nil && gc.RepoDir != "" { + return gc.RepoDir + } + if gc != nil { + if root, err := gc.ToplevelDir(context.Background()); err == nil { + return root + } + } + if cwd, err := os.Getwd(); err == nil { + return cwd + } + return "" +} + +// ResolveHomeDir returns the user's home directory, or "" on error. +func ResolveHomeDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return home +} diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go index 2f6e09ca859..0637e9c196e 100644 --- a/internal/skills/installer/installer_test.go +++ b/internal/skills/installer/installer_test.go @@ -6,26 +6,30 @@ import ( "net/http" "os" "path/filepath" - "strings" + "sync/atomic" "testing" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestInstallLocalSkill(t *testing.T) { +func TestInstallLocal(t *testing.T) { tests := []struct { - name string - skill discovery.Skill - setup func(t *testing.T, srcDir string) - verify func(t *testing.T, destDir string) + name string + skills []discovery.Skill + useAgentHost bool + setup func(t *testing.T, srcDir string) + verify func(t *testing.T, destDir string) + wantErr string }{ { - name: "copies files", - skill: discovery.Skill{Name: "code-review", Path: "skills/code-review"}, + name: "copies files via Dir", + skills: []discovery.Skill{{Name: "code-review", Path: "skills/code-review"}}, setup: func(t *testing.T, srcDir string) { t.Helper() skillSrc := filepath.Join(srcDir, "skills", "code-review") @@ -44,8 +48,8 @@ func TestInstallLocalSkill(t *testing.T) { }, }, { - name: "nested directories", - skill: discovery.Skill{Name: "issue-triage", Path: "skills/issue-triage"}, + name: "nested directories", + skills: []discovery.Skill{{Name: "issue-triage", Path: "skills/issue-triage"}}, setup: func(t *testing.T, srcDir string) { t.Helper() deep := filepath.Join(srcDir, "skills", "issue-triage", "prompts", "templates") @@ -62,15 +66,15 @@ func TestInstallLocalSkill(t *testing.T) { }, }, { - name: "skips symlinks", - skill: discovery.Skill{Name: "pr-summary", Path: "skills/pr-summary"}, + name: "skips symlinks", + skills: []discovery.Skill{{Name: "pr-summary", Path: "skills/pr-summary"}}, setup: func(t *testing.T, srcDir string) { t.Helper() skillSrc := filepath.Join(srcDir, "skills", "pr-summary") require.NoError(t, os.MkdirAll(skillSrc, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# PR Summary"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "prompt.txt"), []byte("summarize"), 0o644)) - os.Symlink(filepath.Join(skillSrc, "prompt.txt"), filepath.Join(skillSrc, "link.txt")) + require.NoError(t, os.Symlink(filepath.Join(skillSrc, "prompt.txt"), filepath.Join(skillSrc, "link.txt"))) }, verify: func(t *testing.T, destDir string) { t.Helper() @@ -81,8 +85,8 @@ func TestInstallLocalSkill(t *testing.T) { }, }, { - name: "injects metadata into SKILL.md", - skill: discovery.Skill{Name: "copilot-helper", Path: "skills/copilot-helper"}, + name: "injects metadata into SKILL.md", + skills: []discovery.Skill{{Name: "copilot-helper", Path: "skills/copilot-helper"}}, setup: func(t *testing.T, srcDir string) { t.Helper() skillSrc := filepath.Join(srcDir, "skills", "copilot-helper") @@ -93,9 +97,52 @@ func TestInstallLocalSkill(t *testing.T) { t.Helper() content, err := os.ReadFile(filepath.Join(destDir, "copilot-helper", "SKILL.md")) require.NoError(t, err) - assert.True(t, strings.Contains(string(content), "local-path"), - "expected SKILL.md to contain local-path metadata, got: %s", string(content)) + assert.Contains(t, string(content), "local-path") + }, + }, + { + name: "multiple skills", + skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review"}, + {Name: "issue-triage", Path: "skills/issue-triage"}, + }, + setup: func(t *testing.T, srcDir string) { + t.Helper() + for _, name := range []string{"code-review", "issue-triage"} { + skillSrc := filepath.Join(srcDir, "skills", name) + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + verify: func(t *testing.T, destDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(destDir, "code-review", "SKILL.md")) + assert.NoError(t, err) + _, err = os.Stat(filepath.Join(destDir, "issue-triage", "SKILL.md")) + assert.NoError(t, err) + }, + }, + { + name: "resolves install dir from AgentHost and Scope", + skills: []discovery.Skill{{Name: "code-review", Path: "skills/code-review"}}, + useAgentHost: true, + setup: func(t *testing.T, srcDir string) { + t.Helper() + skillSrc := filepath.Join(srcDir, "skills", "code-review") + require.NoError(t, os.MkdirAll(skillSrc, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("# Code Review"), 0o644)) }, + verify: func(t *testing.T, destDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(destDir, ".github", "skills", "code-review", "SKILL.md")) + assert.NoError(t, err) + }, + }, + { + name: "no dir or agent host", + skills: []discovery.Skill{{Name: "code-review"}}, + setup: func(t *testing.T, srcDir string) {}, + wantErr: "either Dir or AgentHost must be specified", }, } for _, tt := range tests { @@ -104,8 +151,32 @@ func TestInstallLocalSkill(t *testing.T) { destDir := t.TempDir() tt.setup(t, srcDir) - err := installLocalSkill(srcDir, tt.skill, destDir) + opts := &LocalOptions{ + SourceDir: srcDir, + Skills: tt.skills, + Dir: destDir, + } + if tt.useAgentHost { + host, err := registry.FindByID("github-copilot") + require.NoError(t, err) + opts.Dir = "" + opts.AgentHost = host + opts.Scope = registry.ScopeProject + opts.GitRoot = destDir + } + if tt.wantErr != "" { + opts.Dir = "" + } + + result, err := InstallLocal(opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } require.NoError(t, err) + assert.NotEmpty(t, result.Dir) + assert.Len(t, result.Installed, len(tt.skills)) tt.verify(t, destDir) }) } @@ -258,23 +329,31 @@ func stubTreeAndBlob(reg *httpmock.Registry, treeSHA string) { } func TestInstall(t *testing.T) { + var progressCount atomic.Int32 + tests := []struct { name string skills []discovery.Skill stubs func(*httpmock.Registry) + onProgress func(done, total int) wantInstalled []string wantErr string }{ { - name: "single skill", + name: "single skill calls OnProgress", skills: []discovery.Skill{ {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, }, - stubs: func(reg *httpmock.Registry) { stubTreeAndBlob(reg, "tree-cr") }, + stubs: func(reg *httpmock.Registry) { stubTreeAndBlob(reg, "tree-cr") }, + onProgress: func(done, total int) { + + progressCount.Add(1) + + }, wantInstalled: []string{"code-review"}, }, { - name: "multiple skills concurrently", + name: "multiple skills concurrently with progress", skills: []discovery.Skill{ {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, {Name: "issue-triage", Path: "skills/issue-triage", TreeSHA: "tree-it"}, @@ -283,8 +362,28 @@ func TestInstall(t *testing.T) { stubTreeAndBlob(reg, "tree-cr") stubTreeAndBlob(reg, "tree-it") }, + onProgress: func(done, total int) { + + progressCount.Add(1) + + }, wantInstalled: []string{"code-review", "issue-triage"}, }, + { + name: "partial failure returns successful installs and error", + skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-cr"}, + {Name: "issue-triage", Path: "skills/issue-triage", TreeSHA: "tree-fail"}, + }, + stubs: func(reg *httpmock.Registry) { + stubTreeAndBlob(reg, "tree-cr") + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-fail"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantInstalled: []string{"code-review"}, + wantErr: "failed to install skill", + }, { name: "no dir or agent host", skills: []discovery.Skill{{Name: "code-review"}}, @@ -294,7 +393,10 @@ func TestInstall(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + progressCount.Store(0) + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) destDir := t.TempDir() reg := &httpmock.Registry{} @@ -303,16 +405,17 @@ func TestInstall(t *testing.T) { client := api.NewClientFromHTTP(&http.Client{Transport: reg}) opts := &Options{ - Host: "github.com", - Owner: "monalisa", - Repo: "octocat-skills", - Ref: "v1.0", - SHA: "commit123", - Client: client, - Skills: tt.skills, - Dir: destDir, + Host: "github.com", + Owner: "monalisa", + Repo: "octocat-skills", + Ref: "v1.0", + SHA: "commit123", + Client: client, + Skills: tt.skills, + Dir: destDir, + OnProgress: tt.onProgress, } - if tt.wantErr != "" { + if tt.wantErr != "" && len(tt.wantInstalled) == 0 { opts.Dir = "" } @@ -320,19 +423,58 @@ func TestInstall(t *testing.T) { if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) + if len(tt.wantInstalled) > 0 { + require.NotNil(t, result, "partial failure should return non-nil result") + assert.ElementsMatch(t, tt.wantInstalled, result.Installed) + } return } require.NoError(t, err) assert.ElementsMatch(t, tt.wantInstalled, result.Installed) assert.Equal(t, destDir, result.Dir) - homeDir, _ := os.UserHomeDir() + homeDir, _ = os.UserHomeDir() lockPath := filepath.Join(homeDir, ".agents", ".skill-lock.json") lockData, err := os.ReadFile(lockPath) require.NoError(t, err, "lockfile should have been written") for _, name := range tt.wantInstalled { assert.Contains(t, string(lockData), name) } + if tt.onProgress != nil { + assert.True(t, progressCount.Load() > 0, "OnProgress should have been called") + } + }) + } +} + +func TestResolveGitRoot(t *testing.T) { + tests := []struct { + name string + client *git.Client + wantDir string + }{ + { + name: "returns RepoDir when set", + client: &git.Client{RepoDir: "/monalisa/repo"}, + wantDir: "/monalisa/repo", + }, + { + name: "nil client falls back to cwd", + client: nil, + }, + { + name: "empty RepoDir falls back to ToplevelDir or cwd", + client: &git.Client{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveGitRoot(tt.client) + if tt.wantDir != "" { + assert.Equal(t, tt.wantDir, got) + } else { + assert.NotEmpty(t, got, "should fall back to ToplevelDir or cwd") + } }) } } diff --git a/internal/skills/lockfile/lockfile.go b/internal/skills/lockfile/lockfile.go index 5761d24cfe2..3a6ccd893f7 100644 --- a/internal/skills/lockfile/lockfile.go +++ b/internal/skills/lockfile/lockfile.go @@ -131,6 +131,11 @@ func newFile() *file { } } +var ( + lockRetries = 30 + lockRetryInterval = 100 * time.Millisecond +) + // acquireLock creates an exclusive lock file to serialize concurrent access. // Returns an unlock function. If locking fails after retries, it proceeds // unlocked rather than blocking the user indefinitely. @@ -146,7 +151,7 @@ func acquireLock() (unlock func()) { return func() {} } - for i := 0; i < 30; i++ { + for range lockRetries { f, createErr := os.OpenFile(lkPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if createErr == nil { f.Close() @@ -162,7 +167,7 @@ func acquireLock() (unlock func()) { os.Remove(lkPath) continue } - time.Sleep(100 * time.Millisecond) + time.Sleep(lockRetryInterval) } // Best-effort: proceed without lock. diff --git a/internal/skills/lockfile/lockfile_test.go b/internal/skills/lockfile/lockfile_test.go index b53d6aafc3e..d4a44f76db9 100644 --- a/internal/skills/lockfile/lockfile_test.go +++ b/internal/skills/lockfile/lockfile_test.go @@ -5,16 +5,18 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// setupHome redirects HOME to a temp dir and returns the expected lockfile path. -func setupHome(t *testing.T) string { +// setupTestHome redirects HOME to a temp dir and returns the expected lockfile path. +func setupTestHome(t *testing.T) string { t.Helper() home := t.TempDir() t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) return filepath.Join(home, agentsDir, lockFile) } @@ -39,7 +41,7 @@ func TestRecordInstall(t *testing.T) { treeSHA: "abc123", verify: func(t *testing.T, lockPath string) { t.Helper() - f := readLockfile(t, lockPath) + f := readTestLockfile(t, lockPath) require.Contains(t, f.Skills, "code-review") e := f.Skills["code-review"] assert.Equal(t, "monalisa/octocat-skills", e.Source) @@ -62,128 +64,163 @@ func TestRecordInstall(t *testing.T) { pinnedRef: "v1.0.0", verify: func(t *testing.T, lockPath string) { t.Helper() - f := readLockfile(t, lockPath) + f := readTestLockfile(t, lockPath) assert.Equal(t, "v1.0.0", f.Skills["pr-summary"].PinnedRef) }, }, { - name: "update preserves InstalledAt and updates treeSHA", + name: "multiple skills coexist", setup: func(t *testing.T) { t.Helper() - require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "old-sha", "")) + require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "sha1", "")) }, - skill: "code-review", + skill: "issue-triage", owner: "monalisa", repo: "octocat-skills", - skillPath: "skills/code-review/SKILL.md", - treeSHA: "new-sha", + skillPath: "skills/issue-triage/SKILL.md", + treeSHA: "sha2", verify: func(t *testing.T, lockPath string) { t.Helper() - f := readLockfile(t, lockPath) - e := f.Skills["code-review"] - assert.Equal(t, "new-sha", e.SkillFolderHash, "treeSHA should be updated") - // InstalledAt should be preserved (not empty proves it wasn't clobbered) - assert.NotEmpty(t, e.InstalledAt, "InstalledAt should be preserved from first install") + f := readTestLockfile(t, lockPath) + assert.Contains(t, f.Skills, "code-review") + assert.Contains(t, f.Skills, "issue-triage") }, }, { - name: "multiple skills coexist", + name: "succeeds despite stale lock file", setup: func(t *testing.T) { t.Helper() - require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "sha1", "")) + lockPath, err := lockfilePath() + require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + lkPath := lockPath + ".lk" + f, err := os.Create(lkPath) + require.NoError(t, err) + f.Close() + staleTime := time.Now().Add(-60 * time.Second) + require.NoError(t, os.Chtimes(lkPath, staleTime, staleTime)) }, - skill: "issue-triage", + skill: "code-review", owner: "monalisa", repo: "octocat-skills", - skillPath: "skills/issue-triage/SKILL.md", - treeSHA: "sha2", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", verify: func(t *testing.T, lockPath string) { t.Helper() - f := readLockfile(t, lockPath) - assert.Contains(t, f.Skills, "code-review") - assert.Contains(t, f.Skills, "issue-triage") + f := readTestLockfile(t, lockPath) + require.Contains(t, f.Skills, "code-review") + _, err := os.Stat(lockPath + ".lk") + assert.True(t, os.IsNotExist(err), "stale lock should be removed after RecordInstall") }, }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - lockPath := setupHome(t) - if tt.setup != nil { - tt.setup(t) - } - - err := RecordInstall(tt.skill, tt.owner, tt.repo, tt.skillPath, tt.treeSHA, tt.pinnedRef) - require.NoError(t, err) - tt.verify(t, lockPath) - }) - } -} - -func TestRead(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T, lockPath string) - wantSkill bool - }{ { - name: "missing file returns fresh state", - setup: func(t *testing.T, lockPath string) {}, - }, - { - name: "corrupt JSON returns fresh state", - setup: func(t *testing.T, lockPath string) { + name: "proceeds without lock after retries exhausted", + setup: func(t *testing.T) { t.Helper() + // Reduce retries to avoid 3s wait in tests. + origRetries := lockRetries + origInterval := lockRetryInterval + lockRetries = 1 + lockRetryInterval = 0 + t.Cleanup(func() { + lockRetries = origRetries + lockRetryInterval = origInterval + }) + // Create a fresh (non-stale) lock file that won't be broken. + lockPath, err := lockfilePath() + require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) - require.NoError(t, os.WriteFile(lockPath, []byte("{invalid json"), 0o644)) + f, err := os.Create(lockPath + ".lk") + require.NoError(t, err) + f.Close() + }, + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + require.Contains(t, f.Skills, "code-review", "should succeed best-effort without lock") }, }, { - name: "wrong version returns fresh state", - setup: func(t *testing.T, lockPath string) { + name: "recovers from corrupt lockfile", + setup: func(t *testing.T) { t.Helper() + lockPath, err := lockfilePath() + require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) - data, _ := json.Marshal(file{Version: 999, Skills: map[string]entry{"x": {}}}) - require.NoError(t, os.WriteFile(lockPath, data, 0o644)) + require.NoError(t, os.WriteFile(lockPath, []byte("{invalid json"), 0o644)) + }, + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + assert.Equal(t, lockVersion, f.Version) + require.Contains(t, f.Skills, "code-review") }, }, { - name: "valid lockfile", - setup: func(t *testing.T, lockPath string) { + name: "recovers from wrong version lockfile", + setup: func(t *testing.T) { t.Helper() - require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) - f := &file{ - Version: lockVersion, - Skills: map[string]entry{ - "code-review": {Source: "monalisa/octocat-skills", SourceType: "github"}, - }, - } - data, err := json.MarshalIndent(f, "", " ") + lockPath, err := lockfilePath() require.NoError(t, err) + require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) + data, _ := json.Marshal(file{Version: 999, Skills: map[string]entry{"old-skill": {}}}) require.NoError(t, os.WriteFile(lockPath, data, 0o644)) }, - wantSkill: true, + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + assert.Equal(t, lockVersion, f.Version) + require.Contains(t, f.Skills, "code-review") + assert.NotContains(t, f.Skills, "old-skill", "wrong-version data should be discarded") + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - lockPath := setupHome(t) - tt.setup(t, lockPath) + lockPath := setupTestHome(t) + if tt.setup != nil { + tt.setup(t) + } - loaded, err := read() + err := RecordInstall(tt.skill, tt.owner, tt.repo, tt.skillPath, tt.treeSHA, tt.pinnedRef) require.NoError(t, err) - assert.Equal(t, lockVersion, loaded.Version) - - if tt.wantSkill { - assert.Contains(t, loaded.Skills, "code-review") - } else { - assert.Empty(t, loaded.Skills) - } + tt.verify(t, lockPath) }) } + + // This case lives outside the table because it needs to read the lockfile + // between two RecordInstall calls to capture the first InstalledAt value. + t.Run("update preserves InstalledAt and updates treeSHA", func(t *testing.T) { + lockPath := setupTestHome(t) + + require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "old-sha", "")) + firstInstalledAt := readTestLockfile(t, lockPath).Skills["code-review"].InstalledAt + + require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "new-sha", "")) + entry := readTestLockfile(t, lockPath).Skills["code-review"] + + assert.Equal(t, "new-sha", entry.SkillFolderHash, "treeSHA should be updated") + assert.Equal(t, firstInstalledAt, entry.InstalledAt, "InstalledAt should be preserved from first install") + }) } -// readLockfile is a test helper that reads and parses the lockfile from disk. -func readLockfile(t *testing.T, path string) *file { +// readTestLockfile is a test helper that reads and parses the lockfile from disk. +func readTestLockfile(t *testing.T, path string) *file { t.Helper() data, err := os.ReadFile(path) require.NoError(t, err, "lockfile should exist at %s", path) diff --git a/internal/skills/registry/registry_test.go b/internal/skills/registry/registry_test.go index f37c35e960a..e17668b87a1 100644 --- a/internal/skills/registry/registry_test.go +++ b/internal/skills/registry/registry_test.go @@ -70,6 +70,20 @@ func TestInstallDir(t *testing.T) { homeDir: "/home/monalisa", wantErr: true, }, + { + name: "user scope without home dir", + scope: ScopeUser, + gitRoot: "/tmp/monalisa-repo", + homeDir: "", + wantErr: true, + }, + { + name: "invalid scope", + scope: "bogus", + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -95,6 +109,7 @@ func TestRepoNameFromRemote(t *testing.T) { {"git@github.com:monalisa/octocat-skills", "monalisa/octocat-skills"}, {"ssh://git@github.com/monalisa/octocat-skills.git", "monalisa/octocat-skills"}, {"ssh://git@github.com/monalisa/octocat-skills", "monalisa/octocat-skills"}, + {"not-a-url", ""}, {"", ""}, } for _, tt := range tests { diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index eac2e4a00bf..8188c8f3f14 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -1,7 +1,6 @@ package install import ( - "context" "errors" "fmt" "io" @@ -284,8 +283,8 @@ func installRun(opts *installOptions) error { return err } - gitRoot := resolveGitRoot(opts.GitClient) - homeDir := resolveHomeDir() + gitRoot := installer.ResolveGitRoot(opts.GitClient) + homeDir := installer.ResolveHomeDir() source = ghrepo.FullName(opts.repo) type hostPlan struct { @@ -423,8 +422,8 @@ func runLocalInstall(opts *installOptions) error { return err } - gitRoot := resolveGitRoot(opts.GitClient) - homeDir := resolveHomeDir() + gitRoot := installer.ResolveGitRoot(opts.GitClient) + homeDir := installer.ResolveHomeDir() type hostPlan struct { host *registry.AgentHost @@ -570,7 +569,7 @@ func logConventions(io *iostreams.IOStreams, skills []discovery.Skill) { fmt.Fprintf(io.ErrOut, "Note: found %d namespaced skill(s) in skills/{author}/ directories\n", n) } if n, ok := conventions["plugins"]; ok { - fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) using the Claude Code plugins/ convention\n", n) + fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) using the plugins/ convention\n", n) } if n, ok := conventions["root"]; ok { fmt.Fprintf(io.ErrOut, "Note: found %d skill(s) at the repository root\n", n) @@ -952,7 +951,7 @@ func printFileTree(w io.Writer, cs *iostreams.ColorScheme, dir string, skillName func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) { entries, err := os.ReadDir(dir) if err != nil { - fmt.Fprintf(w, "%s%s\n", indent, cs.Gray("(could not read directory)")) + fmt.Fprintf(w, "%s%s\n", indent, cs.Muted("(could not read directory)")) return } for i, entry := range entries { @@ -965,10 +964,10 @@ func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) { } name := entry.Name() if entry.IsDir() { - fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), cs.Bold(name+"/")) - printTreeDir(w, cs, filepath.Join(dir, name), indent+cs.Gray(childIndent)) + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), cs.Bold(name+"/")) + printTreeDir(w, cs, filepath.Join(dir, name), indent+cs.Muted(childIndent)) } else { - fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), name) + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), name) } } } @@ -990,28 +989,3 @@ func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo string, skillN } fmt.Fprintln(w) } - -func resolveGitRoot(gc *git.Client) string { - if gc == nil { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - return "" - } - root, err := gc.ToplevelDir(context.Background()) - if err != nil { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - return "" - } - return root -} - -func resolveHomeDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return home -} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 658815b630b..a4f67f1f1fb 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -1,6 +1,7 @@ package install import ( + "bytes" "encoding/base64" "fmt" "net/http" @@ -9,11 +10,11 @@ import ( "strings" "testing" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" - "github.com/cli/cli/v2/internal/skills/discovery" - "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -22,633 +23,90 @@ import ( "github.com/stretchr/testify/require" ) -func TestNewCmdInstall_Help(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{ - IOStreams: ios, - Prompter: &prompter.PrompterMock{}, - GitClient: &git.Client{}, - } - - cmd := NewCmdInstall(f, func(opts *installOptions) error { - return nil - }) - - assert.Equal(t, "install []", cmd.Use) - assert.NotEmpty(t, cmd.Short) - assert.NotEmpty(t, cmd.Long) - assert.NotEmpty(t, cmd.Example) -} - -func TestNewCmdInstall_Alias(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) - assert.Contains(t, cmd.Aliases, "add") -} - -func TestNewCmdInstall_Flags(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) - - flags := []string{"agent", "scope", "pin", "all", "dir", "force"} - for _, name := range flags { - assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag: --%s", name) - } -} - -func TestNewCmdInstall_MaxArgs(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - cmd := NewCmdInstall(f, func(_ *installOptions) error { return nil }) - - cmd.SetArgs([]string{"a", "b", "c"}) - err := cmd.Execute() - assert.Error(t, err) -} - -func TestResolveRepoArg(t *testing.T) { - tests := []struct { - input string - owner string - repo string - wantErr bool - }{ - {"github/awesome-copilot", "github", "awesome-copilot", false}, - {"owner/repo", "owner", "repo", false}, - {"a/b", "a", "b", false}, - {"https://github.com/owner/repo", "owner", "repo", false}, - {"https://github.com/owner/repo.git", "owner", "repo", false}, - {"invalid", "", "", true}, - {"", "", "", true}, - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - repo, _, err := resolveRepoArg(tt.input, false, nil) - if tt.wantErr { - assert.Error(t, err) - return - } - require.NoError(t, err) - assert.Equal(t, tt.owner, repo.RepoOwner()) - assert.Equal(t, tt.repo, repo.RepoName()) - }) - } -} - -func TestParseSkillFromOpts(t *testing.T) { +func TestNewCmdInstall(t *testing.T) { tests := []struct { - name string - skillName string - pin string - wantName string - wantVer string + name string + cli string + wantOpts installOptions + wantLocalPath bool + wantErr bool }{ { - name: "name with version", - skillName: "git-commit@v1.2.0", - wantName: "git-commit", - wantVer: "v1.2.0", + name: "repo argument only", + cli: "monalisa/skills-repo", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, }, { - name: "name without version", - skillName: "git-commit", - wantName: "git-commit", - wantVer: "", + name: "repo and skill", + cli: "monalisa/skills-repo git-commit", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, }, { - name: "inline version takes precedence over pin", - skillName: "git-commit@v1.0.0", - pin: "v2.0.0", - wantName: "git-commit", - wantVer: "v1.0.0", + name: "all flags", + cli: "monalisa/skills-repo git-commit --agent github-copilot --scope user --pin v1.0.0 --force", + wantOpts: installOptions{ + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "user", + Pin: "v1.0.0", + Force: true, + }, }, { - name: "pin flag alone", - skillName: "git-commit", - pin: "v3.0.0", - wantName: "git-commit", - wantVer: "v3.0.0", + name: "all flag", + cli: "monalisa/skills-repo --all", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", All: true, Scope: "project"}, }, { - name: "empty", - skillName: "", - wantName: "", - wantVer: "", + name: "dir flag", + cli: "monalisa/skills-repo git-commit --dir ./custom-skills", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Dir: "./custom-skills", Scope: "project"}, }, { - name: "@ at start is not version", - skillName: "@foo", - wantName: "@foo", - wantVer: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - opts := &installOptions{SkillName: tt.skillName, Pin: tt.pin} - parseSkillFromOpts(opts) - assert.Equal(t, tt.wantName, opts.SkillName) - assert.Equal(t, tt.wantVer, opts.version) - }) - } -} - -func TestInstallRun_NonInteractive_NoRepo(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - opts := &installOptions{ - IO: ios, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - - err := installRun(opts) - assert.Error(t, err) - assert.Equal(t, "must specify a repository to install from", err.Error()) -} - -func TestInstallRun_NonInteractive_NoSkill(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - opts := &installOptions{IO: ios, repo: ghrepo.New("o", "r")} - skills := []discovery.Skill{{Name: "test-skill", Path: "skills/test-skill"}} - _, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - assert.Error(t, err) - assert.Contains(t, err.Error(), "must specify a skill name or use --all") -} - -func TestSelectSkills_All(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "a"}, - {Name: "b"}, - } - opts := &installOptions{All: true, IO: ios, repo: ghrepo.New("o", "r")} - got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - require.NoError(t, err) - assert.Len(t, got, 2) -} - -func TestSelectSkills_ByName(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "alpha"}, - {Name: "beta"}, - } - opts := &installOptions{SkillName: "beta", IO: ios, repo: ghrepo.New("o", "r")} - got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - require.NoError(t, err) - assert.Len(t, got, 1) - assert.Equal(t, "beta", got[0].Name) -} - -func TestSelectSkills_NotFound(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "alpha"}, - } - opts := &installOptions{SkillName: "nonexistent", IO: ios, repo: ghrepo.New("o", "r")} - _, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - assert.Error(t, err) -} - -func TestSkillSearchFunc_EmptyQuery(t *testing.T) { - skills := []discovery.Skill{ - {Name: "alpha", Description: "first skill"}, - {Name: "beta", Description: "second skill"}, - } - fn := skillSearchFunc(skills, 40) - result := fn("") - assert.Nil(t, result.Err) - assert.Len(t, result.Keys, 2) - assert.Equal(t, "alpha", result.Keys[0]) - assert.Equal(t, "beta", result.Keys[1]) - assert.Equal(t, 0, result.MoreResults) -} - -func TestSkillSearchFunc_FilterByName(t *testing.T) { - skills := []discovery.Skill{ - {Name: "git-commit"}, - {Name: "code-review"}, - {Name: "git-push"}, - } - fn := skillSearchFunc(skills, 40) - result := fn("git") - assert.Nil(t, result.Err) - assert.Len(t, result.Keys, 2) - assert.Equal(t, "git-commit", result.Keys[0]) - assert.Equal(t, "git-push", result.Keys[1]) -} - -func TestSkillSearchFunc_FilterByDescription(t *testing.T) { - skills := []discovery.Skill{ - {Name: "alpha", Description: "handles authentication"}, - {Name: "beta", Description: "builds docker images"}, - } - fn := skillSearchFunc(skills, 40) - result := fn("docker") - assert.Nil(t, result.Err) - assert.Len(t, result.Keys, 1) - assert.Equal(t, "beta", result.Keys[0]) -} - -func TestSkillSearchFunc_CaseInsensitive(t *testing.T) { - skills := []discovery.Skill{ - {Name: "Git-Commit"}, - } - fn := skillSearchFunc(skills, 40) - result := fn("GIT") - assert.Nil(t, result.Err) - assert.Len(t, result.Keys, 1) -} - -func TestSkillSearchFunc_MoreResults(t *testing.T) { - skills := make([]discovery.Skill, 50) - for i := range skills { - skills[i] = discovery.Skill{Name: fmt.Sprintf("skill-%d", i)} - } - fn := skillSearchFunc(skills, 40) - result := fn("") - assert.Equal(t, maxSearchResults, len(result.Keys)) - assert.Equal(t, 50-maxSearchResults, result.MoreResults) -} - -func TestMatchSelectedSkills(t *testing.T) { - skills := []discovery.Skill{ - {Name: "alpha"}, - {Name: "beta"}, - {Name: "gamma"}, - } - got, err := matchSelectedSkills(skills, []string{"alpha", "gamma"}) - require.NoError(t, err) - assert.Len(t, got, 2) - assert.Equal(t, "alpha", got[0].Name) - assert.Equal(t, "gamma", got[1].Name) -} - -func TestMatchSelectedSkills_NoMatch(t *testing.T) { - skills := []discovery.Skill{{Name: "alpha"}} - _, err := matchSelectedSkills(skills, []string{"nonexistent"}) - assert.Error(t, err) -} - -func TestResolveHosts_ByFlag(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{Agent: "claude-code", IO: ios} - hosts, err := resolveHosts(opts, false) - require.NoError(t, err) - assert.Len(t, hosts, 1) - assert.Equal(t, "claude-code", hosts[0].ID) -} - -func TestResolveHosts_InvalidFlag(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{Agent: "nonexistent", IO: ios} - _, err := resolveHosts(opts, false) - assert.Error(t, err) -} - -func TestResolveHosts_DefaultNonInteractive(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{IO: ios} - hosts, err := resolveHosts(opts, false) - require.NoError(t, err) - assert.Len(t, hosts, 1) - assert.Equal(t, "github-copilot", hosts[0].ID) -} - -func TestResolveHosts_MultiSelect(t *testing.T) { - ios, _, _, _ := iostreams.Test() - pm := &prompter.PrompterMock{ - MultiSelectFunc: func(_ string, _ []string, _ []string) ([]int, error) { - return []int{0, 1}, nil + name: "too many args", + cli: "a b c", + wantErr: true, }, - } - opts := &installOptions{IO: ios, Prompter: pm} - hosts, err := resolveHosts(opts, true) - require.NoError(t, err) - assert.Len(t, hosts, 2) -} - -func TestResolveHosts_NoneSelected(t *testing.T) { - ios, _, _, _ := iostreams.Test() - pm := &prompter.PrompterMock{ - MultiSelectFunc: func(_ string, _ []string, _ []string) ([]int, error) { - return []int{}, nil + { + name: "invalid agent flag", + cli: "monalisa/skills-repo git-commit --agent nonexistent", + wantErr: true, }, - } - opts := &installOptions{IO: ios, Prompter: pm} - _, err := resolveHosts(opts, true) - assert.Error(t, err) -} - -func TestTruncateDescription(t *testing.T) { - tests := []struct { - name string - input string - maxWidth int - }{ - {"short stays short", "A short description", 60}, - {"newlines collapsed", "Line one.\nLine two.\nLine three.", 60}, - {"excessive whitespace", " lots of spaces ", 60}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := truncateDescription(tt.input, tt.maxWidth) - assert.NotContains(t, got, "\n") - }) - } - - long := "Execute git commit with conventional commit message analysis and intelligent staging" - got := truncateDescription(long, 30) - assert.LessOrEqual(t, len(got), 33) // allow room for ellipsis -} - -func TestIsLocalPath(t *testing.T) { - tests := []struct { - arg string - want bool - }{ - {".", true}, - {"./skills", true}, - {"../other", true}, - {"/tmp/skills", true}, - {"~/skills", true}, - {"github/awesome-copilot", false}, - {"owner/repo", false}, - {"", false}, - } - for _, tt := range tests { - t.Run(tt.arg, func(t *testing.T) { - got := isLocalPath(tt.arg) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestIsSkillPath(t *testing.T) { - tests := []struct { - name string - want bool - }{ - {"skills/test-skill", true}, - {"skills/author/skill", true}, - {"plugins/author/skills/skill", true}, - {"skills/author/skill/SKILL.md", true}, - {"git-commit", false}, - {"", false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, isSkillPath(tt.name)) - }) - } -} - -func TestRunLocalInstall_NonInteractive(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "test-local") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - content := "---\nname: test-local\ndescription: A local skill\n---\n# Test\n" - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) - - targetDir := t.TempDir() - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetColorEnabled(false) - - opts := &installOptions{ - IO: ios, - SkillSource: dir, - localPath: dir, - All: true, - Force: true, - Agent: "github-copilot", - Scope: "project", - Dir: targetDir, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - - err := installRun(opts) - require.NoError(t, err) - - assert.Contains(t, stdout.String(), "Installed test-local") - - installed, err := os.ReadFile(filepath.Join(targetDir, "test-local", "SKILL.md")) - require.NoError(t, err) - assert.Contains(t, string(installed), "local-path") -} - -func TestRunLocalInstall_SingleSkillDir(t *testing.T) { - dir := t.TempDir() - content := "---\nname: direct-skill\ndescription: Direct\n---\n# Direct\n" - require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644)) - - targetDir := t.TempDir() - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetColorEnabled(false) - - opts := &installOptions{ - IO: ios, - SkillSource: dir, - localPath: dir, - All: true, - Force: true, - Agent: "github-copilot", - Scope: "project", - Dir: targetDir, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - - err := installRun(opts) - require.NoError(t, err) - - assert.Contains(t, stdout.String(), "Installed direct-skill") -} - -func TestCollisionError(t *testing.T) { - t.Run("no collisions", func(t *testing.T) { - skills := []discovery.Skill{ - {Name: "a"}, - {Name: "b"}, - } - assert.NoError(t, collisionError(skills, "REPO")) - }) - - t.Run("no collisions with different namespaces", func(t *testing.T) { - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "author1"}, - {Name: "xlsx-pro", Namespace: "author2"}, - } - assert.NoError(t, collisionError(skills, "REPO")) - }) - - t.Run("has collisions same name no namespace", func(t *testing.T) { - skills := []discovery.Skill{ - {Name: "xlsx-pro", Convention: "skills"}, - {Name: "xlsx-pro", Convention: "root"}, - } - err := collisionError(skills, "REPO") - assert.Error(t, err) - assert.Contains(t, err.Error(), "conflicting names") - assert.Contains(t, err.Error(), "gh skills install REPO") - }) - - t.Run("local source hint", func(t *testing.T) { - skills := []discovery.Skill{ - {Name: "xlsx-pro", Convention: "skills"}, - {Name: "xlsx-pro", Convention: "root"}, - } - err := collisionError(skills, "PATH") - assert.Error(t, err) - assert.Contains(t, err.Error(), "conflicting names") - assert.Contains(t, err.Error(), "gh skills install PATH") - }) -} - -func TestMatchSkillByName_Ambiguous(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "alice"}, - {Name: "xlsx-pro", Namespace: "bob"}, - } - opts := &installOptions{SkillName: "xlsx-pro", IO: ios, repo: ghrepo.New("o", "r")} - _, err := matchSkillByName(opts, skills) - assert.Error(t, err) - assert.Contains(t, err.Error(), "ambiguous") -} - -func TestMatchSkillByName_NamespacedExact(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "alice"}, - {Name: "xlsx-pro", Namespace: "bob"}, - } - opts := &installOptions{SkillName: "bob/xlsx-pro", IO: ios, repo: ghrepo.New("o", "r")} - got, err := matchSkillByName(opts, skills) - require.NoError(t, err) - assert.Len(t, got, 1) - assert.Equal(t, "bob", got[0].Namespace) -} - -func TestFriendlyDir(t *testing.T) { - // Test home directory path - home, err := os.UserHomeDir() - require.NoError(t, err) - got := friendlyDir(filepath.Join(home, ".github", "skills")) - assert.True(t, strings.HasPrefix(got, "~"), "expected ~ prefix, got %q", got) -} - -func TestResolveScope_ExplicitFlag(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{ - IO: ios, - Scope: "user", - ScopeChanged: true, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - scope, err := resolveScope(opts, true) - require.NoError(t, err) - assert.Equal(t, "user", string(scope)) -} - -func TestResolveScope_DirBypasses(t *testing.T) { - ios, _, _, _ := iostreams.Test() - opts := &installOptions{ - IO: ios, - Dir: "/tmp/custom", - Scope: "project", - GitClient: &git.Client{RepoDir: t.TempDir()}, - } - scope, err := resolveScope(opts, true) - require.NoError(t, err) - assert.Equal(t, "project", string(scope)) -} - -func TestCheckOverwrite_NoExisting(t *testing.T) { - ios, _, _, _ := iostreams.Test() - targetDir := t.TempDir() - skills := []discovery.Skill{{Name: "new-skill"}} - host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} - opts := &installOptions{IO: ios, Dir: targetDir} - - got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) - require.NoError(t, err) - assert.Len(t, got, 1) -} - -func TestCheckOverwrite_ExistingWithForce(t *testing.T) { - targetDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "existing-skill"), 0o755)) - - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{{Name: "existing-skill"}} - host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} - opts := &installOptions{IO: ios, Dir: targetDir, Force: true} - - got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) - require.NoError(t, err) - assert.Len(t, got, 1) -} - -func TestCheckOverwrite_ExistingNonInteractive(t *testing.T) { - targetDir := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "existing-skill"), 0o755)) - - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{{Name: "existing-skill"}} - host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} - opts := &installOptions{IO: ios, Dir: targetDir} - - _, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) - assert.Error(t, err) - assert.Contains(t, err.Error(), "already installed") -} - -func TestNewCmdInstall(t *testing.T) { - tests := []struct { - name string - input string - wantOpts installOptions - wantErr bool - }{ { - name: "repo argument only", - input: "owner/repo", - wantOpts: installOptions{SkillSource: "owner/repo", Scope: "project"}, + name: "pin conflicts with inline version", + cli: "monalisa/skills-repo git-commit@v1.0.0 --pin v2.0.0", + wantErr: true, }, { - name: "repo and skill", - input: "owner/repo my-skill", - wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Scope: "project"}, + name: "alias add works", + cli: "monalisa/skills-repo git-commit", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, }, { - name: "with all flags", - input: "owner/repo my-skill --agent github-copilot --scope user --pin v1.0.0 --force", - wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Agent: "github-copilot", Scope: "user", Pin: "v1.0.0", Force: true}, + name: "dot-slash local path sets localPath", + cli: "./local-dir", + wantOpts: installOptions{SkillSource: "./local-dir", Scope: "project"}, + wantLocalPath: true, }, { - name: "all flag", - input: "owner/repo --all", - wantOpts: installOptions{SkillSource: "owner/repo", All: true, Scope: "project"}, + name: "absolute path sets localPath", + cli: "/absolute/path", + wantOpts: installOptions{SkillSource: "/absolute/path", Scope: "project"}, + wantLocalPath: true, }, { - name: "dir flag", - input: "owner/repo my-skill --dir /tmp/skills", - wantOpts: installOptions{SkillSource: "owner/repo", SkillName: "my-skill", Dir: "/tmp/skills", Scope: "project"}, + name: "tilde path sets localPath", + cli: "~/skills", + wantOpts: installOptions{SkillSource: "~/skills", Scope: "project"}, + wantLocalPath: true, }, { - name: "too many args", - input: "a b c", - wantErr: true, + name: "owner/repo does not set localPath", + cli: "monalisa/skills-repo", + wantOpts: installOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, }, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, _, _ := iostreams.Test() @@ -664,20 +122,20 @@ func TestNewCmdInstall(t *testing.T) { return nil }) - args, err := shlex.Split(tt.input) + args, err := shlex.Split(tt.cli) require.NoError(t, err) cmd.SetArgs(args) - cmd.SetIn(&strings.Reader{}) - cmd.SetOut(&strings.Builder{}) - cmd.SetErr(&strings.Builder{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) - _, err = cmd.ExecuteC() + err = cmd.Execute() if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) return } require.NoError(t, err) + require.NotNil(t, gotOpts) assert.Equal(t, tt.wantOpts.SkillSource, gotOpts.SkillSource) assert.Equal(t, tt.wantOpts.SkillName, gotOpts.SkillName) assert.Equal(t, tt.wantOpts.Agent, gotOpts.Agent) @@ -686,205 +144,1634 @@ func TestNewCmdInstall(t *testing.T) { assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) assert.Equal(t, tt.wantOpts.All, gotOpts.All) assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) + if tt.wantLocalPath { + assert.NotEmpty(t, gotOpts.localPath, "expected localPath to be set") + } else { + assert.Empty(t, gotOpts.localPath, "expected localPath to be empty") + } }) } -} -func TestInstallRun_RemoteInstall(t *testing.T) { - t.Setenv("HOME", t.TempDir()) + // Verify command metadata separately. + t.Run("command metadata", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} + cmd := NewCmdInstall(f, nil) - skillContent := "---\nname: test-skill\ndescription: A test\n---\n# Test\n" - encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + assert.Equal(t, "install []", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + assert.NotEmpty(t, cmd.Example) + assert.Contains(t, cmd.Aliases, "add") - reg := &httpmock.Registry{} + for _, flag := range []string{"agent", "scope", "pin", "all", "dir", "force"} { + assert.NotNil(t, cmd.Flags().Lookup(flag), "missing flag: --%s", flag) + } + }) +} + +// --- HTTP stub helpers --- + +// stubResolveVersion registers API stubs for latest release + tag resolution. +func stubResolveVersion(reg *httpmock.Registry, owner, repo, tag, sha string) { reg.Register( - httpmock.REST("GET", "repos/owner/repo/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/releases/latest", owner, repo)), + httpmock.StringResponse(fmt.Sprintf(`{"tag_name": %q}`, tag)), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), - httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, tag)), + httpmock.StringResponse(fmt.Sprintf(`{"object": {"sha": %q, "type": "commit"}}`, sha)), ) +} + +// stubDiscoverTree registers the single recursive-tree call used by DiscoverSkills. +func stubDiscoverTree(reg *httpmock.Registry, owner, repo, sha, treeJSON string) { reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), - httpmock.StringResponse(`{"sha": "abc123", "tree": [{"path": "skills/test-skill", "type": "tree", "sha": "treeSHA"}, {"path": "skills/test-skill/SKILL.md", "type": "blob", "sha": "blobSHA"}]}`), + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, sha)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "tree": [%s]}`, sha, treeJSON)), ) +} + +// stubInstallFiles registers subtree + blob stubs for installer.Install (one skill). +func stubInstallFiles(reg *httpmock.Registry, owner, repo, treeSHA, blobSHA, content string) { + encoded := base64.StdEncoding.EncodeToString([]byte(content)) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), - httpmock.StringResponse(`{"tree": [{"path": "SKILL.md", "type": "blob", "sha": "blobSHA", "size": 50}]}`), + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, treeSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"tree": [{"path": "SKILL.md", "type": "blob", "sha": %q, "size": 50}]}`, blobSHA)), ) reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSHA"), - httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobSHA", "content": "%s", "encoding": "base64"}`, encodedContent)), + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/git/blobs/%s", owner, repo, blobSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "content": %q, "encoding": "base64"}`, blobSHA, encoded)), ) - - targetDir := t.TempDir() - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetColorEnabled(false) - - opts := &installOptions{ - IO: ios, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: t.TempDir()}, - SkillSource: "owner/repo", - SkillName: "test-skill", - Agent: "github-copilot", - Scope: "project", - Dir: targetDir, - } - - defer reg.Verify(t) - err := installRun(opts) - require.NoError(t, err) - - assert.Contains(t, stdout.String(), "Installed test-skill") - - installed, readErr := os.ReadFile(filepath.Join(targetDir, "test-skill", "SKILL.md")) - require.NoError(t, readErr) - assert.Contains(t, string(installed), "github-owner: owner") - assert.Contains(t, string(installed), "github-repo: repo") } -func TestPrintFileTree(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "my-skill") - require.NoError(t, os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# test"), 0o644)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "scripts", "run.sh"), []byte("#!/bin/bash"), 0o644)) - - ios, _, stdout, _ := iostreams.Test() - cs := ios.ColorScheme() - - printFileTree(stdout, cs, dir, []string{"my-skill"}) - - out := stdout.String() - assert.Contains(t, out, "my-skill/") - assert.Contains(t, out, "SKILL.md") - assert.Contains(t, out, "scripts/") - assert.Contains(t, out, "run.sh") +// stubSkillByPath registers stubs for DiscoverSkillByPath (contents API + tree). +func stubSkillByPath(reg *httpmock.Registry, owner, repo, sha, skillPath, skillName, treeSHA string) { + parentPath := skillPath + if idx := strings.LastIndex(skillPath, "/"); idx >= 0 { + parentPath = skillPath[:idx] + } + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, parentPath)), + httpmock.StringResponse(fmt.Sprintf(`[{"name": %q, "path": %q, "sha": %q, "type": "dir"}]`, skillName, skillPath, treeSHA)), + ) } -func TestPrintFileTree_Empty(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - cs := ios.ColorScheme() - - printFileTree(stdout, cs, t.TempDir(), nil) - assert.Empty(t, stdout.String()) +// writeLocalTestSkill creates a skill directory with a SKILL.md file. +func writeLocalTestSkill(t *testing.T, baseDir, subPath, content string) { + t.Helper() + skillDir := filepath.Join(baseDir, subPath) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) } -func TestPrintTreeDir_Unreadable(t *testing.T) { - ios, _, stdout, _ := iostreams.Test() - cs := ios.ColorScheme() - - printTreeDir(stdout, cs, filepath.Join(t.TempDir(), "nonexistent"), " ") - assert.Contains(t, stdout.String(), "(could not read directory)") +// --- Skill content constants --- + +var gitCommitContent = heredoc.Doc(` + --- + name: git-commit + description: Writes commits + --- + # Git Commit +`) + +// singleSkillTreeJSON returns tree entries for a single skill with the given name. +func singleSkillTreeJSON(name, treeSHA, blobSHA string) string { + return fmt.Sprintf( + `{"path": "skills/%s", "type": "tree", "sha": %q}, {"path": "skills/%s/SKILL.md", "type": "blob", "sha": %q}`, + name, treeSHA, name, blobSHA, + ) } -func TestPrintReviewHint_Remote(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - cs := ios.ColorScheme() - - printReviewHint(stderr, cs, "owner/repo", []string{"my-skill", "other-skill"}) +func TestInstallRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + setup func(t *testing.T) + stubs func(*httpmock.Registry) + opts func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions + verify func(t *testing.T) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "non-interactive without repo errors", + isTTY: false, + opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "must specify a repository to install from", + }, + { + name: "non-interactive without skill name errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + } + }, + wantErr: "must specify a skill name or use --all", + }, + { + name: "remote install writes files with tracking metadata", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --all installs multiple skills", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := fmt.Sprintf("%s, %s", + singleSkillTreeJSON("code-review", "tree0", "blob0"), + singleSkillTreeJSON("git-commit", "tree1", "blob1")) + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "tree0", "blob0", + "---\nname: code-review\ndescription: Reviews\n---\n# B\n") + stubInstallFiles(reg, "monalisa", "skills-repo", "tree1", "blob1", + "---\nname: git-commit\ndescription: Commits\n---\n# A\n") + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + All: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed", + }, + { + name: "remote install with --agent claude-code", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "claude-code", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install defaults to github-copilot non-interactively", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --scope user", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "user", + ScopeChanged: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --dir bypasses scope resolution", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with --force overwrites existing skill", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + targetDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install existing skill without force non-interactive errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + targetDir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + } + }, + wantErr: "already installed", + }, + { + name: "remote install skill not found errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "nonexistent", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: `skill "nonexistent" not found`, + }, + { + name: "remote install ambiguous skill name errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + // Two namespaced skills with the same name + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "xlsx-pro", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: "ambiguous", + }, + { + name: "remote install namespaced exact match resolves ambiguity", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", + "---\nname: xlsx-pro\ndescription: Bob version\n---\n# B\n") + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "bob/xlsx-pro", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed bob/xlsx-pro", + }, + { + name: "remote install with invalid repo argument errors", + isTTY: false, + opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "invalid", + SkillName: "git-commit", + } + }, + wantErr: "invalid repository reference", + }, + { + name: "remote install with pin flag resolves version", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "def456", "type": "commit"}}`), + ) + stubDiscoverTree(reg, "monalisa", "skills-repo", "def456", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Pin: "v2.0.0", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "v2.0.0", + }, + { + name: "remote install outputs review hint", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "prompt injections or malicious scripts", + }, + { + name: "remote install outputs file tree for TTY", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "SKILL.md", + }, + { + name: "remote install with inline version parses name and version", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.2.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit@v1.2.0", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "v1.2.0", + }, + { + name: "remote install by skill path skips full discovery", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubSkillByPath(reg, "monalisa", "skills-repo", "abc123", "skills/git-commit", "git-commit", "treeSHA") + // DiscoverSkillByPath: tree + blob (for fetchDescription) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + // installer.Install: tree + blob (again, for writing files) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "skills/git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install with URL repo argument", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "https://github.com/monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install all with collisions errors", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + // Two skills with the same install name: skills/xlsx-pro and root xlsx-pro + treeJSON := `{"path": "skills/xlsx-pro", "type": "tree", "sha": "tree0"}, ` + + `{"path": "skills/xlsx-pro/SKILL.md", "type": "blob", "sha": "blob0"}, ` + + `{"path": "xlsx-pro", "type": "tree", "sha": "tree1"}, ` + + `{"path": "xlsx-pro/SKILL.md", "type": "blob", "sha": "blob1"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + All: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantErr: "conflicting names", + }, + { + name: "remote install all with namespaced skills avoids collisions", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/bob", "type": "tree", "sha": "nsB"}, ` + + `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeA", "blobA", + "---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n") + stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", + "---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n") + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + All: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed", + }, + { + name: "remote install friendlyDir shows tilde for home paths", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "user", + ScopeChanged: true, + } + }, + wantStdout: "~", + }, + { + name: "interactive skill selection via prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + // 31 skills to exercise maxSearchResults cap + one without description + var treeEntries []string + for i := range 31 { + name := fmt.Sprintf("skill-%02d", i) + treeEntries = append(treeEntries, + fmt.Sprintf(`{"path": "skills/%s", "type": "tree", "sha": "tree-%s"}`, name, name), + fmt.Sprintf(`{"path": "skills/%s/SKILL.md", "type": "blob", "sha": "blob-%s"}`, name, name)) + } + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + strings.Join(treeEntries, ", ")) + // Blob stubs for FetchDescriptionsConcurrent (one per skill) + for i := range 31 { + name := fmt.Sprintf("skill-%02d", i) + blobSHA := fmt.Sprintf("blob-%s", name) + var content string + if i == 0 { + // First skill has no description (exercises else branch in label building) + content = fmt.Sprintf("---\nname: %s\n---\n# Skill\n", name) + } else { + content = fmt.Sprintf("---\nname: %s\ndescription: Does %s things\n---\n# Skill\n", name, name) + } + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/octocat-skills/git/blobs/%s", blobSHA)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": %q, "content": %q, "encoding": "base64"}`, blobSHA, encoded))) + } + // Install stubs for the selected skill (skill-01) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-skill-01", "blob-skill-01", + "---\nname: skill-01\ndescription: Does skill-01 things\n---\n# Skill\n") + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(prompt, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) { + // Exercise searchFunc: empty query hits maxSearchResults cap (31 > 30) + all := searchFunc("") + if all.MoreResults == 0 { + return nil, fmt.Errorf("expected MoreResults > 0 for 31 skills") + } + // Non-empty query filters down + filtered := searchFunc("skill-01") + if len(filtered.Keys) == 0 { + return nil, fmt.Errorf("search returned no results") + } + return []string{filtered.Keys[0]}, nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed skill-01", + }, + { + name: "interactive scope prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive overwrite confirmation declined", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + destDir := t.TempDir() + writeLocalTestSkill(t, destDir, "git-commit", gitCommitContent) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + return false, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStderr: "No skills to install", + }, + { + name: "interactive host selection via MultiSelect", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{0}, nil // select first agent + }, + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + }, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "scope prompt uses Remotes for repo name", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Remotes: func() (context.Remotes, error) { + return context.Remotes{ + {Remote: &git.Remote{Name: "origin"}, Repo: ghrepo.New("monalisa", "octocat-skills")}, + }, nil + }, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive overwrite shows source info", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + destDir := t.TempDir() + existingContent := heredoc.Doc(` + --- + name: git-commit + description: Writes commits + metadata: + github-owner: someowner + github-repo: somerepo + github-ref: v0.5.0 + --- + # Git Commit + `) + writeLocalTestSkill(t, destDir, "git-commit", existingContent) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + assert.Contains(t, prompt, "someowner/somerepo@v0.5.0") + return true, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "select all skills in interactive prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + // Blob stub for FetchDescriptionsConcurrent + encoded := base64.StdEncoding.EncodeToString([]byte(gitCommitContent)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-gc"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob-gc", "content": %q, "encoding": "base64"}`, encoded))) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(prompt, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{"(all skills)"}, nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive repo prompt via Input", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + InputFunc: func(prompt, defaultValue string) (string, error) { + return "monalisa/octocat-skills", nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "interactive scope prompt selects user scope", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 1, nil // user scope + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Force: true, + } + }, + wantStdout: "~", + }, + { + name: "interactive overwrite without metadata shows plain prompt", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + destDir := t.TempDir() + // Existing skill without github metadata in frontmatter + writeLocalTestSkill(t, destDir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: No metadata + --- + # Git Commit + `)) + pm := &prompter.PrompterMock{ + ConfirmFunc: func(prompt string, defaultValue bool) (bool, error) { + assert.Contains(t, prompt, "already exists") + assert.NotContains(t, prompt, "installed from") + return true, nil + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: destDir, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "remote install single exact match by name", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/alice", "type": "tree", "sha": "nsA"}, ` + + `{"path": "skills/alice/xlsx-pro", "type": "tree", "sha": "treeA"}, ` + + `{"path": "skills/alice/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobA"}, ` + + `{"path": "skills/git-commit", "type": "tree", "sha": "treeGC"}, ` + + `{"path": "skills/git-commit/SKILL.md", "type": "blob", "sha": "blobGC"}` + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeGC", "blobGC", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "multi-host install outputs per-host headers", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + // Two install rounds (one per host) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{0, 1}, nil // select two agents + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + } + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Force: true, + } + }, + wantStdout: "Installed git-commit", + wantStderr: "Installing to", + }, + } - out := stderr.String() - assert.Contains(t, out, "prompt injections or malicious scripts") - assert.Contains(t, out, "gh skills preview owner/repo my-skill") - assert.Contains(t, out, "gh skills preview owner/repo other-skill") -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) -func TestPrintReviewHint_Local(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - cs := ios.ColorScheme() + reg := &httpmock.Registry{} + defer reg.Verify(t) - printReviewHint(stderr, cs, "", []string{"my-skill"}) + if tt.stubs != nil { + tt.stubs(reg) + } + if tt.setup != nil { + tt.setup(t) + } - out := stderr.String() - assert.Contains(t, out, "prompt injections or malicious scripts") - assert.Contains(t, out, "Review the installed files before use.") - assert.NotContains(t, out, "gh skills preview") -} + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + opts := tt.opts(ios, reg) -func TestPrintReviewHint_Empty(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - cs := ios.ColorScheme() + err := installRun(opts) - printReviewHint(stderr, cs, "owner/repo", nil) - assert.Empty(t, stderr.String()) -} + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } -func TestSelectSkills_AllWithNamespacedSkills(t *testing.T) { - ios, _, _, _ := iostreams.Test() - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "alice", Convention: "skills-namespaced"}, - {Name: "xlsx-pro", Namespace: "bob", Convention: "skills-namespaced"}, - {Name: "other-skill", Convention: "skills"}, + require.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t) + } + }) } - opts := &installOptions{All: true, IO: ios, repo: ghrepo.New("o", "r")} - got, err := selectSkillsWithSelector(opts, skills, false, skillSelector{matchByName: matchSkillByName, sourceHint: "REPO"}) - require.NoError(t, err) - assert.Len(t, got, 3) } -func TestRunLocalInstall_NamespacedSkills(t *testing.T) { - dir := t.TempDir() - - // Create two skills with the same name under different namespaces - for _, ns := range []string{"alice", "bob"} { - skillDir := filepath.Join(dir, "skills", ns, "xlsx-pro") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - content := fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) +func TestRunLocalInstall(t *testing.T) { + tests := []struct { + name string + isTTY bool + setup func(t *testing.T, sourceDir, targetDir string) + opts func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions + verify func(t *testing.T, targetDir string) + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "installs skill with local-path metadata", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + verify: func(t *testing.T, targetDir string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(targetDir, "git-commit", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(data), "local-path") + }, + wantStdout: "Installed git-commit", + }, + { + name: "single skill directory (SKILL.md at root)", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + content := heredoc.Doc(` + --- + name: direct-skill + description: Direct + --- + # Direct + `) + require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "SKILL.md"), []byte(content), 0o644)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed direct-skill", + }, + { + name: "namespaced skills install to separate directories", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + for _, ns := range []string{"alice", "bob"} { + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", ns, "xlsx-pro"), + fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns)) + } + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + verify: func(t *testing.T, targetDir string) { + t.Helper() + _, err := os.Stat(filepath.Join(targetDir, "alice", "xlsx-pro", "SKILL.md")) + assert.NoError(t, err, "alice/xlsx-pro should be installed") + _, err = os.Stat(filepath.Join(targetDir, "bob", "xlsx-pro", "SKILL.md")) + assert.NoError(t, err, "bob/xlsx-pro should be installed") + }, + wantStdout: "Installed alice/xlsx-pro", + }, + { + name: "local install with --force overwrites namespaced skill", + isTTY: false, + setup: func(t *testing.T, sourceDir, targetDir string) { + t.Helper() + for _, ns := range []string{"alice", "bob"} { + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", ns, "xlsx-pro"), + fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns)) + } + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "alice", "xlsx-pro"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed", + }, + { + name: "local install existing skill without force non-interactive errors", + isTTY: false, + setup: func(t *testing.T, sourceDir, targetDir string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "already installed", + }, + { + name: "local install with no skills found errors", + isTTY: false, + setup: func(_ *testing.T, _, _ string) {}, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "no skills found", + }, + { + name: "local install outputs review hint", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStderr: "Review the installed files before use", + }, + { + name: "local install with --agent claude-code", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "claude-code", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local install by skill name selects one", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "code-review"), heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "git-commit", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local install outputs file tree for TTY", + isTTY: true, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + skillDir := filepath.Join(sourceDir, "skills", "git-commit") + require.NoError(t, os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: git-commit\ndescription: Commits\n---\n# A\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "scripts", "run.sh"), + []byte("#!/bin/bash"), 0o644)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "SKILL.md", + }, + { + name: "local path with tilde expansion", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + t.Setenv("HOME", sourceDir) + t.Setenv("USERPROFILE", sourceDir) + return &installOptions{ + IO: ios, + SkillSource: "~/", + localPath: "~/", + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local path with bare tilde expansion", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + t.Setenv("HOME", sourceDir) + t.Setenv("USERPROFILE", sourceDir) + return &installOptions{ + IO: ios, + SkillSource: "~", + localPath: "~", + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "local skill not found by name", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "git-commit"), heredoc.Doc(` + --- + name: git-commit + description: A local skill + --- + # Git Commit + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "nonexistent-skill", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "not found in local directory", + }, } - targetDir := t.TempDir() - ios, _, stdout, _ := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetColorEnabled(false) - - opts := &installOptions{ - IO: ios, - SkillSource: dir, - localPath: dir, - All: true, - Force: true, - Agent: "github-copilot", - Scope: "project", - Dir: targetDir, - GitClient: &git.Client{RepoDir: t.TempDir()}, - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) - err := installRun(opts) - require.NoError(t, err) + sourceDir := t.TempDir() + targetDir := t.TempDir() - out := stdout.String() - assert.Contains(t, out, "Installed alice/xlsx-pro") - assert.Contains(t, out, "Installed bob/xlsx-pro") + if tt.setup != nil { + tt.setup(t, sourceDir, targetDir) + } - // Both should be installed in separate directories - _, err = os.Stat(filepath.Join(targetDir, "alice", "xlsx-pro", "SKILL.md")) - assert.NoError(t, err, "alice/xlsx-pro should be installed") - _, err = os.Stat(filepath.Join(targetDir, "bob", "xlsx-pro", "SKILL.md")) - assert.NoError(t, err, "bob/xlsx-pro should be installed") -} + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + opts := tt.opts(ios, sourceDir, targetDir) -func TestCheckOverwrite_NamespacedSkill(t *testing.T) { - ios, _, _, _ := iostreams.Test() - targetDir := t.TempDir() + err := installRun(opts) - // Pre-create a namespaced skill directory - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "alice", "xlsx-pro"), 0o755)) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } - skills := []discovery.Skill{ - {Name: "xlsx-pro", Namespace: "alice"}, - {Name: "xlsx-pro", Namespace: "bob"}, + require.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t, targetDir) + } + }) } - host := ®istry.AgentHost{ID: "test", ProjectDir: "skills"} - opts := &installOptions{IO: ios, Dir: targetDir, Force: true} - - got, err := checkOverwrite(opts, skills, host, registry.ScopeProject, "/tmp", "/home", false) - require.NoError(t, err) - assert.Len(t, got, 2, "both skills should be installable (force mode)") } diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 9541f423086..f06d4c1822f 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -199,16 +199,16 @@ func renderAllFiles(opts *previewOptions, cs *iostreams.ColorScheme, skill disco totalBytes := 0 for _, f := range extraFiles { if fetched >= maxFiles { - fmt.Fprintf(out, "\n%s\n", cs.Gray(fmt.Sprintf("(skipped remaining files — showing first %d)", maxFiles))) + fmt.Fprintf(out, "\n%s\n", cs.Muted(fmt.Sprintf("(skipped remaining files — showing first %d)", maxFiles))) break } if totalBytes+f.Size > maxTotalBytes && fetched > 0 { - fmt.Fprintf(out, "\n%s\n", cs.Gray("(skipped remaining files — size limit reached)")) + fmt.Fprintf(out, "\n%s\n", cs.Muted("(skipped remaining files — size limit reached)")) break } fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, f.SHA) if fetchErr != nil { - fmt.Fprintf(out, "\n%s\n\n%s\n", cs.Bold("── "+f.Path+" ──"), cs.Gray("(could not fetch file)")) + fmt.Fprintf(out, "\n%s\n\n%s\n", cs.Bold("── "+f.Path+" ──"), cs.Muted("(could not fetch file)")) continue } fetched++ @@ -373,10 +373,10 @@ func printTree(w io.Writer, cs *iostreams.ColorScheme, nodes []*treeNode, indent childIndent = " " } if node.isDir { - fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), cs.Bold(node.name+"/")) - printTree(w, cs, node.children, indent+cs.Gray(childIndent)) + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), cs.Bold(node.name+"/")) + printTree(w, cs, node.children, indent+cs.Muted(childIndent)) } else { - fmt.Fprintf(w, "%s%s%s\n", indent, cs.Gray(connector), node.name) + fmt.Fprintf(w, "%s%s%s\n", indent, cs.Muted(connector), node.name) } } } diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index b774558283c..4c386480980 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -3,9 +3,12 @@ package preview import ( "encoding/base64" "fmt" + "io" "net/http" + "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" @@ -13,6 +16,7 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestNewCmdPreview(t *testing.T) { @@ -62,31 +66,32 @@ func TestNewCmdPreview(t *testing.T) { args, _ := shlex.Split(tt.input) cmd.SetArgs(args) - cmd.SetOut(&discardWriter{}) - cmd.SetErr(&discardWriter{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) err := cmd.Execute() if tt.wantErr { - assert.Error(t, err) + require.Error(t, err) return } - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, tt.wantRepo, gotOpts.RepoArg) assert.Equal(t, tt.wantSkillName, gotOpts.SkillName) }) } } -func TestNewCmdPreview_Alias(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}} - cmd := NewCmdPreview(f, func(_ *previewOptions) error { return nil }) - assert.Contains(t, cmd.Aliases, "show") -} - func TestPreviewRun(t *testing.T) { - skillContent := "---\nname: my-skill\ndescription: A test skill\n---\n# My Skill\n\nThis is the skill content." + skillContent := heredoc.Doc(` + --- + name: my-skill + description: A test skill + --- + # My Skill + + This is the skill content. + `) encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) tests := []struct { @@ -266,11 +271,11 @@ func TestPreviewRun(t *testing.T) { err := previewRun(tt.opts) if tt.wantErr != "" { - assert.EqualError(t, err, tt.wantErr) + require.EqualError(t, err, tt.wantErr) return } - assert.NoError(t, err) + require.NoError(t, err) if tt.wantStdout != "" { assert.Contains(t, stdout.String(), tt.wantStdout) } @@ -338,12 +343,19 @@ func TestPreviewRun_Interactive(t *testing.T) { } err := previewRun(opts) - assert.NoError(t, err) + require.NoError(t, err) assert.Contains(t, stdout.String(), "Selected Skill") } func TestPreviewRun_ShowsFileTree(t *testing.T) { - skillContent := "---\nname: my-skill\ndescription: test\n---\n# My Skill\nBody." + skillContent := heredoc.Doc(` + --- + name: my-skill + description: test + --- + # My Skill + Body. + `) encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) scriptContent := "#!/bin/bash\necho hello" @@ -426,7 +438,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { } err := previewRun(opts) - assert.NoError(t, err) + require.NoError(t, err) out := stdout.String() assert.Contains(t, out, "echo hello") @@ -450,7 +462,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { } err := previewRun(opts) - assert.NoError(t, err) + require.NoError(t, err) out := stdout.String() assert.Contains(t, out, "my-skill/") @@ -460,7 +472,174 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { }) } -// discardWriter is a no-op writer for suppressing cobra output in tests. -type discardWriter struct{} +func TestPreviewRun_RenderLimits(t *testing.T) { + skillContent := heredoc.Doc(` + --- + name: my-skill + description: test + --- + # My Skill + `) + encodedSkill := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + // Helper: build a tree JSON with N extra files (beyond SKILL.md) + buildTree := func(n int) string { + entries := []string{ + `{"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}`, + `{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"}`, + } + for i := range n { + entries = append(entries, fmt.Sprintf( + `{"path": "skills/my-skill/file%03d.txt", "type": "blob", "sha": "blob%03d"}`, i, i)) + } + return fmt.Sprintf(`{"sha":"abc123","truncated":false,"tree":[%s]}`, + strings.Join(entries, ",")) + } + + // Helper: build subtree JSON with N extra files + buildSubtree := func(n int, sizes []int) string { + entries := []string{ + `{"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50}`, + } + for i := range n { + sz := 10 + if i < len(sizes) { + sz = sizes[i] + } + entries = append(entries, fmt.Sprintf( + `{"path": "file%03d.txt", "type": "blob", "sha": "blob%03d", "size": %d}`, i, i, sz)) + } + return fmt.Sprintf(`{"tree":[%s]}`, strings.Join(entries, ",")) + } + + // Common stubs for resolve + discover + registerBase := func(reg *httpmock.Registry, treeJSON, subtreeJSON string) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/trees/abc123"), + httpmock.StringResponse(treeJSON), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/trees/treeSHA"), + httpmock.StringResponse(subtreeJSON), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobSKILL"), + httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedSkill+`", "encoding": "base64"}`), + ) + } + + t.Run("maxFiles cap truncates at 20", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + n := 22 + treeJSON := buildTree(n) + subtreeJSON := buildSubtree(n, nil) + registerBase(reg, treeJSON, subtreeJSON) + + // Register blob stubs for files 0-19 (first 20 get fetched) + tinyContent := base64.StdEncoding.EncodeToString([]byte("tiny")) + for i := range 20 { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/monalisa/skills-repo/git/blobs/blob%03d", i)), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob%03d", "content": "%s", "encoding": "base64"}`, i, tinyContent)), + ) + } + // Files 20 and 21 should NOT be fetched + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) -func (d *discardWriter) Write(p []byte) (int, error) { return len(p), nil } + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("monalisa", "skills-repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "showing first 20") + assert.Contains(t, out, "file019.txt") // last fetched + }) + + t.Run("maxBytes cap stops fetching", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + // Two files: first is 500KB, second would exceed 512KB cap + sizes := []int{500 * 1024, 100 * 1024} + treeJSON := buildTree(2) + subtreeJSON := buildSubtree(2, sizes) + registerBase(reg, treeJSON, subtreeJSON) + + bigContent := base64.StdEncoding.EncodeToString(make([]byte, 500*1024)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blob000"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob000", "content": "%s", "encoding": "base64"}`, bigContent)), + ) + // blob001 should NOT be fetched — size limit reached + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("monalisa", "skills-repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "size limit reached") + }) + + t.Run("blob fetch error shows fallback message", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + treeJSON := buildTree(1) + subtreeJSON := buildSubtree(1, nil) + registerBase(reg, treeJSON, subtreeJSON) + + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blob000"), + httpmock.StatusStringResponse(500, "server error"), + ) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("monalisa", "skills-repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "could not fetch file") + }) +} diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 9a920013159..200e3e2dd51 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -15,7 +15,6 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" - giturl "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" @@ -40,10 +39,9 @@ type publishOptions struct { Dir string // directory to validate (default: cwd) // Flags - Fix bool // --fix flag: auto-fix issues where possible - Plugins bool // --plugins flag: generate .claude-plugin/ manifest - DryRun bool // --dry-run flag: validate only, don't publish - Tag string // --tag flag: release tag to create + Fix bool // --fix flag: auto-fix issues where possible + DryRun bool // --dry-run flag: validate only, don't publish + Tag string // --tag flag: release tag to create // Testing overrides client *api.Client // injectable for tests; nil means use factory HttpClient @@ -126,8 +124,6 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. Use --dry-run to validate without publishing. Use --tag to publish non-interactively with a specific tag. Use --fix to automatically strip install metadata from committed files. - Use --plugins to generate a .claude-plugin/plugin.json manifest for - Claude Code plugin discovery. `), Example: heredoc.Doc(` # Validate and publish interactively @@ -141,9 +137,6 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. # Validate and strip install metadata $ gh skills publish --fix - - # Generate Claude Code plugin manifest - $ gh skills publish --plugins `), Aliases: []string{"validate"}, Args: cobra.MaximumNArgs(1), @@ -159,7 +152,6 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. } cmd.Flags().BoolVar(&opts.Fix, "fix", false, "Auto-fix issues where possible (e.g. strip install metadata)") - cmd.Flags().BoolVar(&opts.Plugins, "plugins", false, "Generate .claude-plugin/ manifest for Claude Code plugin discovery") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Validate without publishing") cmd.Flags().StringVar(&opts.Tag, "tag", "", "Version tag for the release (e.g. v1.0.0)") @@ -181,7 +173,6 @@ func publishRun(opts *publishOptions) error { return fmt.Errorf("could not resolve path: %w", err) } - cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() // Use injected client or create one from the factory HttpClient @@ -405,19 +396,6 @@ func publishRun(opts *publishOptions) error { renderDiagnosticsPlain(opts, diagnostics, errors, warnings) } - // Generate Claude Code plugin manifest if requested - if opts.Plugins { - pluginDiags := generateClaudePlugin(dir, skillDirs, owner, repo) - for _, d := range pluginDiags { - switch d.severity { - case "error": - fmt.Fprintf(opts.IO.ErrOut, "%s %s\n", cs.FailureIcon(), d.message) - default: - fmt.Fprintf(opts.IO.Out, "%s %s\n", cs.SuccessIcon(), d.message) - } - } - } - if errors > 0 { return fmt.Errorf("validation failed with %d error(s)", errors) } @@ -577,9 +555,9 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re // 4. Inform if not on default branch var currentBranch string if opts.GitClient != nil { - bc := *opts.GitClient - bc.RepoDir = dir - if b, err := bc.CurrentBranch(context.Background()); err == nil { + branchGitClient := opts.GitClient.Copy() + branchGitClient.RepoDir = dir + if b, err := branchGitClient.CurrentBranch(context.Background()); err == nil { currentBranch = b } } @@ -597,7 +575,7 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re } if !confirmed { fmt.Fprintf(opts.IO.ErrOut, "Publish cancelled.\n") - return nil + return cmdutil.CancelError } } @@ -825,9 +803,9 @@ func checkInstalledSkillDirs(gitClient *git.Client, repoDir string) []publishDia } if gitClient != nil { - ic := *gitClient - ic.RepoDir = repoDir - if ic.IsIgnored(context.Background(), relPath) { + ignoreGitClient := gitClient.Copy() + ignoreGitClient.RepoDir = repoDir + if ignoreGitClient.IsIgnored(context.Background(), relPath) { continue } } @@ -899,7 +877,7 @@ func detectGitHubRemote(gitClient *git.Client) (owner, repo string) { // parseGitHubURL extracts owner/repo from a GitHub remote URL. // Only GitHub.com URLs are recognized. func parseGitHubURL(rawURL string) (owner, repo string) { - u, err := giturl.ParseURL(rawURL) + u, err := git.ParseURL(rawURL) if err != nil { return "", "" } @@ -921,16 +899,16 @@ func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDia return nil } - dc := *gitClient - dc.RepoDir = dir - if _, err := dc.GitDir(context.Background()); err != nil { + dirGitClient := gitClient.Copy() + dirGitClient.RepoDir = dir + if _, err := dirGitClient.GitDir(context.Background()); err != nil { return []publishDiagnostic{{ severity: "warning", message: "not a git repository — initialize with: git init && gh repo create", }} } - remotes, err := dc.Remotes(context.Background()) + remotes, err := dirGitClient.Remotes(context.Background()) if err != nil || len(remotes) == 0 { return []publishDiagnostic{{ severity: "warning", @@ -940,7 +918,7 @@ func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDia var urls []string for _, r := range remotes { - if url, err := dc.RemoteURL(context.Background(), r.Name); err == nil { + if url, err := dirGitClient.RemoteURL(context.Background(), r.Name); err == nil { urls = append(urls, url) } } @@ -1062,185 +1040,3 @@ func stripGitHubMetadata(content string) (string, error) { return frontmatter.Serialize(result.RawYAML, result.Body) } - -// claudePluginJSON is the .claude-plugin/plugin.json structure. -type claudePluginJSON struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Version string `json:"version,omitempty"` - Author *claudeAuthor `json:"author,omitempty"` - Homepage string `json:"homepage,omitempty"` - Repository string `json:"repository,omitempty"` - License string `json:"license,omitempty"` - Keywords []string `json:"keywords,omitempty"` -} - -type claudeAuthor struct { - Name string `json:"name"` -} - -// claudeMarketplaceJSON is the .claude-plugin/marketplace.json structure. -type claudeMarketplaceJSON struct { - Name string `json:"name"` - Owner claudeAuthor `json:"owner"` - Plugins []claudeMarketplacePlugin `json:"plugins"` -} - -type claudeMarketplacePlugin struct { - Name string `json:"name"` - Source string `json:"source"` - Description string `json:"description,omitempty"` -} - -// generateClaudePlugin creates .claude-plugin/plugin.json (and optionally -// marketplace.json for multi-skill repos). -func generateClaudePlugin(dir string, skillDirs []string, owner, repo string) []publishDiagnostic { - var diags []publishDiagnostic - - pluginDir := filepath.Join(dir, ".claude-plugin") - pluginPath := filepath.Join(pluginDir, "plugin.json") - - // Don't overwrite existing plugin.json - if _, err := os.Stat(pluginPath); err == nil { - diags = append(diags, publishDiagnostic{ - severity: "info", - message: ".claude-plugin/plugin.json already exists (skipped)", - }) - return diags - } - - pluginName := filepath.Base(dir) - if repo != "" { - pluginName = repo - } - - description := buildPluginDescription(dir, skillDirs) - - plugin := claudePluginJSON{ - Name: pluginName, - Description: description, - Version: "1.0.0", - Keywords: []string{"agent-skills"}, - } - - if owner != "" && repo != "" { - plugin.Repository = fmt.Sprintf("https://github.com/%s/%s", owner, repo) - plugin.Homepage = fmt.Sprintf("https://github.com/%s/%s", owner, repo) - plugin.Author = &claudeAuthor{Name: owner} - } - - // Collect license from any skill - for _, skillName := range skillDirs { - skillPath := filepath.Join(dir, "skills", skillName, "SKILL.md") - content, err := os.ReadFile(skillPath) - if err != nil { - continue - } - result, err := frontmatter.Parse(string(content)) - if err != nil { - continue - } - if result.Metadata.License != "" { - plugin.License = result.Metadata.License - break - } - } - - if err := os.MkdirAll(pluginDir, 0o755); err != nil { - diags = append(diags, publishDiagnostic{ - severity: "error", - message: fmt.Sprintf("could not create .claude-plugin/: %v", err), - }) - return diags - } - - data, err := json.MarshalIndent(plugin, "", " ") - if err != nil { - diags = append(diags, publishDiagnostic{ - severity: "error", - message: fmt.Sprintf("could not serialize plugin.json: %v", err), - }) - return diags - } - - if err := os.WriteFile(pluginPath, append(data, '\n'), 0o644); err != nil { - diags = append(diags, publishDiagnostic{ - severity: "error", - message: fmt.Sprintf("could not write plugin.json: %v", err), - }) - return diags - } - - diags = append(diags, publishDiagnostic{ - severity: "info", - message: fmt.Sprintf("generated .claude-plugin/plugin.json for %q with %d skill(s)", pluginName, len(skillDirs)), - }) - - // Generate marketplace.json for multi-skill repos with a GitHub remote - if len(skillDirs) > 1 && owner != "" && repo != "" { - marketplacePath := filepath.Join(pluginDir, "marketplace.json") - if _, err := os.Stat(marketplacePath); err != nil { - mDiags := generateMarketplace(marketplacePath, pluginName, owner, skillDirs, dir) - diags = append(diags, mDiags...) - } - } - - return diags -} - -// generateMarketplace creates a marketplace.json for plugin marketplace discovery. -func generateMarketplace(path, pluginName, owner string, skillDirs []string, dir string) []publishDiagnostic { - desc := buildPluginDescription(dir, skillDirs) - plugins := []claudeMarketplacePlugin{{ - Name: pluginName, - Source: ".", - Description: desc, - }} - - marketplace := claudeMarketplaceJSON{ - Name: pluginName, - Owner: claudeAuthor{Name: owner}, - Plugins: plugins, - } - - data, err := json.MarshalIndent(marketplace, "", " ") - if err != nil { - return []publishDiagnostic{{ - severity: "error", - message: fmt.Sprintf("could not serialize marketplace.json: %v", err), - }} - } - - if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil { - return []publishDiagnostic{{ - severity: "error", - message: fmt.Sprintf("could not write marketplace.json: %v", err), - }} - } - - return []publishDiagnostic{{ - severity: "info", - message: "generated .claude-plugin/marketplace.json for plugin marketplace discovery", - }} -} - -// buildPluginDescription creates a description from skill names and descriptions. -func buildPluginDescription(dir string, skillDirs []string) string { - if len(skillDirs) == 1 { - skillPath := filepath.Join(dir, "skills", skillDirs[0], "SKILL.md") - if content, err := os.ReadFile(skillPath); err == nil { - if result, err := frontmatter.Parse(string(content)); err == nil && result.Metadata.Description != "" { - return result.Metadata.Description - } - } - } - - var names []string - for _, name := range skillDirs { - names = append(names, name) - } - if len(names) <= 5 { - return fmt.Sprintf("Agent skills: %s", strings.Join(names, ", ")) - } - return fmt.Sprintf("Agent skills collection with %d skills", len(names)) -} diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 56e3b1e0ade..e4c368b70f9 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -2,24 +2,25 @@ package publish import ( "bytes" - "encoding/json" - "fmt" "net/http" "os" "os/exec" "path/filepath" - "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func testPublishGitClient(t *testing.T, remoteURLs map[string]string) *git.Client { +func newTestGitClient(t *testing.T, remoteURLs map[string]string) *git.Client { t.Helper() dir := t.TempDir() runGit := func(args ...string) { @@ -38,77 +39,29 @@ func testPublishGitClient(t *testing.T, remoteURLs map[string]string) *git.Clien return &git.Client{RepoDir: dir} } -func TestPublishCmd_Help(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := stubFactory(ios) - cmd := NewCmdPublish(&f, nil) - if cmd.Use == "" { - t.Error("publish command has no Use string") - } - if cmd.Short == "" { - t.Error("publish command has no Short description") - } -} - -func TestPublishCmd_Alias(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := stubFactory(ios) - cmd := NewCmdPublish(&f, nil) - found := false - for _, alias := range cmd.Aliases { - if alias == "validate" { - found = true - break - } - } - if !found { - t.Error("publish command should have 'validate' alias") - } -} - -func TestPublish_ValidSkill(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "git-commit") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: git-commit -description: A skill for writing good git commits -allowed-tools: git -license: MIT ---- -You are a git commit expert. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - reg := &httpmock.Registry{} - defer reg.Verify(t) +// stubAllSecureRemote registers the standard stubs for a fully-configured remote +// repo (topics, tags, rulesets, security) so publishRun skips all remote warnings. +func stubAllSecureRemote(reg *httpmock.Registry, owner, repo string) { reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/topics"), + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/topics"), httpmock.JSONResponse(map[string]interface{}{ "names": []string{"agent-skills"}, }), ) reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/tags"), + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/tags"), httpmock.JSONResponse([]map[string]interface{}{ {"name": "v1.0.0"}, }), ) reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/rulesets"), + httpmock.REST("GET", "repos/"+owner+"/"+repo+"/rulesets"), httpmock.JSONResponse([]map[string]interface{}{ {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, }), ) reg.Register( - httpmock.REST("GET", "repos/test/skills-repo"), + httpmock.REST("GET", "repos/"+owner+"/"+repo), httpmock.JSONResponse(map[string]interface{}{ "security_and_analysis": map[string]interface{}{ "secret_scanning": map[string]interface{}{"status": "enabled"}, @@ -116,944 +69,1267 @@ You are a git commit expert. }, }), ) - - opts := &publishOptions{ - IO: ios, - Dir: dir, - GitClient: testPublishGitClient(t, map[string]string{ - "origin": "https://github.com/test/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", - } - - err := publishRun(opts) - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - out := stdout.String() - if !strings.Contains(out, "ok") { - t.Errorf("expected 'ok' output, got: %s", out) - } -} - -func TestPublish_MissingName(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "git-commit") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -description: A skill for writing good git commits ---- -Body text. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for missing name") - } - - out := stdout.String() - if !strings.Contains(out, "missing required field: name") { - t.Errorf("expected name error in output, got: %s", out) - } -} - -func TestPublish_NameMismatch(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "git-commit") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: wrong-name -description: A skill ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for name mismatch") - } - - out := stdout.String() - if !strings.Contains(out, "does not match directory name") { - t.Errorf("expected name mismatch error, got: %s", out) - } -} - -func TestPublish_NonSpecCompliantName(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "My_Skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: My_Skill -description: A skill with non-compliant name ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for non-spec-compliant name") - } - - out := stdout.String() - if !strings.Contains(out, "naming convention") { - t.Errorf("expected naming convention error, got: %s", out) - } } -func TestPublish_AllowedToolsArray(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "bad-tools") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: bad-tools -description: A skill with array allowed-tools -allowed-tools: - - git - - curl ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for array allowed-tools") - } - - out := stdout.String() - if !strings.Contains(out, "allowed-tools must be a string") { - t.Errorf("expected allowed-tools error, got: %s", out) - } -} - -func TestPublish_StripMetadata(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "test-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: test-skill -description: A test skill -metadata: - github-owner: someone - github-repo: something - github-ref: v1.0.0 - github-sha: abc123 - github-tree-sha: def456 ---- -Body. -` - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, _, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - Fix: true, - } - - err := publishRun(opts) - if err != nil { - t.Fatalf("expected no error with --fix, got: %v", err) - } - - fixed, err := os.ReadFile(skillPath) - if err != nil { - t.Fatal(err) - } - - fixedStr := string(fixed) - if strings.Contains(fixedStr, "github-owner") { - t.Errorf("expected github-owner to be stripped, got:\n%s", fixedStr) - } - if strings.Contains(fixedStr, "github-sha") { - t.Errorf("expected github-sha to be stripped, got:\n%s", fixedStr) - } - if strings.Contains(fixedStr, "metadata:") { - t.Errorf("expected empty metadata map to be removed, got:\n%s", fixedStr) - } -} - -func TestPublish_MetadataWithoutFix(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "test-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: test-skill -description: A test skill -metadata: - github-owner: someone - github-sha: abc123 ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - Fix: false, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error without --fix when metadata present") - } - - out := stdout.String() - if !strings.Contains(out, "install metadata") { - t.Errorf("expected install metadata error, got: %s", out) - } - if !strings.Contains(out, "--fix") { - t.Errorf("expected --fix suggestion, got: %s", out) - } -} - -func TestPublish_NoSkillsDir(t *testing.T) { - dir := t.TempDir() - ios, _, _, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for missing skills/ directory") - } - if !strings.Contains(err.Error(), "no skills/ directory") { - t.Errorf("expected 'no skills/ directory' error, got: %v", err) - } -} - -func TestPublish_MissingSKILLMD(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "empty-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err == nil { - t.Fatal("expected error for missing SKILL.md") - } - - out := stdout.String() - if !strings.Contains(out, "missing SKILL.md") { - t.Errorf("expected missing SKILL.md error, got: %s", out) - } -} - -func TestPublish_DryRun(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "good-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: good-skill -description: A good skill -license: MIT ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/topics"), - httpmock.JSONResponse(map[string]interface{}{ - "names": []string{"agent-skills"}, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/tags"), - httpmock.JSONResponse([]map[string]interface{}{ - {"name": "v1.0.0"}, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/test/skills-repo/rulesets"), - httpmock.JSONResponse([]map[string]interface{}{ - {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/test/skills-repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "enabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, - }, - }), - ) - - opts := &publishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: testPublishGitClient(t, map[string]string{ - "origin": "https://github.com/test/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", - } - - err := publishRun(opts) - if err != nil { - t.Fatalf("expected no error, got: %v", err) - } - - errOut := stderr.String() - if !strings.Contains(errOut, "Dry run complete") { - t.Errorf("stderr should confirm dry run, got: %s", errOut) - } -} - -func TestPublish_LicenseWarning(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "skills", "no-license") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - - content := `--- -name: no-license -description: A skill without license ---- -Body. -` - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - - ios, _, stdout, _ := iostreams.Test() - - opts := &publishOptions{ - IO: ios, - Dir: dir, - } - - err := publishRun(opts) - if err != nil { - t.Fatalf("expected no error (warnings only), got: %v", err) - } - - out := stdout.String() - if !strings.Contains(out, "license") { - t.Errorf("expected license warning, got: %s", out) - } -} - -func TestSuggestNextTag(t *testing.T) { +func TestNewCmdPublish(t *testing.T) { tests := []struct { - input string - want string + name string + cli string + wantsErr bool + wantsOpts publishOptions }{ - {"v1.0.0", "v1.0.1"}, - {"v2.3.4", "v2.3.5"}, - {"1.0.0", "1.0.1"}, - {"v0.0.9", "v0.0.10"}, - {"not-semver", ""}, - {"v1", ""}, - {"v1.0", ""}, + { + name: "all flags", + cli: "./monalisa-skills --dry-run --fix --tag v1.0.0", + wantsOpts: publishOptions{ + Dir: "./monalisa-skills", + DryRun: true, + Fix: true, + Tag: "v1.0.0", + }, + }, + { + name: "directory only", + cli: "./octocat-repo", + wantsOpts: publishOptions{ + Dir: "./octocat-repo", + }, + }, + { + name: "no args leaves dir empty", + cli: "", + wantsOpts: publishOptions{}, + }, + { + name: "dry-run flag only", + cli: "--dry-run", + wantsOpts: publishOptions{ + DryRun: true, + }, + }, } for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - got := suggestNextTag(tt.input) - if got != tt.want { - t.Errorf("suggestNextTag(%q) = %q, want %q", tt.input, got, tt.want) + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := cmdutil.Factory{IOStreams: ios} + + var gotOpts *publishOptions + cmd := NewCmdPublish(&f, func(opts *publishOptions) error { + gotOpts = opts + return nil + }) + + args, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(args) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + err = cmd.Execute() + if tt.wantsErr { + require.Error(t, err) + return } + require.NoError(t, err) + require.NotNil(t, gotOpts) + assert.Equal(t, tt.wantsOpts.Dir, gotOpts.Dir) + assert.Equal(t, tt.wantsOpts.DryRun, gotOpts.DryRun) + assert.Equal(t, tt.wantsOpts.Fix, gotOpts.Fix) + assert.Equal(t, tt.wantsOpts.Tag, gotOpts.Tag) }) } } -func TestParseGitHubURL(t *testing.T) { +func TestPublishRun(t *testing.T) { tests := []struct { - url string - wantOwner string - wantRepo string + name string + isTTY bool + setup func(t *testing.T, dir string) + stubs func(*httpmock.Registry) + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions + verify func(t *testing.T, dir string) + wantErr string + wantStdout string + wantStderr string }{ - {"git@github.com:github/gh-skills.git", "github", "gh-skills"}, - {"https://github.com/github/gh-skills.git", "github", "gh-skills"}, - {"https://github.com/github/gh-skills", "github", "gh-skills"}, - {"git@github.com:owner/repo.git", "owner", "repo"}, - {"https://gitlab.com/owner/repo.git", "", ""}, - {"not-a-url", "", ""}, - } - for _, tt := range tests { - t.Run(tt.url, func(t *testing.T) { - owner, repo := parseGitHubURL(tt.url) - if owner != tt.wantOwner || repo != tt.wantRepo { - t.Errorf("parseGitHubURL(%q) = (%q, %q), want (%q, %q)", tt.url, owner, repo, tt.wantOwner, tt.wantRepo) - } - }) - } -} - -func TestRepoHasTopic(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/topics"), - httpmock.JSONResponse(map[string]interface{}{ - "names": []string{"golang", "agent-skills"}, - }), - ) - - if !repoHasTopic(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") { - t.Error("expected true when topic present") - } -} - -func TestRepoHasTopic_Missing(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/topics"), - httpmock.JSONResponse(map[string]interface{}{ - "names": []string{"golang"}, - }), - ) - - if repoHasTopic(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") { - t.Error("expected false when topic missing") - } -} - -func TestFetchTags_NoTags(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/tags"), - httpmock.JSONResponse([]interface{}{}), - ) - - tags := fetchTags(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") - if len(tags) != 0 { - t.Errorf("expected no tags, got %d", len(tags)) - } -} - -func TestFetchTags_WithTags(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/tags"), - httpmock.JSONResponse([]map[string]interface{}{ - {"name": "v1.2.3"}, - }), - ) - - tags := fetchTags(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") - if len(tags) != 1 { - t.Fatalf("expected 1 tag, got %d", len(tags)) - } - if tags[0].Name != "v1.2.3" { - t.Errorf("expected v1.2.3, got %s", tags[0].Name) - } -} - -func TestCheckTagProtection_Active(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/rulesets"), - httpmock.JSONResponse([]map[string]interface{}{ - {"id": 1, "name": "protect-tags", "target": "tag", "enforcement": "active"}, - }), - ) - - diags := checkTagProtection(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") - if len(diags) != 0 { - t.Errorf("expected no diagnostics when tag protection active, got: %v", diags) - } -} - -func TestCheckTagProtection_Missing(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/rulesets"), - httpmock.JSONResponse([]map[string]interface{}{ - {"id": 1, "name": "branch-protection", "target": "branch", "enforcement": "active"}, - }), - ) - - diags := checkTagProtection(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo") - if len(diags) != 1 { - t.Fatalf("expected 1 diagnostic, got %d", len(diags)) - } - if !strings.Contains(diags[0].message, "tag protection") { - t.Errorf("expected tag protection warning, got: %s", diags[0].message) - } -} - -func TestCheckSecuritySettings_AllEnabled(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "enabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + { + name: "no skills directory", + setup: func(_ *testing.T, _ string) {}, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} }, - }), - ) - - skillsDir := t.TempDir() - - diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) - if len(diags) != 0 { - t.Errorf("expected no diagnostics when all security enabled, got %d: %v", len(diags), diags) - } -} - -func TestCheckSecuritySettings_NoneEnabled(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "disabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "disabled"}, + wantErr: "no skills/ directory", + }, + { + name: "missing SKILL.md", + setup: func(t *testing.T, dir string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "skills", "empty-skill"), 0o755)) }, - }), - ) - - skillsDir := t.TempDir() - - diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) - if len(diags) != 2 { - t.Errorf("expected 2 diagnostics (secret scanning + push protection), got %d: %v", len(diags), diags) - } - for _, d := range diags { - if d.severity != "warning" { - t.Errorf("secret scanning diagnostics should be warnings, got %q: %s", d.severity, d.message) - } - } -} - -func TestCheckSecuritySettings_WithCodeFiles(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "enabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} }, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/code-scanning/alerts"), - httpmock.StatusStringResponse(404, "not found"), - ) - - skillsDir := t.TempDir() - scriptDir := filepath.Join(skillsDir, "my-skill", "scripts") - if err := os.MkdirAll(scriptDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(scriptDir, "helper.sh"), []byte("#!/bin/bash"), 0o644); err != nil { - t.Fatal(err) - } - - diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) - hasCodeScanInfo := false - for _, d := range diags { - if strings.Contains(d.message, "code scanning") { - hasCodeScanInfo = true - if d.severity != "info" { - t.Errorf("code scanning suggestion should be info, got %q", d.severity) - } - } - } - if !hasCodeScanInfo { - t.Error("expected code scanning info when code files present") - } -} - -func TestCheckSecuritySettings_WithManifests(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - reg.Register( - httpmock.REST("GET", "repos/owner/repo"), - httpmock.JSONResponse(map[string]interface{}{ - "security_and_analysis": map[string]interface{}{ - "secret_scanning": map[string]interface{}{"status": "enabled"}, - "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + wantErr: "validation failed", + wantStdout: "missing SKILL.md", + }, + { + name: "missing name in frontmatter", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + description: A skill for writing good git commits + --- + Body text. + `)) }, - }), - ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/vulnerability-alerts"), - httpmock.StatusStringResponse(404, "not found"), - ) - - skillsDir := t.TempDir() - skillDir := filepath.Join(skillsDir, "my-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(skillDir, "package.json"), []byte("{}"), 0o644); err != nil { - t.Fatal(err) + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "missing required field: name", + }, + { + name: "name does not match directory", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: wrong-name + description: A skill + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "does not match directory name", + }, + { + name: "non-spec-compliant name", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "My_Skill", heredoc.Doc(` + --- + name: My_Skill + description: A skill with non-compliant name + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "naming convention", + }, + { + name: "valid skill dry-run passes validation", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "good-skill", heredoc.Doc(` + --- + name: good-skill + description: A good skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "1 skill(s) validated successfully", + wantStderr: "Dry run complete", + }, + { + name: "valid skill with --tag publishes release", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: A skill for writing good git commits + allowed-tools: git + license: MIT + --- + You are a git commit expert. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + // topic already present, so no PUT needed + // immutable releases check + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch for branch comparison + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.1", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.1", + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published v1.0.1", + }, + { + name: "strip metadata with --fix", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "test-skill", heredoc.Doc(` + --- + name: test-skill + description: A test skill + metadata: + github-owner: someone + github-repo: something + github-ref: v1.0.0 + github-sha: abc123 + github-tree-sha: def456 + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir, Fix: true} + }, + wantStdout: "stripped install metadata", + verify: func(t *testing.T, dir string) { + t.Helper() + fixed, err := os.ReadFile(filepath.Join(dir, "skills", "test-skill", "SKILL.md")) + require.NoError(t, err) + fixedStr := string(fixed) + assert.NotContains(t, fixedStr, "github-owner") + assert.NotContains(t, fixedStr, "github-sha") + assert.NotContains(t, fixedStr, "metadata:") + }, + }, + { + name: "metadata without --fix errors with hint", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "test-skill", heredoc.Doc(` + --- + name: test-skill + description: A test skill + metadata: + github-owner: someone + github-sha: abc123 + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir, Fix: false} + }, + wantErr: "validation failed", + wantStdout: "--fix", + }, + { + name: "missing license warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "no-license", heredoc.Doc(` + --- + name: no-license + description: A skill without license + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantStdout: "license", + }, + { + name: "allowed-tools array error", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "bad-tools", heredoc.Doc(` + --- + name: bad-tools + description: A skill with array allowed-tools + allowed-tools: + - git + - curl + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{IO: ios, Dir: dir} + }, + wantErr: "validation failed", + wantStdout: "allowed-tools must be a string", + }, + { + name: "security warnings when features disabled", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "branch-only", "target": "branch", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/secure-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "disabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "disabled"}, + }, + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/octocat/secure-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "secret scanning is not enabled", + }, + { + name: "tag protection warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo/rulesets"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/tag-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/octocat/tag-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "tag protection", + }, + { + name: "code files trigger code scanning info", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "code-skill", heredoc.Doc(` + --- + name: code-skill + description: A skill with code + license: MIT + --- + Body. + `)) + scriptDir := filepath.Join(dir, "skills", "code-skill", "scripts") + require.NoError(t, os.MkdirAll(scriptDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(scriptDir, "helper.sh"), []byte("#!/bin/bash"), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/code-repo/code-scanning/alerts"), + httpmock.StatusStringResponse(404, "not found"), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/octocat/code-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStderr: "code scanning", + }, + { + name: "manifest files trigger dependabot info", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "dep-skill", heredoc.Doc(` + --- + name: dep-skill + description: A skill with manifests + license: MIT + --- + Body. + `)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "skills", "dep-skill", "package.json"), + []byte("{}"), 0o644, + )) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/octocat/dep-repo/vulnerability-alerts"), + httpmock.StatusStringResponse(404, "not found"), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/octocat/dep-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStderr: "Dependabot", + }, + { + name: "installed skill dirs not gitignored warns", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "skills", "installed"), 0o755)) + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") + + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: ".gitignore", + }, + { + name: "installed skill dirs gitignored no warning", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "skills", "installed"), 0o755)) + + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".github/skills\n"), 0o644)) + runGitInDir(t, dir, "add", ".gitignore") + runGitInDir(t, dir, "commit", "-m", "init") + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: "no git remote", + verify: func(t *testing.T, dir string) { + t.Helper() + // The key assertion: .gitignored dirs should NOT produce a warning + }, + }, + { + name: "no GitHub remote warns", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") + runGitInDir(t, dir, "remote", "add", "origin", "https://gitlab.com/hubot/bar.git") + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: "not a GitHub repository", + }, + { + name: "fallback remote detection uses non-origin GitHub remote", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "octocat", "repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://gitlab.com/hubot/bar.git", + "upstream": "git@github.com:octocat/repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStderr: "octocat/repo", + }, + { + name: "publish adds missing topic via --tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + // topic missing + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"golang"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // addAgentSkillsTopic fetches topics again then PUTs + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"golang"}, + }), + ) + reg.Register( + httpmock.REST("PUT", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{}), + ) + // immutable releases + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.0", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.0", + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Added \"agent-skills\" topic", + }, + { + name: "tag suggestion uses existing tags", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{ + "names": []string{"agent-skills"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "v2.3.4"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{ + {"id": 1, "name": "tags", "target": "tag", "enforcement": "active"}, + }), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // immutable releases + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // default branch + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + // create release with the suggested v2.3.5 tag + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v2.3.5", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Tag: "v2.3.5", + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published v2.3.5", + }, + { + name: "duplicate tag errors", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantErr: "tag v1.0.0 already exists", + }, + { + name: "valid skill non-tty plain output", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "git-commit", heredoc.Doc(` + --- + name: git-commit + description: A skill for writing good git commits + allowed-tools: git + license: MIT + --- + You are a git commit expert. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "ok", + }, + { + name: "no remote and non-tty shows validation passed message", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + } + }, + wantStdout: "ok", + }, + { + name: "interactive publish with topic and semver tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + // No topic yet — first GET for diagnostic check + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{"names": []string{}}), + ) + // Second GET inside addAgentSkillsTopic + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), + httpmock.JSONResponse(map[string]interface{}{"names": []string{}}), + ) + // Add topic + reg.Register( + httpmock.REST("PUT", "repos/monalisa/skills-repo/topics"), + httpmock.StringResponse("{}"), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/tags"), + httpmock.JSONResponse([]map[string]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/rulesets"), + httpmock.JSONResponse([]map[string]interface{}{}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{ + "default_branch": "main", + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]interface{}{"status": "enabled"}, + "secret_scanning_push_protection": map[string]interface{}{"status": "enabled"}, + }, + }), + ) + // Immutable releases already enabled + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + // Create release + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.0", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + confirmCall := 0 + return &publishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + confirmCall++ + return true, nil // accept topic + final confirm + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil // semver strategy + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.0", nil // accept suggested tag + }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published v1.0.0", + }, + { + name: "interactive publish with custom tag", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/beta-1", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + return true, nil + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 1, nil // custom tag strategy + }, + InputFunc: func(msg string, def string) (string, error) { + return "beta-1", nil + }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Published beta-1", + }, + { + name: "interactive publish declined at final confirm", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": true}), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + confirmCall := 0 + return &publishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + confirmCall++ + if confirmCall >= 1 { + return false, nil // decline final confirm + } + return true, nil + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.1", nil + }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantErr: "CancelError", + wantStderr: "Publish cancelled", + }, + { + name: "interactive immutable releases prompt", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + // Immutable releases NOT enabled + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.JSONResponse(map[string]interface{}{"enabled": false}), + ) + // Enable immutable releases + reg.Register( + httpmock.REST("PATCH", "repos/monalisa/skills-repo/immutable-releases"), + httpmock.StringResponse("{}"), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo"), + httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), + ) + reg.Register( + httpmock.REST("POST", "repos/monalisa/skills-repo/releases"), + httpmock.JSONResponse(map[string]interface{}{ + "html_url": "https://github.com/monalisa/skills-repo/releases/tag/v1.0.1", + }), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + t.Helper() + return &publishOptions{ + IO: ios, + Dir: dir, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, def bool) (bool, error) { + return true, nil // accept all confirms (immutable + final) + }, + SelectFunc: func(msg string, def string, opts []string) (int, error) { + return 0, nil + }, + InputFunc: func(msg string, def string) (string, error) { + return "v1.0.1", nil + }, + }, + GitClient: newTestGitClient(t, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }), + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "Enabled immutable releases", + }, } - diags := checkSecuritySettings(api.NewClientFromHTTP(&http.Client{Transport: reg}), "github.com", "owner", "repo", skillsDir) - hasDependabotInfo := false - for _, d := range diags { - if strings.Contains(d.message, "Dependabot") { - hasDependabotInfo = true - if d.severity != "info" { - t.Errorf("Dependabot suggestion should be info, got %q", d.severity) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + tt.setup(t, dir) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) } - } - } - if !hasDependabotInfo { - t.Error("expected Dependabot info when manifest files present") - } -} - -func TestDetectCodeAndManifests(t *testing.T) { - dir := t.TempDir() - - hasCode, hasManifests := detectCodeAndManifests(dir) - if hasCode || hasManifests { - t.Error("empty dir should have no code or manifests") - } - - if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte("#!/bin/bash"), 0o644); err != nil { - t.Fatal(err) - } - hasCode, hasManifests = detectCodeAndManifests(dir) - if !hasCode { - t.Error("should detect .sh as code") - } - if hasManifests { - t.Error("should not detect manifests") - } - if err := os.WriteFile(filepath.Join(dir, "requirements.txt"), []byte("flask"), 0o644); err != nil { - t.Fatal(err) - } - hasCode, hasManifests = detectCodeAndManifests(dir) - if !hasCode || !hasManifests { - t.Error("should detect both code and manifests") - } -} + opts := tt.opts(ios, dir, reg) + err := publishRun(opts) -func TestCheckInstalledSkillDirs_NotPresent(t *testing.T) { - dir := t.TempDir() - diags := checkInstalledSkillDirs(nil, dir) - if len(diags) != 0 { - t.Errorf("expected no diagnostics for empty dir, got %d", len(diags)) - } -} - -func TestCheckInstalledSkillDirs_PresentNotIgnored(t *testing.T) { - gitClient := testPublishGitClient(t, nil) - dir := gitClient.RepoDir - - installedDir := filepath.Join(dir, ".github", "skills", "some-skill") - if err := os.MkdirAll(installedDir, 0o755); err != nil { - t.Fatal(err) - } - - diags := checkInstalledSkillDirs(gitClient, dir) - if len(diags) == 0 { - t.Fatal("expected warning for unignored .github/skills/") - } - if diags[0].severity != "warning" { - t.Errorf("expected warning, got %q", diags[0].severity) - } - if !strings.Contains(diags[0].message, ".gitignore") { - t.Errorf("expected .gitignore mention, got: %s", diags[0].message) - } -} - -func TestCheckInstalledSkillDirs_PresentAndIgnored(t *testing.T) { - gitClient := testPublishGitClient(t, nil) - dir := gitClient.RepoDir - - installedDir := filepath.Join(dir, ".github", "skills", "some-skill") - if err := os.MkdirAll(installedDir, 0o755); err != nil { - t.Fatal(err) - } - - // Add .gitignore so git check-ignore recognises the path. - if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".github/skills\n"), 0o644); err != nil { - t.Fatal(err) - } - runGit := func(args ...string) { - t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) - cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+dir) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "git %v: %s", args, out) - } - runGit("add", ".gitignore") - runGit("commit", "-m", "init") - - diags := checkInstalledSkillDirs(gitClient, dir) - if len(diags) != 0 { - t.Errorf("expected no diagnostics when gitignored, got %d: %v", len(diags), diags) - } -} - -func TestGenerateClaudePlugin(t *testing.T) { - dir := t.TempDir() - - for _, name := range []string{"git-commit", "code-review"} { - skillDir := filepath.Join(dir, "skills", name) - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - content := fmt.Sprintf("---\nname: %s\ndescription: A %s skill\nlicense: MIT\n---\nBody.\n", name, name) - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } - } - - diags := generateClaudePlugin(dir, []string{"git-commit", "code-review"}, "testowner", "testrepo") - - var generated int - for _, d := range diags { - if d.severity == "error" { - t.Errorf("unexpected error: %s", d.message) - } - if d.severity == "info" && strings.Contains(d.message, "generated") { - generated++ - } - } - if generated != 2 { - t.Errorf("expected 2 generated files, got %d", generated) - } - - pluginData, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "plugin.json")) - if err != nil { - t.Fatalf("plugin.json not created: %v", err) - } - var plugin claudePluginJSON - if err := json.Unmarshal(pluginData, &plugin); err != nil { - t.Fatalf("invalid plugin.json: %v", err) - } - if plugin.Name != "testrepo" { - t.Errorf("plugin.Name = %q, want %q", plugin.Name, "testrepo") - } - if plugin.License != "MIT" { - t.Errorf("plugin.License = %q, want %q", plugin.License, "MIT") - } - if plugin.Repository != "https://github.com/testowner/testrepo" { - t.Errorf("plugin.Repository = %q", plugin.Repository) - } - - marketData, err := os.ReadFile(filepath.Join(dir, ".claude-plugin", "marketplace.json")) - if err != nil { - t.Fatalf("marketplace.json not created: %v", err) - } - var marketplace claudeMarketplaceJSON - if err := json.Unmarshal(marketData, &marketplace); err != nil { - t.Fatalf("invalid marketplace.json: %v", err) - } - if marketplace.Name != "testrepo" { - t.Errorf("marketplace.Name = %q, want %q", marketplace.Name, "testrepo") - } - if len(marketplace.Plugins) != 1 || marketplace.Plugins[0].Source != "." { - t.Errorf("marketplace.Plugins = %+v", marketplace.Plugins) - } -} - -func TestGenerateClaudePlugin_SkipsExisting(t *testing.T) { - dir := t.TempDir() - - skillDir := filepath.Join(dir, "skills", "my-skill") - if err := os.MkdirAll(skillDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\ndescription: test\n---\nBody.\n"), 0o644); err != nil { - t.Fatal(err) - } - - pluginDir := filepath.Join(dir, ".claude-plugin") - if err := os.MkdirAll(pluginDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(`{"name":"existing"}`), 0o644); err != nil { - t.Fatal(err) - } - - diags := generateClaudePlugin(dir, []string{"my-skill"}, "owner", "repo") - - for _, d := range diags { - if d.severity == "error" { - t.Errorf("unexpected error: %s", d.message) - } - if strings.Contains(d.message, "generated") { - t.Error("should not regenerate existing plugin.json") - } - } -} - -func TestDetectGitHubRemote(t *testing.T) { - gitClient := testPublishGitClient(t, map[string]string{ - "origin": "https://github.com/myorg/myrepo.git", - }) - - owner, repo := detectGitHubRemote(gitClient) - if owner != "myorg" || repo != "myrepo" { - t.Errorf("expected myorg/myrepo, got %s/%s", owner, repo) - } -} - -func TestDetectGitHubRemote_Fallback(t *testing.T) { - gitClient := testPublishGitClient(t, map[string]string{ - "origin": "https://gitlab.com/foo/bar.git", - "upstream": "git@github.com:org/repo.git", - }) - - owner, repo := detectGitHubRemote(gitClient) - if owner != "org" || repo != "repo" { - t.Errorf("expected org/repo, got %s/%s", owner, repo) - } -} - -func TestDetectGitHubRemote_NoGitHub(t *testing.T) { - gitClient := testPublishGitClient(t, map[string]string{ - "origin": "https://gitlab.com/foo/bar.git", - }) - - owner, repo := detectGitHubRemote(gitClient) - if owner != "" || repo != "" { - t.Errorf("expected empty, got %s/%s", owner, repo) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t, dir) + } + }) } } -func TestPublishCmd_RunFHook(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := stubFactory(ios) - - var capturedOpts *publishOptions - cmd := NewCmdPublish(&f, func(opts *publishOptions) error { - capturedOpts = opts - return nil - }) - - cmd.SetArgs([]string{"./my-skills", "--dry-run", "--fix", "--tag", "v1.0.0"}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - - err := cmd.Execute() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if capturedOpts == nil { - t.Fatal("runF was not called") - } - if capturedOpts.Dir != "./my-skills" { - t.Errorf("Dir = %q, want %q", capturedOpts.Dir, "./my-skills") - } - if !capturedOpts.DryRun { - t.Error("expected DryRun to be true") - } - if !capturedOpts.Fix { - t.Error("expected Fix to be true") - } - if capturedOpts.Tag != "v1.0.0" { - t.Errorf("Tag = %q, want %q", capturedOpts.Tag, "v1.0.0") - } +// writeSkill creates skills//SKILL.md with the given content. +func writeSkill(t *testing.T, dir, name, content string) { + t.Helper() + skillDir := filepath.Join(dir, "skills", name) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) } -// stubFactory creates a minimal cmdutil.Factory for tests. -func stubFactory(ios *iostreams.IOStreams) cmdutil.Factory { - return cmdutil.Factory{ - IOStreams: ios, - } +// runGitInDir runs a git command in the given directory with isolation env vars. +func runGitInDir(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+dir) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) } diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 0d7e39043ff..6a0cc95d0d6 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -365,18 +365,32 @@ func truncateForProcessing(skills []skillResult, page, limit int) []skillResult } // enrichSkills fetches descriptions and star counts concurrently. +// Each function collects results into a map; merges happen after both complete +// to avoid concurrent writes to the shared skills slice. func enrichSkills(client *api.Client, host string, skills []skillResult) { + var descMap map[int]string + var starsMap map[int]int + var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() - fetchDescriptions(client, host, skills) + descMap = fetchDescriptions(client, host, skills) }() go func() { defer wg.Done() - fetchRepoStars(client, host, skills) + starsMap = fetchRepoStars(client, host, skills) }() wg.Wait() + + for i := range skills { + if desc, ok := descMap[i]; ok { + skills[i].Description = desc + } + if stars, ok := starsMap[i]; ok { + skills[i].Stars = stars + } + } } // paginate slices results to the requested page window. @@ -423,7 +437,7 @@ func renderResults(opts *searchOptions, skills []skillResult, totalPages int) er cs := opts.IO.ColorScheme() header := fmt.Sprintf("\n%s Showing %s matching %q", cs.SuccessIcon(), - pluralize(len(skills), "skill"), + text.Pluralize(len(skills), "skill"), opts.Query, ) if totalPages > 1 { @@ -498,14 +512,14 @@ func promptInstall(opts *searchOptions, skills []skillResult) error { for i, s := range skills { starStr := "" if s.Stars > 0 { - starStr = " " + cs.Gray("★ "+formatStars(s.Stars)) + starStr = " " + cs.Muted("★ "+formatStars(s.Stars)) } descStr := "" if s.Description != "" { - desc := collapseWhitespace(s.Description) - descStr = "\n " + cs.Gray(text.Truncate(descWidth, desc)) + desc := strings.Join(strings.Fields(s.Description), " ") + descStr = "\n " + cs.Muted(text.Truncate(descWidth, desc)) } - options[i] = s.SkillName + " " + cs.Gray(s.Repo) + starStr + descStr + options[i] = s.SkillName + " " + cs.Muted(s.Repo) + starStr + descStr } indices, err := opts.Prompter.MultiSelect( @@ -564,7 +578,7 @@ func promptInstall(opts *searchOptions, skills []skillResult) error { // - Exact skill name match (10 000 points) // - Partial skill name match (1 000 points) // - Description contains query (100 points) -// - Repository stars (logarithmic bonus, up to ~700 points) +// - Repository stars (sqrt bonus, ~2 400 for 6k stars) func relevanceScore(s skillResult, query string) int { term := strings.ToLower(query) termHyphen := strings.ReplaceAll(term, " ", "-") @@ -574,7 +588,7 @@ func relevanceScore(s skillResult, query string) int { // use hyphens as word separators (e.g. query "mcp apps" → "mcp-apps"). skillLower := strings.ToLower(s.SkillName) if skillLower == term || skillLower == termHyphen { - score += 10_000 + score += 3_000 } else if strings.Contains(skillLower, term) || strings.Contains(skillLower, termHyphen) { score += 1_000 } @@ -584,10 +598,10 @@ func relevanceScore(s skillResult, query string) int { score += 100 } - // Stars bonus: use log₁₀ scaling so popular repos rank higher without - // completely drowning out less-popular but more relevant results. + // Stars bonus: use √n scaling so popular repos rank meaningfully higher + // without completely drowning out less-popular but more relevant results. if s.Stars > 0 { - score += int(math.Log10(float64(s.Stars)) * 150) + score += int(math.Sqrt(float64(s.Stars)) * 30) } return score @@ -763,12 +777,14 @@ func splitRepo(fullName string) (string, string) { // fetchDescriptions fetches SKILL.md frontmatter descriptions concurrently // for all search results. Each result may come from a different repo. -func fetchDescriptions(client *api.Client, host string, skills []skillResult) { +func fetchDescriptions(client *api.Client, host string, skills []skillResult) map[int]string { const maxWorkers = 10 sem := make(chan struct{}, maxWorkers) var wg sync.WaitGroup var mu sync.Mutex + descs := make(map[int]string) + for i := range skills { if skills[i].BlobSHA == "" { continue @@ -789,11 +805,13 @@ func fetchDescriptions(client *api.Client, host string, skills []skillResult) { } mu.Lock() - skills[idx].Description = result.Metadata.Description + descs[idx] = result.Metadata.Description mu.Unlock() }(i) } wg.Wait() + + return descs } // extractSkillName derives the skill name from a SKILL.md path, but only if @@ -803,21 +821,8 @@ func extractSkillName(filePath string) string { return discovery.MatchesSkillPath(filePath) } -func pluralize(count int, singular string) string { - if count == 1 { - return fmt.Sprintf("%d %s", count, singular) - } - return fmt.Sprintf("%d %ss", count, singular) -} - -// collapseWhitespace replaces runs of whitespace (newlines, tabs, etc.) -// with a single space. -func collapseWhitespace(s string) string { - fields := strings.Fields(s) - return strings.Join(fields, " ") -} - // formatStars formats a star count for display (e.g. 1700 → "1.7k"). +// TODO kw: Could be swaped for go-humanize. func formatStars(n int) string { if n >= 1000 { return fmt.Sprintf("%.1fk", float64(n)/1000) @@ -832,7 +837,7 @@ type repoInfo struct { // fetchRepoStars fetches stargazer counts for each unique repository in // the result set, using bounded concurrency. -func fetchRepoStars(client *api.Client, host string, skills []skillResult) { +func fetchRepoStars(client *api.Client, host string, skills []skillResult) map[int]int { const maxWorkers = 10 sem := make(chan struct{}, maxWorkers) var wg sync.WaitGroup @@ -865,9 +870,11 @@ func fetchRepoStars(client *api.Client, host string, skills []skillResult) { } wg.Wait() - for i := range skills { - if stars, ok := repoStars[skills[i].Repo]; ok { - skills[i].Stars = stars + result := make(map[int]int, len(skills)) + for i, s := range skills { + if stars, ok := repoStars[s.Repo]; ok { + result[i] = stars } } + return result } diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index db266f46026..e3b8b26d682 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -1,7 +1,9 @@ package search import ( + "io" "net/http" + "strings" "testing" "github.com/cli/cli/v2/internal/config" @@ -88,19 +90,15 @@ func TestNewCmdSearch(t *testing.T) { argv := []string{} if tt.args != "" { - for _, part := range splitOnSpaces(tt.args) { - if part != "" { - argv = append(argv, part) - } - } + argv = strings.Fields(tt.args) } cmd.SetArgs(argv) - cmd.SetOut(&discardWriter{}) - cmd.SetErr(&discardWriter{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) _, err := cmd.ExecuteC() if tt.wantErr != "" { - assert.Error(t, err) + require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) return } @@ -252,6 +250,78 @@ func TestSearchRun(t *testing.T) { }, wantErr: rateLimitErrorMessage, }, + { + name: "HTTP 429 returns rate limit error", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.StatusStringResponse(429, `{"message": "Too Many Requests"}`), + ) + } + }, + wantErr: rateLimitErrorMessage, + }, + { + name: "HTTP 403 with Retry-After returns rate limit error", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.WithHeader( + httpmock.StatusJSONResponse(403, map[string]string{"message": "secondary rate limit"}), + "Retry-After", "60", + ), + ) + } + }, + wantErr: rateLimitErrorMessage, + }, + { + name: "no results with owner scope", + tty: true, + opts: &searchOptions{Query: "nonexistent", Owner: "monalisa", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + // With --owner set, only path + primary searches fire (no owner search). + for range 2 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.StringResponse(emptyCodeResponse), + ) + } + }, + wantErr: `no skills found matching "nonexistent" from owner "monalisa"`, + }, + { + name: "enriches results with blob descriptions", + tty: false, + opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + codeResponse := `{"total_count": 1, "incomplete_results": false, "items": [ + {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "sha": "abc123", + "repository": {"full_name": "org/repo"}} + ]}` + stubKeywordSearch(reg, codeResponse) + // Blob fetch for description enrichment + reg.Register( + httpmock.REST("GET", "repos/org/repo/git/blobs/abc123"), + httpmock.JSONResponse(map[string]string{ + "content": "LS0tCmRlc2NyaXB0aW9uOiBBdXRvbWF0ZXMgVGVycmFmb3JtIGluZnJhc3RydWN0dXJlCi0tLQojIFRlcnJhZm9ybSBTa2lsbAo=", + "encoding": "base64", + }), + ) + // Repo stars fetch + reg.Register( + httpmock.REST("GET", "repos/org/repo"), + httpmock.JSONResponse(map[string]int{"stargazers_count": 42}), + ) + }, + wantStdout: "org/repo\tterraform\tAutomates Terraform infrastructure\t42\n", + }, } for _, tt := range tests { @@ -396,28 +466,3 @@ func TestFormatStars(t *testing.T) { assert.Equal(t, "1.7k", formatStars(1700)) assert.Equal(t, "12.5k", formatStars(12500)) } - -func splitOnSpaces(s string) []string { - var parts []string - current := "" - for _, c := range s { - if c == ' ' { - if current != "" { - parts = append(parts, current) - current = "" - } - } else { - current += string(c) - } - } - if current != "" { - parts = append(parts, current) - } - return parts -} - -type discardWriter struct{} - -func (d *discardWriter) Write(p []byte) (n int, err error) { - return len(p), nil -} diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 42995a315a7..1dfe76007d1 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -1,7 +1,6 @@ package update import ( - "context" "fmt" "net/http" "os" @@ -38,6 +37,7 @@ type updateOptions struct { All bool // --all flag (update without prompting) Force bool // --force flag (re-download even if SHAs match) DryRun bool // --dry-run flag (report only, no changes) + Unpin bool // --unpin flag (clear pinned ref and include in update) Dir string // --dir flag (scan a custom directory) } @@ -86,7 +86,8 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co checks only those specific skills. Pinned skills (installed with --pin) are skipped with a notice. - Use "gh skills install --pin " to change the pinned version. + Use --unpin to clear the pinned version and include those skills + in the update. Skills without GitHub metadata (e.g. installed manually or by another tool) are prompted for their source repository in interactive mode. @@ -116,6 +117,9 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co # Check for updates without applying (read-only) $ gh skills update --dry-run + + # Unpin skills and update them to latest + $ gh skills update --unpin `), RunE: func(cmd *cobra.Command, args []string) error { opts.Skills = args @@ -129,6 +133,7 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co cmd.Flags().BoolVar(&opts.All, "all", false, "Update all skills without prompting") cmd.Flags().BoolVar(&opts.Force, "force", false, "Re-download even if already up to date") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Report available updates without modifying files") + cmd.Flags().BoolVar(&opts.Unpin, "unpin", false, "Clear pinned version and include pinned skills in update") cmd.Flags().StringVar(&opts.Dir, "dir", "", "Scan a custom directory for installed skills") return cmd @@ -150,8 +155,8 @@ func updateRun(opts *updateOptions) error { } hostname, _ := cfg.Authentication().DefaultHost() - gitRoot := resolveGitRoot(opts.GitClient) - homeDir := resolveHomeDir() + gitRoot := installer.ResolveGitRoot(opts.GitClient) + homeDir := installer.ResolveHomeDir() // Scan for installed skills var installed []installedSkill @@ -162,7 +167,7 @@ func updateRun(opts *updateOptions) error { } installed = skills } else { - installed = scanAllHosts(gitRoot, homeDir) + installed = scanAllAgents(gitRoot, homeDir) } if len(installed) == 0 { @@ -238,7 +243,7 @@ func updateRun(opts *updateOptions) error { if s.owner == "" || s.repo == "" { continue } - if s.pinned != "" { + if s.pinned != "" && !opts.Unpin { pinned = append(pinned, s) continue } @@ -315,7 +320,7 @@ func updateRun(opts *updateOptions) error { } for _, s := range pinned { - fmt.Fprintf(opts.IO.ErrOut, "%s %s is pinned to %s (skipped)\n", cs.Gray("⊘"), s.name, s.pinned) + fmt.Fprintf(opts.IO.ErrOut, "%s %s is pinned to %s (skipped)\n", cs.Muted("⊘"), s.name, s.pinned) } for _, name := range noMeta { fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata — reinstall to enable updates\n", cs.WarningIcon(), name) @@ -339,7 +344,7 @@ func updateRun(opts *updateOptions) error { } else { fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s → %s [%s]\n", cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, - cs.Gray(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA), + cs.Muted(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA), u.resolved.Ref) } } @@ -359,7 +364,7 @@ func updateRun(opts *updateOptions) error { } if !confirmed { fmt.Fprintf(opts.IO.ErrOut, "Update cancelled.\n") - return nil + return cmdutil.CancelError } } @@ -409,9 +414,9 @@ func updateRun(opts *updateOptions) error { return nil } -// scanAllHosts walks every known host directory (project + user scope) and +// scanAllAgents walks every registered agent's skill directory (project + user scope) and // collects installed skills. Skills are deduplicated by directory path. -func scanAllHosts(gitRoot, homeDir string) []installedSkill { +func scanAllAgents(gitRoot, homeDir string) []installedSkill { seen := make(map[string]bool) var all []installedSkill @@ -533,28 +538,3 @@ func promptForSkillOrigin(p prompter.Prompter, skillName string) (owner, repo, r } return r.RepoOwner(), r.RepoName(), "", true, nil } - -func resolveGitRoot(gc *git.Client) string { - if gc == nil { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - return "" - } - root, err := gc.ToplevelDir(context.Background()) - if err != nil { - if cwd, err := os.Getwd(); err == nil { - return cwd - } - return "" - } - return root -} - -func resolveHomeDir() string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return home -} diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index 735536b0d7a..81fc87efef1 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" @@ -43,14 +44,14 @@ func TestNewCmdUpdate_Flags(t *testing.T) { f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} cmd := NewCmdUpdate(f, func(_ *updateOptions) error { return nil }) - flags := []string{"all", "force", "dry-run", "dir"} + flags := []string{"all", "force", "dry-run", "dir", "unpin"} for _, name := range flags { assert.NotNil(t, cmd.Flags().Lookup(name), "missing flag: --%s", name) } } func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { - ios, _, _, _ := iostreams.Test() + ios, _, stdout, stderr := iostreams.Test() f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} var gotOpts *updateOptions @@ -61,8 +62,8 @@ func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { args, _ := shlex.Split("mcp-cli git-commit --all --force") cmd.SetArgs(args) - cmd.SetOut(os.Stdout) - cmd.SetErr(os.Stderr) + cmd.SetOut(stdout) + cmd.SetErr(stderr) err := cmd.Execute() require.NoError(t, err) assert.Equal(t, []string{"mcp-cli", "git-commit"}, gotOpts.Skills) @@ -71,321 +72,1072 @@ func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { } func TestScanInstalledSkills(t *testing.T) { - dir := t.TempDir() - - skillDir := filepath.Join(dir, "git-commit") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - content := "---\nname: git-commit\ndescription: Git commit helper\nmetadata:\n github-owner: github\n github-repo: awesome-copilot\n github-tree-sha: abc123\n github-path: skills/git-commit\n---\nBody content\n" - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) - - noMetaDir := filepath.Join(dir, "unknown-skill") - require.NoError(t, os.MkdirAll(noMetaDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(noMetaDir, "SKILL.md"), []byte("---\nname: unknown-skill\n---\nNo metadata here\n"), 0o644)) - - pinnedDir := filepath.Join(dir, "pinned-skill") - require.NoError(t, os.MkdirAll(pinnedDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(pinnedDir, "SKILL.md"), []byte("---\nname: pinned-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: def456\n github-pinned: v1.0.0\n---\nPinned content\n"), 0o644)) + tests := []struct { + name string + setup func(t *testing.T, dir string) + verify func(t *testing.T, skills []installedSkill, err error) + }{ + { + name: "happy path with metadata, no metadata, and pinned skills", + setup: func(t *testing.T, dir string) { + t.Helper() - skills, err := scanInstalledSkills(dir, nil, "") - require.NoError(t, err) - assert.Len(t, skills, 3) - - byName := make(map[string]installedSkill) - for _, s := range skills { - byName[s.name] = s - } + // Skill with full metadata + skillDir := filepath.Join(dir, "git-commit") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + content := heredoc.Doc(` + --- + name: git-commit + description: Git commit helper + metadata: + github-owner: monalisa + github-repo: awesome-copilot + github-tree-sha: abc123 + github-path: skills/git-commit + --- + Body content + `) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) - gc := byName["git-commit"] - assert.Equal(t, "github", gc.owner) - assert.Equal(t, "awesome-copilot", gc.repo) - assert.Equal(t, "abc123", gc.treeSHA) - assert.Equal(t, "skills/git-commit", gc.sourcePath) - assert.Empty(t, gc.pinned) + // Skill without metadata + noMetaDir := filepath.Join(dir, "unknown-skill") + require.NoError(t, os.MkdirAll(noMetaDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(noMetaDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: unknown-skill + --- + No metadata here + `)), 0o644)) - us := byName["unknown-skill"] - assert.Empty(t, us.owner) - assert.Empty(t, us.repo) - - ps := byName["pinned-skill"] - assert.Equal(t, "v1.0.0", ps.pinned) -} + // Pinned skill + pinnedDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(pinnedDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(pinnedDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: def456 + github-pinned: v1.0.0 + --- + Pinned content + `)), 0o644)) + }, + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + assert.Len(t, skills, 3) -func TestScanInstalledSkills_NonExistentDir(t *testing.T) { - skills, err := scanInstalledSkills("/nonexistent/path", nil, "") - require.NoError(t, err) - assert.Nil(t, skills) -} + byName := make(map[string]installedSkill) + for _, s := range skills { + byName[s.name] = s + } -func TestScanInstalledSkills_CorruptedYAML(t *testing.T) { - dir := t.TempDir() - skillDir := filepath.Join(dir, "corrupt") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nnot: valid: yaml: [broken\n---\nbody\n"), 0o644)) + gc := byName["git-commit"] + assert.Equal(t, "monalisa", gc.owner) + assert.Equal(t, "awesome-copilot", gc.repo) + assert.Equal(t, "abc123", gc.treeSHA) + assert.Equal(t, "skills/git-commit", gc.sourcePath) + assert.Empty(t, gc.pinned) - skills, err := scanInstalledSkills(dir, nil, "") - require.NoError(t, err) - assert.Len(t, skills, 0) -} + us := byName["unknown-skill"] + assert.Empty(t, us.owner) + assert.Empty(t, us.repo) -func TestPromptForSkillOrigin_Valid(t *testing.T) { - pm := &prompter.PrompterMock{ - InputFunc: func(prompt string, defaultValue string) (string, error) { - return "github/awesome-copilot", nil + ps := byName["pinned-skill"] + assert.Equal(t, "v1.0.0", ps.pinned) + }, }, - } - owner, repo, _, ok, err := promptForSkillOrigin(pm, "test-skill") - require.NoError(t, err) - assert.True(t, ok) - assert.Equal(t, "github", owner) - assert.Equal(t, "awesome-copilot", repo) -} - -func TestPromptForSkillOrigin_Empty(t *testing.T) { - pm := &prompter.PrompterMock{ - InputFunc: func(prompt string, defaultValue string) (string, error) { - return "", nil + { + name: "non-existent directory returns nil", + // no setup — dir does not exist + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + assert.Nil(t, skills) + }, }, - } - _, _, _, ok, err := promptForSkillOrigin(pm, "test-skill") - require.NoError(t, err) - assert.False(t, ok) -} - -func TestPromptForSkillOrigin_Invalid(t *testing.T) { - pm := &prompter.PrompterMock{ - InputFunc: func(prompt string, defaultValue string) (string, error) { - return "just-a-name", nil + { + name: "corrupted YAML is skipped gracefully", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "corrupt") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + not: valid: yaml: [broken + --- + body + `)), 0o644)) + }, + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + assert.Len(t, skills, 0) + }, }, } - _, _, reason, ok, err := promptForSkillOrigin(pm, "test-skill") - require.NoError(t, err) - assert.False(t, ok) - assert.Contains(t, reason, "invalid repository") -} - -func TestUpdateRun_NoInstalledSkills(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(false) - dir := t.TempDir() + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // For the non-existent directory case, pass a path that doesn't exist + dir := filepath.Join(t.TempDir(), "skills") + if tt.setup != nil { + require.NoError(t, os.MkdirAll(dir, 0o755)) + tt.setup(t, dir) + } - reg := &httpmock.Registry{} - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, + skills, err := scanInstalledSkills(dir, nil, "") + tt.verify(t, skills, err) + }) } - - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "No installed skills found.") } -func TestUpdateRun_SpecificSkillNotInstalled(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(false) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "existing-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: existing-skill\nmetadata:\n github-owner: owner\n github-repo: repo\n github-tree-sha: abc\n---\n"), 0o644)) - - reg := &httpmock.Registry{} - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil +func TestPromptForSkillOrigin(t *testing.T) { + tests := []struct { + name string + input string + wantOK bool + wantOwner string + wantRepo string + wantReason string + }{ + { + name: "valid owner/repo", + input: "monalisa/awesome-copilot", + wantOK: true, + wantOwner: "monalisa", + wantRepo: "awesome-copilot", }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - Skills: []string{"nonexistent"}, - } - - defer reg.Verify(t) - err := updateRun(opts) - assert.EqualError(t, err, "none of the specified skills are installed") -} - -func TestUpdateRun_PinnedSkillsSkipped(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "pinned-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: pinned-skill\nmetadata:\n github-owner: owner\n github-repo: repo\n github-tree-sha: abc123\n github-pinned: v1.0.0\n---\n"), 0o644)) - - reg := &httpmock.Registry{} - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil + { + name: "empty input skips", + input: "", + wantOK: false, }, - Prompter: &prompter.PrompterMock{}, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - } - - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "pinned-skill is pinned to v1.0.0 (skipped)") - assert.Contains(t, stderr.String(), "All skills are up to date.") -} - -func TestUpdateRun_NoMetaSkipsNonInteractive(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetStdinTTY(false) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "manual-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: manual-skill\n---\nNo metadata\n"), 0o644)) - - reg := &httpmock.Registry{} - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil + { + name: "invalid format returns reason", + input: "just-a-name", + wantOK: false, + wantReason: "invalid repository", }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, } - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "manual-skill has no GitHub metadata") -} - -func TestUpdateRun_AllUpToDate(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdoutTTY(false) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "my-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: abc123def456\n github-path: skills/my-skill\n---\n"), 0o644)) - - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", "repos/octo/skills/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v1.0.0"), - httpmock.StringResponse(`{"object": {"sha": "commitsha123", "type": "commit"}}`), - ) - reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/octo/skills/git/trees/commitsha123")), - httpmock.StringResponse(`{"sha": "commitsha123", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha1"}, {"path": "skills/my-skill", "type": "tree", "sha": "abc123def456"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`), - ) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pm := &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return tt.input, nil + }, + } - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, + owner, repo, reason, ok, err := promptForSkillOrigin(pm, "test-skill") + require.NoError(t, err) + assert.Equal(t, tt.wantOK, ok) + assert.Equal(t, tt.wantOwner, owner) + assert.Equal(t, tt.wantRepo, repo) + if tt.wantReason != "" { + assert.Contains(t, reason, tt.wantReason) + } + }) } - - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "All skills are up to date.") } -func TestUpdateRun_DryRun(t *testing.T) { - ios, _, stdout, stderr := iostreams.Test() - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - dir := t.TempDir() - skillDir := filepath.Join(dir, "my-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: oldsha123\n github-path: skills/my-skill\n---\n"), 0o644)) - - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", "repos/octo/skills/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v2.0.0"), - httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/trees/newcommit456"), - httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/my-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), - ) - - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil +func TestUpdateRun(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + stubs func(reg *httpmock.Registry) + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions + verify func(t *testing.T, dir string) + wantErr string + wantStderr string + wantStdout string + }{ + { + name: "scans all agents when no --dir is set", + setup: func(t *testing.T, dir string) { + t.Helper() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + skillDir := filepath.Join(dir, ".github", "skills", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: currentsha + github-path: skills/code-review + --- + Installed content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commit1", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commit1"), + httpmock.StringResponse(`{"sha": "commit1", "tree": [{"path": "skills/code-review", "type": "tree", "sha": "currentsha"}, {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob1"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStderr: "All skills are up to date.", + }, + { + name: "no installed skills", + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "No installed skills found.", + }, + { + name: "specific skill not installed", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "octocat-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: octocat-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: abc + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + Skills: []string{"nonexistent"}, + } + }, + wantErr: "none of the specified skills are installed", + }, + { + name: "pinned skills are skipped", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: abc123 + github-pinned: v1.0.0 + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "pinned", + }, + { + name: "no metadata skips in non-interactive mode", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + No metadata + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "no GitHub metadata", + }, + { + name: "all up to date", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "monalisa-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: monalisa-skill + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: abc123def456 + github-path: skills/monalisa-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commitsha123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commitsha123"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "commitsha123", "tree": [{"path": "skills/monalisa-skill/SKILL.md", "type": "blob", "sha": "blobsha1"}, {"path": "skills/monalisa-skill", "type": "tree", "sha": "abc123def456"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "All skills are up to date.", + }, + { + name: "dry run reports available updates", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "hubot-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: hubot-skill + metadata: + github-owner: hubot + github-repo: octocat-skills + github-tree-sha: oldsha123 + github-path: skills/hubot-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/trees/newcommit456"), + httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/hubot-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/hubot-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + DryRun: true, + } + }, + wantStderr: "1 update(s) available:", + wantStdout: "hubot-skill", + }, + { + name: "non-interactive without --all errors", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "hubot-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: hubot-skill + metadata: + github-owner: hubot + github-repo: octocat-skills + github-tree-sha: oldsha123 + github-path: skills/hubot-skill + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/hubot/octocat-skills/git/trees/newcommit456"), + httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/hubot-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/hubot-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), + ) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantErr: "updates available; re-run with --all to apply, or run interactively to confirm", + }, + { + name: "force update rewrites SKILL.md on disk", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Force: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-owner: monalisa") + assert.NotContains(t, string(content), "Old content") + }, + wantStdout: "Updated code-review", + }, + { + name: "namespaced skill with --dir resolves install base correctly", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "monalisa", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/monalisa/code-review + --- + Old namespaced content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/monalisa/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/monalisa/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills/monalisa", "type": "tree", "sha": "nstresha"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBOYW1lc3BhY2VkIFNraWxsIFVwZGF0ZWQ="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Force: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "monalisa", "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-owner: monalisa") + assert.NotContains(t, string(content), "Old namespaced content") + }, + wantStdout: "Updated monalisa/code-review", + }, + { + name: "install failure during update reports error and continues", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Original content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StatusStringResponse(500, "server error")) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "Original content", "file should not be modified on failure") + }, + wantStderr: "Failed to update code-review", + wantErr: "SilentError", + }, + { + name: "interactive confirm applies update", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return true, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Old content") + }, + wantStdout: "Updated code-review", + }, + { + name: "interactive confirm cancelled", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: oldsha000 + github-path: skills/code-review + --- + Old content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v3.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v3.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return false, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantErr: "CancelError", + wantStderr: "Update cancelled", + }, + { + name: "no-metadata skill prompted interactively and skipped", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + No metadata + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "", nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + wantStderr: "no GitHub metadata", + }, + { + name: "no-metadata skill enriched via prompt then updated", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + Old manual content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "commit123", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commit123"), + httpmock.StringResponse(`{"sha": "commit123", "tree": [{"path": "skills/manual-skill/SKILL.md", "type": "blob", "sha": "blob1"}, {"path": "skills/manual-skill", "type": "tree", "sha": "newtree1"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newtree1"), + httpmock.StringResponse(`{"sha": "newtree1", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "blob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob1", "encoding": "base64", "content": "%s"}`, + "IyBNYW51YWwgU2tpbGwgVXBkYXRlZA=="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "monalisa/octocat-skills", nil + }, + ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { + return true, nil + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "manual-skill", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Old manual content") + assert.Contains(t, string(content), "github-owner: monalisa") + }, + wantStdout: "Updated manual-skill", + }, + { + name: "unpin clears pin and applies update", + setup: func(t *testing.T, dir string) { + t.Helper() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: oldsha000 + github-pinned: v1.0.0 + github-path: skills/pinned-skill + --- + Pinned content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/pinned-skill/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/pinned-skill", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newsha999"), + httpmock.StringResponse(`{"sha": "newsha999", "tree": [{"path": "SKILL.md", "type": "blob", "sha": "newblob1", "size": 20}], "truncated": false}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/blobs/newblob1"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, + "IyBVbnBpbm5lZCBhbmQgVXBkYXRlZA=="))) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(false) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Unpin: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "pinned-skill", "SKILL.md")) + require.NoError(t, err) + assert.NotContains(t, string(content), "Pinned content") + assert.NotContains(t, string(content), "github-pinned") + }, + wantStdout: "Updated pinned-skill", + }, + { + name: "pinned skills still skipped without --unpin", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: abc123 + github-pinned: v1.0.0 + --- + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + Unpin: false, + } + }, + wantStderr: "pinned", + }, + { + name: "unpin with dry-run reports update without modifying files", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "pinned-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: pinned-skill + metadata: + github-owner: octocat + github-repo: hubot-skills + github-tree-sha: oldsha000 + github-pinned: v1.0.0 + github-path: skills/pinned-skill + --- + Pinned content + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v2.0.0"}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/ref/tags/v2.0.0"), + httpmock.StringResponse(`{"object": {"sha": "newcommit789", "type": "commit"}}`)) + reg.Register( + httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newcommit789"), + httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/pinned-skill/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/pinned-skill", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + return &updateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + DryRun: true, + Unpin: true, + } + }, + verify: func(t *testing.T, dir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "pinned-skill", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "github-pinned: v1.0.0", "dry-run should not modify files") + }, + wantStderr: "1 update(s) available:", + wantStdout: "pinned-skill", }, - Prompter: &prompter.PrompterMock{}, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - DryRun: true, } - defer reg.Verify(t) - err := updateRun(opts) - require.NoError(t, err) - assert.Contains(t, stderr.String(), "1 update(s) available:") - assert.Contains(t, stdout.String(), "my-skill") - assert.Contains(t, stdout.String(), "octo/skills") -} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() -func TestUpdateRun_NonInteractiveNoAll(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdoutTTY(false) - ios.SetStdinTTY(false) + dir := t.TempDir() + if tt.setup != nil { + tt.setup(t, dir) + } - dir := t.TempDir() - skillDir := filepath.Join(dir, "my-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: my-skill\nmetadata:\n github-owner: octo\n github-repo: skills\n github-tree-sha: oldsha123\n github-path: skills/my-skill\n---\n"), 0o644)) + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.stubs != nil { + tt.stubs(reg) + } - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("GET", "repos/octo/skills/releases/latest"), - httpmock.StringResponse(`{"tag_name": "v2.0.0"}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/ref/tags/v2.0.0"), - httpmock.StringResponse(`{"object": {"sha": "newcommit456", "type": "commit"}}`), - ) - reg.Register( - httpmock.REST("GET", "repos/octo/skills/git/trees/newcommit456"), - httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/my-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), - ) + opts := tt.opts(ios, dir, reg) + err := updateRun(opts) - opts := &updateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + return + } + require.NoError(t, err) + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.verify != nil { + tt.verify(t, dir) + } + }) } - - defer reg.Verify(t) - err := updateRun(opts) - assert.EqualError(t, err, "updates available; re-run with --all to apply, or run interactively to confirm") } From ba61ded4b30a361b65285d81fd8e8021edf156da Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 8 Apr 2026 12:07:59 +0100 Subject: [PATCH 008/182] use markdown renderer in preview when previewing multi-file skills --- pkg/cmd/skills/preview/preview.go | 64 ++++++++++++++++---- pkg/cmd/skills/preview/preview_test.go | 84 ++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 13 deletions(-) diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index f06d4c1822f..ce7c49ef255 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "net/http" + "path" "sort" "strings" @@ -24,6 +25,7 @@ type previewOptions struct { HttpClient func() (*http.Client, error) Prompter prompter.Prompter Executable func() string + RenderFile func(string, string) string RepoArg string SkillName string @@ -39,6 +41,9 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra. Prompter: f.Prompter, Executable: f.Executable, } + opts.RenderFile = func(filePath, content string) string { + return renderMarkdownPreview(opts.IO, filePath, content) + } cmd := &cobra.Command{ Use: "preview []", @@ -139,18 +144,7 @@ func previewRun(opts *previewOptions) error { return err } - parsed, parseErr := frontmatter.Parse(content) - if parseErr == nil { - content = parsed.Body - } - - rendered, err := markdown.Render(content, - markdown.WithTheme(opts.IO.TerminalTheme()), - markdown.WithWrap(opts.IO.TerminalWidth()), - markdown.WithoutIndentation()) - if err != nil { - rendered = content - } + rendered := opts.renderFile("SKILL.md", content) // Collect extra files (everything that isn't SKILL.md) var extraFiles []discovery.SkillFile @@ -268,7 +262,7 @@ func renderInteractive(opts *previewOptions, cs *iostreams.ColorScheme, skill di fmt.Fprintf(opts.IO.ErrOut, "%s could not fetch %s: %v\n", cs.Red("!"), selectedFile.Path, fetchErr) continue } - content = fileContent + content = renderSelectedFilePreview(opts, selectedFile.Path, fileContent) if !strings.HasSuffix(content, "\n") { content += "\n" } @@ -282,6 +276,50 @@ func renderInteractive(opts *previewOptions, cs *iostreams.ColorScheme, skill di } } +func (opts *previewOptions) renderFile(filePath, content string) string { + if opts.RenderFile != nil { + return opts.RenderFile(filePath, content) + } + + return renderMarkdownPreview(opts.IO, filePath, content) +} + +func renderSelectedFilePreview(opts *previewOptions, filePath, content string) string { + if !isMarkdownFile(filePath) { + return content + } + + return opts.renderFile(filePath, content) +} + +func renderMarkdownPreview(io *iostreams.IOStreams, filePath, content string) string { + if filePath == "SKILL.md" { + parsed, err := frontmatter.Parse(content) + if err == nil { + content = parsed.Body + } + } + + rendered, err := markdown.Render(content, + markdown.WithTheme(io.TerminalTheme()), + markdown.WithWrap(io.TerminalWidth()), + markdown.WithoutIndentation()) + if err != nil { + return content + } + + return rendered +} + +func isMarkdownFile(filePath string) bool { + switch strings.ToLower(path.Ext(filePath)) { + case ".md", ".markdown", ".mdown", ".mkd", ".mkdn": + return true + default: + return false + } +} + func selectSkill(opts *previewOptions, skills []discovery.Skill) (discovery.Skill, error) { if opts.SkillName != "" { for _, s := range skills { diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index 4c386480980..0cbea6ae733 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -445,6 +445,90 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { assert.Equal(t, 2, selectCalls) }) + t.Run("interactive markdown file uses markdown renderer", func(t *testing.T) { + readmeContent := "# Usage\n\nUse **carefully**." + encodedReadme := base64.StdEncoding.EncodeToString([]byte(readmeContent)) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"}, + {"path": "skills/my-skill/README.md", "type": "blob", "sha": "blobREADME"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50}, + {"path": "README.md", "type": "blob", "sha": "blobREADME", "size": 28} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSKILL"), + httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobREADME"), + httpmock.StringResponse(`{"sha": "blobREADME", "content": "`+encodedReadme+`", "encoding": "base64"}`), + ) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetColorEnabled(false) + + renderCalls := 0 + + selectCalls := 0 + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + selectCalls++ + if selectCalls == 1 { + assert.Equal(t, []string{"SKILL.md", "README.md"}, options) + return 1, nil + } + return 0, fmt.Errorf("user cancelled") + }, + } + + opts := &previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + repo: ghrepo.New("owner", "repo"), + SkillName: "my-skill", + RenderFile: func(filePath, content string) string { + renderCalls++ + return fmt.Sprintf("rendered:%s", filePath) + }, + } + + err := previewRun(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "rendered:README.md") + assert.Equal(t, 2, selectCalls) + assert.Equal(t, 2, renderCalls) + }) + t.Run("non-interactive dumps all files", func(t *testing.T) { reg := makeReg() defer reg.Verify(t) From b26256a10d486de0abc573fb5f9f423e32c59ce5 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 8 Apr 2026 12:17:31 +0100 Subject: [PATCH 009/182] show loading spinner during installation, even for multi-file skills --- internal/skills/installer/installer.go | 4 +-- internal/skills/installer/installer_test.go | 36 +++++++++++++++++++++ pkg/cmd/skills/install/install.go | 6 ++-- pkg/cmd/skills/install/install_test.go | 8 +++++ 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index 8ae3da28fbb..ce5370004ab 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -69,6 +69,7 @@ func Install(opts *Options) (*Result, error) { skill := opts.Skills[0] if opts.OnProgress != nil { opts.OnProgress(0, 1) + defer opts.OnProgress(1, 1) } if err := installSkill(opts, skill, targetDir); err != nil { return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err) @@ -77,9 +78,6 @@ func Install(opts *Options) (*Result, error) { if err := lockfile.RecordInstall(skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil { warnings = append(warnings, fmt.Sprintf("could not record install for %s: %v", skill.InstallName(), err)) } - if opts.OnProgress != nil { - opts.OnProgress(1, 1) - } return &Result{Installed: []string{skill.InstallName()}, Dir: targetDir, Warnings: warnings}, nil } diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go index 0637e9c196e..ea9c619c29f 100644 --- a/internal/skills/installer/installer_test.go +++ b/internal/skills/installer/installer_test.go @@ -447,6 +447,42 @@ func TestInstall(t *testing.T) { } } +func TestInstallSingleSkillFailureStillCompletesProgress(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + destDir := t.TempDir() + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-fail"), + httpmock.StatusStringResponse(500, "server error"), + ) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + var events []struct{ done, total int } + result, err := Install(&Options{ + Host: "github.com", + Owner: "monalisa", + Repo: "octocat-skills", + Ref: "v1.0", + SHA: "commit123", + Client: client, + Skills: []discovery.Skill{ + {Name: "code-review", Path: "skills/code-review", TreeSHA: "tree-fail"}, + }, + Dir: destDir, + OnProgress: func(done, total int) { + events = append(events, struct{ done, total int }{done: done, total: total}) + }, + }) + + require.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, []struct{ done, total int }{{done: 0, total: 1}, {done: 1, total: 1}}, events) +} + func TestResolveGitRoot(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 8188c8f3f14..149d682eb42 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -907,13 +907,15 @@ func existingSkillPrompt(targetDir string, incoming discovery.Skill) string { return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) } +const installProgressLabel = "Downloading skill files" + func installProgress(io *iostreams.IOStreams, total int) func(done, total int) { - if total <= 1 { + if total <= 0 { return nil } return func(done, total int) { if done == 0 { - io.StartProgressIndicator() + io.StartProgressIndicatorWithLabel(installProgressLabel) } else if done >= total { io.StopProgressIndicator() } diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index a4f67f1f1fb..84fb24b7aad 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -1324,6 +1324,14 @@ func TestInstallRun(t *testing.T) { } } +func TestInstallProgress(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + assert.Nil(t, installProgress(ios, 0)) + assert.NotNil(t, installProgress(ios, 1)) + assert.NotNil(t, installProgress(ios, 2)) +} + func TestRunLocalInstall(t *testing.T) { tests := []struct { name string From 3b50bbbf16adc6beeb2c8f968dc30cbd0b3d76cd Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 8 Apr 2026 14:52:14 +0100 Subject: [PATCH 010/182] add .agents/skills as default installation path for hosts that support it: cursor, codex, gemini CLI, github copilot, antigravity. excluded: claude code --- internal/skills/registry/registry.go | 12 +- internal/skills/registry/registry_test.go | 69 +++++++++--- pkg/cmd/skills/install/install.go | 130 +++++++++++++--------- pkg/cmd/skills/install/install_test.go | 41 +++++++ pkg/cmd/skills/publish/publish_test.go | 6 +- pkg/cmd/skills/update/update.go | 16 ++- pkg/cmd/skills/update/update_test.go | 45 +++++++- 7 files changed, 231 insertions(+), 88 deletions(-) diff --git a/internal/skills/registry/registry.go b/internal/skills/registry/registry.go index ecaaaa48de4..a8fdc59935c 100644 --- a/internal/skills/registry/registry.go +++ b/internal/skills/registry/registry.go @@ -27,6 +27,8 @@ type Scope string const ( ScopeProject Scope = "project" ScopeUser Scope = "user" + + sharedProjectSkillsDir = ".agents/skills" ) // Agents contains all known agent hosts. @@ -34,7 +36,7 @@ var Agents = []AgentHost{ { ID: "github-copilot", Name: "GitHub Copilot", - ProjectDir: ".github/skills", + ProjectDir: sharedProjectSkillsDir, UserDir: ".copilot/skills", }, { @@ -46,25 +48,25 @@ var Agents = []AgentHost{ { ID: "cursor", Name: "Cursor", - ProjectDir: ".cursor/skills", + ProjectDir: sharedProjectSkillsDir, UserDir: ".cursor/skills", }, { ID: "codex", Name: "Codex", - ProjectDir: ".agents/skills", + ProjectDir: sharedProjectSkillsDir, UserDir: ".codex/skills", }, { ID: "gemini", Name: "Gemini CLI", - ProjectDir: ".agent/skills", + ProjectDir: sharedProjectSkillsDir, UserDir: ".gemini/skills", }, { ID: "antigravity", Name: "Antigravity", - ProjectDir: ".agent/skills", + ProjectDir: sharedProjectSkillsDir, UserDir: ".gemini/antigravity/skills", }, } diff --git a/internal/skills/registry/registry_test.go b/internal/skills/registry/registry_test.go index e17668b87a1..003a28afa1f 100644 --- a/internal/skills/registry/registry_test.go +++ b/internal/skills/registry/registry_test.go @@ -38,11 +38,9 @@ func TestFindByID(t *testing.T) { } func TestInstallDir(t *testing.T) { - host, err := FindByID("github-copilot") - require.NoError(t, err) - tests := []struct { name string + hostID string scope Scope gitRoot string homeDir string @@ -50,21 +48,64 @@ func TestInstallDir(t *testing.T) { wantErr bool }{ { - name: "project scope", + name: "github copilot project scope", + hostID: "github-copilot", scope: ScopeProject, gitRoot: "/tmp/monalisa-repo", homeDir: "/home/monalisa", - wantDir: filepath.Join("/tmp/monalisa-repo", ".github", "skills"), + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), }, { - name: "user scope", + name: "github copilot user scope", + hostID: "github-copilot", scope: ScopeUser, gitRoot: "/tmp/monalisa-repo", homeDir: "/home/monalisa", wantDir: filepath.Join("/home/monalisa", ".copilot", "skills"), }, + { + name: "claude code project scope", + hostID: "claude-code", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".claude", "skills"), + }, + { + name: "cursor project scope", + hostID: "cursor", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, + { + name: "codex project scope", + hostID: "codex", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, + { + name: "gemini project scope", + hostID: "gemini", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, + { + name: "antigravity project scope", + hostID: "antigravity", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, { name: "project scope without git root", + hostID: "github-copilot", scope: ScopeProject, gitRoot: "", homeDir: "/home/monalisa", @@ -72,6 +113,7 @@ func TestInstallDir(t *testing.T) { }, { name: "user scope without home dir", + hostID: "github-copilot", scope: ScopeUser, gitRoot: "/tmp/monalisa-repo", homeDir: "", @@ -79,6 +121,7 @@ func TestInstallDir(t *testing.T) { }, { name: "invalid scope", + hostID: "github-copilot", scope: "bogus", gitRoot: "/tmp/monalisa-repo", homeDir: "/home/monalisa", @@ -87,6 +130,9 @@ func TestInstallDir(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + host, err := FindByID(tt.hostID) + require.NoError(t, err) + dir, err := host.InstallDir(tt.scope, tt.gitRoot, tt.homeDir) if tt.wantErr { assert.Error(t, err) @@ -121,16 +167,7 @@ func TestRepoNameFromRemote(t *testing.T) { func TestUniqueProjectDirs(t *testing.T) { dirs := UniqueProjectDirs() - require.NotEmpty(t, dirs) - - // Should deduplicate — e.g. gemini and antigravity share .agent/skills - seen := map[string]int{} - for _, d := range dirs { - seen[d]++ - } - for dir, count := range seen { - assert.Equalf(t, 1, count, "directory %q appears %d times, expected 1", dir, count) - } + assert.Equal(t, []string{".agents/skills", ".claude/skills"}, dirs) } func TestScopeLabels(t *testing.T) { diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 149d682eb42..b0ca4bf4aa1 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -82,17 +82,22 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. scope (in your home directory, available everywhere): Host Project User - GitHub Copilot .github/skills ~/.copilot/skills + GitHub Copilot .agents/skills ~/.copilot/skills Claude Code .claude/skills ~/.claude/skills - Cursor .cursor/skills ~/.cursor/skills + Cursor .agents/skills ~/.cursor/skills Codex .agents/skills ~/.codex/skills - Gemini CLI .agent/skills ~/.gemini/skills - Antigravity .agent/skills ~/.gemini/antigravity/skills + Gemini CLI .agents/skills ~/.gemini/skills + Antigravity .agents/skills ~/.gemini/antigravity/skills Use %[1]s--agent%[1]s and %[1]s--scope%[1]s to control placement, or %[1]s--dir%[1]s for a custom directory. The default scope is %[1]sproject%[1]s, and the default agent is %[1]sgithub-copilot%[1]s (when running non-interactively). + At project scope, GitHub Copilot, Cursor, Codex, Gemini CLI, and + Antigravity all use the shared %[1]s.agents/skills%[1]s directory. If you + select multiple hosts that resolve to the same destination, each skill is + installed there only once. + The first argument can be a GitHub repository in %[1]sOWNER/REPO%[1]s format or a local directory path (e.g. %[1]s.%[1]s, %[1]s./my-skills%[1]s, %[1]s~/skills%[1]s). For local directories, skills are auto-discovered using the same @@ -287,26 +292,14 @@ func installRun(opts *installOptions) error { homeDir := installer.ResolveHomeDir() source = ghrepo.FullName(opts.repo) - type hostPlan struct { - host *registry.AgentHost - skills []discovery.Skill - } - var plans []hostPlan - for _, host := range selectedHosts { - installSkills, err := checkOverwrite(opts, selectedSkills, host, scope, gitRoot, homeDir, canPrompt) - if err != nil { - return err - } - if len(installSkills) == 0 { - fmt.Fprintf(opts.IO.ErrOut, "No skills to install for %s.\n", host.Name) - continue - } - plans = append(plans, hostPlan{host: host, skills: installSkills}) + plans, err := buildInstallPlans(opts, selectedSkills, selectedHosts, scope, gitRoot, homeDir, canPrompt) + if err != nil { + return err } for _, plan := range plans { if len(plans) > 1 { - fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s...\n", plan.host.Name) + fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s for %s...\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts)) } result, err := installer.Install(&installer.Options{ @@ -317,11 +310,7 @@ func installRun(opts *installOptions) error { SHA: resolved.SHA, PinnedRef: opts.Pin, Skills: plan.skills, - AgentHost: plan.host, - Scope: scope, - Dir: opts.Dir, - GitRoot: gitRoot, - HomeDir: homeDir, + Dir: plan.dir, Client: apiClient, OnProgress: installProgress(opts.IO, len(plan.skills)), }) @@ -425,36 +414,20 @@ func runLocalInstall(opts *installOptions) error { gitRoot := installer.ResolveGitRoot(opts.GitClient) homeDir := installer.ResolveHomeDir() - type hostPlan struct { - host *registry.AgentHost - skills []discovery.Skill - } - var plans []hostPlan - for _, host := range selectedHosts { - installSkills, err := checkOverwrite(opts, selectedSkills, host, scope, gitRoot, homeDir, canPrompt) - if err != nil { - return err - } - if len(installSkills) == 0 { - fmt.Fprintf(opts.IO.ErrOut, "No skills to install for %s.\n", host.Name) - continue - } - plans = append(plans, hostPlan{host: host, skills: installSkills}) + plans, err := buildInstallPlans(opts, selectedSkills, selectedHosts, scope, gitRoot, homeDir, canPrompt) + if err != nil { + return err } for _, plan := range plans { if len(plans) > 1 { - fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s...\n", plan.host.Name) + fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s for %s...\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts)) } result, err := installer.InstallLocal(&installer.LocalOptions{ SourceDir: absSource, Skills: plan.skills, - AgentHost: plan.host, - Scope: scope, - Dir: opts.Dir, - GitRoot: gitRoot, - HomeDir: homeDir, + Dir: plan.dir, }) if err != nil { return err @@ -586,6 +559,12 @@ type skillSelector struct { fetchDescriptions func() } +type installPlan struct { + dir string + hosts []*registry.AgentHost + skills []discovery.Skill +} + func selectSkillsWithSelector(opts *installOptions, skills []discovery.Skill, canPrompt bool, sel skillSelector) ([]discovery.Skill, error) { checkCollisions := func(ss []discovery.Skill) error { return collisionError(ss, sel.sourceHint) @@ -823,20 +802,63 @@ func resolveScope(opts *installOptions, canPrompt bool) (registry.Scope, error) return registry.ScopeUser, nil } -func truncateDescription(s string, maxWidth int) string { - return text.Truncate(maxWidth, text.RemoveExcessiveWhitespace(s)) -} +func buildInstallPlans(opts *installOptions, selectedSkills []discovery.Skill, selectedHosts []*registry.AgentHost, scope registry.Scope, gitRoot, homeDir string, canPrompt bool) ([]installPlan, error) { + byDir := make(map[string]*installPlan) + orderedDirs := make([]string, 0, len(selectedHosts)) -func checkOverwrite(opts *installOptions, skills []discovery.Skill, host *registry.AgentHost, scope registry.Scope, gitRoot, homeDir string, canPrompt bool) ([]discovery.Skill, error) { - targetDir := opts.Dir - if targetDir == "" { - var err error - targetDir, err = host.InstallDir(scope, gitRoot, homeDir) + for _, host := range selectedHosts { + targetDir, err := resolveInstallDir(opts, host, scope, gitRoot, homeDir) if err != nil { return nil, err } + + plan, ok := byDir[targetDir] + if !ok { + plan = &installPlan{dir: targetDir} + byDir[targetDir] = plan + orderedDirs = append(orderedDirs, targetDir) + } + plan.hosts = append(plan.hosts, host) } + plans := make([]installPlan, 0, len(orderedDirs)) + for _, dir := range orderedDirs { + plan := byDir[dir] + installSkills, err := checkOverwrite(opts, selectedSkills, plan.dir, canPrompt) + if err != nil { + return nil, err + } + if len(installSkills) == 0 { + fmt.Fprintf(opts.IO.ErrOut, "No skills to install in %s for %s.\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts)) + continue + } + plan.skills = installSkills + plans = append(plans, *plan) + } + + return plans, nil +} + +func resolveInstallDir(opts *installOptions, host *registry.AgentHost, scope registry.Scope, gitRoot, homeDir string) (string, error) { + if opts.Dir != "" { + return opts.Dir, nil + } + return host.InstallDir(scope, gitRoot, homeDir) +} + +func formatPlanHosts(hosts []*registry.AgentHost) string { + names := make([]string, len(hosts)) + for i, host := range hosts { + names[i] = host.Name + } + return strings.Join(names, ", ") +} + +func truncateDescription(s string, maxWidth int) string { + return text.Truncate(maxWidth, text.RemoveExcessiveWhitespace(s)) +} + +func checkOverwrite(opts *installOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, error) { var existing, fresh []discovery.Skill for _, s := range skills { dir := filepath.Join(targetDir, filepath.FromSlash(s.InstallName())) diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 84fb24b7aad..c753ae51ca6 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -1332,6 +1332,47 @@ func TestInstallProgress(t *testing.T) { assert.NotNil(t, installProgress(ios, 2)) } +func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + return []int{0, 2}, nil // GitHub Copilot + Cursor share .agents/skills + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + return 0, nil // project scope + }, + } + + err := installRun(&installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Force: true, + }) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(stdout.String(), "Installed git-commit")) + assert.NotContains(t, stderr.String(), "Installing to") +} + func TestRunLocalInstall(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index e4c368b70f9..862dbc9f75f 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -661,7 +661,7 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { t.Helper() - require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "skills", "installed"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) runGitInDir(t, dir, "init", "--initial-branch=main") runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") @@ -686,12 +686,12 @@ func TestPublishRun(t *testing.T) { --- Body. `)) - require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "skills", "installed"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) runGitInDir(t, dir, "init", "--initial-branch=main") runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") - require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".github/skills\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".agents/skills\n"), 0o644)) runGitInDir(t, dir, "add", ".gitignore") runGitInDir(t, dir, "commit", "-m", "init") }, diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 1dfe76007d1..afb8377e34f 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -415,9 +415,9 @@ func updateRun(opts *updateOptions) error { } // scanAllAgents walks every registered agent's skill directory (project + user scope) and -// collects installed skills. Skills are deduplicated by directory path. +// collects installed skills. Shared install roots are scanned only once. func scanAllAgents(gitRoot, homeDir string) []installedSkill { - seen := make(map[string]bool) + scannedDirs := make(map[string]bool) var all []installedSkill for i := range registry.Agents { @@ -427,17 +427,15 @@ func scanAllAgents(gitRoot, homeDir string) []installedSkill { if err != nil { continue } + if scannedDirs[dir] { + continue + } + scannedDirs[dir] = true skills, err := scanInstalledSkills(dir, host, scope) if err != nil { continue } - for _, s := range skills { - if seen[s.dir] { - continue - } - seen[s.dir] = true - all = append(all, s) - } + all = append(all, skills...) } } diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index 81fc87efef1..0c7953f6645 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -243,6 +244,48 @@ func TestPromptForSkillOrigin(t *testing.T) { } } +func TestScanAllAgentsDeduplicatesSharedProjectDirs(t *testing.T) { + repoDir := t.TempDir() + homeDir := t.TempDir() + + sharedSkillDir := filepath.Join(repoDir, ".agents", "skills", "git-commit") + require.NoError(t, os.MkdirAll(sharedSkillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(sharedSkillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: git-commit + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: abc123 + --- + Body + `)), 0o644)) + + claudeSkillDir := filepath.Join(repoDir, ".claude", "skills", "code-review") + require.NoError(t, os.MkdirAll(claudeSkillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(claudeSkillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: code-review + metadata: + github-owner: monalisa + github-repo: octocat-skills + github-tree-sha: def456 + --- + Body + `)), 0o644)) + + skills := scanAllAgents(repoDir, homeDir) + require.Len(t, skills, 2) + + byName := make(map[string]installedSkill) + for _, skill := range skills { + byName[skill.name] = skill + } + + assert.Equal(t, registry.ScopeProject, byName["git-commit"].scope) + assert.Equal(t, registry.ScopeProject, byName["code-review"].scope) +} + func TestUpdateRun(t *testing.T) { tests := []struct { name string @@ -260,7 +303,7 @@ func TestUpdateRun(t *testing.T) { t.Helper() t.Setenv("HOME", dir) t.Setenv("USERPROFILE", dir) - skillDir := filepath.Join(dir, ".github", "skills", "code-review") + skillDir := filepath.Join(dir, ".agents", "skills", "code-review") require.NoError(t, os.MkdirAll(skillDir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` --- From 663df07fcf3003e0be0293142701a7349e35ad52 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 8 Apr 2026 15:12:37 +0100 Subject: [PATCH 011/182] cleanup frontmatter fields remove git sha because we only need git tree sha remove github-owner from frontmatter, and make github-repo support full url. Only support github.com as host, error out otherwise --- internal/skills/frontmatter/frontmatter.go | 9 +- .../skills/frontmatter/frontmatter_test.go | 20 ++--- internal/skills/installer/installer.go | 2 +- internal/skills/installer/installer_test.go | 4 +- internal/skills/source/source.go | 66 ++++++++++++++ internal/skills/source/source_test.go | 76 ++++++++++++++++ pkg/cmd/skills/install/install.go | 33 ++++--- pkg/cmd/skills/install/install_test.go | 19 +++- pkg/cmd/skills/preview/preview.go | 4 + pkg/cmd/skills/preview/preview_test.go | 10 +++ pkg/cmd/skills/publish/publish.go | 62 +++++++++---- pkg/cmd/skills/publish/publish_test.go | 21 +++++ pkg/cmd/skills/search/search.go | 4 + pkg/cmd/skills/search/search_test.go | 19 ++++ pkg/cmd/skills/update/update.go | 60 ++++++++----- pkg/cmd/skills/update/update_test.go | 86 ++++++++++--------- 16 files changed, 383 insertions(+), 112 deletions(-) create mode 100644 internal/skills/source/source.go create mode 100644 internal/skills/source/source_test.go diff --git a/internal/skills/frontmatter/frontmatter.go b/internal/skills/frontmatter/frontmatter.go index 03406888455..87ad067a0a8 100644 --- a/internal/skills/frontmatter/frontmatter.go +++ b/internal/skills/frontmatter/frontmatter.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/cli/cli/v2/internal/skills/source" "gopkg.in/yaml.v3" ) @@ -66,7 +67,7 @@ func Parse(content string) (*ParseResult, error) { // collisions with other tools' metadata. // pinnedRef is the user's explicit --pin value; empty string means unpinned. // skillPath is the skill's source path in the repo (e.g. "skills/author/my-skill"). -func InjectGitHubMetadata(content string, owner, repo, ref, sha, treeSHA, pinnedRef, skillPath string) (string, error) { +func InjectGitHubMetadata(content string, host, owner, repo, ref, treeSHA, pinnedRef, skillPath string) (string, error) { result, err := Parse(content) if err != nil { return "", err @@ -80,10 +81,10 @@ func InjectGitHubMetadata(content string, owner, repo, ref, sha, treeSHA, pinned if meta == nil { meta = make(map[string]interface{}) } - meta["github-owner"] = owner - meta["github-repo"] = repo + delete(meta, "github-owner") + meta["github-repo"] = source.BuildRepoURL(host, owner, repo) meta["github-ref"] = ref - meta["github-sha"] = sha + delete(meta, "github-sha") meta["github-tree-sha"] = treeSHA meta["github-path"] = skillPath if pinnedRef != "" { diff --git a/internal/skills/frontmatter/frontmatter_test.go b/internal/skills/frontmatter/frontmatter_test.go index 51fe09133b1..229eadd18e6 100644 --- a/internal/skills/frontmatter/frontmatter_test.go +++ b/internal/skills/frontmatter/frontmatter_test.go @@ -67,10 +67,10 @@ func TestInjectGitHubMetadata(t *testing.T) { tests := []struct { name string content string + host string owner string repo string ref string - sha string treeSHA string pinnedRef string skillPath string @@ -86,23 +86,23 @@ func TestInjectGitHubMetadata(t *testing.T) { --- # Body `), + host: "github.com", owner: "monalisa", repo: "octocat-skills", ref: "v1.0.0", - sha: "abc123", treeSHA: "tree456", pinnedRef: "", skillPath: "skills/my-skill", wantContains: []string{ - "github-owner: monalisa", - "github-repo: octocat-skills", + "github-repo: https://github.com/monalisa/octocat-skills", "github-ref: v1.0.0", - "github-sha: abc123", "github-tree-sha: tree456", "github-path: skills/my-skill", "# Body", }, wantNotContain: []string{ + "github-owner", + "github-sha", "github-pinned", }, }, @@ -114,10 +114,10 @@ func TestInjectGitHubMetadata(t *testing.T) { --- # Body `), + host: "github.com", owner: "monalisa", repo: "octocat-skills", ref: "v1.0.0", - sha: "abc", treeSHA: "tree", pinnedRef: "v1.0.0", skillPath: "skills/my-skill", @@ -128,24 +128,24 @@ func TestInjectGitHubMetadata(t *testing.T) { { name: "injects metadata into content with no frontmatter", content: "# Body only\n", + host: "github.com", owner: "monalisa", repo: "octocat-skills", ref: "v1.0.0", - sha: "abc123", treeSHA: "tree456", pinnedRef: "", skillPath: "skills/my-skill", wantContains: []string{ - "github-owner: monalisa", - "github-repo: octocat-skills", + "github-repo: https://github.com/monalisa/octocat-skills", "# Body only", }, + wantNotContain: []string{"github-owner", "github-sha"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := InjectGitHubMetadata(tt.content, tt.owner, tt.repo, tt.ref, tt.sha, tt.treeSHA, tt.pinnedRef, tt.skillPath) + got, err := InjectGitHubMetadata(tt.content, tt.host, tt.owner, tt.repo, tt.ref, tt.treeSHA, tt.pinnedRef, tt.skillPath) require.NoError(t, err) for _, s := range tt.wantContains { assert.Contains(t, got, s) diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index ce5370004ab..5fdf99ce0e9 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -282,7 +282,7 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { } if filepath.Base(relPath) == "SKILL.md" { - content, err = frontmatter.InjectGitHubMetadata(content, opts.Owner, opts.Repo, opts.Ref, file.SHA, skill.TreeSHA, opts.PinnedRef, skill.Path) + content, err = frontmatter.InjectGitHubMetadata(content, opts.Host, opts.Owner, opts.Repo, opts.Ref, skill.TreeSHA, opts.PinnedRef, skill.Path) if err != nil { return fmt.Errorf("could not inject metadata: %w", err) } diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go index ea9c619c29f..85c8bcf18ca 100644 --- a/internal/skills/installer/installer_test.go +++ b/internal/skills/installer/installer_test.go @@ -248,8 +248,8 @@ func TestInstallSkill(t *testing.T) { t.Helper() content, err := os.ReadFile(filepath.Join(destDir, "pr-summary", "SKILL.md")) require.NoError(t, err) - assert.Contains(t, string(content), "github-owner: monalisa") - assert.Contains(t, string(content), "github-repo: octocat-skills") + assert.NotContains(t, string(content), "github-owner:") + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") }, }, { diff --git a/internal/skills/source/source.go b/internal/skills/source/source.go new file mode 100644 index 00000000000..5e8f5288805 --- /dev/null +++ b/internal/skills/source/source.go @@ -0,0 +1,66 @@ +package source + +import ( + "fmt" + "strings" + + "github.com/cli/cli/v2/internal/ghrepo" +) + +const SupportedHost = "github.com" + +// BuildRepoURL returns the canonical repository URL stored in skill metadata. +func BuildRepoURL(host, owner, repo string) string { + return ghrepo.GenerateRepoURL(ghrepo.NewWithHost(owner, repo, host), "") +} + +// ParseRepoURL parses a repository URL stored in skill metadata. +func ParseRepoURL(raw string) (ghrepo.Interface, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("repository URL is empty") + } + + repo, err := ghrepo.FromFullName(raw) + if err != nil { + return nil, fmt.Errorf("invalid repository URL %q: %w", raw, err) + } + + return repo, nil +} + +// ParseMetadataRepo extracts repository information from skill metadata. +func ParseMetadataRepo(meta map[string]interface{}) (ghrepo.Interface, bool, error) { + if meta == nil { + return nil, false, nil + } + + repoValue, _ := meta["github-repo"].(string) + if repoValue == "" { + return nil, false, nil + } + + repo, err := ParseRepoURL(repoValue) + if err != nil { + return nil, true, err + } + + return repo, true, nil +} + +// ValidateSupportedHost rejects hosts that are not supported in public preview. +func ValidateSupportedHost(host string) error { + host = normalizeHost(host) + if host == "" { + return fmt.Errorf("could not determine repository host") + } + if host != SupportedHost { + return fmt.Errorf("GitHub Skills currently supports only %s as a host; got %s", SupportedHost, host) + } + return nil +} + +func normalizeHost(host string) string { + host = strings.TrimSpace(strings.ToLower(host)) + return strings.TrimPrefix(host, "www.") +} diff --git a/internal/skills/source/source_test.go b/internal/skills/source/source_test.go new file mode 100644 index 00000000000..f797591b4c6 --- /dev/null +++ b/internal/skills/source/source_test.go @@ -0,0 +1,76 @@ +package source + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildRepoURL(t *testing.T) { + assert.Equal(t, "https://github.com/monalisa/octocat-skills", BuildRepoURL("github.com", "monalisa", "octocat-skills")) +} + +func TestParseMetadataRepo(t *testing.T) { + tests := []struct { + name string + meta map[string]interface{} + wantOwner string + wantRepo string + wantHost string + wantFound bool + wantErr string + }{ + { + name: "parses repo url metadata", + meta: map[string]interface{}{ + "github-repo": "https://github.com/monalisa/octocat-skills", + }, + wantOwner: "monalisa", + wantRepo: "octocat-skills", + wantHost: SupportedHost, + wantFound: true, + }, + { + name: "invalid repo url", + meta: map[string]interface{}{ + "github-repo": "not a url", + }, + wantFound: true, + wantErr: "invalid repository URL", + }, + { + name: "missing repo metadata", + meta: map[string]interface{}{}, + wantFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo, found, err := ParseMetadataRepo(tt.meta) + assert.Equal(t, tt.wantFound, found) + if !tt.wantFound { + require.NoError(t, err) + assert.Nil(t, repo) + return + } + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, repo) + assert.Equal(t, tt.wantOwner, repo.RepoOwner()) + assert.Equal(t, tt.wantRepo, repo.RepoName()) + assert.Equal(t, tt.wantHost, repo.RepoHost()) + }) + } +} + +func TestValidateSupportedHost(t *testing.T) { + require.NoError(t, ValidateSupportedHost("github.com")) + require.ErrorContains(t, ValidateSupportedHost("acme.ghes.com"), "supports only github.com") +} diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index b0ca4bf4aa1..0cc5c613196 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -20,6 +20,7 @@ import ( "github.com/cli/cli/v2/internal/skills/frontmatter" "github.com/cli/cli/v2/internal/skills/installer" "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/skills/source" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -126,10 +127,11 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. name or use the %[1]s--pin%[1]s flag. The version is resolved as a git tag or commit SHA. Installed skills have GitHub tracking metadata injected into their - frontmatter (%[1]sgithub-owner%[1]s, %[1]sgithub-repo%[1]s, %[1]sgithub-ref%[1]s, - %[1]sgithub-sha%[1]s, %[1]sgithub-tree-sha%[1]s, %[1]sgithub-path%[1]s). This + frontmatter (%[1]sgithub-repo%[1]s, %[1]sgithub-ref%[1]s, + %[1]sgithub-tree-sha%[1]s, %[1]sgithub-path%[1]s). This metadata identifies the source repository and enables %[1]sgh skills update%[1]s to detect changes — the tree SHA serves as an ETag for staleness checks. + The %[1]sgithub-repo%[1]s value is stored as a full repository URL. When run interactively, the command prompts for any missing arguments. When run non-interactively, %[1]srepository%[1]s is required, and either a @@ -226,12 +228,12 @@ func installRun(opts *installOptions) error { return runLocalInstall(opts) } - repo, source, err := resolveRepoArg(opts.SkillSource, canPrompt, opts.Prompter) + repo, repoSource, err := resolveRepoArg(opts.SkillSource, canPrompt, opts.Prompter) if err != nil { return err } opts.repo = repo - opts.SkillSource = source + opts.SkillSource = repoSource parseSkillFromOpts(opts) @@ -242,6 +244,9 @@ func installRun(opts *installOptions) error { apiClient := api.NewClientFromHTTP(httpClient) hostname := opts.repo.RepoHost() + if err := source.ValidateSupportedHost(hostname); err != nil { + return err + } resolved, err := resolveVersion(opts, apiClient, hostname) if err != nil { @@ -290,7 +295,7 @@ func installRun(opts *installOptions) error { gitRoot := installer.ResolveGitRoot(opts.GitClient) homeDir := installer.ResolveHomeDir() - source = ghrepo.FullName(opts.repo) + repoSource = ghrepo.FullName(opts.repo) plans, err := buildInstallPlans(opts, selectedSkills, selectedHosts, scope, gitRoot, homeDir, canPrompt) if err != nil { @@ -322,11 +327,11 @@ func installRun(opts *installOptions) error { for _, name := range result.Installed { fmt.Fprintf(opts.IO.Out, "%s Installed %s (from %s@%s) in %s\n", - cs.SuccessIcon(), name, source, resolved.Ref, friendlyDir(result.Dir)) + cs.SuccessIcon(), name, repoSource, resolved.Ref, friendlyDir(result.Dir)) } printFileTree(opts.IO.Out, cs, result.Dir, result.Installed) - printReviewHint(opts.IO.ErrOut, cs, source, result.Installed) + printReviewHint(opts.IO.ErrOut, cs, repoSource, result.Installed) } if err != nil { @@ -914,16 +919,18 @@ func existingSkillPrompt(targetDir string, incoming discovery.Skill) string { return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) } - owner, _ := result.Metadata.Meta["github-owner"].(string) - repo, _ := result.Metadata.Meta["github-repo"].(string) + repoInfo, _, err := source.ParseMetadataRepo(result.Metadata.Meta) ref, _ := result.Metadata.Meta["github-ref"].(string) + if err != nil { + return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) + } - if owner != "" && repo != "" { - source := owner + "/" + repo + if repoInfo != nil { + sourceName := ghrepo.FullName(repoInfo) if ref != "" { - source += "@" + ref + sourceName += "@" + ref } - return fmt.Sprintf("Skill %q already installed from %s. Overwrite?", incoming.DisplayName(), source) + return fmt.Sprintf("Skill %q already installed from %s. Overwrite?", incoming.DisplayName(), sourceName) } return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index c753ae51ca6..060b146a4ca 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -1049,8 +1049,7 @@ func TestInstallRun(t *testing.T) { name: git-commit description: Writes commits metadata: - github-owner: someowner - github-repo: somerepo + github-repo: https://github.com/someowner/somerepo github-ref: v0.5.0 --- # Git Commit @@ -1077,6 +1076,22 @@ func TestInstallRun(t *testing.T) { }, wantStdout: "Installed git-commit", }, + { + name: "unsupported host returns error", + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + t.Helper() + return &installOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "acme.ghes.com/monalisa/octocat-skills", + SkillName: "git-commit", + } + }, + wantErr: "supports only github.com", + }, { name: "select all skills in interactive prompt", isTTY: true, diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index ce7c49ef255..55ba125e388 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/source" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/markdown" @@ -99,6 +100,9 @@ func previewRun(opts *previewOptions) error { owner := repo.RepoOwner() repoName := repo.RepoName() hostname := repo.RepoHost() + if err := source.ValidateSupportedHost(hostname); err != nil { + return err + } httpClient, err := opts.HttpClient() if err != nil { diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index 0cbea6ae733..cd623fa06f1 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -283,6 +283,16 @@ func TestPreviewRun(t *testing.T) { } } +func TestPreviewRun_UnsupportedHost(t *testing.T) { + ios, _, _, _ := iostreams.Test() + err := previewRun(&previewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, + repo: ghrepo.NewWithHost("github", "awesome-copilot", "acme.ghes.com"), + }) + require.ErrorContains(t, err, "supports only github.com") +} + func TestPreviewRun_Interactive(t *testing.T) { skillContent := "# Selected Skill\n\nContent here." encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 200e3e2dd51..96683bece63 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -16,12 +16,12 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" - "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/skills/source" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -342,10 +342,27 @@ func publishRun(opts *publishOptions) error { diagnostics = append(diagnostics, installedDirDiags...) // Remote repository checks (best-effort) - owner, repo := detectGitHubRemote(opts.GitClient) + repoInfo, remoteErr := detectGitHubRemote(opts.GitClient) + if remoteErr != nil { + return remoteErr + } + owner, repo := "", "" + if repoInfo != nil { + owner = repoInfo.RepoOwner() + repo = repoInfo.RepoName() + } hasTopic := false var existingTags []tagEntry if owner != "" && repo != "" { + if host == "" && repoInfo != nil { + host = repoInfo.RepoHost() + } + if host != "" { + if err := source.ValidateSupportedHost(host); err != nil { + return err + } + } + // Create API client for remote checks if not already injected if client == nil { httpClient, httpErr := opts.HttpClient() @@ -354,6 +371,9 @@ func publishRun(opts *publishOptions) error { cfg, cfgErr := opts.Config() if cfgErr == nil { host, _ = cfg.Authentication().DefaultHost() + if err := source.ValidateSupportedHost(host); err != nil { + return err + } client = apiClient } } @@ -844,53 +864,59 @@ func suggestNextTag(latest string) string { } // detectGitHubRemote attempts to detect the GitHub owner/repo from git remotes. -func detectGitHubRemote(gitClient *git.Client) (owner, repo string) { +func detectGitHubRemote(gitClient *git.Client) (ghrepo.Interface, error) { if gitClient == nil { - return "", "" + return nil, nil } // Try origin first if url, err := gitClient.RemoteURL(context.Background(), "origin"); err == nil { - if o, r := parseGitHubURL(url); o != "" { - return o, r + repo, parseErr := parseGitHubURL(url) + if parseErr != nil { + return nil, parseErr + } + if repo != nil { + return repo, nil } } // Fall back to any remote that points to GitHub remotes, err := gitClient.Remotes(context.Background()) if err != nil { - return "", "" + return nil, nil } for _, r := range remotes { if r.Name == "origin" { continue } if url, err := gitClient.RemoteURL(context.Background(), r.Name); err == nil { - if o, rp := parseGitHubURL(url); o != "" { - return o, rp + repo, parseErr := parseGitHubURL(url) + if parseErr != nil { + return nil, parseErr + } + if repo != nil { + return repo, nil } } } - return "", "" + return nil, nil } // parseGitHubURL extracts owner/repo from a GitHub remote URL. // Only GitHub.com URLs are recognized. -func parseGitHubURL(rawURL string) (owner, repo string) { +func parseGitHubURL(rawURL string) (ghrepo.Interface, error) { u, err := git.ParseURL(rawURL) if err != nil { - return "", "" + return nil, nil } r, err := ghrepo.FromURL(u) if err != nil { - return "", "" + return nil, nil } - // Only match github.com — the default GitHub host. - host := strings.ToLower(r.RepoHost()) - if host != ghinstance.Default() { - return "", "" + if err := source.ValidateSupportedHost(r.RepoHost()); err != nil { + return nil, nil } - return r.RepoOwner(), r.RepoName() + return r, nil } // detectMissingRepoDiagnostic explains why remote checks were skipped. diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 862dbc9f75f..d54d04ad392 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -140,6 +140,27 @@ func TestNewCmdPublish(t *testing.T) { } } +func TestPublishRun_UnsupportedHost(t *testing.T) { + dir := t.TempDir() + writeSkill(t, dir, "test-skill", heredoc.Doc(` + --- + name: test-skill + description: A test skill + --- + Body. + `)) + + ios, _, _, _ := iostreams.Test() + err := publishRun(&publishOptions{ + IO: ios, + Dir: dir, + GitClient: newTestGitClient(t, map[string]string{"origin": "https://github.com/monalisa/skills-repo.git"}), + client: api.NewClientFromHTTP(&http.Client{}), + host: "acme.ghes.com", + }) + require.ErrorContains(t, err, "supports only github.com") +} + func TestPublishRun(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 6a0cc95d0d6..03874c30cef 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -19,6 +19,7 @@ import ( "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/skills/source" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" @@ -200,6 +201,9 @@ func searchRun(opts *searchOptions) error { return err } host, _ := cfg.Authentication().DefaultHost() + if err := source.ValidateSupportedHost(host); err != nil { + return err + } opts.IO.StartProgressIndicatorWithLabel("Searching for skills") diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index e3b8b26d682..6e35465cdb2 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -15,6 +15,25 @@ import ( "github.com/stretchr/testify/require" ) +func TestSearchRun_UnsupportedHost(t *testing.T) { + ios, _, _, _ := iostreams.Test() + cfg := config.NewBlankConfig() + authCfg := cfg.Authentication() + authCfg.SetDefaultHost("acme.ghes.com", "user") + cfg.AuthenticationFunc = func() gh.AuthConfig { + return authCfg + } + err := searchRun(&searchOptions{ + IO: ios, + Query: "terraform", + Page: 1, + Limit: defaultLimit, + HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, + Config: func() (gh.Config, error) { return cfg, nil }, + }) + require.ErrorContains(t, err, "supports only github.com") +} + func TestNewCmdSearch(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index afb8377e34f..0a9d1b1fa77 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -17,6 +17,7 @@ import ( "github.com/cli/cli/v2/internal/skills/frontmatter" "github.com/cli/cli/v2/internal/skills/installer" "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/skills/source" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -43,15 +44,17 @@ type updateOptions struct { // installedSkill represents a locally installed skill parsed from its SKILL.md frontmatter. type installedSkill struct { - name string - owner string - repo string - treeSHA string // tree SHA at install time - pinned string // explicit pin value (empty = unpinned) - sourcePath string // original path in source repo (e.g. "skills/author/name") - dir string // local directory path - host *registry.AgentHost - scope registry.Scope + name string + repoHost string + owner string + repo string + treeSHA string // tree SHA at install time + pinned string // explicit pin value (empty = unpinned) + sourcePath string // original path in source repo (e.g. "skills/author/name") + dir string // local directory path + host *registry.AgentHost + scope registry.Scope + metadataErr error } // pendingUpdate describes a single skill that has an available update. @@ -149,12 +152,6 @@ func updateRun(opts *updateOptions) error { } apiClient := api.NewClientFromHTTP(httpClient) - cfg, err := opts.Config() - if err != nil { - return err - } - hostname, _ := cfg.Authentication().DefaultHost() - gitRoot := installer.ResolveGitRoot(opts.GitClient) homeDir := installer.ResolveHomeDir() @@ -193,6 +190,12 @@ func updateRun(opts *updateOptions) error { installed = filtered } + for _, s := range installed { + if s.metadataErr != nil { + return fmt.Errorf("skill %s has invalid repository metadata: %w", s.name, s.metadataErr) + } + } + // Prompt for metadata on skills missing it (before starting progress indicator) var noMeta []string // Track skills where the user provided a source repo interactively. @@ -226,6 +229,7 @@ func updateRun(opts *updateOptions) error { } s.owner = owner s.repo = repo + s.repoHost = source.SupportedHost prompted[s.dir] = promptedEntry{name: s.name, source: owner + "/" + repo} } @@ -234,7 +238,7 @@ func updateRun(opts *updateOptions) error { var updates []pendingUpdate var pinned []installedSkill - type repoKey struct{ owner, repo string } + type repoKey struct{ host, owner, repo string } repoSkills := make(map[repoKey][]discovery.Skill) repoRefs := make(map[repoKey]*discovery.ResolvedRef) repoErrors := make(map[repoKey]bool) @@ -248,7 +252,7 @@ func updateRun(opts *updateOptions) error { continue } - key := repoKey{s.owner, s.repo} + key := repoKey{s.repoHost, s.owner, s.repo} if repoErrors[key] { continue @@ -256,7 +260,7 @@ func updateRun(opts *updateOptions) error { // Resolve ref and discover skills once per repo if _, ok := repoRefs[key]; !ok { - resolved, resolveErr := discovery.ResolveRef(apiClient, hostname, s.owner, s.repo, "") + resolved, resolveErr := discovery.ResolveRef(apiClient, s.repoHost, s.owner, s.repo, "") if resolveErr != nil { repoErrors[key] = true opts.IO.StopProgressIndicator() @@ -266,7 +270,7 @@ func updateRun(opts *updateOptions) error { } repoRefs[key] = resolved - skills, discoverErr := discovery.DiscoverSkills(apiClient, hostname, s.owner, s.repo, resolved.SHA) + skills, discoverErr := discovery.DiscoverSkills(apiClient, s.repoHost, s.owner, s.repo, resolved.SHA) if discoverErr != nil { repoErrors[key] = true opts.IO.StopProgressIndicator() @@ -302,7 +306,7 @@ func updateRun(opts *updateOptions) error { // Warn about prompted skills that weren't found in the remote repo for _, entry := range prompted { parts := strings.SplitN(entry.source, "/", 2) - key := repoKey{parts[0], parts[1]} + key := repoKey{source.SupportedHost, parts[0], parts[1]} skills, resolved := repoSkills[key] if !resolved { continue @@ -371,7 +375,7 @@ func updateRun(opts *updateOptions) error { var failed bool for _, u := range updates { installOpts := &installer.Options{ - Host: hostname, + Host: u.local.repoHost, Owner: u.local.owner, Repo: u.local.repo, Ref: u.resolved.Ref, @@ -507,8 +511,18 @@ func parseInstalledSkill(data []byte, name, dir string, host *registry.AgentHost } if result.Metadata.Meta != nil { - s.owner, _ = result.Metadata.Meta["github-owner"].(string) - s.repo, _ = result.Metadata.Meta["github-repo"].(string) + repoInfo, ok, repoErr := source.ParseMetadataRepo(result.Metadata.Meta) + if repoErr != nil { + s.metadataErr = repoErr + } else if ok { + if err := source.ValidateSupportedHost(repoInfo.RepoHost()); err != nil { + s.metadataErr = err + } else { + s.repoHost = repoInfo.RepoHost() + s.owner = repoInfo.RepoOwner() + s.repo = repoInfo.RepoName() + } + } s.treeSHA, _ = result.Metadata.Meta["github-tree-sha"].(string) s.pinned, _ = result.Metadata.Meta["github-pinned"].(string) s.sourcePath, _ = result.Metadata.Meta["github-path"].(string) diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index 0c7953f6645..b9aabe86a1f 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -91,8 +91,7 @@ func TestScanInstalledSkills(t *testing.T) { name: git-commit description: Git commit helper metadata: - github-owner: monalisa - github-repo: awesome-copilot + github-repo: https://github.com/monalisa/awesome-copilot github-tree-sha: abc123 github-path: skills/git-commit --- @@ -117,8 +116,7 @@ func TestScanInstalledSkills(t *testing.T) { --- name: pinned-skill metadata: - github-owner: octocat - github-repo: hubot-skills + github-repo: https://github.com/octocat/hubot-skills github-tree-sha: def456 github-pinned: v1.0.0 --- @@ -138,6 +136,7 @@ func TestScanInstalledSkills(t *testing.T) { gc := byName["git-commit"] assert.Equal(t, "monalisa", gc.owner) assert.Equal(t, "awesome-copilot", gc.repo) + assert.Equal(t, "github.com", gc.repoHost) assert.Equal(t, "abc123", gc.treeSHA) assert.Equal(t, "skills/git-commit", gc.sourcePath) assert.Empty(t, gc.pinned) @@ -147,9 +146,34 @@ func TestScanInstalledSkills(t *testing.T) { assert.Empty(t, us.repo) ps := byName["pinned-skill"] + assert.Equal(t, "github.com", ps.repoHost) assert.Equal(t, "v1.0.0", ps.pinned) }, }, + { + name: "unsupported host metadata returns error", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "enterprise-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: enterprise-skill + metadata: + github-repo: https://acme.ghes.com/monalisa/octocat-skills + github-tree-sha: abc123 + --- + body + `)), 0o644)) + }, + verify: func(t *testing.T, skills []installedSkill, err error) { + t.Helper() + require.NoError(t, err) + require.Len(t, skills, 1) + require.Error(t, skills[0].metadataErr) + assert.Contains(t, skills[0].metadataErr.Error(), "supports only github.com") + }, + }, { name: "non-existent directory returns nil", // no setup — dir does not exist @@ -254,8 +278,7 @@ func TestScanAllAgentsDeduplicatesSharedProjectDirs(t *testing.T) { --- name: git-commit metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: abc123 --- Body @@ -267,8 +290,7 @@ func TestScanAllAgentsDeduplicatesSharedProjectDirs(t *testing.T) { --- name: code-review metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: def456 --- Body @@ -309,8 +331,7 @@ func TestUpdateRun(t *testing.T) { --- name: code-review metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: currentsha github-path: skills/code-review --- @@ -368,8 +389,7 @@ func TestUpdateRun(t *testing.T) { --- name: octocat-skill metadata: - github-owner: octocat - github-repo: hubot-skills + github-repo: https://github.com/octocat/hubot-skills github-tree-sha: abc --- `)), 0o644)) @@ -400,8 +420,7 @@ func TestUpdateRun(t *testing.T) { --- name: pinned-skill metadata: - github-owner: octocat - github-repo: hubot-skills + github-repo: https://github.com/octocat/hubot-skills github-tree-sha: abc123 github-pinned: v1.0.0 --- @@ -463,8 +482,7 @@ func TestUpdateRun(t *testing.T) { --- name: monalisa-skill metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: abc123def456 github-path: skills/monalisa-skill --- @@ -508,8 +526,7 @@ func TestUpdateRun(t *testing.T) { --- name: hubot-skill metadata: - github-owner: hubot - github-repo: octocat-skills + github-repo: https://github.com/hubot/octocat-skills github-tree-sha: oldsha123 github-path: skills/hubot-skill --- @@ -557,8 +574,7 @@ func TestUpdateRun(t *testing.T) { --- name: hubot-skill metadata: - github-owner: hubot - github-repo: octocat-skills + github-repo: https://github.com/hubot/octocat-skills github-tree-sha: oldsha123 github-path: skills/hubot-skill --- @@ -606,8 +622,7 @@ func TestUpdateRun(t *testing.T) { --- name: code-review metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: oldsha000 github-path: skills/code-review --- @@ -650,7 +665,7 @@ func TestUpdateRun(t *testing.T) { t.Helper() content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) require.NoError(t, err) - assert.Contains(t, string(content), "github-owner: monalisa") + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") assert.NotContains(t, string(content), "Old content") }, wantStdout: "Updated code-review", @@ -668,8 +683,7 @@ func TestUpdateRun(t *testing.T) { --- name: code-review metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: oldsha000 github-path: skills/monalisa/code-review --- @@ -712,7 +726,7 @@ func TestUpdateRun(t *testing.T) { t.Helper() content, err := os.ReadFile(filepath.Join(dir, "monalisa", "code-review", "SKILL.md")) require.NoError(t, err) - assert.Contains(t, string(content), "github-owner: monalisa") + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") assert.NotContains(t, string(content), "Old namespaced content") }, wantStdout: "Updated monalisa/code-review", @@ -730,8 +744,7 @@ func TestUpdateRun(t *testing.T) { --- name: code-review metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: oldsha000 github-path: skills/code-review --- @@ -790,8 +803,7 @@ func TestUpdateRun(t *testing.T) { --- name: code-review metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: oldsha000 github-path: skills/code-review --- @@ -853,8 +865,7 @@ func TestUpdateRun(t *testing.T) { --- name: code-review metadata: - github-owner: monalisa - github-repo: octocat-skills + github-repo: https://github.com/monalisa/octocat-skills github-tree-sha: oldsha000 github-path: skills/code-review --- @@ -990,7 +1001,7 @@ func TestUpdateRun(t *testing.T) { content, err := os.ReadFile(filepath.Join(dir, "manual-skill", "SKILL.md")) require.NoError(t, err) assert.NotContains(t, string(content), "Old manual content") - assert.Contains(t, string(content), "github-owner: monalisa") + assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") }, wantStdout: "Updated manual-skill", }, @@ -1007,8 +1018,7 @@ func TestUpdateRun(t *testing.T) { --- name: pinned-skill metadata: - github-owner: octocat - github-repo: hubot-skills + github-repo: https://github.com/octocat/hubot-skills github-tree-sha: oldsha000 github-pinned: v1.0.0 github-path: skills/pinned-skill @@ -1067,8 +1077,7 @@ func TestUpdateRun(t *testing.T) { --- name: pinned-skill metadata: - github-owner: octocat - github-repo: hubot-skills + github-repo: https://github.com/octocat/hubot-skills github-tree-sha: abc123 github-pinned: v1.0.0 --- @@ -1102,8 +1111,7 @@ func TestUpdateRun(t *testing.T) { --- name: pinned-skill metadata: - github-owner: octocat - github-repo: hubot-skills + github-repo: https://github.com/octocat/hubot-skills github-tree-sha: oldsha000 github-pinned: v1.0.0 github-path: skills/pinned-skill From 1f5a6b8396ad574e40c31c8adbb358384be2e2ca Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 8 Apr 2026 16:38:38 +0100 Subject: [PATCH 012/182] clean up interface and fix a few bugs support specifying a sha in gh skills preview command --- internal/skills/discovery/discovery.go | 157 +++++++++++----- internal/skills/discovery/discovery_test.go | 170 ++++++++++++++++-- .../skills/frontmatter/frontmatter_test.go | 9 +- pkg/cmd/skills/install/install.go | 60 +++---- pkg/cmd/skills/install/install_test.go | 167 +++++++++++------ pkg/cmd/skills/preview/preview.go | 22 ++- pkg/cmd/skills/preview/preview_test.go | 65 +++++++ pkg/cmd/skills/publish/publish.go | 10 +- pkg/cmd/skills/search/search.go | 8 +- pkg/cmd/skills/skills.go | 5 +- pkg/cmd/skills/update/update.go | 16 +- 11 files changed, 521 insertions(+), 168 deletions(-) diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 05a531bc9dd..4e54fd5e3af 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -2,8 +2,10 @@ package discovery import ( "encoding/base64" + "errors" "fmt" "io" + "net/http" "os" "path" "path/filepath" @@ -68,10 +70,27 @@ func (s Skill) InstallName() string { // ResolvedRef contains the resolved git reference and its SHA. type ResolvedRef struct { - Ref string // tag name, branch name, or SHA + Ref string // fully qualified ref (refs/heads/*, refs/tags/*) or commit SHA SHA string // commit SHA } +// IsFullyQualifiedRef returns true if ref uses the "refs/heads/" or "refs/tags/" prefix. +func IsFullyQualifiedRef(ref string) bool { + return strings.HasPrefix(ref, "refs/heads/") || strings.HasPrefix(ref, "refs/tags/") +} + +// ShortRef strips the "refs/heads/" or "refs/tags/" prefix from a fully qualified ref, +// returning the short name. If the ref is not fully qualified it is returned as-is. +func ShortRef(ref string) string { + if after, ok := strings.CutPrefix(ref, "refs/heads/"); ok { + return after + } + if after, ok := strings.CutPrefix(ref, "refs/tags/"); ok { + return after + } + return ref +} + type treeEntry struct { Path string `json:"path"` Mode string `json:"mode"` @@ -117,58 +136,125 @@ func ResolveRef(client *api.Client, host, owner, repo, version string) (*Resolve if err == nil { return ref, nil } + // Only fall back to the default branch when the repository genuinely + // has no releases (404) or the latest release has no tag. Any other + // API error (403, 500, network failure, …) is surfaced immediately + // so it cannot silently mask problems and cause an unexpected ref to + // be used. + var nre *noReleasesError + if !errors.As(err, &nre) { + return nil, err + } return resolveDefaultBranch(client, host, owner, repo) } -// resolveExplicitRef resolves a user-supplied --pin value. It tries, in order: -// tag → commit SHA. Branches are deliberately excluded because they are mutable -// and pinning to one gives a false sense of reproducibility. +// resolveExplicitRef resolves a user-supplied version string. It supports: +// - fully qualified refs: "refs/tags/v1.0" or "refs/heads/main" +// - short names: tried as branch first, then tag, then commit SHA +// - bare SHAs: resolved as commit SHA +// +// When a short name matches both a branch and a tag, the branch wins. +// The returned Ref is always a fully qualified ref (refs/heads/* or refs/tags/*) +// unless the input resolves to a bare commit SHA. func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*ResolvedRef, error) { - tagPath := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, ref) + // Handle fully-qualified refs: resolve directly without ambiguity. + if after, ok := strings.CutPrefix(ref, "refs/tags/"); ok { + return resolveTagRef(client, host, owner, repo, after) + } + if after, ok := strings.CutPrefix(ref, "refs/heads/"); ok { + return resolveBranchRef(client, host, owner, repo, after) + } + + // Short name: try branch first, then tag, then commit SHA. + if resolved, err := resolveBranchRef(client, host, owner, repo, ref); err == nil { + return resolved, nil + } + if resolved, err := resolveTagRef(client, host, owner, repo, ref); err == nil { + return resolved, nil + } + + commitPath := fmt.Sprintf("repos/%s/%s/commits/%s", owner, repo, ref) + var commitResp struct { + SHA string `json:"sha"` + } + if err := client.REST(host, "GET", commitPath, nil, &commitResp); err == nil { + return &ResolvedRef{Ref: commitResp.SHA, SHA: commitResp.SHA}, nil + } + + return nil, fmt.Errorf("ref %q not found as branch, tag, or commit in %s/%s", ref, owner, repo) +} + +// resolveTagRef looks up a tag by short name and returns a fully qualified ref. +// For annotated tags, the tag object is dereferenced to obtain the commit SHA. +func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*ResolvedRef, error) { + tagPath := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, tag) var refResp struct { Object struct { SHA string `json:"sha"` Type string `json:"type"` } `json:"object"` } - if err := client.REST(host, "GET", tagPath, nil, &refResp); err == nil { - sha := refResp.Object.SHA - if refResp.Object.Type == "tag" { - derefPath := fmt.Sprintf("repos/%s/%s/git/tags/%s", owner, repo, sha) - var tagResp struct { - Object struct { - SHA string `json:"sha"` - } `json:"object"` - } - if err := client.REST(host, "GET", derefPath, nil, &tagResp); err != nil { - return nil, fmt.Errorf("could not dereference annotated tag %q: %w", ref, err) - } - sha = tagResp.Object.SHA + if err := client.REST(host, "GET", tagPath, nil, &refResp); err != nil { + return nil, fmt.Errorf("tag %q not found in %s/%s: %w", tag, owner, repo, err) + } + sha := refResp.Object.SHA + if refResp.Object.Type == "tag" { + derefPath := fmt.Sprintf("repos/%s/%s/git/tags/%s", owner, repo, sha) + var tagResp struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` } - return &ResolvedRef{Ref: ref, SHA: sha}, nil + if err := client.REST(host, "GET", derefPath, nil, &tagResp); err != nil { + return nil, fmt.Errorf("could not dereference annotated tag %q: %w", tag, err) + } + sha = tagResp.Object.SHA } + return &ResolvedRef{Ref: "refs/tags/" + tag, SHA: sha}, nil +} - commitPath := fmt.Sprintf("repos/%s/%s/commits/%s", owner, repo, ref) - var commitResp struct { - SHA string `json:"sha"` +// resolveBranchRef looks up a branch by short name and returns a fully qualified ref. +func resolveBranchRef(client *api.Client, host, owner, repo, branch string) (*ResolvedRef, error) { + refPath := fmt.Sprintf("repos/%s/%s/git/ref/heads/%s", owner, repo, branch) + var refResp struct { + Object struct { + SHA string `json:"sha"` + } `json:"object"` } - if err := client.REST(host, "GET", commitPath, nil, &commitResp); err == nil { - return &ResolvedRef{Ref: commitResp.SHA, SHA: commitResp.SHA}, nil + if err := client.REST(host, "GET", refPath, nil, &refResp); err != nil { + return nil, fmt.Errorf("branch %q not found in %s/%s: %w", branch, owner, repo, err) } + return &ResolvedRef{Ref: "refs/heads/" + branch, SHA: refResp.Object.SHA}, nil +} - return nil, fmt.Errorf("ref %q not found as tag or commit in %s/%s", ref, owner, repo) +// noReleasesError signals that the repository has no usable releases, +// which is the only case where ResolveRef should fall back to the +// default branch. +type noReleasesError struct { + reason string } +func (e *noReleasesError) Error() string { return e.reason } + func resolveLatestRelease(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", owner, repo) var release releaseResponse if err := client.REST(host, "GET", apiPath, nil, &release); err != nil { - return nil, fmt.Errorf("no releases found: %w", err) + // A 404 means the repository has no releases — this is the + // only case where falling back to the default branch is safe. + // Any other HTTP error (403, 500, …) or network failure is + // returned as-is so ResolveRef surfaces it rather than + // silently falling back. + var httpErr api.HTTPError + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + return nil, &noReleasesError{reason: fmt.Sprintf("no releases found for %s/%s", owner, repo)} + } + return nil, fmt.Errorf("could not fetch latest release: %w", err) } if release.TagName == "" { - return nil, fmt.Errorf("latest release has no tag") + return nil, &noReleasesError{reason: "latest release has no tag"} } - return resolveExplicitRef(client, host, owner, repo, release.TagName) + return resolveTagRef(client, host, owner, repo, release.TagName) } func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { @@ -181,18 +267,7 @@ func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*Resolv if branch == "" { branch = "main" } - - refPath := fmt.Sprintf("repos/%s/%s/git/ref/heads/%s", owner, repo, branch) - var refResp struct { - Object struct { - SHA string `json:"sha"` - } `json:"object"` - } - if err := client.REST(host, "GET", refPath, nil, &refResp); err != nil { - return nil, fmt.Errorf("could not resolve branch %q: %w", branch, err) - } - - return &ResolvedRef{Ref: branch, SHA: refResp.Object.SHA}, nil + return resolveBranchRef(client, host, owner, repo, branch) } // skillMatch represents a matched SKILL.md file and its convention. @@ -267,7 +342,7 @@ func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([] if tree.Truncated { return nil, fmt.Errorf( "repository tree for %s/%s is too large for full discovery\n"+ - " Use path-based install instead: gh skills install %s/%s skills/", + " Use path-based install instead: gh skill install %s/%s skills/", owner, repo, owner, repo, ) } diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 9740525303d..3bc719ae848 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -162,6 +162,45 @@ func TestIsSpecCompliant(t *testing.T) { } } +func TestIsFullyQualifiedRef(t *testing.T) { + tests := []struct { + name string + ref string + want bool + }{ + {name: "branch ref", ref: "refs/heads/main", want: true}, + {name: "tag ref", ref: "refs/tags/v1.0", want: true}, + {name: "short branch name", ref: "main", want: false}, + {name: "short tag name", ref: "v1.0", want: false}, + {name: "bare SHA", ref: "abc123def456", want: false}, + {name: "empty", ref: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsFullyQualifiedRef(tt.ref)) + }) + } +} + +func TestShortRef(t *testing.T) { + tests := []struct { + name string + ref string + want string + }{ + {name: "branch ref", ref: "refs/heads/main", want: "main"}, + {name: "tag ref", ref: "refs/tags/v1.0", want: "v1.0"}, + {name: "short name passthrough", ref: "main", want: "main"}, + {name: "bare SHA passthrough", ref: "abc123", want: "abc123"}, + {name: "empty passthrough", ref: "", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ShortRef(tt.ref)) + }) + } +} + func TestResolveRef(t *testing.T) { tests := []struct { name string @@ -172,22 +211,41 @@ func TestResolveRef(t *testing.T) { wantErr string }{ { - name: "explicit version resolves lightweight tag", + name: "short name resolves as branch first", + version: "main", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "branch-sha"}, + })) + }, + wantRef: "refs/heads/main", + wantSHA: "branch-sha", + }, + { + name: "short name falls back to tag when branch not found", version: "v1.0", stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v1.0"), + httpmock.StatusStringResponse(404, "not found")) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0"), httpmock.JSONResponse(map[string]interface{}{ "object": map[string]interface{}{"sha": "abc123", "type": "commit"}, })) }, - wantRef: "v1.0", + wantRef: "refs/tags/v1.0", wantSHA: "abc123", }, { - name: "explicit version resolves annotated tag", + name: "short name resolves annotated tag", version: "v2.0", stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v2.0"), + httpmock.StatusStringResponse(404, "not found")) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v2.0"), httpmock.JSONResponse(map[string]interface{}{ @@ -199,13 +257,16 @@ func TestResolveRef(t *testing.T) { "object": map[string]interface{}{"sha": "real-commit-sha"}, })) }, - wantRef: "v2.0", + wantRef: "refs/tags/v2.0", wantSHA: "real-commit-sha", }, { - name: "explicit version falls back to commit SHA", + name: "short name falls back to commit SHA", version: "deadbeef", stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/deadbeef"), + httpmock.StatusStringResponse(404, "not found")) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/deadbeef"), httpmock.StatusStringResponse(404, "not found")) @@ -217,9 +278,12 @@ func TestResolveRef(t *testing.T) { wantSHA: "deadbeef", }, { - name: "explicit version not found anywhere", + name: "short name not found anywhere", version: "nonexistent", stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/nonexistent"), httpmock.StatusStringResponse(404, "not found")) @@ -227,10 +291,70 @@ func TestResolveRef(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/commits/nonexistent"), httpmock.StatusStringResponse(404, "not found")) }, - wantErr: `ref "nonexistent" not found as tag or commit in monalisa/octocat-skills`, + wantErr: `ref "nonexistent" not found as branch, tag, or commit in monalisa/octocat-skills`, }, { - name: "no version uses latest release", + name: "branch wins over tag with same short name", + version: "release", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/release"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "branch-sha"}, + })) + // tag stub is not registered because branch succeeds first + }, + wantRef: "refs/heads/release", + wantSHA: "branch-sha", + }, + { + name: "fully qualified tag ref resolved directly", + version: "refs/tags/v1.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v1.0"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "tag-sha", "type": "commit"}, + })) + }, + wantRef: "refs/tags/v1.0", + wantSHA: "tag-sha", + }, + { + name: "fully qualified branch ref resolved directly", + version: "refs/heads/feature", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/feature"), + httpmock.JSONResponse(map[string]interface{}{ + "object": map[string]interface{}{"sha": "feature-sha"}, + })) + }, + wantRef: "refs/heads/feature", + wantSHA: "feature-sha", + }, + { + name: "fully qualified tag ref not found", + version: "refs/tags/nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + }, + wantErr: `tag "nonexistent" not found in monalisa/octocat-skills`, + }, + { + name: "fully qualified branch ref not found", + version: "refs/heads/nonexistent", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/nonexistent"), + httpmock.StatusStringResponse(404, "not found")) + }, + wantErr: `branch "nonexistent" not found in monalisa/octocat-skills`, + }, + { + name: "no version uses latest release with fully qualified ref", stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), @@ -241,11 +365,11 @@ func TestResolveRef(t *testing.T) { "object": map[string]interface{}{"sha": "release-sha", "type": "commit"}, })) }, - wantRef: "v3.0", + wantRef: "refs/tags/v3.0", wantSHA: "release-sha", }, { - name: "no version falls back to default branch when no releases", + name: "no version falls back to default branch with fully qualified ref", stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), @@ -259,12 +383,12 @@ func TestResolveRef(t *testing.T) { "object": map[string]interface{}{"sha": "branch-sha"}, })) }, - wantRef: "main", + wantRef: "refs/heads/main", wantSHA: "branch-sha", }, { name: "annotated tag dereference failure", - version: "v4.0", + version: "refs/tags/v4.0", stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v4.0"), @@ -277,6 +401,24 @@ func TestResolveRef(t *testing.T) { }, wantErr: "could not dereference annotated tag", }, + { + name: "no version with server error does not fall back to default branch", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(500, "internal server error")) + }, + wantErr: "could not fetch latest release", + }, + { + name: "no version with forbidden error does not fall back to default branch", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), + httpmock.StatusStringResponse(403, "forbidden")) + }, + wantErr: "could not fetch latest release", + }, { name: "empty tag_name in latest release falls back to default branch", stubs: func(reg *httpmock.Registry) { @@ -292,7 +434,7 @@ func TestResolveRef(t *testing.T) { "object": map[string]interface{}{"sha": "fallback-sha"}, })) }, - wantRef: "main", + wantRef: "refs/heads/main", wantSHA: "fallback-sha", }, { @@ -310,7 +452,7 @@ func TestResolveRef(t *testing.T) { "object": map[string]interface{}{"sha": "main-sha"}, })) }, - wantRef: "main", + wantRef: "refs/heads/main", wantSHA: "main-sha", }, } diff --git a/internal/skills/frontmatter/frontmatter_test.go b/internal/skills/frontmatter/frontmatter_test.go index 229eadd18e6..d88811ea2f2 100644 --- a/internal/skills/frontmatter/frontmatter_test.go +++ b/internal/skills/frontmatter/frontmatter_test.go @@ -89,13 +89,13 @@ func TestInjectGitHubMetadata(t *testing.T) { host: "github.com", owner: "monalisa", repo: "octocat-skills", - ref: "v1.0.0", + ref: "refs/tags/v1.0.0", treeSHA: "tree456", pinnedRef: "", skillPath: "skills/my-skill", wantContains: []string{ "github-repo: https://github.com/monalisa/octocat-skills", - "github-ref: v1.0.0", + "github-ref: refs/tags/v1.0.0", "github-tree-sha: tree456", "github-path: skills/my-skill", "# Body", @@ -117,7 +117,7 @@ func TestInjectGitHubMetadata(t *testing.T) { host: "github.com", owner: "monalisa", repo: "octocat-skills", - ref: "v1.0.0", + ref: "refs/tags/v1.0.0", treeSHA: "tree", pinnedRef: "v1.0.0", skillPath: "skills/my-skill", @@ -131,12 +131,13 @@ func TestInjectGitHubMetadata(t *testing.T) { host: "github.com", owner: "monalisa", repo: "octocat-skills", - ref: "v1.0.0", + ref: "refs/heads/main", treeSHA: "tree456", pinnedRef: "", skillPath: "skills/my-skill", wantContains: []string{ "github-repo: https://github.com/monalisa/octocat-skills", + "github-ref: refs/heads/main", "# Body only", }, wantNotContain: []string{"github-owner", "github-sha"}, diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 0cc5c613196..fc65e2f0cb9 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -54,7 +54,6 @@ type installOptions struct { ScopeChanged bool // true when --scope was explicitly set Pin string // --pin flag Dir string // --dir flag (overrides host+scope) - All bool // --all flag Force bool // --force flag // Resolved at runtime @@ -129,47 +128,44 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. Installed skills have GitHub tracking metadata injected into their frontmatter (%[1]sgithub-repo%[1]s, %[1]sgithub-ref%[1]s, %[1]sgithub-tree-sha%[1]s, %[1]sgithub-path%[1]s). This - metadata identifies the source repository and enables %[1]sgh skills update%[1]s + metadata identifies the source repository and enables %[1]sgh skill update%[1]s to detect changes — the tree SHA serves as an ETag for staleness checks. The %[1]sgithub-repo%[1]s value is stored as a full repository URL. When run interactively, the command prompts for any missing arguments. - When run non-interactively, %[1]srepository%[1]s is required, and either a - skill name or %[1]s--all%[1]s must be specified. + When run non-interactively, %[1]srepository%[1]s and a skill name are + required. `, "`"), Example: heredoc.Doc(` # Interactive: choose repo, skill, and agent - $ gh skills install + $ gh skill install # Choose a skill from the repo interactively - $ gh skills install github/awesome-copilot + $ gh skill install github/awesome-copilot # Install a specific skill - $ gh skills install github/awesome-copilot git-commit + $ gh skill install github/awesome-copilot git-commit # Install a specific version - $ gh skills install github/awesome-copilot git-commit@v1.2.0 - - # Install all skills from a repo - $ gh skills install github/awesome-copilot --all + $ gh skill install github/awesome-copilot git-commit@v1.2.0 # Install from a large namespaced repo by path (efficient, skips full discovery) - $ gh skills install github/awesome-copilot skills/monalisa/code-review + $ gh skill install github/awesome-copilot skills/monalisa/code-review # Install from a local directory (auto-discovers skills) - $ gh skills install ./my-skills-repo + $ gh skill install ./my-skills-repo # Install from current directory - $ gh skills install . + $ gh skill install . # Install a single local skill directory - $ gh skills install ./skills/git-commit + $ gh skill install ./skills/git-commit # Install for Claude Code at user scope - $ gh skills install github/awesome-copilot git-commit --agent claude-code --scope user + $ gh skill install github/awesome-copilot git-commit --agent claude-code --scope user # Pin to a specific git ref - $ gh skills install github/awesome-copilot git-commit --pin v2.0.0 + $ gh skill install github/awesome-copilot git-commit --pin v2.0.0 `), Aliases: []string{"add"}, Args: cobra.MaximumNArgs(2), @@ -214,7 +210,6 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope") cmd.Flags().StringVar(&opts.Pin, "pin", "", "pin to a specific git tag or commit SHA") cmd.Flags().StringVar(&opts.Dir, "dir", "", "install to a custom directory (overrides --agent and --scope)") - cmd.Flags().BoolVar(&opts.All, "all", false, "install all skills from the repository") cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "overwrite existing skills without prompting") return cmd @@ -327,11 +322,11 @@ func installRun(opts *installOptions) error { for _, name := range result.Installed { fmt.Fprintf(opts.IO.Out, "%s Installed %s (from %s@%s) in %s\n", - cs.SuccessIcon(), name, repoSource, resolved.Ref, friendlyDir(result.Dir)) + cs.SuccessIcon(), name, repoSource, discovery.ShortRef(resolved.Ref), friendlyDir(result.Dir)) } printFileTree(opts.IO.Out, cs, result.Dir, result.Installed) - printReviewHint(opts.IO.ErrOut, cs, repoSource, result.Installed) + printReviewHint(opts.IO.ErrOut, cs, repoSource, resolved.SHA, result.Installed) } if err != nil { @@ -444,7 +439,7 @@ func runLocalInstall(opts *installOptions) error { } printFileTree(opts.IO.Out, cs, result.Dir, result.Installed) - printReviewHint(opts.IO.ErrOut, cs, "", result.Installed) + printReviewHint(opts.IO.ErrOut, cs, "", "", result.Installed) } return nil @@ -515,7 +510,7 @@ func resolveVersion(opts *installOptions, client *api.Client, hostname string) ( if err != nil { return nil, fmt.Errorf("could not resolve version: %w", err) } - fmt.Fprintf(opts.IO.ErrOut, "Using ref %s (%s)\n", resolved.Ref, git.ShortSHA(resolved.SHA)) + fmt.Fprintf(opts.IO.ErrOut, "Using ref %s (%s)\n", discovery.ShortRef(resolved.Ref), git.ShortSHA(resolved.SHA)) return resolved, nil } @@ -575,19 +570,12 @@ func selectSkillsWithSelector(opts *installOptions, skills []discovery.Skill, ca return collisionError(ss, sel.sourceHint) } - if opts.All { - if err := checkCollisions(skills); err != nil { - return nil, err - } - return skills, nil - } - if opts.SkillName != "" { return sel.matchByName(opts, skills) } if !canPrompt { - return nil, cmdutil.FlagErrorf("must specify a skill name or use --all when not running interactively") + return nil, cmdutil.FlagErrorf("must specify a skill name when not running interactively") } if sel.fetchDescriptions != nil { @@ -743,7 +731,7 @@ func collisionError(ss []discovery.Skill, sourceHint string) error { cannot install skills with conflicting names — they would overwrite each other: %s Install these skills individually using the full name: - gh skills install %s namespace/skill-name + gh skill install %s namespace/skill-name `, discovery.FormatCollisions(collisions), sourceHint)) } @@ -1004,7 +992,9 @@ func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) { } // printReviewHint warns the user to review installed skills and suggests preview commands. -func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo string, skillNames []string) { +// When sha is non-empty the suggested commands include @SHA so the user previews +// exactly the version that was installed. +func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo, sha string, skillNames []string) { if len(skillNames) == 0 { return } @@ -1016,7 +1006,11 @@ func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo string, skillN fmt.Fprintln(w, " Review installed content before use:") fmt.Fprintln(w) for _, name := range skillNames { - fmt.Fprintf(w, " gh skills preview %s %s\n", repo, name) + if sha != "" { + fmt.Fprintf(w, " gh skill preview %s %s@%s\n", repo, name, sha) + } else { + fmt.Fprintf(w, " gh skill preview %s %s\n", repo, name) + } } fmt.Fprintln(w) } diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 060b146a4ca..b15f5a9b2bb 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -53,11 +53,6 @@ func TestNewCmdInstall(t *testing.T) { Force: true, }, }, - { - name: "all flag", - cli: "monalisa/skills-repo --all", - wantOpts: installOptions{SkillSource: "monalisa/skills-repo", All: true, Scope: "project"}, - }, { name: "dir flag", cli: "monalisa/skills-repo git-commit --dir ./custom-skills", @@ -142,7 +137,6 @@ func TestNewCmdInstall(t *testing.T) { assert.Equal(t, tt.wantOpts.Scope, gotOpts.Scope) assert.Equal(t, tt.wantOpts.Pin, gotOpts.Pin) assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) - assert.Equal(t, tt.wantOpts.All, gotOpts.All) assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) if tt.wantLocalPath { assert.NotEmpty(t, gotOpts.localPath, "expected localPath to be set") @@ -164,7 +158,7 @@ func TestNewCmdInstall(t *testing.T) { assert.NotEmpty(t, cmd.Example) assert.Contains(t, cmd.Aliases, "add") - for _, flag := range []string{"agent", "scope", "pin", "all", "dir", "force"} { + for _, flag := range []string{"agent", "scope", "pin", "dir", "force"} { assert.NotNil(t, cmd.Flags().Lookup(flag), "missing flag: --%s", flag) } }) @@ -287,7 +281,7 @@ func TestInstallRun(t *testing.T) { ScopeChanged: true, } }, - wantErr: "must specify a skill name or use --all", + wantErr: "must specify a skill name when not running interactively", }, { name: "remote install writes files with tracking metadata", @@ -314,36 +308,6 @@ func TestInstallRun(t *testing.T) { }, wantStdout: "Installed git-commit", }, - { - name: "remote install with --all installs multiple skills", - isTTY: true, - stubs: func(reg *httpmock.Registry) { - stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") - treeJSON := fmt.Sprintf("%s, %s", - singleSkillTreeJSON("code-review", "tree0", "blob0"), - singleSkillTreeJSON("git-commit", "tree1", "blob1")) - stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) - stubInstallFiles(reg, "monalisa", "skills-repo", "tree0", "blob0", - "---\nname: code-review\ndescription: Reviews\n---\n# B\n") - stubInstallFiles(reg, "monalisa", "skills-repo", "tree1", "blob1", - "---\nname: git-commit\ndescription: Commits\n---\n# A\n") - }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { - t.Helper() - return &installOptions{ - IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - GitClient: &git.Client{RepoDir: t.TempDir()}, - SkillSource: "monalisa/skills-repo", - All: true, - Agent: "github-copilot", - Scope: "project", - ScopeChanged: true, - Dir: t.TempDir(), - } - }, - wantStdout: "Installed", - }, { name: "remote install with --agent claude-code", isTTY: true, @@ -597,6 +561,9 @@ func TestInstallRun(t *testing.T) { name: "remote install with pin flag resolves version", isTTY: true, stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/heads/v2.0.0"), + httpmock.StatusStringResponse(404, "not found")) reg.Register( httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v2.0.0"), httpmock.StringResponse(`{"object": {"sha": "def456", "type": "commit"}}`), @@ -647,7 +614,7 @@ func TestInstallRun(t *testing.T) { } }, wantStdout: "Installed git-commit", - wantStderr: "prompt injections or malicious scripts", + wantStderr: "gh skill preview monalisa/skills-repo git-commit@abc123", }, { name: "remote install outputs file tree for TTY", @@ -678,6 +645,9 @@ func TestInstallRun(t *testing.T) { name: "remote install with inline version parses name and version", isTTY: true, stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/heads/v1.2.0"), + httpmock.StatusStringResponse(404, "not found")) reg.Register( httpmock.REST("GET", "repos/monalisa/skills-repo/git/ref/tags/v1.2.0"), httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), @@ -757,7 +727,7 @@ func TestInstallRun(t *testing.T) { }, { name: "remote install all with collisions errors", - isTTY: false, + isTTY: true, stubs: func(reg *httpmock.Registry) { stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") // Two skills with the same install name: skills/xlsx-pro and root xlsx-pro @@ -769,12 +739,17 @@ func TestInstallRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } return &installOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", - All: true, Agent: "github-copilot", Scope: "project", ScopeChanged: true, @@ -795,6 +770,15 @@ func TestInstallRun(t *testing.T) { `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) + // Extra blob stubs consumed by FetchDescriptionsConcurrent during interactive selection. + contentA := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n")) + contentB := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobA"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobA", "content": %q, "encoding": "base64"}`, contentA))) + reg.Register( + httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobB"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobB", "content": %q, "encoding": "base64"}`, contentB))) stubInstallFiles(reg, "monalisa", "skills-repo", "treeA", "blobA", "---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n") stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", @@ -802,12 +786,17 @@ func TestInstallRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } return &installOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", - All: true, Agent: "github-copilot", Scope: "project", ScopeChanged: true, @@ -1418,7 +1407,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + SkillName: "git-commit", Force: true, Agent: "github-copilot", Scope: "project", @@ -1455,7 +1444,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + SkillName: "direct-skill", Force: true, Agent: "github-copilot", Scope: "project", @@ -1468,7 +1457,7 @@ func TestRunLocalInstall(t *testing.T) { }, { name: "namespaced skills install to separate directories", - isTTY: false, + isTTY: true, setup: func(t *testing.T, sourceDir, _ string) { t.Helper() for _, ns := range []string{"alice", "bob"} { @@ -1478,11 +1467,16 @@ func TestRunLocalInstall(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } return &installOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + Prompter: pm, Force: true, Agent: "github-copilot", Scope: "project", @@ -1502,7 +1496,7 @@ func TestRunLocalInstall(t *testing.T) { }, { name: "local install with --force overwrites namespaced skill", - isTTY: false, + isTTY: true, setup: func(t *testing.T, sourceDir, targetDir string) { t.Helper() for _, ns := range []string{"alice", "bob"} { @@ -1513,11 +1507,16 @@ func TestRunLocalInstall(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { t.Helper() + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } return &installOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + Prompter: pm, Force: true, Agent: "github-copilot", Scope: "project", @@ -1568,7 +1567,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + SkillName: "anything", Agent: "github-copilot", Scope: "project", ScopeChanged: true, @@ -1597,7 +1596,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + SkillName: "git-commit", Force: true, Agent: "github-copilot", Scope: "project", @@ -1627,7 +1626,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + SkillName: "git-commit", Force: true, Agent: "claude-code", Scope: "project", @@ -1693,7 +1692,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: sourceDir, localPath: sourceDir, - All: true, + SkillName: "git-commit", Force: true, Agent: "github-copilot", Scope: "project", @@ -1725,7 +1724,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: "~/", localPath: "~/", - All: true, + SkillName: "git-commit", Force: true, Agent: "github-copilot", Scope: "project", @@ -1757,7 +1756,7 @@ func TestRunLocalInstall(t *testing.T) { IO: ios, SkillSource: "~", localPath: "~", - All: true, + SkillName: "git-commit", Force: true, Agent: "github-copilot", Scope: "project", @@ -1839,3 +1838,63 @@ func TestRunLocalInstall(t *testing.T) { }) } } + +func Test_printReviewHint(t *testing.T) { + tests := []struct { + name string + repo string + sha string + skillNames []string + wantOutput string + }{ + { + name: "remote install with SHA includes SHA in preview command", + repo: "owner/repo", + sha: "abc123def456", + skillNames: []string{"my-skill"}, + wantOutput: "gh skill preview owner/repo my-skill@abc123def456", + }, + { + name: "remote install without SHA omits SHA from preview command", + repo: "owner/repo", + sha: "", + skillNames: []string{"my-skill"}, + wantOutput: "gh skill preview owner/repo my-skill\n", + }, + { + name: "multiple skills with SHA", + repo: "owner/repo", + sha: "deadbeef", + skillNames: []string{"skill-a", "skill-b"}, + wantOutput: "skill-a@deadbeef", + }, + { + name: "local install shows generic message", + repo: "", + sha: "", + skillNames: []string{"my-skill"}, + wantOutput: "Review the installed files before use", + }, + { + name: "no skills produces no output", + repo: "owner/repo", + sha: "abc123", + skillNames: []string{}, + wantOutput: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + cs := ios.ColorScheme() + var buf strings.Builder + printReviewHint(&buf, cs, tt.repo, tt.sha, tt.skillNames) + if tt.wantOutput == "" { + assert.Empty(t, buf.String()) + } else { + assert.Contains(t, buf.String(), tt.wantOutput) + } + }) + } +} diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 55ba125e388..ee33b04c14f 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -30,6 +30,7 @@ type previewOptions struct { RepoArg string SkillName string + Version string // resolved from @suffix on SkillName repo ghrepo.Interface } @@ -61,13 +62,23 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra. When run with only a repository argument, lists available skills and prompts for selection. + + To preview a specific version of the skill, append @VERSION to the + skill name. The version is resolved as a git tag, branch, or commit + SHA. `), Example: heredoc.Doc(` # Preview a specific skill - $ gh skills preview github/awesome-copilot code-review + $ gh skill preview github/awesome-copilot code-review + + # Preview a skill at a specific version + $ gh skill preview github/awesome-copilot code-review@v1.2.0 + + # Preview a skill at a specific commit SHA + $ gh skill preview github/awesome-copilot code-review@abc123def456 # Browse and preview interactively - $ gh skills preview github/awesome-copilot + $ gh skill preview github/awesome-copilot `), Aliases: []string{"show"}, Args: cobra.RangeArgs(1, 2), @@ -77,6 +88,11 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra. opts.SkillName = args[1] } + if i := strings.LastIndex(opts.SkillName, "@"); i > 0 { + opts.Version = opts.SkillName[i+1:] + opts.SkillName = opts.SkillName[:i] + } + repo, err := ghrepo.FromFullName(opts.RepoArg) if err != nil { return err @@ -111,7 +127,7 @@ func previewRun(opts *previewOptions) error { apiClient := api.NewClientFromHTTP(httpClient) opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Resolving %s/%s", owner, repoName)) - resolved, err := discovery.ResolveRef(apiClient, hostname, owner, repoName, "") + resolved, err := discovery.ResolveRef(apiClient, hostname, owner, repoName, opts.Version) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("could not resolve version: %w", err) diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index cd623fa06f1..debdfbff2cf 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -25,6 +25,7 @@ func TestNewCmdPreview(t *testing.T) { input string wantRepo string wantSkillName string + wantVersion string wantErr bool }{ { @@ -33,6 +34,20 @@ func TestNewCmdPreview(t *testing.T) { wantRepo: "github/awesome-copilot", wantSkillName: "my-skill", }, + { + name: "repo and skill with version", + input: "github/awesome-copilot my-skill@v1.2.0", + wantRepo: "github/awesome-copilot", + wantSkillName: "my-skill", + wantVersion: "v1.2.0", + }, + { + name: "repo and skill with SHA", + input: "github/awesome-copilot my-skill@abc123def456", + wantRepo: "github/awesome-copilot", + wantSkillName: "my-skill", + wantVersion: "abc123def456", + }, { name: "repo only", input: "github/awesome-copilot", @@ -78,6 +93,7 @@ func TestNewCmdPreview(t *testing.T) { require.NoError(t, err) assert.Equal(t, tt.wantRepo, gotOpts.RepoArg) assert.Equal(t, tt.wantSkillName, gotOpts.SkillName) + assert.Equal(t, tt.wantVersion, gotOpts.Version) }) } } @@ -248,6 +264,55 @@ func TestPreviewRun(t *testing.T) { }, wantErr: "must specify a skill name when not running interactively", }, + { + name: "preview with explicit version", + tty: true, + opts: &previewOptions{ + repo: ghrepo.New("github", "awesome-copilot"), + SkillName: "my-skill", + Version: "abc123def456", + }, + httpStubs: func(reg *httpmock.Registry) { + // ResolveRef with explicit version tries branch first, then tag, then commit + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/heads/abc123def456"), + httpmock.StatusStringResponse(404, "not found"), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/ref/tags/abc123def456"), + httpmock.StatusStringResponse(404, "not found"), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/commits/abc123def456"), + httpmock.StringResponse(`{"sha": "abc123def456789012345678901234567890abcd"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/abc123def456789012345678901234567890abcd"), + httpmock.StringResponse(`{ + "sha": "abc123def456789012345678901234567890abcd", + "truncated": false, + "tree": [ + {"path": "skills", "type": "tree", "sha": "tree1"}, + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob123", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/github/awesome-copilot/git/blobs/blob123"), + httpmock.StringResponse(`{"sha": "blob123", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + }, + wantStdout: "My Skill", + }, } for _, tt := range tests { diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 96683bece63..22d87bb7340 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -127,13 +127,13 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. `), Example: heredoc.Doc(` # Validate and publish interactively - $ gh skills publish + $ gh skill publish # Publish with a specific tag (non-interactive) - $ gh skills publish --tag v1.0.0 + $ gh skill publish --tag v1.0.0 # Validate only (no publish) - $ gh skills publish --dry-run + $ gh skill publish --dry-run # Validate and strip install metadata $ gh skills publish --fix @@ -621,8 +621,8 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re } fmt.Fprintf(opts.IO.Out, "%s Published %s\n", cs.SuccessIcon(), tag) - fmt.Fprintf(opts.IO.Out, "%s Install with: gh skills install %s/%s\n", cs.SuccessIcon(), owner, repo) - fmt.Fprintf(opts.IO.Out, "%s Pin with: gh skills install %s/%s --pin %s\n", cs.SuccessIcon(), owner, repo, tag) + fmt.Fprintf(opts.IO.Out, "%s Install with: gh skill install %s/%s\n", cs.SuccessIcon(), owner, repo) + fmt.Fprintf(opts.IO.Out, "%s Pin with: gh skill install %s/%s --pin %s\n", cs.SuccessIcon(), owner, repo, tag) return nil } diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 03874c30cef..48ea9f3582d 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -89,16 +89,16 @@ func NewCmdSearch(f *cmdutil.Factory, runF func(*searchOptions) error) *cobra.Co `), Example: heredoc.Doc(` # Search for skills related to terraform - $ gh skills search terraform + $ gh skill search terraform # Search for skills from a specific owner - $ gh skills search terraform --owner hashicorp + $ gh skill search terraform --owner hashicorp # View the second page of results - $ gh skills search terraform --page 2 + $ gh skill search terraform --page 2 # Limit results to 5 - $ gh skills search terraform --limit 5 + $ gh skill search terraform --limit 5 `), Args: cmdutil.MinimumArgs(1, "cannot search: query argument required"), RunE: func(c *cobra.Command, args []string) error { diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index 8a136731485..8f9c45faf73 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -10,12 +10,13 @@ import ( "github.com/spf13/cobra" ) -// NewCmdSkills returns the top-level "skills" command. +// NewCmdSkills returns the top-level "skill" command. func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ - Use: "skills ", + Use: "skill ", Short: "Install and manage agent skills", Long: "Install and manage agent skills from GitHub repositories.", + Aliases: []string{"skills"}, GroupID: "core", } diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 0a9d1b1fa77..11db14a346b 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -107,22 +107,22 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co `), Example: heredoc.Doc(` # Check and update all skills interactively - $ gh skills update + $ gh skill update # Update specific skills - $ gh skills update mcp-cli git-commit + $ gh skill update mcp-cli git-commit # Update all without prompting - $ gh skills update --all + $ gh skill update --all # Re-download all skills (restore locally modified files) - $ gh skills update --force --all + $ gh skill update --force --all # Check for updates without applying (read-only) - $ gh skills update --dry-run + $ gh skill update --dry-run # Unpin skills and update them to latest - $ gh skills update --unpin + $ gh skill update --unpin `), RunE: func(cmd *cobra.Command, args []string) error { opts.Skills = args @@ -344,12 +344,12 @@ func updateRun(opts *updateOptions) error { if u.local.treeSHA == u.newSHA { fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s (reinstall) [%s]\n", cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, - git.ShortSHA(u.newSHA), u.resolved.Ref) + git.ShortSHA(u.newSHA), discovery.ShortRef(u.resolved.Ref)) } else { fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s → %s [%s]\n", cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, cs.Muted(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA), - u.resolved.Ref) + discovery.ShortRef(u.resolved.Ref)) } } fmt.Fprintln(opts.IO.ErrOut) From 45d0ec0b5173f6fb2e9a118276f84b982af56740 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 8 Apr 2026 19:14:11 +0100 Subject: [PATCH 013/182] address review comments Co-authored-by: Sam Morrow --- .gitignore | 1 + .../testdata/skills/skills-install-all.txtar | 5 - .../skills/skills-install-force.txtar | 6 +- .../skills/skills-install-from-local.txtar | 15 + .../skills/skills-install-invalid-agent.txtar | 4 +- .../skills/skills-install-invalid-repo.txtar | 2 +- .../skills/skills-install-nested-files.txtar | 2 +- .../skills-install-nonexistent-skill.txtar | 2 +- .../testdata/skills/skills-install-pin.txtar | 4 +- .../skills/skills-install-scope.txtar | 8 +- .../testdata/skills/skills-install.txtar | 8 +- .../skills-preview-noninteractive.txtar | 2 +- .../testdata/skills/skills-preview.txtar | 4 +- .../skills/skills-publish-dry-run.txtar | 10 +- .../skills/skills-publish-lifecycle.txtar | 12 +- .../skills/skills-search-noresults.txtar | 2 +- .../testdata/skills/skills-search-page.txtar | 2 +- .../testdata/skills/skills-search.txtar | 6 +- .../skills/skills-update-noinstalled.txtar | 2 +- .../testdata/skills/skills-update.txtar | 10 +- git/client.go | 20 +- git/client_test.go | 120 +++++++ go.mod | 2 +- internal/flock/flock.go | 8 + internal/flock/flock_test.go | 99 ++++++ internal/flock/flock_unix.go | 32 ++ internal/flock/flock_windows.go | 41 +++ internal/skills/discovery/discovery.go | 137 +++++--- internal/skills/discovery/discovery_test.go | 39 ++- internal/skills/installer/installer.go | 46 +-- internal/skills/installer/installer_test.go | 2 +- internal/skills/lockfile/lockfile.go | 118 +++---- internal/skills/lockfile/lockfile_test.go | 63 +--- internal/skills/registry/registry.go | 8 +- pkg/cmd/root/root.go | 2 +- pkg/cmd/skills/install/install.go | 219 ++++++------ pkg/cmd/skills/install/install_test.go | 322 +++++++++++------- .../skills/install/install_windows_test.go | 63 ---- pkg/cmd/skills/preview/preview.go | 28 +- pkg/cmd/skills/preview/preview_test.go | 34 +- pkg/cmd/skills/publish/publish.go | 76 +++-- pkg/cmd/skills/publish/publish_test.go | 157 +++++---- pkg/cmd/skills/search/search.go | 26 +- pkg/cmd/skills/search/search_test.go | 52 +-- pkg/cmd/skills/skills.go | 28 +- pkg/cmd/skills/update/update.go | 54 +-- pkg/cmd/skills/update/update_test.go | 86 ++--- 47 files changed, 1203 insertions(+), 786 deletions(-) delete mode 100644 acceptance/testdata/skills/skills-install-all.txtar create mode 100644 acceptance/testdata/skills/skills-install-from-local.txtar create mode 100644 internal/flock/flock.go create mode 100644 internal/flock/flock_test.go create mode 100644 internal/flock/flock_unix.go create mode 100644 internal/flock/flock_windows.go delete mode 100644 pkg/cmd/skills/install/install_windows_test.go diff --git a/.gitignore b/.gitignore index b82a00c7274..ffcbbb6c505 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ *~ vendor/ +gh diff --git a/acceptance/testdata/skills/skills-install-all.txtar b/acceptance/testdata/skills/skills-install-all.txtar deleted file mode 100644 index 6efd7747e57..00000000000 --- a/acceptance/testdata/skills/skills-install-all.txtar +++ /dev/null @@ -1,5 +0,0 @@ -# Install all skills from a repo with mixed conventions (skills/ + plugins/) -# This previously failed with "conflicting names" — now uses namespaced dirs -exec gh skills install github/awesome-copilot --all --scope user --force --agent github-copilot -stdout 'Installed' -! stderr 'conflicting names' diff --git a/acceptance/testdata/skills/skills-install-force.txtar b/acceptance/testdata/skills/skills-install-force.txtar index 5623fce84ce..e6bd520b9cf 100644 --- a/acceptance/testdata/skills/skills-install-force.txtar +++ b/acceptance/testdata/skills/skills-install-force.txtar @@ -1,11 +1,11 @@ # Install with --force should overwrite an existing skill without error -exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/force-test +exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/force-test stdout 'Installed git-commit' # Install again with --force — should succeed (overwrite) -exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/force-test +exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/force-test stdout 'Installed git-commit' # Without --force, non-interactive should fail when skill exists -! exec gh skills install github/awesome-copilot git-commit --dir $WORK/force-test +! exec gh skill install github/awesome-copilot git-commit --dir $WORK/force-test stderr 'already installed' diff --git a/acceptance/testdata/skills/skills-install-from-local.txtar b/acceptance/testdata/skills/skills-install-from-local.txtar new file mode 100644 index 00000000000..0b003fd3ef2 --- /dev/null +++ b/acceptance/testdata/skills/skills-install-from-local.txtar @@ -0,0 +1,15 @@ +# Install from a local directory using --from-local +exec gh skill install --from-local $WORK/local-repo git-commit --dir $WORK/output --force +stdout 'Installed git-commit' + +# Verify the skill was copied +exists $WORK/output/git-commit/SKILL.md +grep 'local-path' $WORK/output/git-commit/SKILL.md + +-- local-repo/skills/git-commit/SKILL.md -- +--- +name: git-commit +description: Write good git commits +--- +# Git Commit +Body content. diff --git a/acceptance/testdata/skills/skills-install-invalid-agent.txtar b/acceptance/testdata/skills/skills-install-invalid-agent.txtar index 23883524fa9..7e85a9faea1 100644 --- a/acceptance/testdata/skills/skills-install-invalid-agent.txtar +++ b/acceptance/testdata/skills/skills-install-invalid-agent.txtar @@ -1,4 +1,4 @@ # Invalid agent ID should error with valid options -! exec gh skills install github/awesome-copilot git-commit --agent bogus-agent --force -stderr 'unknown agent' +! exec gh skill install github/awesome-copilot git-commit --agent bogus-agent --force +stderr 'invalid argument' stderr 'github-copilot' diff --git a/acceptance/testdata/skills/skills-install-invalid-repo.txtar b/acceptance/testdata/skills/skills-install-invalid-repo.txtar index 26ecbc718de..2b59582e19d 100644 --- a/acceptance/testdata/skills/skills-install-invalid-repo.txtar +++ b/acceptance/testdata/skills/skills-install-invalid-repo.txtar @@ -1,3 +1,3 @@ # Nonexistent repo should error -! exec gh skills install nonexistent-owner-xyz/nonexistent-repo-abc --force --dir $WORK/tmp +! exec gh skill install nonexistent-owner-xyz/nonexistent-repo-abc --force --dir $WORK/tmp stderr 'Not Found' diff --git a/acceptance/testdata/skills/skills-install-nested-files.txtar b/acceptance/testdata/skills/skills-install-nested-files.txtar index c5cf19e566f..c4fe085e446 100644 --- a/acceptance/testdata/skills/skills-install-nested-files.txtar +++ b/acceptance/testdata/skills/skills-install-nested-files.txtar @@ -1,3 +1,3 @@ # Install a skill that has nested subdirectories and verify file tree -exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/nested-test +exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/nested-test exists $WORK/nested-test/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar b/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar index 23f72cee829..44187c4ff8d 100644 --- a/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar +++ b/acceptance/testdata/skills/skills-install-nonexistent-skill.txtar @@ -1,3 +1,3 @@ # Installing a skill that doesn't exist in a valid repo should error -! exec gh skills install github/awesome-copilot nonexistent-skill-xyz --force --dir $WORK/tmp +! exec gh skill install github/awesome-copilot nonexistent-skill-xyz --force --dir $WORK/tmp stderr 'not found' diff --git a/acceptance/testdata/skills/skills-install-pin.txtar b/acceptance/testdata/skills/skills-install-pin.txtar index 43d780e3e16..7c87e4b33ff 100644 --- a/acceptance/testdata/skills/skills-install-pin.txtar +++ b/acceptance/testdata/skills/skills-install-pin.txtar @@ -1,7 +1,7 @@ # Install with --pin to a specific ref -exec gh skills install github/awesome-copilot git-commit --scope user --force --pin main +exec gh skill install github/awesome-copilot git-commit --scope user --force --pin main stdout 'Installed git-commit' # Install without --pin should resolve latest version -exec gh skills install github/awesome-copilot git-commit --scope user --force +exec gh skill install github/awesome-copilot git-commit --scope user --force stdout 'Installed git-commit' diff --git a/acceptance/testdata/skills/skills-install-scope.txtar b/acceptance/testdata/skills/skills-install-scope.txtar index da2df19ea8f..52270178a08 100644 --- a/acceptance/testdata/skills/skills-install-scope.txtar +++ b/acceptance/testdata/skills/skills-install-scope.txtar @@ -1,9 +1,9 @@ -# Install with --scope project writes to the git repo's .github/skills/ +# Install with --scope project writes to the git repo's .agents/skills/ exec git init --initial-branch=main $WORK/myrepo cd $WORK/myrepo -exec gh skills install github/awesome-copilot git-commit --scope project --force --agent github-copilot -exists $WORK/myrepo/.github/skills/git-commit/SKILL.md +exec gh skill install github/awesome-copilot git-commit --scope project --force --agent github-copilot +exists $WORK/myrepo/.agents/skills/git-commit/SKILL.md # Install with --scope user writes to home directory -exec gh skills install github/awesome-copilot git-commit --scope user --force --agent github-copilot +exec gh skill install github/awesome-copilot git-commit --scope user --force --agent github-copilot exists $HOME/.copilot/skills/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-install.txtar b/acceptance/testdata/skills/skills-install.txtar index 183f930fdff..c365cb83389 100644 --- a/acceptance/testdata/skills/skills-install.txtar +++ b/acceptance/testdata/skills/skills-install.txtar @@ -1,20 +1,20 @@ # Install a single skill from a public repo -exec gh skills install github/awesome-copilot git-commit --scope user --force --agent github-copilot +exec gh skill install github/awesome-copilot git-commit --scope user --force --agent github-copilot stdout 'Installed git-commit' # Verify SKILL.md has frontmatter metadata injected exists $HOME/.copilot/skills/git-commit/SKILL.md -grep 'github-owner' $HOME/.copilot/skills/git-commit/SKILL.md grep 'github-repo' $HOME/.copilot/skills/git-commit/SKILL.md +grep 'github-tree-sha' $HOME/.copilot/skills/git-commit/SKILL.md # Verify lockfile was written exists $HOME/.agents/.skill-lock.json grep 'git-commit' $HOME/.agents/.skill-lock.json # Install with --dir to a custom directory -exec gh skills install github/awesome-copilot git-commit --force --dir $WORK/custom-skills +exec gh skill install github/awesome-copilot git-commit --force --dir $WORK/custom-skills stdout 'Installed git-commit' # Verify the skill was written to the custom directory exists $WORK/custom-skills/git-commit/SKILL.md -grep 'github-owner' $WORK/custom-skills/git-commit/SKILL.md +grep 'github-repo' $WORK/custom-skills/git-commit/SKILL.md diff --git a/acceptance/testdata/skills/skills-preview-noninteractive.txtar b/acceptance/testdata/skills/skills-preview-noninteractive.txtar index 939df0ab6c3..7c276b8d32a 100644 --- a/acceptance/testdata/skills/skills-preview-noninteractive.txtar +++ b/acceptance/testdata/skills/skills-preview-noninteractive.txtar @@ -1,3 +1,3 @@ # Preview with repo only and non-interactive should error -! exec gh skills preview github/awesome-copilot +! exec gh skill preview github/awesome-copilot stderr 'must specify a skill name' diff --git a/acceptance/testdata/skills/skills-preview.txtar b/acceptance/testdata/skills/skills-preview.txtar index 3834c340c0d..be1be5244d7 100644 --- a/acceptance/testdata/skills/skills-preview.txtar +++ b/acceptance/testdata/skills/skills-preview.txtar @@ -1,9 +1,9 @@ # Preview renders skill content and file tree -exec gh skills preview github/awesome-copilot git-commit +exec gh skill preview github/awesome-copilot git-commit stdout 'SKILL.md' # Verify actual content is rendered, not just the filename stdout 'git-commit/' # Preview a skill that doesn't exist should error -! exec gh skills preview github/awesome-copilot nonexistent-skill-xyz +! exec gh skill preview github/awesome-copilot nonexistent-skill-xyz stderr 'not found' diff --git a/acceptance/testdata/skills/skills-publish-dry-run.txtar b/acceptance/testdata/skills/skills-publish-dry-run.txtar index 39c0f234d4b..2dea21d678d 100644 --- a/acceptance/testdata/skills/skills-publish-dry-run.txtar +++ b/acceptance/testdata/skills/skills-publish-dry-run.txtar @@ -1,21 +1,21 @@ # Publish dry-run from a directory with no skills/ should fail gracefully -! exec gh skills publish --dry-run $WORK +! exec gh skill publish --dry-run $WORK stderr 'no skills/ directory found' # Publish dry-run against a valid skill directory should succeed -exec gh skills publish --dry-run $WORK/test-repo +exec gh skill publish --dry-run $WORK/test-repo stdout 'hello-world' # Validate alias should work identically -exec gh skills validate --dry-run $WORK/test-repo +exec gh skill validate --dry-run $WORK/test-repo stdout 'hello-world' # Publish dry-run with --tag -exec gh skills publish --dry-run --tag v1.0.0 $WORK/test-repo +exec gh skill publish --dry-run --tag v1.0.0 $WORK/test-repo stdout 'hello-world' # Publish dry-run with --fix -exec gh skills publish --dry-run --fix $WORK/test-repo +exec gh skill publish --dry-run --fix $WORK/test-repo stdout 'hello-world' -- test-repo/skills/hello-world/SKILL.md -- diff --git a/acceptance/testdata/skills/skills-publish-lifecycle.txtar b/acceptance/testdata/skills/skills-publish-lifecycle.txtar index 0e8a03a1d05..d3d6f0a3a72 100644 --- a/acceptance/testdata/skills/skills-publish-lifecycle.txtar +++ b/acceptance/testdata/skills/skills-publish-lifecycle.txtar @@ -20,31 +20,31 @@ exec git commit -m 'Add test skill' exec git push origin main # Publish with a tag -exec gh skills publish --tag v0.1.0 +exec gh skill publish --tag v0.1.0 # Verify the release was created on GitHub exec gh release view v0.1.0 stdout 'v0.1.0' # Install from our test repo -exec gh skills install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force +exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force stdout 'Installed hello-world' # Verify installed files exist with correct metadata exists $HOME/.copilot/skills/hello-world/SKILL.md exists $HOME/.copilot/skills/hello-world/scripts/setup.sh -grep 'github-owner' $HOME/.copilot/skills/hello-world/SKILL.md +grep 'github-repo' $HOME/.copilot/skills/hello-world/SKILL.md # Install with --pin -exec gh skills install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force --pin v0.1.0 +exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world --scope user --force --pin v0.1.0 stdout 'Installed hello-world' # Preview from our test repo -exec gh skills preview $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world +exec gh skill preview $ORG/$SCRIPT_NAME-$RANDOM_STRING hello-world stdout 'Hello World' # Update dry-run should find installed skill -exec gh skills update --dry-run --all +exec gh skill update --dry-run --all stderr 'up to date' -- skill.md -- diff --git a/acceptance/testdata/skills/skills-search-noresults.txtar b/acceptance/testdata/skills/skills-search-noresults.txtar index 31f8293f0dd..425666556a7 100644 --- a/acceptance/testdata/skills/skills-search-noresults.txtar +++ b/acceptance/testdata/skills/skills-search-noresults.txtar @@ -1,4 +1,4 @@ # Search for something unlikely to exist returns empty stdout # (NoResultsError is silent in non-TTY — exits 0 with no output) -exec gh skills search zzzznonexistenttotallyfakeskillxyz123 +exec gh skill search zzzznonexistenttotallyfakeskillxyz123 ! stdout . diff --git a/acceptance/testdata/skills/skills-search-page.txtar b/acceptance/testdata/skills/skills-search-page.txtar index 71bc6f1de5f..30c044f78cf 100644 --- a/acceptance/testdata/skills/skills-search-page.txtar +++ b/acceptance/testdata/skills/skills-search-page.txtar @@ -1,3 +1,3 @@ # Pagination returns results on page 2 -exec gh skills search copilot --page 2 +exec gh skill search copilot --page 2 stdout 'copilot' diff --git a/acceptance/testdata/skills/skills-search.txtar b/acceptance/testdata/skills/skills-search.txtar index eb4759a41c5..5e8c7744208 100644 --- a/acceptance/testdata/skills/skills-search.txtar +++ b/acceptance/testdata/skills/skills-search.txtar @@ -1,12 +1,12 @@ # Search for skills matching a query -exec gh skills search copilot +exec gh skill search copilot stdout 'copilot' # Search with JSON output -exec gh skills search copilot --json skillName,repo --limit 1 +exec gh skill search copilot --json skillName,repo --limit 1 stdout '"skillName"' stdout '"repo"' # Search with a short query should error -! exec gh skills search a +! exec gh skill search a stderr 'at least' diff --git a/acceptance/testdata/skills/skills-update-noinstalled.txtar b/acceptance/testdata/skills/skills-update-noinstalled.txtar index 7f24291bac5..7fd19541bc0 100644 --- a/acceptance/testdata/skills/skills-update-noinstalled.txtar +++ b/acceptance/testdata/skills/skills-update-noinstalled.txtar @@ -1,5 +1,5 @@ # Update with no installed skills should report appropriately -exec gh skills update --dry-run --all --dir $WORK/empty-dir +exec gh skill update --dry-run --all --dir $WORK/empty-dir stderr 'No installed skills found' -- empty-dir/.gitkeep -- diff --git a/acceptance/testdata/skills/skills-update.txtar b/acceptance/testdata/skills/skills-update.txtar index 7041c84b49f..52933a5f86d 100644 --- a/acceptance/testdata/skills/skills-update.txtar +++ b/acceptance/testdata/skills/skills-update.txtar @@ -1,14 +1,13 @@ # Dry-run update should find the installed skill and report status -exec gh skills update --dry-run --all --dir $WORK/skills-dir -stderr 'update' +exec gh skill update --dry-run --all --dir $WORK/skills-dir stdout 'git-commit' # Force update should re-download and rewrite files -exec gh skills update --force --all --dir $WORK/skills-dir +exec gh skill update --force --all --dir $WORK/skills-dir stdout 'Updated' # Verify the SKILL.md was rewritten with real content (not our placeholder) -grep 'github-owner' $WORK/skills-dir/git-commit/SKILL.md +grep 'github-repo' $WORK/skills-dir/git-commit/SKILL.md ! grep 'Test skill content' $WORK/skills-dir/git-commit/SKILL.md -- skills-dir/git-commit/SKILL.md -- @@ -16,8 +15,7 @@ grep 'github-owner' $WORK/skills-dir/git-commit/SKILL.md name: git-commit description: Git commit helper metadata: - github-owner: github - github-repo: awesome-copilot + github-repo: https://github.com/github/awesome-copilot.git github-tree-sha: 0000000000000000000000000000000000000000 github-path: skills/git-commit --- diff --git a/git/client.go b/git/client.go index 22c4eff16c3..7f2487fce53 100644 --- a/git/client.go +++ b/git/client.go @@ -715,7 +715,7 @@ func (c *Client) IsLocalGitRepo(ctx context.Context) (bool, error) { // RemoteURL returns the fetch URL configured for the named remote. func (c *Client) RemoteURL(ctx context.Context, name string) (string, error) { - cmd, err := c.Command(ctx, "remote", "get-url", name) + cmd, err := c.Command(ctx, "remote", "get-url", "--", name) if err != nil { return "", err } @@ -727,13 +727,23 @@ func (c *Client) RemoteURL(ctx context.Context, name string) (string, error) { } // IsIgnored reports whether the given path is ignored by .gitignore rules. -func (c *Client) IsIgnored(ctx context.Context, path string) bool { - cmd, err := c.Command(ctx, "check-ignore", "-q", path) +// Returns an error for fatal git failures (e.g. path outside repository). +func (c *Client) IsIgnored(ctx context.Context, path string) (bool, error) { + cmd, err := c.Command(ctx, "check-ignore", "-q", "--", path) if err != nil { - return false + return false, err } _, err = cmd.Output() - return err == nil + if err == nil { + return true, nil + } + // Exit 1 here means we can confirm the path is not ignored. + // Any other error is a real git error. + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + return false, nil + } + return false, err } // ShortSHA returns the first 8 characters of a SHA hash for display purposes. diff --git a/git/client_test.go b/git/client_test.go index f59b2607713..7ffee2dc93c 100644 --- a/git/client_test.go +++ b/git/client_test.go @@ -2164,3 +2164,123 @@ func createMockedCommandContext(t *testing.T, commands mockedCommands) commandCt return cmd } } + +func TestClientRemoteURL(t *testing.T) { + tests := []struct { + name string + cmdExitStatus int + cmdStdout string + cmdStderr string + wantCmdArgs string + wantURL string + wantErrorMsg string + }{ + { + name: "returns remote URL", + cmdStdout: "https://github.com/monalisa/skills-repo.git\n", + wantCmdArgs: "path/to/git remote get-url -- origin", + wantURL: "https://github.com/monalisa/skills-repo.git", + }, + { + name: "git error", + cmdExitStatus: 1, + cmdStderr: "fatal: No such remote 'nonexistent'", + wantCmdArgs: "path/to/git remote get-url -- nonexistent", + wantErrorMsg: "failed to run git: fatal: No such remote 'nonexistent'", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr) + client := Client{ + GitPath: "path/to/git", + commandContext: cmdCtx, + } + remoteName := "origin" + if tt.wantErrorMsg != "" { + remoteName = "nonexistent" + } + url, err := client.RemoteURL(context.Background(), remoteName) + assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " ")) + if tt.wantErrorMsg == "" { + assert.NoError(t, err) + assert.Equal(t, tt.wantURL, url) + } else { + assert.EqualError(t, err, tt.wantErrorMsg) + } + }) + } + + // Covers the early return in RemoteURL when Command() itself fails. + // (e.g. git binary not resolvable). + t.Run("returns error when git has a fatal error", func(t *testing.T) { + t.Setenv("PATH", "") + client := Client{} + _, err := client.RemoteURL(context.Background(), "origin") + assert.Error(t, err) + }) +} + +func TestClientIsIgnored(t *testing.T) { + tests := []struct { + name string + cmdExitStatus int + cmdStdout string + cmdStderr string + wantCmdArgs string + wantIgnored bool + wantErr bool + }{ + { + name: "path is ignored", + wantCmdArgs: "path/to/git check-ignore -q -- .github/skills", + wantIgnored: true, + }, + { + name: "path is not ignored", + cmdExitStatus: 1, + wantCmdArgs: "path/to/git check-ignore -q -- .github/skills", + wantIgnored: false, + }, + { + name: "fatal git error", + cmdExitStatus: 128, + cmdStderr: "fatal: not a git repository", + wantCmdArgs: "path/to/git check-ignore -q -- .github/skills", + wantIgnored: false, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr) + client := Client{ + GitPath: "path/to/git", + commandContext: cmdCtx, + } + ignored, err := client.IsIgnored(context.Background(), ".github/skills") + assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " ")) + assert.Equal(t, tt.wantIgnored, ignored) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } + + // Covers the early return in IsIgnored when Command() itself fails + // (e.g. git binary not resolvable). + t.Run("returns error when git has a fatal error", func(t *testing.T) { + t.Setenv("PATH", "") + client := Client{} + ignored, err := client.IsIgnored(context.Background(), ".github/skills") + assert.False(t, ignored) + assert.Error(t, err) + }) +} + +func TestShortSHA(t *testing.T) { + assert.Equal(t, "abc123de", ShortSHA("abc123def456789")) + assert.Equal(t, "short", ShortSHA("short")) +} diff --git a/go.mod b/go.mod index 615b1ebf404..0fc0b1a5e68 100644 --- a/go.mod +++ b/go.mod @@ -57,6 +57,7 @@ require ( github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.50.0 golang.org/x/sync v0.20.0 + golang.org/x/sys v0.43.0 golang.org/x/term v0.42.0 golang.org/x/text v0.36.0 google.golang.org/grpc v1.80.0 @@ -182,7 +183,6 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.53.0 // indirect - golang.org/x/sys v0.43.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect diff --git a/internal/flock/flock.go b/internal/flock/flock.go new file mode 100644 index 00000000000..6d5af9f011b --- /dev/null +++ b/internal/flock/flock.go @@ -0,0 +1,8 @@ +package flock + +import "errors" + +// ErrLocked is returned when the file is already locked by another process. +// Callers can check for this to distinguish contention from permanent errors. +// This is intended to be an OS-agnostic sentinel error. +var ErrLocked = errors.New("file is locked by another process") diff --git a/internal/flock/flock_test.go b/internal/flock/flock_test.go new file mode 100644 index 00000000000..69b3a73b50e --- /dev/null +++ b/internal/flock/flock_test.go @@ -0,0 +1,99 @@ +package flock_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cli/cli/v2/internal/flock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTryLock(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string // returns lock path + wantErr error + verify func(t *testing.T, f *os.File) + }{ + { + name: "acquires lock and returns writable file handle", + setup: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "test.lock") + }, + verify: func(t *testing.T, f *os.File) { + t.Helper() + _, err := f.WriteString("hello") + require.NoError(t, err) + _, err = f.Seek(0, 0) + require.NoError(t, err) + buf := make([]byte, 5) + n, err := f.Read(buf) + assert.NoError(t, err) + assert.Equal(t, "hello", string(buf[:n])) + }, + }, + { + name: "creates lock file if it does not exist", + setup: func(t *testing.T) string { + dir := filepath.Join(t.TempDir(), "subdir") + require.NoError(t, os.MkdirAll(dir, 0o755)) + return filepath.Join(dir, "new.lock") + }, + verify: func(t *testing.T, f *os.File) { + t.Helper() + _, err := os.Stat(f.Name()) + assert.NoError(t, err) + }, + }, + { + name: "second lock on same path returns ErrLocked", + setup: func(t *testing.T) string { + lockPath := filepath.Join(t.TempDir(), "contended.lock") + _, unlock, err := flock.TryLock(lockPath) + require.NoError(t, err) + t.Cleanup(unlock) + return lockPath + }, + wantErr: flock.ErrLocked, + }, + { + name: "lock succeeds after unlock", + setup: func(t *testing.T) string { + lockPath := filepath.Join(t.TempDir(), "reuse.lock") + _, unlock, err := flock.TryLock(lockPath) + require.NoError(t, err) + unlock() + return lockPath + }, + }, + { + name: "fails on non-existent directory", + setup: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "no", "such", "dir", "test.lock") + }, + wantErr: os.ErrNotExist, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lockPath := tt.setup(t) + + f, unlock, err := flock.TryLock(lockPath) + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, f) + defer unlock() + + if tt.verify != nil { + tt.verify(t, f) + } + }) + } +} diff --git a/internal/flock/flock_unix.go b/internal/flock/flock_unix.go new file mode 100644 index 00000000000..73f8b15570c --- /dev/null +++ b/internal/flock/flock_unix.go @@ -0,0 +1,32 @@ +//go:build !windows + +package flock + +import ( + "errors" + "os" + "syscall" +) + +// TryLock attempts to acquire an exclusive, non-blocking flock on the given path. +// Returns the locked file and an unlock function on success. The caller should +// read/write through the returned file to avoid platform differences with +// mandatory locking on Windows. +// Returns ErrLocked if the file is already locked by another process. +func TryLock(path string) (f *os.File, unlock func(), err error) { + f, err = os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = f.Close() + if errors.Is(err, syscall.EWOULDBLOCK) { + return nil, nil, ErrLocked + } + return nil, nil, err + } + return f, func() { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() + }, nil +} diff --git a/internal/flock/flock_windows.go b/internal/flock/flock_windows.go new file mode 100644 index 00000000000..4795af08336 --- /dev/null +++ b/internal/flock/flock_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package flock + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +// TryLock attempts to acquire an exclusive, non-blocking lock on the given path. +// Returns the locked file and an unlock function on success. The caller should +// read/write through the returned file to avoid Windows mandatory lock conflicts. +// Returns ErrLocked if the file is already locked by another process. +func TryLock(path string) (f *os.File, unlock func(), err error) { + f, err = os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) + if err != nil { + return nil, nil, err + } + ol := new(windows.Overlapped) + handle := windows.Handle(f.Fd()) + err = windows.LockFileEx( + handle, + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, + 0, + 1, 0, + ol, + ) + if err != nil { + _ = f.Close() + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return nil, nil, ErrLocked + } + return nil, nil, err + } + return f, func() { + _ = windows.UnlockFileEx(handle, 0, 1, 0, ol) + _ = f.Close() + }, nil +} diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 4e54fd5e3af..84f2aa59671 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -6,12 +6,15 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path" "path/filepath" "regexp" + "sort" "strings" "sync" + "sync/atomic" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/skills/frontmatter" @@ -21,6 +24,17 @@ import ( // 1-64 chars, lowercase alphanumeric + hyphens, no leading/trailing/consecutive hyphens. var specNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) +// TreeTooLargeError is returned when a repository's git tree exceeds the +// GitHub API truncation limit and full skill discovery is not possible. +type TreeTooLargeError struct { + Owner string + Repo string +} + +func (e *TreeTooLargeError) Error() string { + return fmt.Sprintf("repository tree for %s/%s is too large for full discovery", e.Owner, e.Repo) +} + // safeNamePattern matches names that are safe for filesystem use during discovery. // Allows letters (any case), numbers, hyphens, underscores, dots, and spaces. // Must start with a letter or number. This matches copilot-agent-runtime's SKILL_NAME_REGEX. @@ -127,7 +141,7 @@ type repoResponse struct { } // ResolveRef determines the git ref to use for a given owner/repo. -// Priority: explicit version → latest release tag → default branch. +// Priority: explicit version > latest release tag > default branch. func ResolveRef(client *api.Client, host, owner, repo, version string) (*ResolvedRef, error) { if version != "" { return resolveExplicitRef(client, host, owner, repo, version) @@ -166,19 +180,27 @@ func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*Res } // Short name: try branch first, then tag, then commit SHA. + // Only fall through on 404 (not found); surface other errors + // (403, 500, network) immediately to avoid masking real failures. if resolved, err := resolveBranchRef(client, host, owner, repo, ref); err == nil { return resolved, nil + } else if !isNotFound(err) { + return nil, err } if resolved, err := resolveTagRef(client, host, owner, repo, ref); err == nil { return resolved, nil + } else if !isNotFound(err) { + return nil, err } - commitPath := fmt.Sprintf("repos/%s/%s/commits/%s", owner, repo, ref) + commitPath := fmt.Sprintf("repos/%s/%s/commits/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(ref)) var commitResp struct { SHA string `json:"sha"` } if err := client.REST(host, "GET", commitPath, nil, &commitResp); err == nil { return &ResolvedRef{Ref: commitResp.SHA, SHA: commitResp.SHA}, nil + } else if !isNotFound(err) { + return nil, err } return nil, fmt.Errorf("ref %q not found as branch, tag, or commit in %s/%s", ref, owner, repo) @@ -187,7 +209,7 @@ func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*Res // resolveTagRef looks up a tag by short name and returns a fully qualified ref. // For annotated tags, the tag object is dereferenced to obtain the commit SHA. func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*ResolvedRef, error) { - tagPath := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", owner, repo, tag) + tagPath := fmt.Sprintf("repos/%s/%s/git/ref/tags/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(tag)) var refResp struct { Object struct { SHA string `json:"sha"` @@ -199,7 +221,7 @@ func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*Resolved } sha := refResp.Object.SHA if refResp.Object.Type == "tag" { - derefPath := fmt.Sprintf("repos/%s/%s/git/tags/%s", owner, repo, sha) + derefPath := fmt.Sprintf("repos/%s/%s/git/tags/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) var tagResp struct { Object struct { SHA string `json:"sha"` @@ -215,7 +237,7 @@ func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*Resolved // resolveBranchRef looks up a branch by short name and returns a fully qualified ref. func resolveBranchRef(client *api.Client, host, owner, repo, branch string) (*ResolvedRef, error) { - refPath := fmt.Sprintf("repos/%s/%s/git/ref/heads/%s", owner, repo, branch) + refPath := fmt.Sprintf("repos/%s/%s/git/ref/heads/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(branch)) var refResp struct { Object struct { SHA string `json:"sha"` @@ -227,6 +249,12 @@ func resolveBranchRef(client *api.Client, host, owner, repo, branch string) (*Re return &ResolvedRef{Ref: "refs/heads/" + branch, SHA: refResp.Object.SHA}, nil } +// isNotFound returns true if the error is an HTTP 404 response. +func isNotFound(err error) bool { + var httpErr api.HTTPError + return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound +} + // noReleasesError signals that the repository has no usable releases, // which is the only case where ResolveRef should fall back to the // default branch. @@ -237,16 +265,15 @@ type noReleasesError struct { func (e *noReleasesError) Error() string { return e.reason } func resolveLatestRelease(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { - apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", owner, repo) + apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", url.PathEscape(owner), url.PathEscape(repo)) var release releaseResponse if err := client.REST(host, "GET", apiPath, nil, &release); err != nil { - // A 404 means the repository has no releases — this is the + // A 404 means the repository has no releases. This is the // only case where falling back to the default branch is safe. // Any other HTTP error (403, 500, …) or network failure is // returned as-is so ResolveRef surfaces it rather than // silently falling back. - var httpErr api.HTTPError - if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { + if isNotFound(err) { return nil, &noReleasesError{reason: fmt.Sprintf("no releases found for %s/%s", owner, repo)} } return nil, fmt.Errorf("could not fetch latest release: %w", err) @@ -258,14 +285,14 @@ func resolveLatestRelease(client *api.Client, host, owner, repo string) (*Resolv } func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { - apiPath := fmt.Sprintf("repos/%s/%s", owner, repo) + apiPath := fmt.Sprintf("repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo)) var repoResp repoResponse if err := client.REST(host, "GET", apiPath, nil, &repoResp); err != nil { return nil, fmt.Errorf("could not determine default branch: %w", err) } branch := repoResp.DefaultBranch if branch == "" { - branch = "main" + return nil, fmt.Errorf("could not determine default branch for %s/%s", owner, repo) } return resolveBranchRef(client, host, owner, repo, branch) } @@ -333,18 +360,14 @@ func matchSkillConventions(entry treeEntry) *skillMatch { // DiscoverSkills finds all skills in a repository at the given commit SHA. func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([]Skill, error) { - apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, commitSHA) + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(commitSHA)) var tree treeResponse if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { return nil, fmt.Errorf("could not fetch repository tree: %w", err) } if tree.Truncated { - return nil, fmt.Errorf( - "repository tree for %s/%s is too large for full discovery\n"+ - " Use path-based install instead: gh skill install %s/%s skills/", - owner, repo, owner, repo, - ) + return nil, &TreeTooLargeError{Owner: owner, Repo: repo} } treeSHAs := make(map[string]string) @@ -393,6 +416,10 @@ func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([] }) } + sort.SliceStable(skills, func(i, j int) bool { + return skills[i].DisplayName() < skills[j].DisplayName() + }) + return skills, nil } @@ -425,33 +452,31 @@ func FetchDescriptionsConcurrent(client *api.Client, host, owner, repo string, s } const maxWorkers = 10 - sem := make(chan struct{}, maxWorkers) - var mu sync.Mutex - done := 0 - var wg sync.WaitGroup + var done atomic.Int32 + + jobs := make(chan *Skill) + + workers := min(maxWorkers, total) + for range workers { + wg.Go(func() { + for s := range jobs { + s.Description = fetchDescription(client, host, owner, repo, s) + + d := int(done.Add(1)) + if onProgress != nil { + onProgress(d, total) + } + } + }) + } + for i := range skills { - if skills[i].Description != "" { - continue + if skills[i].Description == "" { + jobs <- &skills[i] } - wg.Add(1) - go func(idx int) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - - desc := fetchDescription(client, host, owner, repo, &skills[idx]) - - mu.Lock() - skills[idx].Description = desc - done++ - d := done - mu.Unlock() - if onProgress != nil { - onProgress(d, total) - } - }(i) } + close(jobs) wg.Wait() } @@ -466,7 +491,7 @@ func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skill } parentPath := path.Dir(skillPath) - apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", owner, repo, parentPath, commitSHA) + apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", url.PathEscape(owner), url.PathEscape(repo), parentPath, commitSHA) var contents []struct { Name string `json:"name"` @@ -489,7 +514,7 @@ func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skill return nil, fmt.Errorf("skill directory %q not found in %s/%s", skillPath, owner, repo) } - skillTreePath := fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, treeSHA) + skillTreePath := fmt.Sprintf("repos/%s/%s/git/trees/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(treeSHA)) var skillTree treeResponse if err := client.REST(host, "GET", skillTreePath, nil, &skillTree); err != nil { return nil, fmt.Errorf("could not read skill directory: %w", err) @@ -528,15 +553,15 @@ func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skill // DiscoverSkillFiles returns all file paths belonging to a skill directory // by fetching the skill's subtree directly using its tree SHA. func DiscoverSkillFiles(client *api.Client, host, owner, repo, treeSHA, skillPath string) ([]SkillFile, error) { - apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, treeSHA) + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(treeSHA)) var tree treeResponse if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { return nil, fmt.Errorf("could not fetch skill tree: %w", err) } if tree.Truncated { - // Recursive fetch was truncated — fall back to walking subtrees individually. - return walkTree(client, host, owner, repo, treeSHA, skillPath) + // Recursive fetch was truncated. Fall back to walking subtrees individually. + return walkTree(client, host, owner, repo, treeSHA, skillPath, 0) } var files []SkillFile @@ -556,7 +581,7 @@ func DiscoverSkillFiles(client *api.Client, host, owner, repo, treeSHA, skillPat // ListSkillFiles returns all files in a skill directory as public SkillFile // structs with paths relative to the skill root. func ListSkillFiles(client *api.Client, host, owner, repo, treeSHA string) ([]SkillFile, error) { - apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", owner, repo, treeSHA) + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(treeSHA)) var tree treeResponse if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { return nil, fmt.Errorf("could not fetch skill tree: %w", err) @@ -564,7 +589,7 @@ func ListSkillFiles(client *api.Client, host, owner, repo, treeSHA string) ([]Sk if tree.Truncated { // Fall back to non-recursive traversal when the tree is too large. - return walkTree(client, host, owner, repo, treeSHA, "") + return walkTree(client, host, owner, repo, treeSHA, "", 0) } var files []SkillFile @@ -580,10 +605,18 @@ func ListSkillFiles(client *api.Client, host, owner, repo, treeSHA string) ([]Sk return files, nil } +// maxTreeDepth bounds the recursion in walkTree to prevent unbounded +// API calls on deeply nested repositories. +const maxTreeDepth = 20 + // walkTree enumerates files by fetching each tree level individually, -// avoiding the truncation limit of the recursive tree API. -func walkTree(client *api.Client, host, owner, repo, sha, prefix string) ([]SkillFile, error) { - apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s", owner, repo, sha) +// avoiding the truncation limit of the recursive tree API. Recursion +// depth is bounded by maxTreeDepth to prevent unbounded API calls. +func walkTree(client *api.Client, host, owner, repo, sha, prefix string, depth int) ([]SkillFile, error) { + if depth > maxTreeDepth { + return nil, fmt.Errorf("tree depth exceeds %d levels at %s", maxTreeDepth, prefix) + } + apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) var tree treeResponse if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { return nil, fmt.Errorf("could not fetch tree %s: %w", prefix, err) @@ -599,7 +632,7 @@ func walkTree(client *api.Client, host, owner, repo, sha, prefix string) ([]Skil case "blob": files = append(files, SkillFile{Path: entryPath, SHA: entry.SHA, Size: entry.Size}) case "tree": - sub, err := walkTree(client, host, owner, repo, entry.SHA, entryPath) + sub, err := walkTree(client, host, owner, repo, entry.SHA, entryPath, depth+1) if err != nil { return nil, err } @@ -611,7 +644,7 @@ func walkTree(client *api.Client, host, owner, repo, sha, prefix string) ([]Skil // FetchBlob retrieves the content of a blob by SHA. func FetchBlob(client *api.Client, host, owner, repo, sha string) (string, error) { - apiPath := fmt.Sprintf("repos/%s/%s/git/blobs/%s", owner, repo, sha) + apiPath := fmt.Sprintf("repos/%s/%s/git/blobs/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) var blob blobResponse if err := client.REST(host, "GET", apiPath, nil, &blob); err != nil { return "", fmt.Errorf("could not fetch blob: %w", err) diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 3bc719ae848..2de7ef683a7 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -438,7 +438,7 @@ func TestResolveRef(t *testing.T) { wantSHA: "fallback-sha", }, { - name: "empty default_branch falls back to main", + name: "empty default_branch returns error", stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/releases/latest"), @@ -446,14 +446,41 @@ func TestResolveRef(t *testing.T) { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills"), httpmock.JSONResponse(map[string]interface{}{"default_branch": ""})) + }, + wantErr: "could not determine default branch", + }, + { + name: "short name with server error on branch lookup does not fall through", + version: "main", + stubs: func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/main"), - httpmock.JSONResponse(map[string]interface{}{ - "object": map[string]interface{}{"sha": "main-sha"}, - })) + httpmock.StatusStringResponse(500, "server error")) }, - wantRef: "refs/heads/main", - wantSHA: "main-sha", + wantErr: `branch "main" not found in monalisa/octocat-skills`, + }, + { + name: "short name with forbidden error on branch lookup does not fall through", + version: "develop", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/develop"), + httpmock.StatusStringResponse(403, "forbidden")) + }, + wantErr: `branch "develop" not found in monalisa/octocat-skills`, + }, + { + name: "short name with server error on tag lookup does not fall through", + version: "v5.0", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/heads/v5.0"), + httpmock.StatusStringResponse(404, "not found")) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/ref/tags/v5.0"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: `tag "v5.0" not found in monalisa/octocat-skills`, }, } for _, tt := range tests { diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index 5fdf99ce0e9..0ac9e182c1a 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" @@ -86,31 +87,34 @@ func Install(opts *Options) (*Result, error) { opts.OnProgress(0, total) } - sem := make(chan struct{}, maxConcurrency) + type job struct { + idx int + skill discovery.Skill + } + jobs := make(chan job) + results := make([]skillResult, total) var wg sync.WaitGroup - var mu sync.Mutex - done := 0 - - for i, skill := range opts.Skills { - wg.Add(1) - go func(idx int, s discovery.Skill) { - defer wg.Done() - sem <- struct{}{} - defer func() { <-sem }() - - err := installSkill(opts, s, targetDir) - results[idx] = skillResult{name: s.InstallName(), err: err} - - if opts.OnProgress != nil { - mu.Lock() - done++ - d := done - mu.Unlock() - opts.OnProgress(d, total) + var done atomic.Int32 + + workers := min(maxConcurrency, total) + for range workers { + wg.Go(func() { + for j := range jobs { + err := installSkill(opts, j.skill, targetDir) + results[j.idx] = skillResult{name: j.skill.InstallName(), err: err} + + if opts.OnProgress != nil { + opts.OnProgress(int(done.Add(1)), total) + } } - }(i, skill) + }) + } + + for i, s := range opts.Skills { + jobs <- job{idx: i, skill: s} } + close(jobs) wg.Wait() var installed []string diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go index 85c8bcf18ca..6334add8513 100644 --- a/internal/skills/installer/installer_test.go +++ b/internal/skills/installer/installer_test.go @@ -134,7 +134,7 @@ func TestInstallLocal(t *testing.T) { }, verify: func(t *testing.T, destDir string) { t.Helper() - _, err := os.Stat(filepath.Join(destDir, ".github", "skills", "code-review", "SKILL.md")) + _, err := os.Stat(filepath.Join(destDir, ".agents", "skills", "code-review", "SKILL.md")) assert.NoError(t, err) }, }, diff --git a/internal/skills/lockfile/lockfile.go b/internal/skills/lockfile/lockfile.go index 3a6ccd893f7..42d2abb34c1 100644 --- a/internal/skills/lockfile/lockfile.go +++ b/internal/skills/lockfile/lockfile.go @@ -2,10 +2,14 @@ package lockfile import ( "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" "time" + + "github.com/cli/cli/v2/internal/flock" ) const ( @@ -43,61 +47,68 @@ func lockfilePath() (string, error) { return filepath.Join(home, agentsDir, lockFile), nil } -// read loads the lock file, returning an empty file if it doesn't exist -// or if it's an incompatible version. -func read() (*file, error) { - lockPath, err := lockfilePath() - if err != nil { - return newFile(), nil //nolint:nilerr // graceful: no home dir means fresh state +// readFrom loads the lock file from an open file handle. +// Returns an empty file if the content is empty, corrupt, or incompatible. +func readFrom(f *os.File) (*file, error) { + if _, err := f.Seek(0, 0); err != nil { + return nil, fmt.Errorf("could not seek lock file: %w", err) } - - data, err := os.ReadFile(lockPath) + data, err := io.ReadAll(f) if err != nil { - if os.IsNotExist(err) { - return newFile(), nil - } return nil, fmt.Errorf("could not read lock file: %w", err) } + if len(data) == 0 { + return newFile(), nil + } - var f file - if err := json.Unmarshal(data, &f); err != nil { + var lf file + if err := json.Unmarshal(data, &lf); err != nil { return newFile(), nil //nolint:nilerr // graceful: corrupt file means fresh state } - if f.Version != lockVersion || f.Skills == nil { + if lf.Version != lockVersion || lf.Skills == nil { return newFile(), nil } - return &f, nil + return &lf, nil } -// write persists the lock file to disk. -func write(f *file) error { - lockPath, err := lockfilePath() +// writeTo persists the lock file through an open file handle. +func writeTo(f *os.File, lf *file) error { + data, err := json.MarshalIndent(lf, "", " ") if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + if _, err := f.Seek(0, 0); err != nil { return err } - - data, err := json.MarshalIndent(f, "", " ") - if err != nil { + if err := f.Truncate(0); err != nil { return err } - - return os.WriteFile(lockPath, data, 0o644) + _, err = f.Write(data) + return err } // RecordInstall adds or updates a skill entry in the lock file. // It uses a file-based lock to prevent concurrent read-modify-write races // when multiple install processes run simultaneously. func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) error { - unlock := acquireLock() + lockPath, err := lockfilePath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { + return fmt.Errorf("could not create lock directory: %w", err) + } + + lockedFile, unlock, err := acquireFLock() + if err != nil { + return err + } defer unlock() - f, err := read() + f, err := readFrom(lockedFile) if err != nil { return err } @@ -121,7 +132,7 @@ func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) PinnedRef: pinnedRef, } - return write(f) + return writeTo(lockedFile, f) } func newFile() *file { @@ -132,44 +143,35 @@ func newFile() *file { } var ( - lockRetries = 30 - lockRetryInterval = 100 * time.Millisecond + lockAttempts = 30 + lockAttemptDelay = 100 * time.Millisecond ) -// acquireLock creates an exclusive lock file to serialize concurrent access. -// Returns an unlock function. If locking fails after retries, it proceeds -// unlocked rather than blocking the user indefinitely. -func acquireLock() (unlock func()) { - lockPath, pathErr := lockfilePath() - if pathErr != nil { - return func() {} - } - lkPath := lockPath + ".lk" - - // Ensure the parent directory exists (fresh machine may lack ~/.agents). - if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil { - return func() {} +// acquireFLock attempts to acquire an exclusive file lock to serialize concurrent access. +// Returns the locked file handle and an unlock function, or an error if the lock +// cannot be acquired. The caller should read/write through the returned file to +// avoid Windows mandatory lock conflicts. +func acquireFLock() (f *os.File, unlock func(), err error) { + lockPath, err := lockfilePath() + if err != nil { + return nil, nil, fmt.Errorf("could not determine lock path: %w", err) } - for range lockRetries { - f, createErr := os.OpenFile(lkPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) - if createErr == nil { - f.Close() - return func() { os.Remove(lkPath) } + var lastErr error + for attempt := range lockAttempts { + f, unlock, err := flock.TryLock(lockPath) + if err == nil { + return f, unlock, nil } - // Only retry when the lock file already exists (concurrent process). - // For other errors (permission denied, invalid path, etc.) give up immediately. - if !os.IsExist(createErr) { - return func() {} + lastErr = err + + if !errors.Is(err, flock.ErrLocked) { + return nil, nil, err } - // Break stale locks older than 30s (e.g. from a crashed process). - if info, statErr := os.Stat(lkPath); statErr == nil && time.Since(info.ModTime()) > 30*time.Second { - os.Remove(lkPath) - continue + if attempt < lockAttempts-1 { + time.Sleep(lockAttemptDelay) } - time.Sleep(lockRetryInterval) } - // Best-effort: proceed without lock. - return func() {} + return nil, nil, fmt.Errorf("could not acquire lock after %d attempts: %w", lockAttempts, lastErr) } diff --git a/internal/skills/lockfile/lockfile_test.go b/internal/skills/lockfile/lockfile_test.go index d4a44f76db9..d68e9a8f169 100644 --- a/internal/skills/lockfile/lockfile_test.go +++ b/internal/skills/lockfile/lockfile_test.go @@ -5,8 +5,8 @@ import ( "os" "path/filepath" "testing" - "time" + "github.com/cli/cli/v2/internal/flock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -23,13 +23,14 @@ func setupTestHome(t *testing.T) string { func TestRecordInstall(t *testing.T) { tests := []struct { name string - setup func(t *testing.T) // optional pre-existing state + setup func(t *testing.T) skill string owner string repo string skillPath string treeSHA string pinnedRef string + wantErr bool verify func(t *testing.T, lockPath string) }{ { @@ -87,63 +88,31 @@ func TestRecordInstall(t *testing.T) { }, }, { - name: "succeeds despite stale lock file", + name: "returns error when lock cannot be acquired", setup: func(t *testing.T) { t.Helper() - lockPath, err := lockfilePath() - require.NoError(t, err) - require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) - lkPath := lockPath + ".lk" - f, err := os.Create(lkPath) - require.NoError(t, err) - f.Close() - staleTime := time.Now().Add(-60 * time.Second) - require.NoError(t, os.Chtimes(lkPath, staleTime, staleTime)) - }, - skill: "code-review", - owner: "monalisa", - repo: "octocat-skills", - skillPath: "skills/code-review/SKILL.md", - treeSHA: "abc123", - verify: func(t *testing.T, lockPath string) { - t.Helper() - f := readTestLockfile(t, lockPath) - require.Contains(t, f.Skills, "code-review") - _, err := os.Stat(lockPath + ".lk") - assert.True(t, os.IsNotExist(err), "stale lock should be removed after RecordInstall") - }, - }, - { - name: "proceeds without lock after retries exhausted", - setup: func(t *testing.T) { - t.Helper() - // Reduce retries to avoid 3s wait in tests. - origRetries := lockRetries - origInterval := lockRetryInterval - lockRetries = 1 - lockRetryInterval = 0 + origAttempts := lockAttempts + origDelay := lockAttemptDelay + lockAttempts = 1 + lockAttemptDelay = 0 t.Cleanup(func() { - lockRetries = origRetries - lockRetryInterval = origInterval + lockAttempts = origAttempts + lockAttemptDelay = origDelay }) - // Create a fresh (non-stale) lock file that won't be broken. + // Hold a real flock so acquireFLock fails. lockPath, err := lockfilePath() require.NoError(t, err) require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) - f, err := os.Create(lockPath + ".lk") + _, unlock, err := flock.TryLock(lockPath) require.NoError(t, err) - f.Close() + t.Cleanup(unlock) }, skill: "code-review", owner: "monalisa", repo: "octocat-skills", skillPath: "skills/code-review/SKILL.md", treeSHA: "abc123", - verify: func(t *testing.T, lockPath string) { - t.Helper() - f := readTestLockfile(t, lockPath) - require.Contains(t, f.Skills, "code-review", "should succeed best-effort without lock") - }, + wantErr: true, }, { name: "recovers from corrupt lockfile", @@ -198,6 +167,10 @@ func TestRecordInstall(t *testing.T) { } err := RecordInstall(tt.skill, tt.owner, tt.repo, tt.skillPath, tt.treeSHA, tt.pinnedRef) + if tt.wantErr { + require.Error(t, err) + return + } require.NoError(t, err) tt.verify(t, lockPath) }) diff --git a/internal/skills/registry/registry.go b/internal/skills/registry/registry.go index a8fdc59935c..b112d361a50 100644 --- a/internal/skills/registry/registry.go +++ b/internal/skills/registry/registry.go @@ -28,6 +28,8 @@ const ( ScopeProject Scope = "project" ScopeUser Scope = "user" + DefaultAgentID = "github-copilot" + sharedProjectSkillsDir = ".agents/skills" ) @@ -144,13 +146,13 @@ func (h *AgentHost) InstallDir(scope Scope, gitRoot, homeDir string) (string, er // If repoName is non-empty, it is included in the project-scope label // for additional context. func ScopeLabels(repoName string) []string { - projectLabel := "Project — install in current repository (recommended)" + projectLabel := "Project: install in current repository (recommended)" if repoName != "" { - projectLabel = fmt.Sprintf("Project — %s (recommended)", repoName) + projectLabel = fmt.Sprintf("Project: %s (recommended)", repoName) } return []string{ projectLabel, - "Global — install in home directory (available everywhere)", + "Global: install in home directory (available everywhere)", } } diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 262af1b78a0..d44ad840c8b 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -145,6 +145,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(codespaceCmd.NewCmdCodespace(f)) cmd.AddCommand(projectCmd.NewCmdProject(f)) cmd.AddCommand(previewCmd.NewCmdPreview(f)) + cmd.AddCommand(skillsCmd.NewCmdSkills(f)) // Root commands with standalone functionality and no subcommands cmd.AddCommand(copilotCmd.NewCmdCopilot(f, nil)) @@ -165,7 +166,6 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(repoCmd.NewCmdRepo(&repoResolvingCmdFactory)) cmd.AddCommand(rulesetCmd.NewCmdRuleset(&repoResolvingCmdFactory)) cmd.AddCommand(runCmd.NewCmdRun(&repoResolvingCmdFactory)) - cmd.AddCommand(skillsCmd.NewCmdSkills(f)) cmd.AddCommand(workflowCmd.NewCmdWorkflow(&repoResolvingCmdFactory)) cmd.AddCommand(labelCmd.NewCmdLabel(&repoResolvingCmdFactory)) cmd.AddCommand(cacheCmd.NewCmdCache(&repoResolvingCmdFactory)) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index fc65e2f0cb9..fce53583d69 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -7,7 +7,6 @@ import ( "net/http" "os" "path/filepath" - "sort" "strings" "github.com/MakeNowJust/heredoc" @@ -36,35 +35,32 @@ const ( maxSearchResults = 30 ) -// installOptions holds all dependencies and user-provided flags for the install command. -type installOptions struct { +// InstallOptions holds all dependencies and user-provided flags for the install command. +type InstallOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) Prompter prompter.Prompter GitClient *git.Client Remotes func() (ghContext.Remotes, error) - // Arguments - SkillSource string // owner/repo or local path - SkillName string // skill name, possibly with @version + SkillSource string // owner/repo or local path (when --from-local is set) + SkillName string // possibly with @version suffix + Agent string + Scope string + ScopeChanged bool // true when --scope was explicitly set + Pin string + Dir string // overrides --agent and --scope + Force bool + FromLocal bool // treat SkillSource as a local directory path - // Flags - Agent string // --agent flag - Scope string // --scope flag - ScopeChanged bool // true when --scope was explicitly set - Pin string // --pin flag - Dir string // --dir flag (overrides host+scope) - Force bool // --force flag - - // Resolved at runtime repo ghrepo.Interface // set when SkillSource is a GitHub repository - localPath string // set when SkillSource is a local directory - version string + localPath string // set when FromLocal is true + version string // parsed from SkillName@version } // NewCmdInstall creates the "skills install" command. -func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra.Command { - opts := &installOptions{ +func NewCmdInstall(f *cmdutil.Factory, runF func(*InstallOptions) error) *cobra.Command { + opts := &InstallOptions{ IO: f.IOStreams, Prompter: f.Prompter, GitClient: f.GitClient, @@ -73,21 +69,21 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. } cmd := &cobra.Command{ - Use: "install []", - Short: "Install agent skills from a GitHub repository", + Use: "install [] [flags]", + Short: "Install agent skills from a GitHub repository (preview)", Long: heredoc.Docf(` Install agent skills from a GitHub repository or local directory into your local environment. Skills are placed in a host-specific directory at either project scope (inside the current git repository) or user - scope (in your home directory, available everywhere): + scope (in your home directory, available everywhere). Supported hosts + and their storage directories are (project, user): - Host Project User - GitHub Copilot .agents/skills ~/.copilot/skills - Claude Code .claude/skills ~/.claude/skills - Cursor .agents/skills ~/.cursor/skills - Codex .agents/skills ~/.codex/skills - Gemini CLI .agents/skills ~/.gemini/skills - Antigravity .agents/skills ~/.gemini/antigravity/skills + - GitHub Copilot (%[1]s.agents/skills%[1]s, %[1]s~/.copilot/skills%[1]s) + - Claude Code (%[1]s.claude/skills%[1]s, %[1]s~/.claude/skills%[1]s) + - Cursor (%[1]s.agents/skills%[1]s, %[1]s~/.cursor/skills%[1]s) + - Codex (%[1]s.agents/skills%[1]s, %[1]s~/.codex/skills%[1]s) + - Gemini CLI (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/skills%[1]s) + - Antigravity (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/antigravity/skills%[1]s) Use %[1]s--agent%[1]s and %[1]s--scope%[1]s to control placement, or %[1]s--dir%[1]s for a custom directory. The default scope is %[1]sproject%[1]s, and the default @@ -98,11 +94,11 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. select multiple hosts that resolve to the same destination, each skill is installed there only once. - The first argument can be a GitHub repository in %[1]sOWNER/REPO%[1]s format - or a local directory path (e.g. %[1]s.%[1]s, %[1]s./my-skills%[1]s, %[1]s~/skills%[1]s). - For local directories, skills are auto-discovered using the same - conventions as remote repositories, and files are copied (not symlinked) - with local-path tracking metadata injected into frontmatter. + The first argument is a GitHub repository in %[1]sOWNER/REPO%[1]s format. + Use %[1]s--from-local%[1]s to install from a local directory instead. + Local skills are auto-discovered using the same conventions as remote + repositories, and files are copied (not symlinked) with local-path + tracking metadata injected into frontmatter. Skills are discovered automatically using the %[1]sskills/*/SKILL.md%[1]s convention defined by the Agent Skills specification. For more information on the specification, @@ -125,12 +121,9 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. To pin to a specific version, either append %[1]s@VERSION%[1]s to the skill name or use the %[1]s--pin%[1]s flag. The version is resolved as a git tag or commit SHA. - Installed skills have GitHub tracking metadata injected into their - frontmatter (%[1]sgithub-repo%[1]s, %[1]sgithub-ref%[1]s, - %[1]sgithub-tree-sha%[1]s, %[1]sgithub-path%[1]s). This - metadata identifies the source repository and enables %[1]sgh skill update%[1]s - to detect changes — the tree SHA serves as an ETag for staleness checks. - The %[1]sgithub-repo%[1]s value is stored as a full repository URL. + Installed skills have source tracking metadata injected into their + frontmatter. This metadata identifies the source repository and + enables %[1]sgh skill update%[1]s to detect changes. When run interactively, the command prompts for any missing arguments. When run non-interactively, %[1]srepository%[1]s and a skill name are @@ -152,14 +145,11 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. # Install from a large namespaced repo by path (efficient, skips full discovery) $ gh skill install github/awesome-copilot skills/monalisa/code-review - # Install from a local directory (auto-discovers skills) - $ gh skill install ./my-skills-repo - - # Install from current directory - $ gh skill install . + # Install from a local directory + $ gh skill install ./my-skills-repo --from-local - # Install a single local skill directory - $ gh skill install ./skills/git-commit + # Install a specific local skill + $ gh skill install ./my-skills-repo git-commit --from-local # Install for Claude Code at user scope $ gh skill install github/awesome-copilot git-commit --agent claude-code --scope user @@ -170,9 +160,6 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. Aliases: []string{"add"}, Args: cobra.MaximumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 && !opts.IO.CanPrompt() { - return cmdutil.FlagErrorf("must specify a repository to install from") - } if len(args) >= 1 { opts.SkillSource = args[0] } @@ -182,14 +169,17 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. opts.ScopeChanged = cmd.Flags().Changed("scope") // Resolve the source type early so installRun can branch directly. - if isLocalPath(opts.SkillSource) { + if opts.FromLocal { + if opts.SkillSource == "" { + return cmdutil.FlagErrorf("--from-local requires a directory path argument") + } opts.localPath = opts.SkillSource + } else if len(args) == 0 && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("must specify a repository to install from") } - if opts.Agent != "" { - if _, err := registry.FindByID(opts.Agent); err != nil { - return cmdutil.FlagErrorf("invalid value for --agent: %s", err) - } + if err := cmdutil.MutuallyExclusive("--from-local and --pin cannot be used together", opts.FromLocal, opts.Pin != ""); err != nil { + return err } if opts.Pin != "" && opts.SkillName != "" && strings.Contains(opts.SkillName, "@") { @@ -203,19 +193,17 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*installOptions) error) *cobra. }, } - cmd.Flags().StringVar(&opts.Agent, "agent", "", fmt.Sprintf("target agent (%s)", registry.ValidAgentIDs())) - _ = cmd.RegisterFlagCompletionFunc("agent", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return registry.AgentIDs(), cobra.ShellCompDirectiveNoFileComp - }) + cmdutil.StringEnumFlag(cmd, &opts.Agent, "agent", "", "", registry.AgentIDs(), "Target agent") cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope") - cmd.Flags().StringVar(&opts.Pin, "pin", "", "pin to a specific git tag or commit SHA") - cmd.Flags().StringVar(&opts.Dir, "dir", "", "install to a custom directory (overrides --agent and --scope)") - cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "overwrite existing skills without prompting") + cmd.Flags().StringVar(&opts.Pin, "pin", "", "Pin to a specific git tag or commit SHA") + cmd.Flags().StringVar(&opts.Dir, "dir", "", "Install to a custom directory (overrides --agent and --scope)") + cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting") + cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository") return cmd } -func installRun(opts *installOptions) error { +func installRun(opts *InstallOptions) error { cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() @@ -278,6 +266,8 @@ func installRun(opts *installOptions) error { } } + printPreInstallDisclaimer(opts.IO.ErrOut, cs) + selectedHosts, err := resolveHosts(opts, canPrompt) if err != nil { return err @@ -325,7 +315,7 @@ func installRun(opts *installOptions) error { cs.SuccessIcon(), name, repoSource, discovery.ShortRef(resolved.Ref), friendlyDir(result.Dir)) } - printFileTree(opts.IO.Out, cs, result.Dir, result.Installed) + printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed) printReviewHint(opts.IO.ErrOut, cs, repoSource, resolved.SHA, result.Installed) } @@ -337,33 +327,8 @@ func installRun(opts *installOptions) error { return nil } -// isLocalPath returns true if the argument looks like a local filesystem path -// rather than a GitHub owner/repo reference. -func isLocalPath(arg string) bool { - if arg == "" { - return false - } - sep := string(filepath.Separator) - if arg == "." || arg == ".." || - strings.HasPrefix(arg, "./") || strings.HasPrefix(arg, "../") || - strings.HasPrefix(arg, "."+sep) || strings.HasPrefix(arg, ".."+sep) { - return true - } - // filepath.IsAbs on Windows requires a drive letter, so "/tmp/foo" - // would not be recognized. Check explicitly for a leading "/" so that - // Unix-style absolute paths are never mistaken for owner/repo refs. - if filepath.IsAbs(arg) || arg[0] == '/' || strings.HasPrefix(arg, "~") { - return true - } - info, err := os.Stat(arg) - if err == nil && info.IsDir() { - return true - } - return false -} - // runLocalInstall handles installation from a local directory path. -func runLocalInstall(opts *installOptions) error { +func runLocalInstall(opts *InstallOptions) error { cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() sourcePath := opts.localPath @@ -401,6 +366,8 @@ func runLocalInstall(opts *installOptions) error { return err } + printPreInstallDisclaimer(opts.IO.ErrOut, cs) + selectedHosts, err := resolveHosts(opts, canPrompt) if err != nil { return err @@ -438,7 +405,7 @@ func runLocalInstall(opts *installOptions) error { name, opts.SkillSource, friendlyDir(result.Dir)) } - printFileTree(opts.IO.Out, cs, result.Dir, result.Installed) + printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed) printReviewHint(opts.IO.ErrOut, cs, "", "", result.Installed) } @@ -481,7 +448,7 @@ func resolveRepoArg(skillSource string, canPrompt bool, p prompter.Prompter) (gh return repo, skillSource, nil } -func parseSkillFromOpts(opts *installOptions) { +func parseSkillFromOpts(opts *InstallOptions) { if opts.SkillName != "" { if name, version, ok := cutLast(opts.SkillName, "@"); ok && name != "" { opts.version = version @@ -503,7 +470,7 @@ func cutLast(s, sep string) (before, after string, found bool) { return s, "", false } -func resolveVersion(opts *installOptions, client *api.Client, hostname string) (*discovery.ResolvedRef, error) { +func resolveVersion(opts *InstallOptions, client *api.Client, hostname string) (*discovery.ResolvedRef, error) { opts.IO.StartProgressIndicatorWithLabel("Resolving version") resolved, err := discovery.ResolveRef(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), opts.version) opts.IO.StopProgressIndicator() @@ -514,11 +481,17 @@ func resolveVersion(opts *installOptions, client *api.Client, hostname string) ( return resolved, nil } -func discoverSkills(opts *installOptions, client *api.Client, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { +func discoverSkills(opts *InstallOptions, client *api.Client, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { opts.IO.StartProgressIndicatorWithLabel("Discovering skills") skills, err := discovery.DiscoverSkills(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA) opts.IO.StopProgressIndicator() if err != nil { + var treeTooLarge *discovery.TreeTooLargeError + if errors.As(err, &treeTooLarge) { + fmt.Fprintf(opts.IO.ErrOut, "%s\n Use path-based install instead: gh skill install %s/%s skills/\n", + err, treeTooLarge.Owner, treeTooLarge.Repo) + return nil, err + } return nil, err } logConventions(opts.IO, skills) @@ -527,9 +500,6 @@ func discoverSkills(opts *installOptions, client *api.Client, hostname string, r fmt.Fprintf(opts.IO.ErrOut, "Warning: skill %q does not follow the agentskills.io naming convention\n", s.DisplayName()) } } - sort.Slice(skills, func(i, j int) bool { - return skills[i].DisplayName() < skills[j].DisplayName() - }) return skills, nil } @@ -552,7 +522,7 @@ func logConventions(io *iostreams.IOStreams, skills []discovery.Skill) { // skillSelector holds the callbacks that differ between remote and local skill selection. type skillSelector struct { // matchByName resolves a skill name to matching skills. - matchByName func(opts *installOptions, skills []discovery.Skill) ([]discovery.Skill, error) + matchByName func(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error) // sourceHint is shown in collision error guidance (e.g. "owner/repo" or "/path/to/skills"). sourceHint string // fetchDescriptions, if non-nil, is called before prompting to pre-populate descriptions. @@ -565,9 +535,13 @@ type installPlan struct { skills []discovery.Skill } -func selectSkillsWithSelector(opts *installOptions, skills []discovery.Skill, canPrompt bool, sel skillSelector) ([]discovery.Skill, error) { +func selectSkillsWithSelector(opts *InstallOptions, skills []discovery.Skill, canPrompt bool, sel skillSelector) ([]discovery.Skill, error) { checkCollisions := func(ss []discovery.Skill) error { - return collisionError(ss, sel.sourceHint) + if err := collisionError(ss); err != nil { + fmt.Fprintf(opts.IO.ErrOut, "Hint: install individually using the full name: gh skill install %s namespace/skill-name\n", sel.sourceHint) + return err + } + return nil } if opts.SkillName != "" { @@ -619,7 +593,7 @@ func selectSkillsWithSelector(opts *installOptions, skills []discovery.Skill, ca return result, checkCollisions(result) } -func matchSkillByName(opts *installOptions, skills []discovery.Skill) ([]discovery.Skill, error) { +func matchSkillByName(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error) { for _, s := range skills { if s.DisplayName() == opts.SkillName { return []discovery.Skill{s}, nil @@ -644,13 +618,13 @@ func matchSkillByName(opts *installOptions, skills []discovery.Skill) ([]discove names[i] = m.DisplayName() } return nil, fmt.Errorf( - "skill name %q is ambiguous — multiple matches found:\n %s\n Specify the full name (e.g. %s) to disambiguate", + "skill name %q is ambiguous, multiple matches found:\n %s\n Specify the full name (e.g. %s) to disambiguate", opts.SkillName, strings.Join(names, "\n "), names[0], ) } } -func matchLocalSkillByName(opts *installOptions, skills []discovery.Skill) ([]discovery.Skill, error) { +func matchLocalSkillByName(opts *InstallOptions, skills []discovery.Skill) ([]discovery.Skill, error) { for _, s := range skills { if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName { return []discovery.Skill{s}, nil @@ -687,7 +661,7 @@ func skillSearchFunc(skills []discovery.Skill, descWidth int) func(string) promp for i, s := range matched { keys[i] = s.DisplayName() if s.Description != "" { - labels[i] = fmt.Sprintf("%s — %s", s.DisplayName(), truncateDescription(s.Description, descWidth)) + labels[i] = fmt.Sprintf("%s - %s", s.DisplayName(), truncateDescription(s.Description, descWidth)) } else { labels[i] = s.DisplayName() } @@ -720,22 +694,17 @@ func matchSelectedSkills(skills []discovery.Skill, selected []string) ([]discove return result, nil } -// collisionError checks for name collisions and returns an error with -// guidance on how to install skills individually. -func collisionError(ss []discovery.Skill, sourceHint string) error { +// collisionError checks for name collisions among the selected skills. +func collisionError(ss []discovery.Skill) error { collisions := discovery.FindNameCollisions(ss) if len(collisions) == 0 { return nil } - return errors.New(heredoc.Docf(` - cannot install skills with conflicting names — they would overwrite each other: - %s - Install these skills individually using the full name: - gh skill install %s namespace/skill-name - `, discovery.FormatCollisions(collisions), sourceHint)) + return fmt.Errorf("cannot install skills with conflicting names; they would overwrite each other:\n %s", + discovery.FormatCollisions(collisions)) } -func resolveHosts(opts *installOptions, canPrompt bool) ([]*registry.AgentHost, error) { +func resolveHosts(opts *InstallOptions, canPrompt bool) ([]*registry.AgentHost, error) { if opts.Agent != "" { h, err := registry.FindByID(opts.Agent) if err != nil { @@ -745,7 +714,7 @@ func resolveHosts(opts *installOptions, canPrompt bool) ([]*registry.AgentHost, } if !canPrompt { - h, err := registry.FindByID("github-copilot") + h, err := registry.FindByID(registry.DefaultAgentID) if err != nil { return nil, err } @@ -770,7 +739,7 @@ func resolveHosts(opts *installOptions, canPrompt bool) ([]*registry.AgentHost, return selected, nil } -func resolveScope(opts *installOptions, canPrompt bool) (registry.Scope, error) { +func resolveScope(opts *InstallOptions, canPrompt bool) (registry.Scope, error) { if opts.Dir != "" { return registry.Scope(opts.Scope), nil } @@ -795,7 +764,7 @@ func resolveScope(opts *installOptions, canPrompt bool) (registry.Scope, error) return registry.ScopeUser, nil } -func buildInstallPlans(opts *installOptions, selectedSkills []discovery.Skill, selectedHosts []*registry.AgentHost, scope registry.Scope, gitRoot, homeDir string, canPrompt bool) ([]installPlan, error) { +func buildInstallPlans(opts *InstallOptions, selectedSkills []discovery.Skill, selectedHosts []*registry.AgentHost, scope registry.Scope, gitRoot, homeDir string, canPrompt bool) ([]installPlan, error) { byDir := make(map[string]*installPlan) orderedDirs := make([]string, 0, len(selectedHosts)) @@ -832,7 +801,7 @@ func buildInstallPlans(opts *installOptions, selectedSkills []discovery.Skill, s return plans, nil } -func resolveInstallDir(opts *installOptions, host *registry.AgentHost, scope registry.Scope, gitRoot, homeDir string) (string, error) { +func resolveInstallDir(opts *InstallOptions, host *registry.AgentHost, scope registry.Scope, gitRoot, homeDir string) (string, error) { if opts.Dir != "" { return opts.Dir, nil } @@ -851,7 +820,7 @@ func truncateDescription(s string, maxWidth int) string { return text.Truncate(maxWidth, text.RemoveExcessiveWhitespace(s)) } -func checkOverwrite(opts *installOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, error) { +func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, error) { var existing, fresh []discovery.Skill for _, s := range skills { dir := filepath.Join(targetDir, filepath.FromSlash(s.InstallName())) @@ -948,8 +917,10 @@ func friendlyDir(dir string) string { return rel } } - if home, err := os.UserHomeDir(); err == nil && (dir == home || strings.HasPrefix(dir, home+string(filepath.Separator))) { - return "~" + dir[len(home):] + if home, err := os.UserHomeDir(); err == nil { + if rel, err := filepath.Rel(home, dir); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "~/" + rel + } } return dir } @@ -991,6 +962,12 @@ func printTreeDir(w io.Writer, cs *iostreams.ColorScheme, dir, indent string) { } } +// printPreInstallDisclaimer prints a warning that installed skills are unverified +// and should be inspected before use. +func printPreInstallDisclaimer(w io.Writer, cs *iostreams.ColorScheme) { + fmt.Fprintf(w, "\n%s Skills are not verified by GitHub and may contain prompt injections, hidden instructions, or malicious scripts. Always review skill contents before use.\n\n", cs.WarningIcon()) +} + // printReviewHint warns the user to review installed skills and suggests preview commands. // When sha is non-empty the suggested commands include @SHA so the user previews // exactly the version that was installed. diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index b15f5a9b2bb..4812275247f 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -15,6 +15,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -27,24 +28,24 @@ func TestNewCmdInstall(t *testing.T) { tests := []struct { name string cli string - wantOpts installOptions + wantOpts InstallOptions wantLocalPath bool wantErr bool }{ { name: "repo argument only", cli: "monalisa/skills-repo", - wantOpts: installOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, }, { name: "repo and skill", cli: "monalisa/skills-repo git-commit", - wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, }, { name: "all flags", cli: "monalisa/skills-repo git-commit --agent github-copilot --scope user --pin v1.0.0 --force", - wantOpts: installOptions{ + wantOpts: InstallOptions{ SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Agent: "github-copilot", @@ -56,7 +57,7 @@ func TestNewCmdInstall(t *testing.T) { { name: "dir flag", cli: "monalisa/skills-repo git-commit --dir ./custom-skills", - wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Dir: "./custom-skills", Scope: "project"}, + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Dir: "./custom-skills", Scope: "project"}, }, { name: "too many args", @@ -76,30 +77,45 @@ func TestNewCmdInstall(t *testing.T) { { name: "alias add works", cli: "monalisa/skills-repo git-commit", - wantOpts: installOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, }, { - name: "dot-slash local path sets localPath", - cli: "./local-dir", - wantOpts: installOptions{SkillSource: "./local-dir", Scope: "project"}, + name: "from-local flag sets localPath", + cli: "--from-local ./local-dir", + wantOpts: InstallOptions{SkillSource: "./local-dir", Scope: "project", FromLocal: true}, wantLocalPath: true, }, { - name: "absolute path sets localPath", - cli: "/absolute/path", - wantOpts: installOptions{SkillSource: "/absolute/path", Scope: "project"}, + name: "from-local with absolute path", + cli: "--from-local /absolute/path", + wantOpts: InstallOptions{SkillSource: "/absolute/path", Scope: "project", FromLocal: true}, wantLocalPath: true, }, { - name: "tilde path sets localPath", - cli: "~/skills", - wantOpts: installOptions{SkillSource: "~/skills", Scope: "project"}, + name: "from-local with tilde path", + cli: "--from-local ~/skills", + wantOpts: InstallOptions{SkillSource: "~/skills", Scope: "project", FromLocal: true}, wantLocalPath: true, }, { name: "owner/repo does not set localPath", cli: "monalisa/skills-repo", - wantOpts: installOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project"}, + }, + { + name: "local-looking path without --from-local treated as repo", + cli: "./local-dir", + wantOpts: InstallOptions{SkillSource: "./local-dir", Scope: "project"}, + }, + { + name: "from-local without argument errors", + cli: "--from-local", + wantErr: true, + }, + { + name: "from-local with --pin is mutually exclusive", + cli: "--from-local ./local-dir --pin v1.0.0", + wantErr: true, }, } for _, tt := range tests { @@ -111,8 +127,8 @@ func TestNewCmdInstall(t *testing.T) { GitClient: &git.Client{}, } - var gotOpts *installOptions - cmd := NewCmdInstall(f, func(opts *installOptions) error { + var gotOpts *InstallOptions + cmd := NewCmdInstall(f, func(opts *InstallOptions) error { gotOpts = opts return nil }) @@ -138,6 +154,7 @@ func TestNewCmdInstall(t *testing.T) { assert.Equal(t, tt.wantOpts.Pin, gotOpts.Pin) assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) + assert.Equal(t, tt.wantOpts.FromLocal, gotOpts.FromLocal) if tt.wantLocalPath { assert.NotEmpty(t, gotOpts.localPath, "expected localPath to be set") } else { @@ -152,7 +169,7 @@ func TestNewCmdInstall(t *testing.T) { f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} cmd := NewCmdInstall(f, nil) - assert.Equal(t, "install []", cmd.Use) + assert.Equal(t, "install [] [flags]", cmd.Use) assert.NotEmpty(t, cmd.Short) assert.NotEmpty(t, cmd.Long) assert.NotEmpty(t, cmd.Example) @@ -243,7 +260,7 @@ func TestInstallRun(t *testing.T) { isTTY bool setup func(t *testing.T) stubs func(*httpmock.Registry) - opts func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions + opts func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions verify func(t *testing.T) wantErr string wantStdout string @@ -252,9 +269,9 @@ func TestInstallRun(t *testing.T) { { name: "non-interactive without repo errors", isTTY: false, - opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, GitClient: &git.Client{RepoDir: t.TempDir()}, } @@ -269,9 +286,9 @@ func TestInstallRun(t *testing.T) { stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -292,9 +309,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -317,9 +334,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -342,9 +359,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -366,9 +383,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -390,9 +407,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -413,11 +430,11 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() targetDir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -440,11 +457,11 @@ func TestInstallRun(t *testing.T) { stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() targetDir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -466,9 +483,9 @@ func TestInstallRun(t *testing.T) { stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -496,9 +513,9 @@ func TestInstallRun(t *testing.T) { `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -527,9 +544,9 @@ func TestInstallRun(t *testing.T) { stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", "---\nname: xlsx-pro\ndescription: Bob version\n---\n# B\n") }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -546,9 +563,9 @@ func TestInstallRun(t *testing.T) { { name: "remote install with invalid repo argument errors", isTTY: false, - opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, _ *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "invalid", @@ -572,9 +589,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -590,6 +607,32 @@ func TestInstallRun(t *testing.T) { wantStdout: "Installed git-commit", wantStderr: "v2.0.0", }, + { + name: "remote install shows pre-install disclaimer", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + wantStderr: "not verified by GitHub", + }, { name: "remote install outputs review hint", isTTY: true, @@ -599,9 +642,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -625,9 +668,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -639,7 +682,7 @@ func TestInstallRun(t *testing.T) { Dir: t.TempDir(), } }, - wantStdout: "SKILL.md", + wantStderr: "SKILL.md", }, { name: "remote install with inline version parses name and version", @@ -656,9 +699,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -684,9 +727,9 @@ func TestInstallRun(t *testing.T) { // installer.Install: tree + blob (again, for writing files) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -709,9 +752,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -737,14 +780,14 @@ func TestInstallRun(t *testing.T) { `{"path": "xlsx-pro/SKILL.md", "type": "blob", "sha": "blob1"}` stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { return []string{allSkillsKey}, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -784,14 +827,14 @@ func TestInstallRun(t *testing.T) { stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", "---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n") }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { return []string{allSkillsKey}, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -814,9 +857,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -864,7 +907,7 @@ func TestInstallRun(t *testing.T) { stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-skill-01", "blob-skill-01", "---\nname: skill-01\ndescription: Does skill-01 things\n---\n# Skill\n") }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(prompt, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) { @@ -884,7 +927,7 @@ func TestInstallRun(t *testing.T) { return 0, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -905,14 +948,14 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { return 0, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -933,7 +976,7 @@ func TestInstallRun(t *testing.T) { stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() destDir := t.TempDir() writeLocalTestSkill(t, destDir, "git-commit", gitCommitContent) @@ -942,7 +985,7 @@ func TestInstallRun(t *testing.T) { return false, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -966,9 +1009,9 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -996,14 +1039,14 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { return 0, nil // project scope }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1030,7 +1073,7 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() destDir := t.TempDir() existingContent := heredoc.Doc(` @@ -1050,7 +1093,7 @@ func TestInstallRun(t *testing.T) { return true, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1068,9 +1111,9 @@ func TestInstallRun(t *testing.T) { { name: "unsupported host returns error", stubs: func(reg *httpmock.Registry) {}, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: &prompter.PrompterMock{}, @@ -1095,7 +1138,7 @@ func TestInstallRun(t *testing.T) { httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob-gc", "content": %q, "encoding": "base64"}`, encoded))) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(prompt, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) { @@ -1105,7 +1148,7 @@ func TestInstallRun(t *testing.T) { return 0, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1126,7 +1169,7 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ InputFunc: func(prompt, defaultValue string) (string, error) { @@ -1136,7 +1179,7 @@ func TestInstallRun(t *testing.T) { return 0, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1157,14 +1200,14 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { return 1, nil // user scope }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1186,7 +1229,7 @@ func TestInstallRun(t *testing.T) { singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() destDir := t.TempDir() // Existing skill without github metadata in frontmatter @@ -1204,7 +1247,7 @@ func TestInstallRun(t *testing.T) { return true, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1232,9 +1275,9 @@ func TestInstallRun(t *testing.T) { stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) stubInstallFiles(reg, "monalisa", "skills-repo", "treeGC", "blobGC", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, @@ -1259,7 +1302,7 @@ func TestInstallRun(t *testing.T) { stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) stubInstallFiles(reg, "monalisa", "octocat-skills", "tree-gc", "blob-gc", gitCommitContent) }, - opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *installOptions { + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { @@ -1269,7 +1312,7 @@ func TestInstallRun(t *testing.T) { return 0, nil // project scope }, } - return &installOptions{ + return &InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1363,7 +1406,7 @@ func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) { }, } - err := installRun(&installOptions{ + err := installRun(&InstallOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -1382,7 +1425,7 @@ func TestRunLocalInstall(t *testing.T) { name string isTTY bool setup func(t *testing.T, sourceDir, targetDir string) - opts func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions + opts func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions verify func(t *testing.T, targetDir string) wantErr string wantStdout string @@ -1401,9 +1444,9 @@ func TestRunLocalInstall(t *testing.T) { # Git Commit `)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1438,9 +1481,9 @@ func TestRunLocalInstall(t *testing.T) { `) require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "SKILL.md"), []byte(content), 0o644)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1465,14 +1508,14 @@ func TestRunLocalInstall(t *testing.T) { fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns)) } }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { return []string{allSkillsKey}, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1505,14 +1548,14 @@ func TestRunLocalInstall(t *testing.T) { } require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "alice", "xlsx-pro"), 0o755)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() pm := &prompter.PrompterMock{ MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { return []string{allSkillsKey}, nil }, } - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1541,9 +1584,9 @@ func TestRunLocalInstall(t *testing.T) { `)) require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1561,9 +1604,9 @@ func TestRunLocalInstall(t *testing.T) { name: "local install with no skills found errors", isTTY: false, setup: func(_ *testing.T, _, _ string) {}, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1590,9 +1633,9 @@ func TestRunLocalInstall(t *testing.T) { # Git Commit `)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1620,9 +1663,9 @@ func TestRunLocalInstall(t *testing.T) { # Git Commit `)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1657,9 +1700,9 @@ func TestRunLocalInstall(t *testing.T) { # Code Review `)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1686,9 +1729,9 @@ func TestRunLocalInstall(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(skillDir, "scripts", "run.sh"), []byte("#!/bin/bash"), 0o644)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1701,7 +1744,7 @@ func TestRunLocalInstall(t *testing.T) { GitClient: &git.Client{RepoDir: t.TempDir()}, } }, - wantStdout: "SKILL.md", + wantStderr: "SKILL.md", }, { name: "local path with tilde expansion", @@ -1716,11 +1759,11 @@ func TestRunLocalInstall(t *testing.T) { # Git Commit `)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() t.Setenv("HOME", sourceDir) t.Setenv("USERPROFILE", sourceDir) - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: "~/", localPath: "~/", @@ -1748,11 +1791,11 @@ func TestRunLocalInstall(t *testing.T) { # Git Commit `)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() t.Setenv("HOME", sourceDir) t.Setenv("USERPROFILE", sourceDir) - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: "~", localPath: "~", @@ -1780,9 +1823,9 @@ func TestRunLocalInstall(t *testing.T) { # Git Commit `)) }, - opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *installOptions { + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - return &installOptions{ + return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, @@ -1898,3 +1941,42 @@ func Test_printReviewHint(t *testing.T) { }) } } + +func Test_printPreInstallDisclaimer(t *testing.T) { + ios, _, _, _ := iostreams.Test() + cs := ios.ColorScheme() + var buf strings.Builder + printPreInstallDisclaimer(&buf, cs) + output := buf.String() + assert.Contains(t, output, "not verified by GitHub") + assert.Contains(t, output, "prompt") + assert.Contains(t, output, "malicious") +} + +func Test_selectSkillsWithSelector_noDisclaimer(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + skills := []discovery.Skill{ + {Name: "git-commit", Convention: "skills", Path: "skills/git-commit/SKILL.md"}, + } + + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{"git-commit"}, nil + }, + } + + opts := &InstallOptions{ + IO: ios, + Prompter: pm, + } + + _, err := selectSkillsWithSelector(opts, skills, true, skillSelector{ + matchByName: matchSkillByName, + sourceHint: "owner/repo", + }) + require.NoError(t, err) + assert.NotContains(t, stderr.String(), "not verified by GitHub") +} diff --git a/pkg/cmd/skills/install/install_windows_test.go b/pkg/cmd/skills/install/install_windows_test.go deleted file mode 100644 index 8a184fac497..00000000000 --- a/pkg/cmd/skills/install/install_windows_test.go +++ /dev/null @@ -1,63 +0,0 @@ -//go:build windows - -package install - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestIsLocalPath_Windows(t *testing.T) { - tests := []struct { - name string - arg string - want bool - }{ - // Backslash-relative paths that only exist on Windows. - {`dot-backslash prefix`, `.\skills`, true}, - {`dotdot-backslash prefix`, `..\other`, true}, - {`drive-absolute path`, `C:\Users\me\skills`, true}, - {`drive-relative path`, `D:\projects`, true}, - {`UNC path`, `\\server\share\skills`, true}, - - // Forward-slash forms should still work on Windows. - {`dot-slash prefix`, `./skills`, true}, - {`dotdot-slash prefix`, `../other`, true}, - {`current dir`, `.`, true}, - {`absolute unix-style`, `/tmp/skills`, true}, - {`tilde prefix`, `~/skills`, true}, - - // owner/repo should never be treated as local. - {`owner-repo`, `github/awesome-copilot`, false}, - {`simple name`, `awesome-copilot`, false}, - {`empty string`, ``, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := isLocalPath(tt.arg) - assert.Equal(t, tt.want, got, "isLocalPath(%q)", tt.arg) - }) - } -} - -func TestIsLocalPath_WindowsExistingDir(t *testing.T) { - // A directory that exists on disk should be detected as local even when - // its name looks like owner/repo (the os.Stat safety-net). - dir := t.TempDir() - nested := filepath.Join(dir, "owner", "repo") - if err := os.MkdirAll(nested, 0o755); err != nil { - t.Fatal(err) - } - - // Use a relative path that happens to contain a backslash separator. - rel, err := filepath.Rel(".", nested) - if err != nil { - // If we can't compute a relative path, just use the absolute one. - rel = nested - } - assert.True(t, isLocalPath(rel), "existing dir should be detected as local: %s", rel) -} diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index ee33b04c14f..270912478a4 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -21,7 +21,7 @@ import ( "github.com/spf13/cobra" ) -type previewOptions struct { +type PreviewOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) Prompter prompter.Prompter @@ -36,8 +36,8 @@ type previewOptions struct { } // NewCmdPreview creates the "skills preview" command. -func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra.Command { - opts := &previewOptions{ +func NewCmdPreview(f *cmdutil.Factory, runF func(*PreviewOptions) error) *cobra.Command { + opts := &PreviewOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, Prompter: f.Prompter, @@ -49,7 +49,7 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra. cmd := &cobra.Command{ Use: "preview []", - Short: "Preview a skill from a GitHub repository", + Short: "Preview a skill from a GitHub repository (preview)", Long: heredoc.Doc(` Render a skill's SKILL.md content in the terminal. This fetches the skill file from the repository and displays it using the configured @@ -109,7 +109,7 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*previewOptions) error) *cobra. return cmd } -func previewRun(opts *previewOptions) error { +func previewRun(opts *PreviewOptions) error { cs := opts.IO.ColorScheme() repo := opts.repo @@ -186,7 +186,7 @@ func previewRun(opts *previewOptions) error { } // renderAllFiles dumps the tree, SKILL.md, and all extra files through the pager. -func renderAllFiles(opts *previewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, +func renderAllFiles(opts *PreviewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, files []discovery.SkillFile, rendered string, extraFiles []discovery.SkillFile, apiClient *api.Client, hostname, owner, repo string) error { @@ -213,11 +213,11 @@ func renderAllFiles(opts *previewOptions, cs *iostreams.ColorScheme, skill disco totalBytes := 0 for _, f := range extraFiles { if fetched >= maxFiles { - fmt.Fprintf(out, "\n%s\n", cs.Muted(fmt.Sprintf("(skipped remaining files — showing first %d)", maxFiles))) + fmt.Fprintf(out, "\n%s\n", cs.Muted(fmt.Sprintf("(skipped remaining files, showing first %d)", maxFiles))) break } if totalBytes+f.Size > maxTotalBytes && fetched > 0 { - fmt.Fprintf(out, "\n%s\n", cs.Muted("(skipped remaining files — size limit reached)")) + fmt.Fprintf(out, "\n%s\n", cs.Muted("(skipped remaining files, size limit reached)")) break } fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, f.SHA) @@ -238,7 +238,7 @@ func renderAllFiles(opts *previewOptions, cs *iostreams.ColorScheme, skill disco } // renderInteractive shows the file tree, then a picker to browse individual files. -func renderInteractive(opts *previewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, +func renderInteractive(opts *PreviewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, files []discovery.SkillFile, renderedSkillMD string, extraFiles []discovery.SkillFile, apiClient *api.Client, hostname, owner, repo string) error { @@ -254,7 +254,7 @@ func renderInteractive(opts *previewOptions, cs *iostreams.ColorScheme, skill di choices = append(choices, f.Path) } - // Save original stdout — StopPager closes IO.Out, so we need to + // Save original stdout. StopPager closes IO.Out, so we need to // restore a working writer before each StartPager call. originalOut := opts.IO.Out @@ -276,7 +276,7 @@ func renderInteractive(opts *previewOptions, cs *iostreams.ColorScheme, skill di } else { selectedFile := extraFiles[idx-1] - // Fetch on demand — don't hold blob data in memory + // Fetch on demand; don't hold blob data in memory fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, selectedFile.SHA) if fetchErr != nil { fmt.Fprintf(opts.IO.ErrOut, "%s could not fetch %s: %v\n", cs.Red("!"), selectedFile.Path, fetchErr) @@ -296,7 +296,7 @@ func renderInteractive(opts *previewOptions, cs *iostreams.ColorScheme, skill di } } -func (opts *previewOptions) renderFile(filePath, content string) string { +func (opts *PreviewOptions) renderFile(filePath, content string) string { if opts.RenderFile != nil { return opts.RenderFile(filePath, content) } @@ -304,7 +304,7 @@ func (opts *previewOptions) renderFile(filePath, content string) string { return renderMarkdownPreview(opts.IO, filePath, content) } -func renderSelectedFilePreview(opts *previewOptions, filePath, content string) string { +func renderSelectedFilePreview(opts *PreviewOptions, filePath, content string) string { if !isMarkdownFile(filePath) { return content } @@ -340,7 +340,7 @@ func isMarkdownFile(filePath string) bool { } } -func selectSkill(opts *previewOptions, skills []discovery.Skill) (discovery.Skill, error) { +func selectSkill(opts *PreviewOptions, skills []discovery.Skill) (discovery.Skill, error) { if opts.SkillName != "" { for _, s := range skills { if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName { diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index debdfbff2cf..474ce88b5aa 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -73,8 +73,8 @@ func TestNewCmdPreview(t *testing.T) { Prompter: &prompter.PrompterMock{}, } - var gotOpts *previewOptions - cmd := NewCmdPreview(f, func(opts *previewOptions) error { + var gotOpts *PreviewOptions + cmd := NewCmdPreview(f, func(opts *PreviewOptions) error { gotOpts = opts return nil }) @@ -112,7 +112,7 @@ func TestPreviewRun(t *testing.T) { tests := []struct { name string - opts *previewOptions + opts *PreviewOptions tty bool httpStubs func(*httpmock.Registry) wantStdout string @@ -121,7 +121,7 @@ func TestPreviewRun(t *testing.T) { { name: "preview specific skill", tty: true, - opts: &previewOptions{ + opts: &PreviewOptions{ repo: ghrepo.New("github", "awesome-copilot"), SkillName: "my-skill", }, @@ -164,7 +164,7 @@ func TestPreviewRun(t *testing.T) { { name: "preview with display name match", tty: true, - opts: &previewOptions{ + opts: &PreviewOptions{ repo: ghrepo.New("owner", "repo"), SkillName: "ns/my-skill", }, @@ -208,7 +208,7 @@ func TestPreviewRun(t *testing.T) { { name: "skill not found", tty: true, - opts: &previewOptions{ + opts: &PreviewOptions{ repo: ghrepo.New("owner", "repo"), SkillName: "nonexistent", }, @@ -238,7 +238,7 @@ func TestPreviewRun(t *testing.T) { { name: "no skill name non-interactive errors", tty: false, - opts: &previewOptions{ + opts: &PreviewOptions{ repo: ghrepo.New("owner", "repo"), }, httpStubs: func(reg *httpmock.Registry) { @@ -267,7 +267,7 @@ func TestPreviewRun(t *testing.T) { { name: "preview with explicit version", tty: true, - opts: &previewOptions{ + opts: &PreviewOptions{ repo: ghrepo.New("github", "awesome-copilot"), SkillName: "my-skill", Version: "abc123def456", @@ -350,7 +350,7 @@ func TestPreviewRun(t *testing.T) { func TestPreviewRun_UnsupportedHost(t *testing.T) { ios, _, _, _ := iostreams.Test() - err := previewRun(&previewOptions{ + err := previewRun(&PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, repo: ghrepo.NewWithHost("github", "awesome-copilot", "acme.ghes.com"), @@ -410,7 +410,7 @@ func TestPreviewRun_Interactive(t *testing.T) { }, } - opts := &previewOptions{ + opts := &PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -504,7 +504,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { }, } - opts := &previewOptions{ + opts := &PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -583,7 +583,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { }, } - opts := &previewOptions{ + opts := &PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, @@ -612,7 +612,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { ios.SetStdinTTY(false) ios.SetColorEnabled(false) - opts := &previewOptions{ + opts := &PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: &prompter.PrompterMock{}, @@ -718,7 +718,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { ios.SetStdoutTTY(false) ios.SetStdinTTY(false) - opts := &previewOptions{ + opts := &PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: &prompter.PrompterMock{}, @@ -749,13 +749,13 @@ func TestPreviewRun_RenderLimits(t *testing.T) { httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blob000"), httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob000", "content": "%s", "encoding": "base64"}`, bigContent)), ) - // blob001 should NOT be fetched — size limit reached + // blob001 should NOT be fetched (size limit reached) ios, _, stdout, _ := iostreams.Test() ios.SetStdoutTTY(false) ios.SetStdinTTY(false) - opts := &previewOptions{ + opts := &PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: &prompter.PrompterMock{}, @@ -787,7 +787,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { ios.SetStdoutTTY(false) ios.SetStdinTTY(false) - opts := &previewOptions{ + opts := &PreviewOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: &prompter.PrompterMock{}, diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 22d87bb7340..82202514bb6 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -27,25 +27,21 @@ import ( "github.com/spf13/cobra" ) -// publishOptions holds all dependencies and user-provided flags for the publish command. -type publishOptions struct { +// PublishOptions holds all dependencies and user-provided flags for the publish command. +type PublishOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) Config func() (gh.Config, error) Prompter prompter.Prompter GitClient *git.Client - // Arguments - Dir string // directory to validate (default: cwd) + Dir string + Fix bool + DryRun bool + Tag string - // Flags - Fix bool // --fix flag: auto-fix issues where possible - DryRun bool // --dry-run flag: validate only, don't publish - Tag string // --tag flag: release tag to create - - // Testing overrides - client *api.Client // injectable for tests; nil means use factory HttpClient - host string // API host (e.g. "github.com"); resolved from config in production + client *api.Client // injectable for tests; nil means use factory + host string // resolved from config in production } // publishDiagnostic is a single validation finding. @@ -90,8 +86,8 @@ type repoSecurityResponse struct { } // NewCmdPublish creates the "skills publish" command. -func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra.Command { - opts := &publishOptions{ +func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra.Command { + opts := &PublishOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, Config: f.Config, @@ -100,8 +96,8 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. } cmd := &cobra.Command{ - Use: "publish []", - Short: "Validate and publish skills to a GitHub repository", + Use: "publish [] [flags]", + Short: "Validate and publish skills to a GitHub repository (preview)", Long: heredoc.Doc(` Validate a local repository's skills against the Agent Skills specification and publish them by creating a GitHub release. @@ -158,7 +154,7 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*publishOptions) error) *cobra. return cmd } -func publishRun(opts *publishOptions) error { +func publishRun(opts *PublishOptions) error { dir := opts.Dir if dir == "" { var err error @@ -478,7 +474,7 @@ func fetchTags(client *api.Client, host, owner, repo string) []tagEntry { } // runPublishRelease handles the interactive publish flow: topic, tag, release, immutability. -func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, repo, dir string, hasTopic bool, existingTags []tagEntry) error { +func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, repo, dir string, hasTopic bool, existingTags []tagEntry) error { cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() @@ -515,7 +511,7 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re if canPrompt { strategies := []string{ - fmt.Sprintf("Semver (recommended) — %s", suggested), + fmt.Sprintf("Semver (recommended): %s", suggested), "Custom tag", } idx, err := opts.Prompter.Select("Tagging strategy:", "", strategies) @@ -550,7 +546,7 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re // Validate tag doesn't already exist for _, t := range existingTags { if t.Name == tag { - return fmt.Errorf("tag %s already exists — choose a different version", tag) + return fmt.Errorf("tag %s already exists; choose a different version", tag) } } @@ -565,7 +561,7 @@ func runPublishRelease(opts *publishOptions, client *api.Client, host, owner, re if enableImmutable { if err := enableImmutableReleases(client, host, owner, repo); err != nil { fmt.Fprintf(opts.IO.ErrOut, "%s Could not enable immutable releases: %v\n", cs.WarningIcon(), err) - fmt.Fprintf(opts.IO.ErrOut, " Enable manually in Settings → General → Releases\n") + fmt.Fprintf(opts.IO.ErrOut, " Enable manually in Settings > General > Releases\n") } else { fmt.Fprintf(opts.IO.Out, "%s Enabled immutable releases\n", cs.SuccessIcon()) } @@ -707,7 +703,7 @@ func checkTagProtection(client *api.Client, host, owner, repo string) []publishD return []publishDiagnostic{{ severity: "warning", - message: "no active tag protection rulesets found — consider protecting tags to ensure immutable releases (Settings → Rules → Rulesets)", + message: "no active tag protection rulesets found. Consider protecting tags to ensure immutable releases (Settings > Rules > Rulesets)", }} } @@ -732,14 +728,14 @@ func checkSecuritySettings(client *api.Client, host, owner, repo, skillsDir stri if sa.SecretScanning == nil || sa.SecretScanning.Status != "enabled" { diagnostics = append(diagnostics, publishDiagnostic{ severity: "warning", - message: "secret scanning is not enabled — recommended to prevent accidental credential exposure (gh repo edit --enable-secret-scanning)", + message: "secret scanning is not enabled. Recommended to prevent accidental credential exposure (gh repo edit --enable-secret-scanning)", }) } if sa.SecretScanningPushProtection == nil || sa.SecretScanningPushProtection.Status != "enabled" { diagnostics = append(diagnostics, publishDiagnostic{ severity: "warning", - message: "secret scanning push protection is not enabled — blocks pushes containing secrets (gh repo edit --enable-secret-scanning-push-protection)", + message: "secret scanning push protection is not enabled. Blocks pushes containing secrets (gh repo edit --enable-secret-scanning-push-protection)", }) } @@ -750,7 +746,7 @@ func checkSecuritySettings(client *api.Client, host, owner, repo, skillsDir stri if err := client.REST(host, "GET", alertsPath, nil, new([]interface{})); err != nil { diagnostics = append(diagnostics, publishDiagnostic{ severity: "info", - message: "skills include code files but code scanning does not appear to be configured (Settings → Code security → Code scanning)", + message: "skills include code files but code scanning does not appear to be configured (Settings > Code security > Code scanning)", }) } } @@ -760,7 +756,7 @@ func checkSecuritySettings(client *api.Client, host, owner, repo, skillsDir stri if err := client.REST(host, "GET", dependabotPath, nil, nil); err != nil { diagnostics = append(diagnostics, publishDiagnostic{ severity: "info", - message: "skills include dependency manifests but Dependabot alerts do not appear to be enabled (Settings → Code security → Dependabot)", + message: "skills include dependency manifests but Dependabot alerts do not appear to be enabled (Settings > Code security > Dependabot)", }) } } @@ -825,7 +821,15 @@ func checkInstalledSkillDirs(gitClient *git.Client, repoDir string) []publishDia if gitClient != nil { ignoreGitClient := gitClient.Copy() ignoreGitClient.RepoDir = repoDir - if ignoreGitClient.IsIgnored(context.Background(), relPath) { + ignored, err := ignoreGitClient.IsIgnored(context.Background(), relPath) + if ignored { + continue + } + if err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "warning", + message: fmt.Sprintf("%s/ may contain installed skills that are not gitignored (could not verify: %v)", relPath, err), + }) continue } } @@ -883,7 +887,7 @@ func detectGitHubRemote(gitClient *git.Client) (ghrepo.Interface, error) { // Fall back to any remote that points to GitHub remotes, err := gitClient.Remotes(context.Background()) if err != nil { - return nil, nil + return nil, nil //nolint:nilerr // failing to list remotes is not an error; it just means no repo detected } for _, r := range remotes { if r.Name == "origin" { @@ -907,14 +911,14 @@ func detectGitHubRemote(gitClient *git.Client) (ghrepo.Interface, error) { func parseGitHubURL(rawURL string) (ghrepo.Interface, error) { u, err := git.ParseURL(rawURL) if err != nil { - return nil, nil + return nil, nil //nolint:nilerr // unparseable URL means it's not a GitHub remote } r, err := ghrepo.FromURL(u) if err != nil { - return nil, nil + return nil, nil //nolint:nilerr // URL didn't match GitHub repo format } if err := source.ValidateSupportedHost(r.RepoHost()); err != nil { - return nil, nil + return nil, nil //nolint:nilerr // non-GitHub host is silently ignored } return r, nil } @@ -930,7 +934,7 @@ func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDia if _, err := dirGitClient.GitDir(context.Background()); err != nil { return []publishDiagnostic{{ severity: "warning", - message: "not a git repository — initialize with: git init && gh repo create", + message: "not a git repository. Initialize with: git init && gh repo create", }} } @@ -938,7 +942,7 @@ func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDia if err != nil || len(remotes) == 0 { return []publishDiagnostic{{ severity: "warning", - message: "no git remote found — create a GitHub repository with: gh repo create", + message: "no git remote found. Create a GitHub repository with: gh repo create", }} } @@ -950,11 +954,11 @@ func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDia } return []publishDiagnostic{{ severity: "warning", - message: fmt.Sprintf("remote %q is not a GitHub repository — skills must be hosted on GitHub for discovery", strings.Join(urls, ", ")), + message: fmt.Sprintf("remote %q is not a GitHub repository. Skills must be hosted on GitHub for discovery", strings.Join(urls, ", ")), }} } -func renderDiagnosticsTTY(opts *publishOptions, skillDirs []string, diagnostics []publishDiagnostic, errors, warnings, fixes int, owner, repo string) { +func renderDiagnosticsTTY(opts *PublishOptions, skillDirs []string, diagnostics []publishDiagnostic, errors, warnings, fixes int, owner, repo string) { cs := opts.IO.ColorScheme() // Separate info messages from errors/warnings for cleaner output @@ -1016,7 +1020,7 @@ func renderDiagnosticsTTY(opts *publishOptions, skillDirs []string, diagnostics } } -func renderDiagnosticsPlain(opts *publishOptions, diagnostics []publishDiagnostic, errors, warnings int) { +func renderDiagnosticsPlain(opts *PublishOptions, diagnostics []publishDiagnostic, errors, warnings int) { for _, d := range diagnostics { if d.severity == "info" { continue diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index d54d04ad392..fdcaa6631e1 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -76,12 +76,12 @@ func TestNewCmdPublish(t *testing.T) { name string cli string wantsErr bool - wantsOpts publishOptions + wantsOpts PublishOptions }{ { name: "all flags", cli: "./monalisa-skills --dry-run --fix --tag v1.0.0", - wantsOpts: publishOptions{ + wantsOpts: PublishOptions{ Dir: "./monalisa-skills", DryRun: true, Fix: true, @@ -91,19 +91,19 @@ func TestNewCmdPublish(t *testing.T) { { name: "directory only", cli: "./octocat-repo", - wantsOpts: publishOptions{ + wantsOpts: PublishOptions{ Dir: "./octocat-repo", }, }, { name: "no args leaves dir empty", cli: "", - wantsOpts: publishOptions{}, + wantsOpts: PublishOptions{}, }, { name: "dry-run flag only", cli: "--dry-run", - wantsOpts: publishOptions{ + wantsOpts: PublishOptions{ DryRun: true, }, }, @@ -113,8 +113,8 @@ func TestNewCmdPublish(t *testing.T) { ios, _, _, _ := iostreams.Test() f := cmdutil.Factory{IOStreams: ios} - var gotOpts *publishOptions - cmd := NewCmdPublish(&f, func(opts *publishOptions) error { + var gotOpts *PublishOptions + cmd := NewCmdPublish(&f, func(opts *PublishOptions) error { gotOpts = opts return nil }) @@ -151,7 +151,7 @@ func TestPublishRun_UnsupportedHost(t *testing.T) { `)) ios, _, _, _ := iostreams.Test() - err := publishRun(&publishOptions{ + err := publishRun(&PublishOptions{ IO: ios, Dir: dir, GitClient: newTestGitClient(t, map[string]string{"origin": "https://github.com/monalisa/skills-repo.git"}), @@ -167,7 +167,7 @@ func TestPublishRun(t *testing.T) { isTTY bool setup func(t *testing.T, dir string) stubs func(*httpmock.Registry) - opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions verify func(t *testing.T, dir string) wantErr string wantStdout string @@ -176,9 +176,9 @@ func TestPublishRun(t *testing.T) { { name: "no skills directory", setup: func(_ *testing.T, _ string) {}, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir} + return &PublishOptions{IO: ios, Dir: dir} }, wantErr: "no skills/ directory", }, @@ -188,9 +188,9 @@ func TestPublishRun(t *testing.T) { t.Helper() require.NoError(t, os.MkdirAll(filepath.Join(dir, "skills", "empty-skill"), 0o755)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir} + return &PublishOptions{IO: ios, Dir: dir} }, wantErr: "validation failed", wantStdout: "missing SKILL.md", @@ -206,9 +206,9 @@ func TestPublishRun(t *testing.T) { Body text. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir} + return &PublishOptions{IO: ios, Dir: dir} }, wantErr: "validation failed", wantStdout: "missing required field: name", @@ -225,9 +225,9 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir} + return &PublishOptions{IO: ios, Dir: dir} }, wantErr: "validation failed", wantStdout: "does not match directory name", @@ -244,9 +244,9 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir} + return &PublishOptions{IO: ios, Dir: dir} }, wantErr: "validation failed", wantStdout: "naming convention", @@ -268,9 +268,9 @@ func TestPublishRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { stubAllSecureRemote(reg, "monalisa", "skills-repo") }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, DryRun: true, @@ -320,9 +320,9 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Tag: "v1.0.1", @@ -356,9 +356,9 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir, Fix: true} + return &PublishOptions{IO: ios, Dir: dir, Fix: true} }, wantStdout: "stripped install metadata", verify: func(t *testing.T, dir string) { @@ -386,9 +386,9 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir, Fix: false} + return &PublishOptions{IO: ios, Dir: dir, Fix: false} }, wantErr: "validation failed", wantStdout: "--fix", @@ -405,9 +405,9 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir} + return &PublishOptions{IO: ios, Dir: dir} }, wantStdout: "license", }, @@ -426,9 +426,9 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{IO: ios, Dir: dir} + return &PublishOptions{IO: ios, Dir: dir} }, wantErr: "validation failed", wantStdout: "allowed-tools must be a string", @@ -473,9 +473,9 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, GitClient: newTestGitClient(t, map[string]string{ @@ -525,9 +525,9 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, GitClient: newTestGitClient(t, map[string]string{ @@ -587,9 +587,9 @@ func TestPublishRun(t *testing.T) { httpmock.StatusStringResponse(404, "not found"), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, DryRun: true, @@ -651,9 +651,9 @@ func TestPublishRun(t *testing.T) { httpmock.StatusStringResponse(404, "not found"), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, DryRun: true, @@ -680,14 +680,14 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) runGitInDir(t, dir, "init", "--initial-branch=main") runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, GitClient: &git.Client{RepoDir: dir}, @@ -716,9 +716,9 @@ func TestPublishRun(t *testing.T) { runGitInDir(t, dir, "add", ".gitignore") runGitInDir(t, dir, "commit", "-m", "init") }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, GitClient: &git.Client{RepoDir: dir}, @@ -730,6 +730,31 @@ func TestPublishRun(t *testing.T) { // The key assertion: .gitignored dirs should NOT produce a warning }, }, + { + name: "installed skill dirs git error warns about unverified status", + setup: func(t *testing.T, dir string) { + t.Helper() + writeSkill(t, dir, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A skill + license: MIT + --- + Body. + `)) + // Create install dir but do NOT init git so check-ignore will fail + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) + }, + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + GitClient: &git.Client{RepoDir: dir}, + } + }, + wantStdout: "may contain installed skills that are not gitignored", + }, { name: "no GitHub remote warns", setup: func(t *testing.T, dir string) { @@ -747,9 +772,9 @@ func TestPublishRun(t *testing.T) { runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") runGitInDir(t, dir, "remote", "add", "origin", "https://gitlab.com/hubot/bar.git") }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, GitClient: &git.Client{RepoDir: dir}, @@ -774,9 +799,9 @@ func TestPublishRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { stubAllSecureRemote(reg, "octocat", "repo") }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, DryRun: true, @@ -860,9 +885,9 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Tag: "v1.0.0", @@ -937,9 +962,9 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Tag: "v2.3.5", @@ -968,9 +993,9 @@ func TestPublishRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { stubAllSecureRemote(reg, "monalisa", "skills-repo") }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag @@ -1000,9 +1025,9 @@ func TestPublishRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { stubAllSecureRemote(reg, "monalisa", "skills-repo") }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, GitClient: newTestGitClient(t, map[string]string{ @@ -1027,9 +1052,9 @@ func TestPublishRun(t *testing.T) { Body. `)) }, - opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, } @@ -1051,7 +1076,7 @@ func TestPublishRun(t *testing.T) { `)) }, stubs: func(reg *httpmock.Registry) { - // No topic yet — first GET for diagnostic check + // No topic yet, first GET for diagnostic check reg.Register( httpmock.REST("GET", "repos/monalisa/skills-repo/topics"), httpmock.JSONResponse(map[string]interface{}{"names": []string{}}), @@ -1097,10 +1122,10 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() confirmCall := 0 - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Prompter: &prompter.PrompterMock{ @@ -1155,9 +1180,9 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Prompter: &prompter.PrompterMock{ @@ -1205,10 +1230,10 @@ func TestPublishRun(t *testing.T) { httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() confirmCall := 0 - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Prompter: &prompter.PrompterMock{ @@ -1273,9 +1298,9 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *publishOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - return &publishOptions{ + return &PublishOptions{ IO: ios, Dir: dir, Prompter: &prompter.PrompterMock{ diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 48ea9f3582d..abf9252752b 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -45,7 +45,7 @@ var SkillSearchFields = []string{ "path", } -type searchOptions struct { +type SearchOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) Config func() (gh.Config, error) @@ -61,8 +61,8 @@ type searchOptions struct { } // NewCmdSearch creates the "skills search" command. -func NewCmdSearch(f *cmdutil.Factory, runF func(*searchOptions) error) *cobra.Command { - opts := &searchOptions{ +func NewCmdSearch(f *cmdutil.Factory, runF func(*SearchOptions) error) *cobra.Command { + opts := &SearchOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, Config: f.Config, @@ -71,8 +71,8 @@ func NewCmdSearch(f *cmdutil.Factory, runF func(*searchOptions) error) *cobra.Co } cmd := &cobra.Command{ - Use: "search ", - Short: "Search for skills across GitHub", + Use: "search [flags]", + Short: "Search for skills across GitHub (preview)", Long: heredoc.Doc(` Search across all public GitHub repositories for skills matching a keyword. @@ -188,7 +188,7 @@ func (s skillResult) ExportData(fields []string) map[string]interface{} { return data } -func searchRun(opts *searchOptions) error { +func searchRun(opts *SearchOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -252,7 +252,7 @@ func searchRun(opts *searchOptions) error { } // noResultsMessage returns an appropriate "no results" message. -func noResultsMessage(opts *searchOptions) string { +func noResultsMessage(opts *SearchOptions) string { if opts.Owner != "" { return fmt.Sprintf("no skills found matching %q from owner %q", opts.Query, opts.Owner) } @@ -328,7 +328,7 @@ func searchByKeyword(client *api.Client, host, queryTerm, owner string, page, li return nil, primaryErr } - // Merge: path-matched → hyphen-matched → owner-matched → primary content. + // Merge: path-matched > hyphen-matched > owner-matched > primary content. var merged []codeSearchItem if pathErr == nil && pathResult != nil { @@ -346,7 +346,7 @@ func searchByKeyword(client *api.Client, host, queryTerm, owner string, page, li } // noResults returns an empty JSON array for exporters or a no-results error. -func noResults(opts *searchOptions, msg string) error { +func noResults(opts *SearchOptions, msg string) error { if opts.Exporter != nil { return opts.Exporter.Write(opts.IO, []skillResult{}) } @@ -433,7 +433,7 @@ func deduplicateByName(skills []skillResult) []skillResult { } // renderResults handles all output modes: JSON, interactive picker, or table. -func renderResults(opts *searchOptions, skills []skillResult, totalPages int) error { +func renderResults(opts *SearchOptions, skills []skillResult, totalPages int) error { if opts.Exporter != nil { return opts.Exporter.Write(opts.IO, skills) } @@ -499,7 +499,7 @@ func renderTable(io *iostreams.IOStreams, skills []skillResult) error { // promptInstall shows a multi-select picker for the user to choose skills // to install from the search results, then runs the install command for each. -func promptInstall(opts *searchOptions, skills []skillResult) error { +func promptInstall(opts *SearchOptions, skills []skillResult) error { fmt.Fprintln(opts.IO.ErrOut) cs := opts.IO.ColorScheme() @@ -589,7 +589,7 @@ func relevanceScore(s skillResult, query string) int { score := 0 // Name match. Normalize spaces to hyphens since skill directory names - // use hyphens as word separators (e.g. query "mcp apps" → "mcp-apps"). + // use hyphens as word separators (e.g. query "mcp apps" > "mcp-apps"). skillLower := strings.ToLower(s.SkillName) if skillLower == term || skillLower == termHyphen { score += 3_000 @@ -825,7 +825,7 @@ func extractSkillName(filePath string) string { return discovery.MatchesSkillPath(filePath) } -// formatStars formats a star count for display (e.g. 1700 → "1.7k"). +// formatStars formats a star count for display (e.g. 1700 > "1.7k"). // TODO kw: Could be swaped for go-humanize. func formatStars(n int) string { if n >= 1000 { diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index 6e35465cdb2..98d26d146ab 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -23,7 +23,7 @@ func TestSearchRun_UnsupportedHost(t *testing.T) { cfg.AuthenticationFunc = func() gh.AuthConfig { return authCfg } - err := searchRun(&searchOptions{ + err := searchRun(&SearchOptions{ IO: ios, Query: "terraform", Page: 1, @@ -38,33 +38,33 @@ func TestNewCmdSearch(t *testing.T) { tests := []struct { name string args string - wantOpts searchOptions + wantOpts SearchOptions wantErr string }{ { name: "query argument", args: "terraform", - wantOpts: searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + wantOpts: SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, }, { name: "with page flag", args: "terraform --page 3", - wantOpts: searchOptions{Query: "terraform", Page: 3, Limit: defaultLimit}, + wantOpts: SearchOptions{Query: "terraform", Page: 3, Limit: defaultLimit}, }, { name: "with limit flag", args: "terraform --limit 5", - wantOpts: searchOptions{Query: "terraform", Page: 1, Limit: 5}, + wantOpts: SearchOptions{Query: "terraform", Page: 1, Limit: 5}, }, { name: "with limit short flag", args: "terraform -L 10", - wantOpts: searchOptions{Query: "terraform", Page: 1, Limit: 10}, + wantOpts: SearchOptions{Query: "terraform", Page: 1, Limit: 10}, }, { name: "with owner flag", args: "terraform --owner hashicorp", - wantOpts: searchOptions{Query: "terraform", Owner: "hashicorp", Page: 1, Limit: defaultLimit}, + wantOpts: SearchOptions{Query: "terraform", Owner: "hashicorp", Page: 1, Limit: defaultLimit}, }, { name: "no arguments", @@ -101,8 +101,8 @@ func TestNewCmdSearch(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { f := &cmdutil.Factory{} - var gotOpts *searchOptions - cmd := NewCmdSearch(f, func(opts *searchOptions) error { + var gotOpts *SearchOptions + cmd := NewCmdSearch(f, func(opts *SearchOptions) error { gotOpts = opts return nil }) @@ -149,7 +149,7 @@ func TestSearchRun(t *testing.T) { tests := []struct { name string - opts *searchOptions + opts *SearchOptions tty bool httpStubs func(*httpmock.Registry) wantStdout string @@ -159,7 +159,7 @@ func TestSearchRun(t *testing.T) { { name: "displays results in non-TTY", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) }, @@ -168,7 +168,7 @@ func TestSearchRun(t *testing.T) { { name: "deduplicates results", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 3, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}, {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}, {"name": "SKILL.md", "path": "skills/terraform-aws/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) }, @@ -177,7 +177,7 @@ func TestSearchRun(t *testing.T) { { name: "no results", tty: true, - opts: &searchOptions{Query: "nonexistent", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "nonexistent", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, emptyCodeResponse) }, @@ -186,7 +186,7 @@ func TestSearchRun(t *testing.T) { { name: "nested skill path", tty: false, - opts: &searchOptions{Query: "my-skill", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "my-skill", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/author/my-skill/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) }, @@ -195,7 +195,7 @@ func TestSearchRun(t *testing.T) { { name: "ranks name-matching results first", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 3, "incomplete_results": false, "items": [ {"name": "SKILL.md", "path": "skills/terraform-deploy/SKILL.md", "repository": {"full_name": "org/repo1"}}, @@ -209,7 +209,7 @@ func TestSearchRun(t *testing.T) { { name: "caps total pages at 1000-result limit", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 5000, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) }, @@ -219,7 +219,7 @@ func TestSearchRun(t *testing.T) { { name: "page beyond available results", tty: false, - opts: &searchOptions{Query: "terraform", Page: 999, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 999, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) }, @@ -228,10 +228,10 @@ func TestSearchRun(t *testing.T) { { name: "json output with selected fields", tty: false, - opts: func() *searchOptions { + opts: func() *SearchOptions { exporter := cmdutil.NewJSONExporter() exporter.SetFields([]string{"repo", "skillName", "stars"}) - return &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit, Exporter: exporter} + return &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit, Exporter: exporter} }(), httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "repository": {"full_name": "github/awesome-skills"}}]}`) @@ -241,10 +241,10 @@ func TestSearchRun(t *testing.T) { { name: "json output empty results", tty: false, - opts: func() *searchOptions { + opts: func() *SearchOptions { exporter := cmdutil.NewJSONExporter() exporter.SetFields([]string{"repo", "skillName"}) - return &searchOptions{Query: "nonexistent", Page: 1, Limit: defaultLimit, Exporter: exporter} + return &SearchOptions{Query: "nonexistent", Page: 1, Limit: defaultLimit, Exporter: exporter} }(), httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, emptyCodeResponse) @@ -254,7 +254,7 @@ func TestSearchRun(t *testing.T) { { name: "rate limit error returns friendly message", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { // All search/code calls return 403 with x-ratelimit-remaining: 0 for range 3 { @@ -272,7 +272,7 @@ func TestSearchRun(t *testing.T) { { name: "HTTP 429 returns rate limit error", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { for range 3 { reg.Register( @@ -286,7 +286,7 @@ func TestSearchRun(t *testing.T) { { name: "HTTP 403 with Retry-After returns rate limit error", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { for range 3 { reg.Register( @@ -303,7 +303,7 @@ func TestSearchRun(t *testing.T) { { name: "no results with owner scope", tty: true, - opts: &searchOptions{Query: "nonexistent", Owner: "monalisa", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "nonexistent", Owner: "monalisa", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { // With --owner set, only path + primary searches fire (no owner search). for range 2 { @@ -318,7 +318,7 @@ func TestSearchRun(t *testing.T) { { name: "enriches results with blob descriptions", tty: false, - opts: &searchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, + opts: &SearchOptions{Query: "terraform", Page: 1, Limit: defaultLimit}, httpStubs: func(reg *httpmock.Registry) { codeResponse := `{"total_count": 1, "incomplete_results": false, "items": [ {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "sha": "abc123", diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index 8f9c45faf73..2989c52f861 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -1,6 +1,7 @@ package skills import ( + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/pkg/cmd/skills/install" "github.com/cli/cli/v2/pkg/cmd/skills/preview" "github.com/cli/cli/v2/pkg/cmd/skills/publish" @@ -13,11 +14,32 @@ import ( // NewCmdSkills returns the top-level "skill" command. func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ - Use: "skill ", - Short: "Install and manage agent skills", - Long: "Install and manage agent skills from GitHub repositories.", + Use: "skill ", + Short: "Install and manage agent skills (preview)", + Long: heredoc.Doc(` + Install and manage agent skills from GitHub repositories. + + Working with agent skills in the GitHub CLI is in preview and + subject to change without notice. + `), Aliases: []string{"skills"}, GroupID: "core", + Example: heredoc.Doc(` + # Search for skills + $ gh skill search terraform + + # Install a skill + $ gh skill install github/awesome-copilot code-review + + # Preview a skill before installing + $ gh skill preview github/awesome-copilot code-review + + # Update all installed skills + $ gh skill update --all + + # Validate skills for publishing + $ gh skill publish --dry-run + `), } cmd.AddCommand(install.NewCmdInstall(f, nil)) diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 11db14a346b..766f52515e0 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -23,23 +23,20 @@ import ( "github.com/spf13/cobra" ) -// updateOptions holds all dependencies and user-provided flags for the update command. -type updateOptions struct { +// UpdateOptions holds all dependencies and user-provided flags for the update command. +type UpdateOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) Config func() (gh.Config, error) Prompter prompter.Prompter GitClient *git.Client - // Arguments - Skills []string // optional: specific skills to update - - // Flags - All bool // --all flag (update without prompting) - Force bool // --force flag (re-download even if SHAs match) - DryRun bool // --dry-run flag (report only, no changes) - Unpin bool // --unpin flag (clear pinned ref and include in update) - Dir string // --dir flag (scan a custom directory) + Skills []string + All bool + Force bool + DryRun bool + Unpin bool + Dir string } // installedSkill represents a locally installed skill parsed from its SKILL.md frontmatter. @@ -66,8 +63,8 @@ type pendingUpdate struct { } // NewCmdUpdate creates the "skills update" command. -func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Command { - opts := &updateOptions{ +func NewCmdUpdate(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Command { + opts := &UpdateOptions{ IO: f.IOStreams, Prompter: f.Prompter, Config: f.Config, @@ -76,8 +73,8 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co } cmd := &cobra.Command{ - Use: "update [...]", - Short: "Update installed skills to their latest versions", + Use: "update [...] [flags]", + Short: "Update installed skills to their latest versions (preview)", Long: heredoc.Doc(` Checks installed skills for available updates by comparing the local tree SHA (from SKILL.md frontmatter) against the remote repository. @@ -142,7 +139,7 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*updateOptions) error) *cobra.Co return cmd } -func updateRun(opts *updateOptions) error { +func updateRun(opts *UpdateOptions) error { cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() @@ -190,10 +187,23 @@ func updateRun(opts *updateOptions) error { installed = filtered } - for _, s := range installed { - if s.metadataErr != nil { - return fmt.Errorf("skill %s has invalid repository metadata: %w", s.name, s.metadataErr) + // Skip skills with invalid metadata rather than aborting the entire + // update run. One corrupt skill should not prevent updating others. + { + var valid []installedSkill + for _, s := range installed { + if s.metadataErr != nil { + fmt.Fprintf(opts.IO.ErrOut, "%s Skipping %s: invalid repository metadata: %s\n", cs.WarningIcon(), s.name, s.metadataErr) + continue + } + valid = append(valid, s) } + installed = valid + } + + if len(installed) == 0 { + fmt.Fprintf(opts.IO.ErrOut, "No updatable skills found.\n") + return nil } // Prompt for metadata on skills missing it (before starting progress indicator) @@ -205,7 +215,7 @@ func updateRun(opts *updateOptions) error { name string source string // "owner/repo" } - prompted := make(map[string]promptedEntry) // dir → entry + prompted := make(map[string]promptedEntry) // dir > entry for i := range installed { s := &installed[i] if s.owner != "" && s.repo != "" { @@ -327,7 +337,7 @@ func updateRun(opts *updateOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s %s is pinned to %s (skipped)\n", cs.Muted("⊘"), s.name, s.pinned) } for _, name := range noMeta { - fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata — reinstall to enable updates\n", cs.WarningIcon(), name) + fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata. Reinstall to enable updates\n", cs.WarningIcon(), name) } if len(updates) == 0 { @@ -346,7 +356,7 @@ func updateRun(opts *updateOptions) error { cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, git.ShortSHA(u.newSHA), discovery.ShortRef(u.resolved.Ref)) } else { - fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s → %s [%s]\n", + fmt.Fprintf(opts.IO.Out, " %s %s (%s/%s) %s > %s [%s]\n", cs.Cyan("•"), u.local.name, u.local.owner, u.local.repo, cs.Muted(git.ShortSHA(u.local.treeSHA)), git.ShortSHA(u.newSHA), discovery.ShortRef(u.resolved.Ref)) diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index b9aabe86a1f..0c224e9a0f5 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -30,11 +30,11 @@ func TestNewCmdUpdate_Help(t *testing.T) { GitClient: &git.Client{}, } - cmd := NewCmdUpdate(f, func(opts *updateOptions) error { + cmd := NewCmdUpdate(f, func(opts *UpdateOptions) error { return nil }) - assert.Equal(t, "update [...]", cmd.Use) + assert.Equal(t, "update [...] [flags]", cmd.Use) assert.NotEmpty(t, cmd.Short) assert.NotEmpty(t, cmd.Long) assert.NotEmpty(t, cmd.Example) @@ -43,7 +43,7 @@ func TestNewCmdUpdate_Help(t *testing.T) { func TestNewCmdUpdate_Flags(t *testing.T) { ios, _, _, _ := iostreams.Test() f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - cmd := NewCmdUpdate(f, func(_ *updateOptions) error { return nil }) + cmd := NewCmdUpdate(f, func(_ *UpdateOptions) error { return nil }) flags := []string{"all", "force", "dry-run", "dir", "unpin"} for _, name := range flags { @@ -55,8 +55,8 @@ func TestNewCmdUpdate_ArgsPassedToOptions(t *testing.T) { ios, _, stdout, stderr := iostreams.Test() f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - var gotOpts *updateOptions - cmd := NewCmdUpdate(f, func(opts *updateOptions) error { + var gotOpts *UpdateOptions + cmd := NewCmdUpdate(f, func(opts *UpdateOptions) error { gotOpts = opts return nil }) @@ -176,7 +176,7 @@ func TestScanInstalledSkills(t *testing.T) { }, { name: "non-existent directory returns nil", - // no setup — dir does not exist + // no setup needed; dir does not exist verify: func(t *testing.T, skills []installedSkill, err error) { t.Helper() require.NoError(t, err) @@ -313,7 +313,7 @@ func TestUpdateRun(t *testing.T) { name string setup func(t *testing.T, dir string) stubs func(reg *httpmock.Registry) - opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions + opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions verify func(t *testing.T, dir string) wantErr string wantStderr string @@ -349,9 +349,9 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/commit1"), httpmock.StringResponse(`{"sha": "commit1", "tree": [{"path": "skills/code-review", "type": "tree", "sha": "currentsha"}, {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob1"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -365,9 +365,9 @@ func TestUpdateRun(t *testing.T) { { name: "no installed skills", stubs: func(reg *httpmock.Registry) {}, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -395,9 +395,9 @@ func TestUpdateRun(t *testing.T) { `)), 0o644)) }, stubs: func(reg *httpmock.Registry) {}, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -427,10 +427,10 @@ func TestUpdateRun(t *testing.T) { `)), 0o644)) }, stubs: func(reg *httpmock.Registry) {}, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -457,10 +457,10 @@ func TestUpdateRun(t *testing.T) { `)), 0o644)) }, stubs: func(reg *httpmock.Registry) {}, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) ios.SetStdinTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -502,9 +502,9 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(fmt.Sprintf(`{"sha": "commitsha123", "tree": [{"path": "skills/monalisa-skill/SKILL.md", "type": "blob", "sha": "blobsha1"}, {"path": "skills/monalisa-skill", "type": "tree", "sha": "abc123def456"}, {"path": "skills", "type": "tree", "sha": "treeshaX"}], "truncated": false}`)), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -546,10 +546,10 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/hubot-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/hubot-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -594,10 +594,10 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(`{"sha": "newcommit456", "tree": [{"path": "skills/hubot-skill/SKILL.md", "type": "blob", "sha": "blobsha2"}, {"path": "skills/hubot-skill", "type": "tree", "sha": "newsha456"}, {"path": "skills", "type": "tree", "sha": "treeshaY"}], "truncated": false}`), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) ios.SetStdinTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -647,9 +647,9 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -708,9 +708,9 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, "IyBOYW1lc3BhY2VkIFNraWxsIFVwZGF0ZWQ="))) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -768,9 +768,9 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/newblob1"), httpmock.StatusStringResponse(500, "server error")) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -828,11 +828,11 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, "IyBDb2RlIFJldmlldyBVcGRhdGVk"))) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStdinTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -883,11 +883,11 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/newcommit789"), httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/code-review", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStdinTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -919,11 +919,11 @@ func TestUpdateRun(t *testing.T) { `)), 0o644)) }, stubs: func(reg *httpmock.Registry) {}, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStdinTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -974,11 +974,11 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(fmt.Sprintf(`{"sha": "blob1", "encoding": "base64", "content": "%s"}`, "IyBNYW51YWwgU2tpbGwgVXBkYXRlZA=="))) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStdinTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -1044,9 +1044,9 @@ func TestUpdateRun(t *testing.T) { httpmock.StringResponse(fmt.Sprintf(`{"sha": "newblob1", "encoding": "base64", "content": "%s"}`, "IyBVbnBpbm5lZCBhbmQgVXBkYXRlZA=="))) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -1084,10 +1084,10 @@ func TestUpdateRun(t *testing.T) { `)), 0o644)) }, stubs: func(reg *httpmock.Registry) {}, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { @@ -1130,10 +1130,10 @@ func TestUpdateRun(t *testing.T) { httpmock.REST("GET", "repos/octocat/hubot-skills/git/trees/newcommit789"), httpmock.StringResponse(`{"sha": "newcommit789", "tree": [{"path": "skills/pinned-skill/SKILL.md", "type": "blob", "sha": "newblob1"}, {"path": "skills/pinned-skill", "type": "tree", "sha": "newsha999"}, {"path": "skills", "type": "tree", "sha": "treeshaZ"}], "truncated": false}`)) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *updateOptions { + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(true) ios.SetStderrTTY(true) - return &updateOptions{ + return &UpdateOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, HttpClient: func() (*http.Client, error) { From fd3d1ad2a7bb4a231dac5c1220aa506f42a92c67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:04:04 +0000 Subject: [PATCH 014/182] chore(deps): bump github.com/mattn/go-isatty from 0.0.20 to 0.0.21 Bumps [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) from 0.0.20 to 0.0.21. - [Commits](https://github.com/mattn/go-isatty/compare/v0.0.20...v0.0.21) --- updated-dependencies: - dependency-name: github.com/mattn/go-isatty dependency-version: 0.0.21 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 615b1ebf404..56ed6b76af1 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/klauspost/compress v1.18.5 github.com/mattn/go-colorable v0.1.14 - github.com/mattn/go-isatty v0.0.20 + github.com/mattn/go-isatty v0.0.21 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/microsoft/dev-tunnels v0.1.19 github.com/muhammadmuzzammil1998/jsonc v1.0.0 diff --git a/go.sum b/go.sum index b6e6f64ed51..2db11609313 100644 --- a/go.sum +++ b/go.sum @@ -383,8 +383,8 @@ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= @@ -600,7 +600,6 @@ golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= From d37f6ffded03c9fbc5f171d146efcf4bb4c46c10 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:04:20 +0000 Subject: [PATCH 015/182] chore(deps): bump charm.land/lipgloss/v2 from 2.0.2 to 2.0.3 Bumps [charm.land/lipgloss/v2](https://github.com/charmbracelet/lipgloss) from 2.0.2 to 2.0.3. - [Release notes](https://github.com/charmbracelet/lipgloss/releases) - [Commits](https://github.com/charmbracelet/lipgloss/compare/v2.0.2...v2.0.3) --- updated-dependencies: - dependency-name: charm.land/lipgloss/v2 dependency-version: 2.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 615b1ebf404..4588ddc37f9 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.2 charm.land/huh/v2 v2.0.3 - charm.land/lipgloss/v2 v2.0.2 + charm.land/lipgloss/v2 v2.0.3 github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc v1.0.0 github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 @@ -77,9 +77,9 @@ require ( github.com/blang/semver v3.5.1+incompatible // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392 // indirect @@ -139,8 +139,8 @@ require ( github.com/itchyny/gojq v0.12.17 // indirect github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect - github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect diff --git a/go.sum b/go.sum index b6e6f64ed51..a2b761578f1 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= -charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= -charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= +charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= @@ -110,16 +110,16 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= -github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= @@ -377,8 +377,8 @@ github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= github.com/letsencrypt/boulder v0.20260223.0 h1:xdS2OnJNUasR6TgVIOpqqcvdkOu47+PQQMBk9ThuWBw= github.com/letsencrypt/boulder v0.20260223.0/go.mod h1:r3aTSA7UZ7dbDfiGK+HLHJz0bWNbHk6YSPiXgzl23sA= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -387,8 +387,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= -github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= From 6c7743eaf15d51dcfb0e858974dabd023f1b61cf Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 16:41:09 +0200 Subject: [PATCH 016/182] fix: align relevanceScore comments with implementation and fix typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update comment in relevanceScore() from '10 000 points' to '3 000 points' to match the actual implementation value of 3_000 - Update corresponding test comment for consistency - Fix typo: 'swaped' → 'swapped' in formatStars comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/search/search.go | 4 ++-- pkg/cmd/skills/search/search_test.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index abf9252752b..3d3114f3d67 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -579,7 +579,7 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { // relevanceScore computes a numeric ranking score for a search result. // Higher scores rank first. Signals (in priority order): -// - Exact skill name match (10 000 points) +// - Exact skill name match (3 000 points) // - Partial skill name match (1 000 points) // - Description contains query (100 points) // - Repository stars (sqrt bonus, ~2 400 for 6k stars) @@ -826,7 +826,7 @@ func extractSkillName(filePath string) string { } // formatStars formats a star count for display (e.g. 1700 > "1.7k"). -// TODO kw: Could be swaped for go-humanize. +// TODO kw: Could be swapped for go-humanize. func formatStars(n int) string { if n >= 1000 { return fmt.Sprintf("%.1fk", float64(n)/1000) diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index 98d26d146ab..b3b177c6422 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -456,7 +456,7 @@ func TestRankByRelevance(t *testing.T) { rankByRelevance(skills, "terraform") - // Exact name match scores highest (10 000), then partial name (1 000), + // Exact name match scores highest (3 000), then partial name (1 000), // then description match (100), then body-only (0). assert.Equal(t, "terraform", skills[0].SkillName) assert.Equal(t, "terraform-plan", skills[1].SkillName) From a6f6ab330f4e21b8ae2e87c3409c79a421fdebd1 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 16:53:27 +0200 Subject: [PATCH 017/182] fix: enforce size cap on first preview file, surface corrupted skills, fail on path traversal - preview: remove 'fetched > 0' guard so the 512KB size cap applies uniformly to all files including the first - update: return skills with corrupted YAML frontmatter with metadataErr set instead of silently dropping them from scan results - installer: fail installation when a path traversal is detected in remote or local skill files instead of silently skipping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/skills/installer/installer.go | 4 ++-- internal/skills/installer/installer_test.go | 14 ++++++++------ pkg/cmd/skills/preview/preview.go | 2 +- pkg/cmd/skills/update/update.go | 8 +++++++- pkg/cmd/skills/update/update_test.go | 4 +++- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index 0ac9e182c1a..e27d35f5b46 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -216,7 +216,7 @@ func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) if err != nil { var traversalErr safepaths.PathTraversalError if errors.As(err, &traversalErr) { - return nil + return fmt.Errorf("blocked path traversal in %q", relPath) } return fmt.Errorf("could not resolve destination path: %w", err) } @@ -273,7 +273,7 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { if err != nil { var traversalErr safepaths.PathTraversalError if errors.As(err, &traversalErr) { - continue + return fmt.Errorf("blocked path traversal in %q", relPath) } return fmt.Errorf("could not resolve destination path: %w", err) } diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go index 6334add8513..e05a3541e9a 100644 --- a/internal/skills/installer/installer_test.go +++ b/internal/skills/installer/installer_test.go @@ -253,7 +253,7 @@ func TestInstallSkill(t *testing.T) { }, }, { - name: "skips path traversal from malicious tree", + name: "fails on path traversal from malicious tree", skill: discovery.Skill{Name: "code-review", Path: "skills/code-review", TreeSHA: "tree123"}, stubs: func(reg *httpmock.Registry) { reg.Register( @@ -280,10 +280,7 @@ func TestInstallSkill(t *testing.T) { }, verify: func(t *testing.T, destDir string) { t.Helper() - _, err := os.Stat(filepath.Join(destDir, "code-review", "SKILL.md")) - assert.NoError(t, err) - - _, err = os.Stat(filepath.Join(destDir, "..", "etc", "passwd")) + _, err := os.Stat(filepath.Join(destDir, "..", "etc", "passwd")) assert.True(t, os.IsNotExist(err), "traversal path should not be written") }, }, @@ -305,7 +302,12 @@ func TestInstallSkill(t *testing.T) { } err := installSkill(opts, tt.skill, destDir) - require.NoError(t, err) + if tt.name == "fails on path traversal from malicious tree" { + require.Error(t, err) + assert.Contains(t, err.Error(), "blocked path traversal") + } else { + require.NoError(t, err) + } tt.verify(t, destDir) }) } diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 270912478a4..319288f176f 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -216,7 +216,7 @@ func renderAllFiles(opts *PreviewOptions, cs *iostreams.ColorScheme, skill disco fmt.Fprintf(out, "\n%s\n", cs.Muted(fmt.Sprintf("(skipped remaining files, showing first %d)", maxFiles))) break } - if totalBytes+f.Size > maxTotalBytes && fetched > 0 { + if totalBytes+f.Size > maxTotalBytes { fmt.Fprintf(out, "\n%s\n", cs.Muted("(skipped remaining files, size limit reached)")) break } diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 766f52515e0..ad7c647d5af 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -510,7 +510,13 @@ func scanInstalledSkills(skillsDir string, host *registry.AgentHost, scope regis func parseInstalledSkill(data []byte, name, dir string, host *registry.AgentHost, scope registry.Scope) (installedSkill, bool) { result, err := frontmatter.Parse(string(data)) if err != nil { - return installedSkill{}, false + return installedSkill{ + name: name, + dir: dir, + host: host, + scope: scope, + metadataErr: fmt.Errorf("invalid SKILL.md: %w", err), + }, true } s := installedSkill{ diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index 0c224e9a0f5..86fdcaa80eb 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -199,7 +199,9 @@ func TestScanInstalledSkills(t *testing.T) { verify: func(t *testing.T, skills []installedSkill, err error) { t.Helper() require.NoError(t, err) - assert.Len(t, skills, 0) + require.Len(t, skills, 1) + assert.Equal(t, "corrupt", skills[0].name) + assert.ErrorContains(t, skills[0].metadataErr, "invalid SKILL.md") }, }, } From 1d5c74a83876524cd55b406c6c6ac0b1eb34735d Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 22:56:06 +0200 Subject: [PATCH 018/182] fix: use target directory remotes in skills publish When a directory argument is provided to `gh skill publish`, the remote detection now correctly uses the target directory's git remotes instead of the current working directory's remotes. Previously, `detectGitHubRemote` used the factory-provided git client which pointed to the CWD. This meant that running `gh skill publish /path/to/repo-bar` from inside repo-foo would detect repo-foo's remotes and potentially create the release on the wrong repo. The fix copies the git client and sets `RepoDir` to the target directory, matching the pattern already used by `detectMissingRepoDiagnostic` and `checkInstalledSkillDirs`. Co-authored-by: BagToad <47394200+BagToad@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/skills-publish-dir-remote.txtar | 58 ++++ pkg/cmd/skills/publish/publish.go | 16 +- pkg/cmd/skills/publish/publish_test.go | 321 ++++++++++++------ 3 files changed, 276 insertions(+), 119 deletions(-) create mode 100644 acceptance/testdata/skills/skills-publish-dir-remote.txtar diff --git a/acceptance/testdata/skills/skills-publish-dir-remote.txtar b/acceptance/testdata/skills/skills-publish-dir-remote.txtar new file mode 100644 index 00000000000..8f833a76ca4 --- /dev/null +++ b/acceptance/testdata/skills/skills-publish-dir-remote.txtar @@ -0,0 +1,58 @@ +# When a directory argument is provided to `gh skill publish --dry-run`, +# the remote detection must use the target directory's git remotes, +# not the current working directory's remotes. +# +# This test creates two separate git repos: +# - cwd-repo (the working directory) with remote pointing to owner/cwd-repo +# - target-repo (the dir argument) with remote pointing to owner/target-repo +# +# If the bug is present, the command would detect cwd-repo's remote instead of +# target-repo's remote. + +# Set up credential helper +exec gh auth setup-git + +# Create two test repos on GitHub +exec gh repo create $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING --private --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING + +exec gh repo create $ORG/$SCRIPT_NAME-target-$RANDOM_STRING --private --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-target-$RANDOM_STRING + +# Clone both repos +exec gh repo clone $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING cwd-repo +exec gh repo clone $ORG/$SCRIPT_NAME-target-$RANDOM_STRING target-repo + +# Add a skill to the target repo only +mkdir target-repo/skills/hello-world +cp $WORK/skill.md target-repo/skills/hello-world/SKILL.md +exec git -C $WORK/target-repo add -A +exec git -C $WORK/target-repo commit -m 'Add test skill' +exec git -C $WORK/target-repo push origin main + +# Run publish dry-run from cwd-repo, pointing at target-repo +cd cwd-repo +exec gh skill publish --dry-run $WORK/target-repo + +# Verify the output references the target repo, not the cwd repo +stdout 'hello-world' + +# Publish with a tag from within cwd-repo, targeting target-repo +exec gh skill publish --tag v0.1.0 $WORK/target-repo + +# Verify the release was created on the TARGET repo, not the cwd repo +exec gh release view v0.1.0 --repo $ORG/$SCRIPT_NAME-target-$RANDOM_STRING +stdout 'v0.1.0' + +# Verify NO release was created on the cwd repo +! exec gh release view v0.1.0 --repo $ORG/$SCRIPT_NAME-cwd-$RANDOM_STRING + +-- skill.md -- +--- +name: hello-world +description: A test skill that greets the user. +--- + +# Hello World + +Greet the user warmly. diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 82202514bb6..e178ae5355b 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -338,7 +338,7 @@ func publishRun(opts *PublishOptions) error { diagnostics = append(diagnostics, installedDirDiags...) // Remote repository checks (best-effort) - repoInfo, remoteErr := detectGitHubRemote(opts.GitClient) + repoInfo, remoteErr := detectGitHubRemote(opts.GitClient, dir) if remoteErr != nil { return remoteErr } @@ -867,14 +867,18 @@ func suggestNextTag(latest string) string { return fmt.Sprintf("%s%s.%s.%d", prefix, major, minor, patch+1) } -// detectGitHubRemote attempts to detect the GitHub owner/repo from git remotes. -func detectGitHubRemote(gitClient *git.Client) (ghrepo.Interface, error) { +// detectGitHubRemote attempts to detect the GitHub owner/repo from git remotes +// in the given directory. +func detectGitHubRemote(gitClient *git.Client, dir string) (ghrepo.Interface, error) { if gitClient == nil { return nil, nil } + dirClient := gitClient.Copy() + dirClient.RepoDir = dir + // Try origin first - if url, err := gitClient.RemoteURL(context.Background(), "origin"); err == nil { + if url, err := dirClient.RemoteURL(context.Background(), "origin"); err == nil { repo, parseErr := parseGitHubURL(url) if parseErr != nil { return nil, parseErr @@ -885,7 +889,7 @@ func detectGitHubRemote(gitClient *git.Client) (ghrepo.Interface, error) { } // Fall back to any remote that points to GitHub - remotes, err := gitClient.Remotes(context.Background()) + remotes, err := dirClient.Remotes(context.Background()) if err != nil { return nil, nil //nolint:nilerr // failing to list remotes is not an error; it just means no repo detected } @@ -893,7 +897,7 @@ func detectGitHubRemote(gitClient *git.Client) (ghrepo.Interface, error) { if r.Name == "origin" { continue } - if url, err := gitClient.RemoteURL(context.Background(), r.Name); err == nil { + if url, err := dirClient.RemoteURL(context.Background(), r.Name); err == nil { repo, parseErr := parseGitHubURL(url) if parseErr != nil { return nil, parseErr diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index fdcaa6631e1..4bfd059816c 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -23,20 +23,20 @@ import ( func newTestGitClient(t *testing.T, remoteURLs map[string]string) *git.Client { t.Helper() dir := t.TempDir() - runGit := func(args ...string) { - t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) - cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+dir) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "git %v: %s", args, out) - } - runGit("init", "--initial-branch=main") - runGit("config", "user.email", "monalisa@github.com") - runGit("config", "user.name", "Monalisa Octocat") + initGitRepo(t, dir, remoteURLs) + return &git.Client{RepoDir: dir} +} + +// initGitRepo initializes a git repo in the given directory and adds remotes. +// Use this when the git repo must live in the same directory as the skill files. +func initGitRepo(t *testing.T, dir string, remoteURLs map[string]string) { + t.Helper() + runGitInDir(t, dir, "init", "--initial-branch=main") + runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") + runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") for name, url := range remoteURLs { - runGit("remote", "add", name, url) + runGitInDir(t, dir, "remote", "add", name, url) } - return &git.Client{RepoDir: dir} } // stubAllSecureRemote registers the standard stubs for a fully-configured remote @@ -151,10 +151,11 @@ func TestPublishRun_UnsupportedHost(t *testing.T) { `)) ios, _, _, _ := iostreams.Test() + initGitRepo(t, dir, map[string]string{"origin": "https://github.com/monalisa/skills-repo.git"}) err := publishRun(&PublishOptions{ IO: ios, Dir: dir, - GitClient: newTestGitClient(t, map[string]string{"origin": "https://github.com/monalisa/skills-repo.git"}), + GitClient: &git.Client{}, client: api.NewClientFromHTTP(&http.Client{}), host: "acme.ghes.com", }) @@ -270,15 +271,16 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "1 skill(s) validated successfully", @@ -322,6 +324,9 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ IO: ios, Dir: dir, @@ -329,11 +334,9 @@ func TestPublishRun(t *testing.T) { Prompter: &prompter.PrompterMock{ ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, }, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "Published v1.0.1", @@ -475,14 +478,15 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/octocat/secure-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/octocat/secure-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "secret scanning is not enabled", @@ -527,14 +531,15 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/octocat/tag-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/octocat/tag-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "tag protection", @@ -589,15 +594,16 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/octocat/code-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/octocat/code-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStderr: "code scanning", @@ -653,15 +659,16 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/octocat/dep-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/octocat/dep-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStderr: "Dependabot", @@ -801,16 +808,17 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://gitlab.com/hubot/bar.git", + "upstream": "git@github.com:octocat/repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://gitlab.com/hubot/bar.git", - "upstream": "git@github.com:octocat/repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStderr: "octocat/repo", @@ -887,6 +895,9 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ IO: ios, Dir: dir, @@ -894,11 +905,9 @@ func TestPublishRun(t *testing.T) { Prompter: &prompter.PrompterMock{ ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, }, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "Added \"agent-skills\" topic", @@ -964,15 +973,16 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - Tag: "v2.3.5", - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + Tag: "v2.3.5", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "Published v2.3.5", @@ -995,15 +1005,16 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantErr: "tag v1.0.0 already exists", @@ -1027,14 +1038,15 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ - IO: ios, - Dir: dir, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + IO: ios, + Dir: dir, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "ok", @@ -1125,6 +1137,9 @@ func TestPublishRun(t *testing.T) { opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() confirmCall := 0 + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ IO: ios, Dir: dir, @@ -1140,11 +1155,9 @@ func TestPublishRun(t *testing.T) { return "v1.0.0", nil // accept suggested tag }, }, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "Published v1.0.0", @@ -1182,6 +1195,9 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ IO: ios, Dir: dir, @@ -1196,11 +1212,9 @@ func TestPublishRun(t *testing.T) { return "beta-1", nil }, }, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "Published beta-1", @@ -1233,6 +1247,9 @@ func TestPublishRun(t *testing.T) { opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() confirmCall := 0 + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ IO: ios, Dir: dir, @@ -1251,11 +1268,9 @@ func TestPublishRun(t *testing.T) { return "v1.0.1", nil }, }, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantErr: "CancelError", @@ -1300,6 +1315,9 @@ func TestPublishRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) return &PublishOptions{ IO: ios, Dir: dir, @@ -1314,11 +1332,9 @@ func TestPublishRun(t *testing.T) { return "v1.0.1", nil }, }, - GitClient: newTestGitClient(t, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }), - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), - host: "github.com", + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", } }, wantStdout: "Enabled immutable releases", @@ -1363,6 +1379,85 @@ func TestPublishRun(t *testing.T) { } } +func TestDetectGitHubRemote_UsesDir(t *testing.T) { + // Create two separate git repos: "cwd-repo" simulates the working directory + // and "target-repo" simulates the directory argument passed to publish. + cwdRepo := t.TempDir() + initGitRepo(t, cwdRepo, map[string]string{ + "origin": "https://github.com/monalisa/cwd-repo.git", + }) + + targetRepo := t.TempDir() + initGitRepo(t, targetRepo, map[string]string{ + "origin": "https://github.com/monalisa/target-repo.git", + }) + + // gitClient points at cwd-repo (simulating factory-provided client) + gitClient := &git.Client{RepoDir: cwdRepo} + + // detectGitHubRemote should use targetRepo's remotes, not cwdRepo's + repo, err := detectGitHubRemote(gitClient, targetRepo) + require.NoError(t, err) + require.NotNil(t, repo) + assert.Equal(t, "monalisa", repo.RepoOwner()) + assert.Equal(t, "target-repo", repo.RepoName()) +} + +func TestPublishRun_DirArgUsesTargetRemote(t *testing.T) { + // Regression test: when a directory argument is provided, remote detection + // must use that directory's git remotes, not the factory client's directory. + // + // Scenario: + // 1. User is in cwd-repo (has remote → monalisa/cwd-repo) + // 2. User runs: gh skill publish /path/to/target-repo + // 3. target-repo has remote → monalisa/target-repo + // 4. API calls must go to target-repo, NOT cwd-repo + + cwdRepo := t.TempDir() + initGitRepo(t, cwdRepo, map[string]string{ + "origin": "https://github.com/monalisa/cwd-repo.git", + }) + + targetRepo := t.TempDir() + initGitRepo(t, targetRepo, map[string]string{ + "origin": "https://github.com/monalisa/target-repo.git", + }) + + writeSkill(t, targetRepo, "my-skill", heredoc.Doc(` + --- + name: my-skill + description: A test skill + license: MIT + --- + Body text. + `)) + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + // Stub API calls for target-repo (the correct repo). + // If the bug is present, these stubs won't be called because the code + // would try to hit cwd-repo endpoints instead, and reg.Verify would fail. + stubAllSecureRemote(reg, "monalisa", "target-repo") + + err := publishRun(&PublishOptions{ + IO: ios, + Dir: targetRepo, + DryRun: true, + GitClient: &git.Client{RepoDir: cwdRepo}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + }) + + require.NoError(t, err) + assert.Contains(t, stdout.String(), "1 skill(s) validated successfully") +} + // writeSkill creates skills//SKILL.md with the given content. func writeSkill(t *testing.T, dir, name, content string) { t.Helper() From 92e40eabea2696787ef0ede6d11889611348dbc1 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 23:01:09 +0200 Subject: [PATCH 019/182] fix: preserve namespace in skills search deduplication Skills with the same name but different namespaces (e.g. skills/kynan/commit and skills/will/commit) were being collapsed into a single search result because extractSkillName discarded the namespace. This also caused deduplicateByName to cap results across different namespaces as if they were the same skill. Changes: - Add MatchSkillPath to discovery package returning both name and namespace (the existing MatchesSkillPath is kept for compat) - Add Namespace field to skillResult in search - Fix deduplicateResults to use repo/namespace/name as the dedup key - Fix deduplicateByName to cap by namespace-qualified name - Update table, prompt, and JSON output to show qualified names - Use skill path for install subprocess when namespace is present, ensuring unambiguous install of namespaced skills - Add namespace to --json fields and relevance scoring/filtering - Add unit tests for namespace dedup, qualified names, and filtering - Add acceptance test for namespaced skill search and install Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/skills-search-namespaced.txtar | 60 +++++++++ internal/skills/discovery/discovery.go | 12 ++ internal/skills/discovery/discovery_test.go | 24 ++++ pkg/cmd/skills/search/search.go | 70 +++++++--- pkg/cmd/skills/search/search_test.go | 123 +++++++++++++++--- 5 files changed, 254 insertions(+), 35 deletions(-) create mode 100644 acceptance/testdata/skills/skills-search-namespaced.txtar diff --git a/acceptance/testdata/skills/skills-search-namespaced.txtar b/acceptance/testdata/skills/skills-search-namespaced.txtar new file mode 100644 index 00000000000..e0fb888cbf8 --- /dev/null +++ b/acceptance/testdata/skills/skills-search-namespaced.txtar @@ -0,0 +1,60 @@ +# Two namespaced skills with the same base name in the same repo should +# both appear in search results and be independently installable. + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repo with two namespaced skills that share the name "deploy" +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --public --add-readme +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING +cd $SCRIPT_NAME-$RANDOM_STRING + +mkdir -p skills/alice/deploy +mkdir -p skills/bob/deploy +cp $WORK/alice-skill.md skills/alice/deploy/SKILL.md +cp $WORK/bob-skill.md skills/bob/deploy/SKILL.md + +exec git add -A +exec git commit -m 'Add namespaced skills' +exec git push origin main + +# Publish so the skills are discoverable +exec gh skill publish --tag v1.0.0 + +# Install alice's deploy skill using the full path to disambiguate +exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING skills/alice/deploy --scope user --force +stdout 'Installed alice/deploy' + +# Install bob's deploy skill using the full path +exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING skills/bob/deploy --scope user --force +stdout 'Installed bob/deploy' + +# Verify both were installed to separate directories +exists $HOME/.copilot/skills/alice/deploy/SKILL.md +exists $HOME/.copilot/skills/bob/deploy/SKILL.md + +# Verify each has the correct content +grep 'Alice' $HOME/.copilot/skills/alice/deploy/SKILL.md +grep 'Bob' $HOME/.copilot/skills/bob/deploy/SKILL.md + +-- alice-skill.md -- +--- +name: deploy +description: Alice's deployment skill +--- + +# Deploy by Alice + +Deploys infrastructure using Alice's conventions. + +-- bob-skill.md -- +--- +name: deploy +description: Bob's deployment skill +--- + +# Deploy by Bob + +Deploys infrastructure using Bob's conventions. diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 84f2aa59671..6608bf24a94 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -316,6 +316,18 @@ func MatchesSkillPath(filePath string) string { return m.name } +// MatchSkillPath checks if a file path matches any known skill convention +// and returns the skill name and namespace. Returns empty strings if the +// path doesn't match. The namespace is non-empty for namespaced skills +// (e.g. skills/author/name/SKILL.md) and plugin skills. +func MatchSkillPath(filePath string) (name, namespace string) { + m := matchSkillConventions(treeEntry{Path: filePath}) + if m == nil { + return "", "" + } + return m.name, m.namespace +} + // matchSkillConventions checks if a blob path matches any known skill convention. func matchSkillConventions(entry treeEntry) *skillMatch { if path.Base(entry.Path) != "SKILL.md" { diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 2de7ef683a7..fa50900f0a4 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -898,6 +898,30 @@ func TestMatchesSkillPath(t *testing.T) { } } +func TestMatchSkillPath(t *testing.T) { + tests := []struct { + testName string + path string + wantName string + wantNamespace string + }{ + {testName: "skills convention", path: "skills/code-review/SKILL.md", wantName: "code-review", wantNamespace: ""}, + {testName: "namespaced convention", path: "skills/monalisa/issue-triage/SKILL.md", wantName: "issue-triage", wantNamespace: "monalisa"}, + {testName: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary", wantNamespace: "hubot"}, + {testName: "non-skill file", path: "README.md", wantName: "", wantNamespace: ""}, + {testName: "same name different namespace 1", path: "skills/kynan/commit/SKILL.md", wantName: "commit", wantNamespace: "kynan"}, + {testName: "same name different namespace 2", path: "skills/will/commit/SKILL.md", wantName: "commit", wantNamespace: "will"}, + {testName: "root convention", path: "my-skill/SKILL.md", wantName: "my-skill", wantNamespace: ""}, + } + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + name, namespace := MatchSkillPath(tt.path) + assert.Equal(t, tt.wantName, name) + assert.Equal(t, tt.wantNamespace, namespace) + }) + } +} + func TestDiscoverSkillFiles(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 3d3114f3d67..836fa3de5f2 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -40,6 +40,7 @@ const ( var SkillSearchFields = []string{ "repo", "skillName", + "namespace", "description", "stars", "path", @@ -162,12 +163,22 @@ type skillResult struct { Owner string // parsed from Repo RepoName string // parsed from Repo SkillName string + Namespace string // author/scope prefix for namespaced skills Description string Path string // original file path (e.g. skills/terraform/SKILL.md) BlobSHA string Stars int // repository stargazer count } +// qualifiedName returns the namespace-qualified skill name (e.g. "author/skill") +// or just the skill name if there is no namespace. +func (s skillResult) qualifiedName() string { + if s.Namespace != "" { + return s.Namespace + "/" + s.SkillName + } + return s.SkillName +} + // ExportData implements cmdutil.exportable for --json output. func (s skillResult) ExportData(fields []string) map[string]interface{} { data := map[string]interface{}{} @@ -176,7 +187,9 @@ func (s skillResult) ExportData(fields []string) map[string]interface{} { case "repo": data[f] = s.Repo case "skillName": - data[f] = s.SkillName + data[f] = s.qualifiedName() + case "namespace": + data[f] = s.Namespace case "description": data[f] = s.Description case "stars": @@ -412,17 +425,18 @@ func paginate(skills []skillResult, page, limit int) ([]skillResult, int) { return skills[start:end], totalPages } -// deduplicateByName caps the number of results with the same skill name. -// Since results are pre-sorted by relevance score, the first occurrences +// deduplicateByName caps the number of results with the same qualified skill +// name. Since results are pre-sorted by relevance score, the first occurrences // are the best instances. This prevents aggregator repos (which copy // popular skills verbatim) from flooding results while still showing -// a few alternative sources. +// a few alternative sources. Namespaced skills (e.g. "author/skill") are +// treated as distinct from bare names. func deduplicateByName(skills []skillResult) []skillResult { const maxPerName = 3 counts := make(map[string]int) var result []skillResult for _, s := range skills { - key := strings.ToLower(s.SkillName) + key := strings.ToLower(s.qualifiedName()) if counts[key] >= maxPerName { continue } @@ -485,7 +499,7 @@ func renderTable(io *iostreams.IOStreams, skills []skillResult) error { table := tableprinter.New(io, tableprinter.WithHeader("REPOSITORY", "SKILL", "DESCRIPTION", "STARS")) for _, s := range skills { table.AddField(s.Repo) - table.AddField(s.SkillName) + table.AddField(s.qualifiedName()) desc := s.Description if isTTY { desc = text.Truncate(descWidth, desc) @@ -523,7 +537,7 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { desc := strings.Join(strings.Fields(s.Description), " ") descStr = "\n " + cs.Muted(text.Truncate(descWidth, desc)) } - options[i] = s.SkillName + " " + cs.Muted(s.Repo) + starStr + descStr + options[i] = s.qualifiedName() + " " + cs.Muted(s.Repo) + starStr + descStr } indices, err := opts.Prompter.MultiSelect( @@ -559,18 +573,27 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { for _, idx := range indices { s := skills[idx] + displayName := s.qualifiedName() fmt.Fprintf(opts.IO.ErrOut, "\n%s Installing %s from %s...\n", - cs.Blue("::"), s.SkillName, s.Repo) + cs.Blue("::"), displayName, s.Repo) + + // Use the repo-relative path (e.g. "skills/author/name") for + // disambiguation when installing namespaced skills, so the + // install command can resolve the exact skill without ambiguity. + installArg := s.SkillName + if s.Namespace != "" { + installArg = s.Path + } //nolint:gosec // arguments are from user-selected search results, not arbitrary input - cmd := exec.Command(opts.Executable, "skills", "install", s.Repo, s.SkillName, + cmd := exec.Command(opts.Executable, "skills", "install", s.Repo, installArg, "--agent", host.ID, "--scope", scope) cmd.Stdin = os.Stdin cmd.Stdout = opts.IO.Out cmd.Stderr = opts.IO.ErrOut if err := cmd.Run(); err != nil { fmt.Fprintf(opts.IO.ErrOut, "%s Failed to install %s from %s: %s\n", - cs.Red("!"), s.SkillName, s.Repo, err) + cs.Red("!"), displayName, s.Repo, err) } } @@ -581,6 +604,7 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { // Higher scores rank first. Signals (in priority order): // - Exact skill name match (3 000 points) // - Partial skill name match (1 000 points) +// - Namespace match (500 points) // - Description contains query (100 points) // - Repository stars (sqrt bonus, ~2 400 for 6k stars) func relevanceScore(s skillResult, query string) int { @@ -597,6 +621,11 @@ func relevanceScore(s skillResult, query string) int { score += 1_000 } + // Namespace match. + if s.Namespace != "" && strings.Contains(strings.ToLower(s.Namespace), term) { + score += 500 + } + // Description match. if strings.Contains(strings.ToLower(s.Description), term) { score += 100 @@ -613,7 +642,7 @@ func relevanceScore(s skillResult, query string) int { // filterByRelevance removes results that are not meaningfully related to // the query. A result is kept if the query term appears in the skill name, -// the YAML description, or the repository owner or name. +// the namespace, the YAML description, or the repository owner or name. func filterByRelevance(skills []skillResult, query string) []skillResult { queryTerm := strings.ToLower(query) termHyphen := strings.ReplaceAll(queryTerm, " ", "-") @@ -621,12 +650,14 @@ func filterByRelevance(skills []skillResult, query string) []skillResult { filtered := skills[:0] // reuse backing array for _, s := range skills { nameLower := strings.ToLower(s.SkillName) + namespaceLower := strings.ToLower(s.Namespace) descLower := strings.ToLower(s.Description) ownerLower := strings.ToLower(s.Owner) repoLower := strings.ToLower(s.RepoName) if strings.Contains(nameLower, queryTerm) || strings.Contains(nameLower, termHyphen) || + strings.Contains(namespaceLower, queryTerm) || strings.Contains(descLower, queryTerm) || strings.Contains(ownerLower, queryTerm) || strings.Contains(repoLower, queryTerm) { @@ -740,17 +771,17 @@ func fetchPrimaryPages(client *api.Client, host, query string, displayPage, disp return allItems, totalCount, nil } -// deduplicateResults extracts unique (repo, skill name) pairs from code search hits. +// deduplicateResults extracts unique (repo, namespace, skill name) triples from code search hits. func deduplicateResults(items []codeSearchItem) []skillResult { seen := make(map[string]struct{}) var results []skillResult for _, item := range items { - skillName := extractSkillName(item.Path) + skillName, namespace := extractSkillInfo(item.Path) if skillName == "" { continue } - key := item.Repository.FullName + "/" + skillName + key := item.Repository.FullName + "/" + namespace + "/" + skillName if _, ok := seen[key]; ok { continue } @@ -762,6 +793,7 @@ func deduplicateResults(items []codeSearchItem) []skillResult { Owner: owner, RepoName: repoName, SkillName: skillName, + Namespace: namespace, Path: item.Path, BlobSHA: item.SHA, }) @@ -818,11 +850,11 @@ func fetchDescriptions(client *api.Client, host string, skills []skillResult) ma return descs } -// extractSkillName derives the skill name from a SKILL.md path, but only if -// the path matches a known skill convention (skills/*, skills/scope/*, root-level, -// or plugins/*/skills/*). Returns empty string for non-conforming paths. -func extractSkillName(filePath string) string { - return discovery.MatchesSkillPath(filePath) +// extractSkillInfo derives the skill name and namespace from a SKILL.md path, +// but only if the path matches a known skill convention. Returns empty strings +// for non-conforming paths. +func extractSkillInfo(filePath string) (name, namespace string) { + return discovery.MatchSkillPath(filePath) } // formatStars formats a star count for display (e.g. 1700 > "1.7k"). diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index b3b177c6422..bdfe3ba1913 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -190,7 +190,7 @@ func TestSearchRun(t *testing.T) { httpStubs: func(reg *httpmock.Registry) { stubKeywordSearch(reg, `{"total_count": 1, "incomplete_results": false, "items": [{"name": "SKILL.md", "path": "skills/author/my-skill/SKILL.md", "repository": {"full_name": "org/repo"}}]}`) }, - wantStdout: "org/repo\tmy-skill\t\t0\n", + wantStdout: "org/repo\tauthor/my-skill\t\t0\n", }, { name: "ranks name-matching results first", @@ -225,6 +225,18 @@ func TestSearchRun(t *testing.T) { }, wantErr: `no skills found on page 999 for query "terraform"`, }, + { + name: "namespaced skills are kept distinct in same repo", + tty: false, + opts: &SearchOptions{Query: "commit", Page: 1, Limit: defaultLimit}, + httpStubs: func(reg *httpmock.Registry) { + stubKeywordSearch(reg, `{"total_count": 2, "incomplete_results": false, "items": [ + {"name": "SKILL.md", "path": "skills/kynan/commit/SKILL.md", "repository": {"full_name": "org/skills-repo"}}, + {"name": "SKILL.md", "path": "skills/will/commit/SKILL.md", "repository": {"full_name": "org/skills-repo"}} + ]}`) + }, + wantStdout: "org/skills-repo\tkynan/commit\t\t0\norg/skills-repo\twill/commit\t\t0\n", + }, { name: "json output with selected fields", tty: false, @@ -398,28 +410,52 @@ func TestDeduplicateResults(t *testing.T) { assert.Equal(t, "terraform", results[2].SkillName) } -func TestExtractSkillName(t *testing.T) { +func TestDeduplicateResults_Namespaced(t *testing.T) { + items := []codeSearchItem{ + {Path: "skills/kynan/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/will/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, + {Path: "skills/kynan/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, // duplicate + {Path: "skills/commit/SKILL.md", Repository: codeSearchRepository{FullName: "org/repo"}}, // non-namespaced + } + + results := deduplicateResults(items) + + require.Equal(t, 3, len(results)) + assert.Equal(t, "commit", results[0].SkillName) + assert.Equal(t, "kynan", results[0].Namespace) + assert.Equal(t, "commit", results[1].SkillName) + assert.Equal(t, "will", results[1].Namespace) + assert.Equal(t, "commit", results[2].SkillName) + assert.Equal(t, "", results[2].Namespace) +} + +func TestExtractSkillInfo(t *testing.T) { tests := []struct { - path string - want string + path string + wantName string + wantNamespace string }{ - {"skills/terraform/SKILL.md", "terraform"}, - {"skills/author/my-skill/SKILL.md", "my-skill"}, - {"SKILL.md", ""}, - {"skills/docker/SKILL.md", "docker"}, + {"skills/terraform/SKILL.md", "terraform", ""}, + {"skills/author/my-skill/SKILL.md", "my-skill", "author"}, + {"SKILL.md", "", ""}, + {"skills/docker/SKILL.md", "docker", ""}, // Root-level convention - {"my-skill/SKILL.md", "my-skill"}, + {"my-skill/SKILL.md", "my-skill", ""}, // Plugins convention - {"plugins/openai/skills/chat/SKILL.md", "chat"}, + {"plugins/openai/skills/chat/SKILL.md", "chat", "openai"}, // Non-matching paths should be filtered out - {"random/nested/deep/SKILL.md", ""}, - {".hidden/SKILL.md", ""}, + {"random/nested/deep/SKILL.md", "", ""}, + {".hidden/SKILL.md", "", ""}, + // Same-name skills with different namespaces + {"skills/kynan/commit/SKILL.md", "commit", "kynan"}, + {"skills/will/commit/SKILL.md", "commit", "will"}, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { - got := extractSkillName(tt.path) - assert.Equal(t, tt.want, got) + gotName, gotNamespace := extractSkillInfo(tt.path) + assert.Equal(t, tt.wantName, gotName) + assert.Equal(t, tt.wantNamespace, gotNamespace) }) } } @@ -432,18 +468,22 @@ func TestFilterByRelevance(t *testing.T) { {Repo: "acme/terraform-tools", Owner: "acme", RepoName: "terraform-tools", SkillName: "validator"}, {Repo: "x/y", Owner: "x", RepoName: "y", SkillName: "unrelated", Description: "terraform integration"}, {Repo: "x/z", Owner: "x", RepoName: "z", SkillName: "noise"}, + {Repo: "org/repo3", Owner: "org", RepoName: "repo3", SkillName: "deploy", Namespace: "terraform"}, } filtered := filterByRelevance(skills, "terraform") // Should keep: name match (terraform), owner match (terraform-corp), - // repo name match (terraform-tools), description match (terraform integration). + // repo name match (terraform-tools), description match (terraform integration), + // namespace match (terraform/deploy). // Should drop: docker, noise. - assert.Equal(t, 4, len(filtered)) + assert.Equal(t, 5, len(filtered)) assert.Equal(t, "terraform", filtered[0].SkillName) assert.Equal(t, "linter", filtered[1].SkillName) assert.Equal(t, "validator", filtered[2].SkillName) assert.Equal(t, "unrelated", filtered[3].SkillName) + assert.Equal(t, "deploy", filtered[4].SkillName) + assert.Equal(t, "terraform", filtered[4].Namespace) } func TestRankByRelevance(t *testing.T) { @@ -485,3 +525,54 @@ func TestFormatStars(t *testing.T) { assert.Equal(t, "1.7k", formatStars(1700)) assert.Equal(t, "12.5k", formatStars(12500)) } + +func TestQualifiedName(t *testing.T) { + tests := []struct { + name string + skill skillResult + want string + }{ + { + name: "no namespace", + skill: skillResult{SkillName: "terraform"}, + want: "terraform", + }, + { + name: "with namespace", + skill: skillResult{SkillName: "commit", Namespace: "kynan"}, + want: "kynan/commit", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tt.skill.qualifiedName()) + }) + } +} + +func TestDeduplicateByName_Namespaced(t *testing.T) { + // Skills with the same base name but different namespaces should + // be treated as distinct and not collapsed against each other. + skills := []skillResult{ + {Repo: "org/repo1", SkillName: "commit", Namespace: "kynan"}, + {Repo: "org/repo2", SkillName: "commit", Namespace: "will"}, + {Repo: "org/repo3", SkillName: "commit"}, + {Repo: "org/repo4", SkillName: "commit", Namespace: "kynan"}, + {Repo: "org/repo5", SkillName: "commit", Namespace: "kynan"}, + {Repo: "org/repo6", SkillName: "commit", Namespace: "kynan"}, // should be capped (4th kynan/commit) + } + + result := deduplicateByName(skills) + + // kynan/commit capped at 3, will/commit has 1, bare commit has 1 = 5 total + require.Equal(t, 5, len(result)) + assert.Equal(t, "kynan", result[0].Namespace) + assert.Equal(t, "will", result[1].Namespace) + assert.Equal(t, "", result[2].Namespace) + assert.Equal(t, "kynan", result[3].Namespace) + assert.Equal(t, "kynan", result[4].Namespace) + // repo6 should have been dropped + for _, s := range result { + assert.NotEqual(t, "org/repo6", s.Repo) + } +} From 013d531101d172afeffc6d907ccb60f26bb4dc70 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 23:36:25 +0200 Subject: [PATCH 020/182] chore: remove unused newTestGitClient function Remove the now-unused newTestGitClient helper that was left behind after refactoring tests to use initGitRepo directly. This fixes the golangci-lint 'unused' error in CI. Co-authored-by: BagToad <47394200+BagToad@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish_test.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 4bfd059816c..6da0e01a4c8 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -20,13 +20,6 @@ import ( "github.com/stretchr/testify/require" ) -func newTestGitClient(t *testing.T, remoteURLs map[string]string) *git.Client { - t.Helper() - dir := t.TempDir() - initGitRepo(t, dir, remoteURLs) - return &git.Client{RepoDir: dir} -} - // initGitRepo initializes a git repo in the given directory and adds remotes. // Use this when the git repo must live in the same directory as the skill files. func initGitRepo(t *testing.T, dir string, remoteURLs map[string]string) { From e04dceb3b5bcf8f18117e87314e7e2cd66427a70 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 23:39:11 +0200 Subject: [PATCH 021/182] fix: address review feedback on namespace changes - Keep skillName as bare name in JSON output for backward compat; namespace is available as a separate --json field - Fix Namespace field comment to cover plugin namespaces too - Trim /SKILL.md from install path arg to match comment - Rename acceptance test to skills-install-namespaced since it tests install disambiguation, not search Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...amespaced.txtar => skills-install-namespaced.txtar} | 2 +- pkg/cmd/skills/search/search.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) rename acceptance/testdata/skills/{skills-search-namespaced.txtar => skills-install-namespaced.txtar} (96%) diff --git a/acceptance/testdata/skills/skills-search-namespaced.txtar b/acceptance/testdata/skills/skills-install-namespaced.txtar similarity index 96% rename from acceptance/testdata/skills/skills-search-namespaced.txtar rename to acceptance/testdata/skills/skills-install-namespaced.txtar index e0fb888cbf8..db39bead0f3 100644 --- a/acceptance/testdata/skills/skills-search-namespaced.txtar +++ b/acceptance/testdata/skills/skills-install-namespaced.txtar @@ -1,5 +1,5 @@ # Two namespaced skills with the same base name in the same repo should -# both appear in search results and be independently installable. +# be independently installable using path-based disambiguation. # Use gh as a credential helper exec gh auth setup-git diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 836fa3de5f2..bc1c819e91f 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -163,7 +163,7 @@ type skillResult struct { Owner string // parsed from Repo RepoName string // parsed from Repo SkillName string - Namespace string // author/scope prefix for namespaced skills + Namespace string // namespace prefix: author/scope for skills/{author}/* or plugin name for plugins/{plugin}/skills/* Description string Path string // original file path (e.g. skills/terraform/SKILL.md) BlobSHA string @@ -187,7 +187,7 @@ func (s skillResult) ExportData(fields []string) map[string]interface{} { case "repo": data[f] = s.Repo case "skillName": - data[f] = s.qualifiedName() + data[f] = s.SkillName case "namespace": data[f] = s.Namespace case "description": @@ -577,12 +577,12 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { fmt.Fprintf(opts.IO.ErrOut, "\n%s Installing %s from %s...\n", cs.Blue("::"), displayName, s.Repo) - // Use the repo-relative path (e.g. "skills/author/name") for - // disambiguation when installing namespaced skills, so the + // Use the repo-relative directory path (e.g. "skills/author/name") + // for disambiguation when installing namespaced skills, so the // install command can resolve the exact skill without ambiguity. installArg := s.SkillName if s.Namespace != "" { - installArg = s.Path + installArg = strings.TrimSuffix(s.Path, "/SKILL.md") } //nolint:gosec // arguments are from user-selected search results, not arbitrary input From f2d978d960ee5133ef1e0f5a19c8a3eb259bf556 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 22:58:03 +0200 Subject: [PATCH 022/182] Disable auth check for local-only skill flags Add cmdutil.DisableAuthCheckFlag for --from-local on install so that installing from a local directory does not require authentication. This follows the same pattern used by attestation verify for its --bundle flag. The --dry-run flag on publish is intentionally left with auth enabled because dry-run validation includes remote repository checks (security settings, tag protection, topics). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/install/install.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index fce53583d69..27325b6337a 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -199,6 +199,7 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*InstallOptions) error) *cobra. cmd.Flags().StringVar(&opts.Dir, "dir", "", "Install to a custom directory (overrides --agent and --scope)") cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting") cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository") + cmdutil.DisableAuthCheckFlag(cmd.Flags().Lookup("from-local")) return cmd } From 9f9b93aa6aa1c5d3aedb2d22533ee61d68bf7c83 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 23:34:54 +0200 Subject: [PATCH 023/182] URL-encode parentPath in skills discovery API call The parentPath parameter in the contents API path was not URL-encoded, which would cause failures when paths contain spaces or other special characters. Apply url.PathEscape() to parentPath, consistent with the rest of the file. commitSHA is left unescaped since SHAs are hex-only and never need encoding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/skills/discovery/discovery.go | 2 +- internal/skills/discovery/discovery_test.go | 27 ++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 84f2aa59671..4a6bdb94035 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -491,7 +491,7 @@ func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skill } parentPath := path.Dir(skillPath) - apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", url.PathEscape(owner), url.PathEscape(repo), parentPath, commitSHA) + apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(parentPath), commitSHA) var contents []struct { Name string `json:"name"` diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 2de7ef683a7..e2333dfd14b 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -699,7 +699,7 @@ func TestDiscoverSkillByPath(t *testing.T) { skillPath: "skills/monalisa/issue-triage", stubs: func(reg *httpmock.Registry) { reg.Register( - httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills/monalisa"), + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills%2Fmonalisa"), httpmock.JSONResponse([]map[string]interface{}{ {"name": "issue-triage", "path": "skills/monalisa/issue-triage", "sha": "tree-sha", "type": "dir"}, })) @@ -720,6 +720,31 @@ func TestDiscoverSkillByPath(t *testing.T) { wantName: "issue-triage", wantNS: "monalisa", }, + { + name: "parent path with spaces is URL encoded", + skillPath: "my skills/code-review", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/my%20skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "my skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "code-review", + }, { name: "strips trailing SKILL.md from path", skillPath: "skills/code-review/SKILL.md", From 9552d225ccaebf9c5b36af6023320800d0bf2e68 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 16 Apr 2026 00:35:39 +0200 Subject: [PATCH 024/182] Update acceptance/testdata/skills/skills-search-noresults.txtar Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> --- acceptance/testdata/skills/skills-search-noresults.txtar | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/testdata/skills/skills-search-noresults.txtar b/acceptance/testdata/skills/skills-search-noresults.txtar index 425666556a7..c51d7b56811 100644 --- a/acceptance/testdata/skills/skills-search-noresults.txtar +++ b/acceptance/testdata/skills/skills-search-noresults.txtar @@ -1,4 +1,4 @@ # Search for something unlikely to exist returns empty stdout -# (NoResultsError is silent in non-TTY — exits 0 with no output) +# NoResultsError is silent in non-TTY (exits 0 with no output) exec gh skill search zzzznonexistenttotallyfakeskillxyz123 ! stdout . From 75909ed2ba1bb034630edf49b18fb58ff5b64c04 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:09:11 -0600 Subject: [PATCH 025/182] Suggest and install official extensions for unknown commands When a user runs an unknown command that matches a known official extension (e.g. `gh stack` or `gh aw`), show an install suggestion. In interactive TTY sessions, prompt the user to install it on the spot. The official extension registry is hard-coded in pkg/extensions/. Current entries are github/gh-aw and github/gh-stack. Teams can add their extensions via PR. Install suggestions use an explicit github.com host prefix for GHES compatibility. Refs github/cli#220 Co-Authored-By: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/ghcmd/cmd.go | 53 +++++++++++++++++++++++++++++++++ pkg/extensions/official.go | 40 +++++++++++++++++++++++++ pkg/extensions/official_test.go | 41 +++++++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 pkg/extensions/official.go create mode 100644 pkg/extensions/official_test.go diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 8690078c66e..3bf0f5f4a9b 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -14,15 +14,18 @@ import ( surveyCore "github.com/AlecAivazis/survey/v2/core" "github.com/AlecAivazis/survey/v2/terminal" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/agents" "github.com/cli/cli/v2/internal/build" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/config/migration" + "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/update" "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmd/root" "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/utils" "github.com/cli/safeexec" @@ -140,6 +143,18 @@ func Main() exitCode { return exitCode(extError.ExitCode()) } + // Check if any of the provided args match a known official extension. + // We scan all args rather than just the first because global flags + // (e.g. --repo) may precede the unknown command name. + if strings.HasPrefix(err.Error(), "unknown command ") { + for _, arg := range expandedArgs { + if ext := extensions.FindOfficialExtension(arg); ext != nil { + handleOfficialExtension(cmdFactory.IOStreams, cmdFactory.Prompter, cmdFactory.ExtensionManager, ext, err) + return exitError + } + } + } + printError(stderr, err, cmd, hasDebug) if strings.Contains(err.Error(), "Incorrect function") { @@ -245,3 +260,41 @@ func isUnderHomebrew(ghBinary string) bool { brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator) return strings.HasPrefix(ghBinary, brewBinPrefix) } + +// handleOfficialExtension prints a suggestion for the matched official extension +// and, in interactive TTY sessions, prompts the user to install it. +func handleOfficialExtension(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension, err error) { + stderr := io.ErrOut + + fmt.Fprintln(stderr, err) + + if !io.CanPrompt() { + fmt.Fprint(stderr, heredoc.Docf(` + %q is also available as an official extension. + To install it, run: + gh extension install github.com/%s/%s + `, fmt.Sprintf("gh %s", ext.Name), ext.Owner, ext.Repo)) + return + } + + prompt := heredoc.Docf(` + %q is also available as an official extension. + Would you like to install it now? + `, fmt.Sprintf("gh %s", ext.Name)) + confirmed, promptErr := p.Confirm(prompt, true) + if promptErr != nil || !confirmed { + return + } + + repo := ext.Repository() + io.StartProgressIndicatorWithLabel(fmt.Sprintf("Installing %s/%s...", ext.Owner, ext.Repo)) + defer io.StopProgressIndicator() + installErr := em.Install(repo, "") + io.StopProgressIndicator() + if installErr != nil { + fmt.Fprintf(stderr, "Failed to install extension: %s\n", installErr) + return + } + + fmt.Fprintf(stderr, "Successfully installed %s/%s\n", ext.Owner, ext.Repo) +} diff --git a/pkg/extensions/official.go b/pkg/extensions/official.go new file mode 100644 index 00000000000..a1e6996db13 --- /dev/null +++ b/pkg/extensions/official.go @@ -0,0 +1,40 @@ +package extensions + +import ( + "github.com/cli/cli/v2/internal/ghrepo" +) + +// OfficialExtension describes a GitHub-owned CLI extension that can be +// suggested to users when they invoke an unknown command. +type OfficialExtension struct { + Name string + Owner string + Repo string +} + +// Repository returns a ghrepo.Interface pinned to github.com for use with +// ExtensionManager.Install. +func (e *OfficialExtension) Repository() ghrepo.Interface { + return ghrepo.NewWithHost(e.Owner, e.Repo, "github.com") +} + +// officialExtensions is the hard-coded registry of GitHub-owned extensions +// that gh will suggest installing when the user invokes an unknown command +// matching one of their names. +// Install suggestions include the "github.com/" host prefix so that GHES users +// install from github.com rather than their enterprise host. +var officialExtensions = []OfficialExtension{ + {Name: "aw", Owner: "github", Repo: "gh-aw"}, + {Name: "stack", Owner: "github", Repo: "gh-stack"}, +} + +// FindOfficialExtension returns the matching official extension for +// commandName, or nil if none matches. +func FindOfficialExtension(commandName string) *OfficialExtension { + for _, ext := range officialExtensions { + if ext.Name == commandName { + return &ext + } + } + return nil +} diff --git a/pkg/extensions/official_test.go b/pkg/extensions/official_test.go new file mode 100644 index 00000000000..0a0b5ec52ea --- /dev/null +++ b/pkg/extensions/official_test.go @@ -0,0 +1,41 @@ +package extensions + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindOfficialExtension(t *testing.T) { + tests := []struct { + name string + commandName string + wantNil bool + wantRepo string + }{ + {name: "found", commandName: "stack", wantNil: false, wantRepo: "gh-stack"}, + {name: "not found", commandName: "xyzzy", wantNil: true}, + {name: "empty", commandName: "", wantNil: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ext := FindOfficialExtension(tt.commandName) + if tt.wantNil { + assert.Nil(t, ext) + } else { + require.NotNil(t, ext) + assert.Equal(t, tt.wantRepo, ext.Repo) + } + }) + } +} + +func TestOfficialExtension_Repository(t *testing.T) { + ext := &OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + repo := ext.Repository() + assert.Equal(t, "github", repo.RepoOwner()) + assert.Equal(t, "gh-stack", repo.RepoName()) + assert.Equal(t, "github.com", repo.RepoHost()) +} From 3e0305d941ee3dab0857d367cd17e0b294db108c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:55:45 -0600 Subject: [PATCH 026/182] Disable auth check for `gh extension install` Allow installing extensions without being authenticated. The install command can work with public repositories and local directories without requiring a login, so the auth gate is unnecessary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/extension/command.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 057f911404d..de6e29ab769 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -415,6 +415,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { } cmd.Flags().BoolVar(&forceFlag, "force", false, "Force upgrade extension, or ignore if latest already installed") cmd.Flags().StringVar(&pinFlag, "pin", "", "Pin extension to a release tag or commit ref") + cmdutil.DisableAuthCheck(cmd) return cmd }(), func() *cobra.Command { From a6e9722ec16dcfa89b6ca3dadeeab7a9ebf17092 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 16 Apr 2026 10:20:24 +0100 Subject: [PATCH 027/182] fix skills names in examples --- pkg/cmd/skills/preview/preview.go | 6 +++--- pkg/cmd/skills/skills.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 319288f176f..8ffc75dfcb9 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -69,13 +69,13 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*PreviewOptions) error) *cobra. `), Example: heredoc.Doc(` # Preview a specific skill - $ gh skill preview github/awesome-copilot code-review + $ gh skill preview github/awesome-copilot documentation-writer # Preview a skill at a specific version - $ gh skill preview github/awesome-copilot code-review@v1.2.0 + $ gh skill preview github/awesome-copilot documentation-writer@v1.2.0 # Preview a skill at a specific commit SHA - $ gh skill preview github/awesome-copilot code-review@abc123def456 + $ gh skill preview github/awesome-copilot documentation-writer@abc123def456 # Browse and preview interactively $ gh skill preview github/awesome-copilot diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index 2989c52f861..1dadd3b1f4e 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -29,10 +29,10 @@ func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { $ gh skill search terraform # Install a skill - $ gh skill install github/awesome-copilot code-review + $ gh skill install github/awesome-copilot documentation-writer # Preview a skill before installing - $ gh skill preview github/awesome-copilot code-review + $ gh skill preview github/awesome-copilot documentation-writer # Update all installed skills $ gh skill update --all From 3dce81a0d622e63a0c7703e1c32d964a2f6ad12b Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 16 Apr 2026 14:03:54 +0100 Subject: [PATCH 028/182] docs(skill): improve help docs Signed-off-by: Babak K. Shandiz --- pkg/cmd/skills/install/install.go | 12 ++++++------ pkg/cmd/skills/preview/preview.go | 13 ++++++------- pkg/cmd/skills/publish/publish.go | 16 ++++++++-------- pkg/cmd/skills/search/search.go | 11 +++++------ pkg/cmd/skills/update/update.go | 16 ++++++++-------- 5 files changed, 33 insertions(+), 35 deletions(-) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 27325b6337a..1b6a7fd8fb4 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -78,12 +78,12 @@ func NewCmdInstall(f *cmdutil.Factory, runF func(*InstallOptions) error) *cobra. scope (in your home directory, available everywhere). Supported hosts and their storage directories are (project, user): - - GitHub Copilot (%[1]s.agents/skills%[1]s, %[1]s~/.copilot/skills%[1]s) - - Claude Code (%[1]s.claude/skills%[1]s, %[1]s~/.claude/skills%[1]s) - - Cursor (%[1]s.agents/skills%[1]s, %[1]s~/.cursor/skills%[1]s) - - Codex (%[1]s.agents/skills%[1]s, %[1]s~/.codex/skills%[1]s) - - Gemini CLI (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/skills%[1]s) - - Antigravity (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/antigravity/skills%[1]s) + - GitHub Copilot (%[1]s.agents/skills%[1]s, %[1]s~/.copilot/skills%[1]s) + - Claude Code (%[1]s.claude/skills%[1]s, %[1]s~/.claude/skills%[1]s) + - Cursor (%[1]s.agents/skills%[1]s, %[1]s~/.cursor/skills%[1]s) + - Codex (%[1]s.agents/skills%[1]s, %[1]s~/.codex/skills%[1]s) + - Gemini CLI (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/skills%[1]s) + - Antigravity (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/antigravity/skills%[1]s) Use %[1]s--agent%[1]s and %[1]s--scope%[1]s to control placement, or %[1]s--dir%[1]s for a custom directory. The default scope is %[1]sproject%[1]s, and the default diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 8ffc75dfcb9..e39886ecda9 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -50,12 +50,12 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*PreviewOptions) error) *cobra. cmd := &cobra.Command{ Use: "preview []", Short: "Preview a skill from a GitHub repository (preview)", - Long: heredoc.Doc(` - Render a skill's SKILL.md content in the terminal. This fetches the + Long: heredoc.Docf(` + Render a skill's %[1]sSKILL.md%[1]s content in the terminal. This fetches the skill file from the repository and displays it using the configured pager, without installing anything. - A file tree is shown first, followed by the rendered SKILL.md content. + A file tree is shown first, followed by the rendered %[1]sSKILL.md%[1]s content. When running interactively and the skill contains additional files (scripts, references, etc.), a file picker lets you browse them individually. @@ -63,10 +63,9 @@ func NewCmdPreview(f *cmdutil.Factory, runF func(*PreviewOptions) error) *cobra. When run with only a repository argument, lists available skills and prompts for selection. - To preview a specific version of the skill, append @VERSION to the - skill name. The version is resolved as a git tag, branch, or commit - SHA. - `), + To preview a specific version of the skill, append %[1]s@VERSION%[1]s to the + skill name. The version is resolved as a git tag, branch, or commit SHA. + `, "`"), Example: heredoc.Doc(` # Preview a specific skill $ gh skill preview github/awesome-copilot documentation-writer diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index e178ae5355b..b86a02788e9 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -98,29 +98,29 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. cmd := &cobra.Command{ Use: "publish [] [flags]", Short: "Validate and publish skills to a GitHub repository (preview)", - Long: heredoc.Doc(` + Long: heredoc.Docf(` Validate a local repository's skills against the Agent Skills specification and publish them by creating a GitHub release. Validation checks include: - - Skills follow the skills/*/SKILL.md directory convention + - Skills follow the %[1]sskills/*/SKILL.md%[1]s directory convention - Skill names match the strict agentskills.io naming rules - Each skill name matches its directory name - Required frontmatter fields (name, description) are present - allowed-tools is a string, not an array - - Install metadata (metadata.github-*) is stripped if present + - Install metadata (%[1]smetadata.github-*%[1]s) is stripped if present After validation passes, publish will interactively guide you through: - - Adding the "agent-skills" topic to the repository + - Adding the %[1]sagent-skills%[1]s topic to the repository - Choosing a version tag (semver recommended) - Creating a GitHub release with auto-generated notes - Use --dry-run to validate without publishing. - Use --tag to publish non-interactively with a specific tag. - Use --fix to automatically strip install metadata from committed files. - `), + Use %[1]s--dry-run%[1]s to validate without publishing. + Use %[1]s--tag%[1]s to publish non-interactively with a specific tag. + Use %[1]s--fix%[1]s to automatically strip install metadata from committed files. + `, "`"), Example: heredoc.Doc(` # Validate and publish interactively $ gh skill publish diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 3d3114f3d67..d94d05b473d 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -73,20 +73,19 @@ func NewCmdSearch(f *cmdutil.Factory, runF func(*SearchOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "search [flags]", Short: "Search for skills across GitHub (preview)", - Long: heredoc.Doc(` + Long: heredoc.Docf(` Search across all public GitHub repositories for skills matching a keyword. - Uses the GitHub Code Search API to find SKILL.md files whose name or + Uses the GitHub Code Search API to find %[1]sSKILL.md%[1]s files whose name or description matches the query term. Results are ranked by relevance: skills whose name contains the query term appear first. - Use --owner to scope results to a specific GitHub user or organization. + Use %[1]s--owner%[1]s to scope results to a specific GitHub user or organization. - In interactive mode, you can select skills from the results to install - directly. - `), + In interactive mode, you can select skills from the results to install directly. + `, "`"), Example: heredoc.Doc(` # Search for skills related to terraform $ gh skill search terraform diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index ad7c647d5af..7923a6bdec4 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -75,9 +75,9 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Co cmd := &cobra.Command{ Use: "update [...] [flags]", Short: "Update installed skills to their latest versions (preview)", - Long: heredoc.Doc(` + Long: heredoc.Docf(` Checks installed skills for available updates by comparing the local - tree SHA (from SKILL.md frontmatter) against the remote repository. + tree SHA (from %[1]sSKILL.md%[1]s frontmatter) against the remote repository. Scans all known agent host directories (Copilot, Claude, Cursor, Codex, Gemini, Antigravity) in both project and user scope automatically. @@ -85,8 +85,8 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Co Without arguments, checks all installed skills. With skill names, checks only those specific skills. - Pinned skills (installed with --pin) are skipped with a notice. - Use --unpin to clear the pinned version and include those skills + Pinned skills (installed with %[1]s--pin%[1]s) are skipped with a notice. + Use %[1]s--unpin%[1]s to clear the pinned version and include those skills in the update. Skills without GitHub metadata (e.g. installed manually or by another @@ -94,14 +94,14 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Co The update re-downloads the skill with metadata injected, so future updates work automatically. - With --force, re-downloads skills even when the remote version matches + With %[1]s--force%[1]s, re-downloads skills even when the remote version matches the local tree SHA. This overwrites locally modified skill files with their original content, but does not remove extra files added locally. In interactive mode, shows which skills have updates and asks for - confirmation before proceeding. With --all, updates without prompting. - With --dry-run, reports available updates without modifying any files. - `), + confirmation before proceeding. With %[1]s--all%[1]s, updates without prompting. + With %[1]s--dry-run%[1]s, reports available updates without modifying any files. + `, "`"), Example: heredoc.Doc(` # Check and update all skills interactively $ gh skill update From a1eb707e26ea99c0c640c72011148be19a3e30d6 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Thu, 16 Apr 2026 14:04:29 +0100 Subject: [PATCH 029/182] fix(skill publish): remove misleading `validate` alias Signed-off-by: Babak K. Shandiz --- pkg/cmd/skills/publish/publish.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index b86a02788e9..931b9085c01 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -134,8 +134,7 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. # Validate and strip install metadata $ gh skills publish --fix `), - Aliases: []string{"validate"}, - Args: cobra.MaximumNArgs(1), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 1 { opts.Dir = args[0] From b63f5bfd9ac136546b7d7cda88bb951dbe010a42 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 22:36:33 +0200 Subject: [PATCH 030/182] refactor: use shared discovery logic in publish command Replace the hardcoded skills/ directory requirement in the publish command with the shared DiscoverLocalSkills() function used by install and other commands. This removes the opinionated restriction that skills must live under a skills/ directory and supports all discovery conventions: - skills/*/SKILL.md - skills/{scope}/*/SKILL.md - */SKILL.md (root-level) - plugins/{scope}/skills/*/SKILL.md All per-skill validation (frontmatter, spec-compliant naming, metadata stripping, etc.) is preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish.go | 124 ++++++++++++------------- pkg/cmd/skills/publish/publish_test.go | 83 ++++++++++++++++- 2 files changed, 140 insertions(+), 67 deletions(-) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 931b9085c01..5737c2a31b7 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "os" + "path" "path/filepath" "regexp" "sort" @@ -102,9 +103,15 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. Validate a local repository's skills against the Agent Skills specification and publish them by creating a GitHub release. + Skills are discovered using the same conventions as install: + + - %[1]sskills/*/SKILL.md%[1]s + - %[1]sskills/{scope}/*/SKILL.md%[1]s + - %[1]s*/SKILL.md%[1]s (root-level) + - %[1]splugins/{scope}/skills/*/SKILL.md%[1]s + Validation checks include: - - Skills follow the %[1]sskills/*/SKILL.md%[1]s directory convention - Skill names match the strict agentskills.io naming rules - Each skill name matches its directory name - Required frontmatter fields (name, description) are present @@ -176,36 +183,18 @@ func publishRun(opts *PublishOptions) error { var diagnostics []publishDiagnostic - // Check for skills directory - skillsDir := filepath.Join(dir, "skills") - info, err := os.Stat(skillsDir) - if err != nil || !info.IsDir() { - return fmt.Errorf("no skills/ directory found in %s; run this command from a repository root containing a skills/ directory", dir) - } - - // Discover skill directories - entries, err := os.ReadDir(skillsDir) + skills, err := discovery.DiscoverLocalSkills(dir) if err != nil { - return fmt.Errorf("could not read skills/ directory: %w", err) - } - - var skillDirs []string - for _, e := range entries { - if e.IsDir() { - skillDirs = append(skillDirs, e.Name()) - } + return err } - if len(skillDirs) == 0 { - return fmt.Errorf("no skill directories found in %s/skills/", dir) - } - - for _, dirName := range skillDirs { - skillPath := filepath.Join(skillsDir, dirName, "SKILL.md") + for _, skill := range skills { + dirName := path.Base(skill.Path) + skillPath := filepath.Join(dir, filepath.FromSlash(skill.Path), "SKILL.md") content, err := os.ReadFile(skillPath) if err != nil { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: "missing SKILL.md file", }) @@ -215,7 +204,7 @@ func publishRun(opts *PublishOptions) error { result, err := frontmatter.Parse(string(content)) if err != nil { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: fmt.Sprintf("invalid frontmatter YAML: %s", err), }) @@ -225,7 +214,7 @@ func publishRun(opts *PublishOptions) error { // Validate name field exists if result.Metadata.Name == "" { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: "missing required field: name", }) @@ -233,7 +222,7 @@ func publishRun(opts *PublishOptions) error { // Validate name matches directory if result.Metadata.Name != dirName { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: fmt.Sprintf("name %q does not match directory name %q", result.Metadata.Name, dirName), }) @@ -242,7 +231,7 @@ func publishRun(opts *PublishOptions) error { // Validate name is spec-compliant if !discovery.IsSpecCompliant(result.Metadata.Name) { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: fmt.Sprintf("name %q does not follow agentskills.io naming convention (lowercase alphanumeric + hyphens)", result.Metadata.Name), }) @@ -252,13 +241,13 @@ func publishRun(opts *PublishOptions) error { // Validate description field exists if result.Metadata.Description == "" { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: "missing required field: description", }) } else if len(result.Metadata.Description) > 1024 { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "warning", message: fmt.Sprintf("description is %d chars (recommended max: 1024)", len(result.Metadata.Description)), }) @@ -268,7 +257,7 @@ func publishRun(opts *PublishOptions) error { if raw, ok := result.RawYAML["allowed-tools"]; ok { if _, isSlice := raw.([]interface{}); isSlice { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: "allowed-tools must be a string (space-delimited), not an array", }) @@ -283,26 +272,26 @@ func publishRun(opts *PublishOptions) error { fixed, fixErr := stripGitHubMetadata(string(content)) if fixErr != nil { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: fmt.Sprintf("could not strip install metadata: %s", fixErr), }) } else if writeErr := os.WriteFile(skillPath, []byte(fixed), 0o644); writeErr != nil { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: fmt.Sprintf("could not write fixed SKILL.md: %s", writeErr), }) } else { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "fixed", message: fmt.Sprintf("stripped install metadata: %s", strings.Join(githubKeys, ", ")), }) } } else { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "error", message: fmt.Sprintf("contains install metadata that must be stripped: %s (use --fix)", strings.Join(githubKeys, ", ")), }) @@ -314,7 +303,7 @@ func publishRun(opts *PublishOptions) error { if result.Metadata.License == "" { if _, ok := result.RawYAML["license"]; !ok { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "warning", message: "recommended field missing: license", }) @@ -325,7 +314,7 @@ func publishRun(opts *PublishOptions) error { bodyLines := strings.Count(result.Body, "\n") + 1 if bodyLines > 500 { diagnostics = append(diagnostics, publishDiagnostic{ - skill: dirName, + skill: skill.DisplayName(), severity: "warning", message: fmt.Sprintf("skill body is %d lines (recommended max: 500 for efficient context)", bodyLines), }) @@ -376,7 +365,11 @@ func publishRun(opts *PublishOptions) error { if client != nil { // Security and ruleset checks (advisory, always shown) - securityDiags := checkSecuritySettings(client, host, owner, repo, skillsDir) + var skillAbsDirs []string + for _, skill := range skills { + skillAbsDirs = append(skillAbsDirs, filepath.Join(dir, filepath.FromSlash(skill.Path))) + } + securityDiags := checkSecuritySettings(client, host, owner, repo, skillAbsDirs) diagnostics = append(diagnostics, securityDiags...) rulesetDiags := checkTagProtection(client, host, owner, repo) @@ -406,7 +399,7 @@ func publishRun(opts *PublishOptions) error { } if canPrompt { - renderDiagnosticsTTY(opts, skillDirs, diagnostics, errors, warnings, fixes, owner, repo) + renderDiagnosticsTTY(opts, len(skills), diagnostics, errors, warnings, fixes, owner, repo) } else { renderDiagnosticsPlain(opts, diagnostics, errors, warnings) } @@ -707,7 +700,7 @@ func checkTagProtection(client *api.Client, host, owner, repo string) []publishD } // checkSecuritySettings checks whether recommended security features are enabled. -func checkSecuritySettings(client *api.Client, host, owner, repo, skillsDir string) []publishDiagnostic { +func checkSecuritySettings(client *api.Client, host, owner, repo string, skillDirs []string) []publishDiagnostic { if client == nil { return nil } @@ -738,7 +731,7 @@ func checkSecuritySettings(client *api.Client, host, owner, repo, skillsDir stri }) } - hasCode, hasManifests := detectCodeAndManifests(skillsDir) + hasCode, hasManifests := detectCodeAndManifests(skillDirs) if hasCode { alertsPath := fmt.Sprintf("repos/%s/%s/code-scanning/alerts?per_page=1&state=open", owner, repo) @@ -781,28 +774,35 @@ var manifestFiles = map[string]bool{ "composer.json": true, "composer.lock": true, } -// detectCodeAndManifests walks the skills directory looking for code files +// detectCodeAndManifests walks the skill directories looking for code files // and dependency manifests. -func detectCodeAndManifests(skillsDir string) (hasCode, hasManifests bool) { - _ = filepath.Walk(skillsDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { +func detectCodeAndManifests(skillDirs []string) (hasCode, hasManifests bool) { + for _, dir := range skillDirs { + _ = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + ext := filepath.Ext(info.Name()) + if codeExtensions[ext] { + hasCode = true + } + if manifestFiles[info.Name()] { + hasManifests = true + } + if hasCode && hasManifests { + // Stop walking this skill directory early; the outer loop + // continues to process remaining skill directories. + return filepath.SkipAll + } return nil - } - ext := filepath.Ext(info.Name()) - if codeExtensions[ext] { - hasCode = true - } - if manifestFiles[info.Name()] { - hasManifests = true - } + }) if hasCode && hasManifests { - return filepath.SkipAll + return } - return nil - }) + } return } @@ -961,7 +961,7 @@ func detectMissingRepoDiagnostic(gitClient *git.Client, dir string) []publishDia }} } -func renderDiagnosticsTTY(opts *PublishOptions, skillDirs []string, diagnostics []publishDiagnostic, errors, warnings, fixes int, owner, repo string) { +func renderDiagnosticsTTY(opts *PublishOptions, skillCount int, diagnostics []publishDiagnostic, errors, warnings, fixes int, owner, repo string) { cs := opts.IO.ColorScheme() // Separate info messages from errors/warnings for cleaner output @@ -975,7 +975,7 @@ func renderDiagnosticsTTY(opts *PublishOptions, skillDirs []string, diagnostics } if len(issues) == 0 && fixes == 0 { - fmt.Fprintf(opts.IO.Out, "%s %d skill(s) validated successfully\n", cs.SuccessIcon(), len(skillDirs)) + fmt.Fprintf(opts.IO.Out, "%s %d skill(s) validated successfully\n", cs.SuccessIcon(), skillCount) } else { for _, d := range issues { var prefix string diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 6da0e01a4c8..d3415344151 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -168,16 +168,16 @@ func TestPublishRun(t *testing.T) { wantStderr string }{ { - name: "no skills directory", + name: "no skills found", setup: func(_ *testing.T, _ string) {}, opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() return &PublishOptions{IO: ios, Dir: dir} }, - wantErr: "no skills/ directory", + wantErr: "no skills found", }, { - name: "missing SKILL.md", + name: "empty skills directory has no discoverable skills", setup: func(t *testing.T, dir string) { t.Helper() require.NoError(t, os.MkdirAll(filepath.Join(dir, "skills", "empty-skill"), 0o755)) @@ -186,8 +186,7 @@ func TestPublishRun(t *testing.T) { t.Helper() return &PublishOptions{IO: ios, Dir: dir} }, - wantErr: "validation failed", - wantStdout: "missing SKILL.md", + wantErr: "no skills found", }, { name: "missing name in frontmatter", @@ -245,6 +244,80 @@ func TestPublishRun(t *testing.T) { wantErr: "validation failed", wantStdout: "naming convention", }, + { + name: "root-level skill discovered and validated", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + // Create a root-level skill (*/SKILL.md convention) + skillDir := filepath.Join(dir, "my-root-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: my-root-skill + description: A root-level skill + license: MIT + --- + Body. + `)), 0o644)) + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "1 skill(s) validated successfully", + wantStderr: "Dry run complete", + }, + { + name: "namespaced skill discovered and validated", + isTTY: true, + setup: func(t *testing.T, dir string) { + t.Helper() + // Create a namespaced skill (skills/{scope}/*/SKILL.md convention) + skillDir := filepath.Join(dir, "skills", "monalisa", "scoped-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: scoped-skill + description: A namespaced skill + license: MIT + --- + Body. + `)), 0o644)) + initGitRepo(t, dir, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + }, + stubs: func(reg *httpmock.Registry) { + stubAllSecureRemote(reg, "monalisa", "skills-repo") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() + return &PublishOptions{ + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, + client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + host: "github.com", + } + }, + wantStdout: "1 skill(s) validated successfully", + wantStderr: "Dry run complete", + }, { name: "valid skill dry-run passes validation", isTTY: true, From e559a7cd5bf7789460f3fb4291524cb71b504fb4 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 15 Apr 2026 23:05:11 +0200 Subject: [PATCH 031/182] feat(skills): auto-push unpushed commits before publish Like gh pr create, skill publish now automatically pushes unpushed local commits before creating a release. This prevents the footgun where a release is created against stale remote state when the user has local commits that haven't been pushed yet. The ensurePushed function checks for unpushed commits using git rev-list @{push}..HEAD. If commits exist or the branch has never been pushed, it pushes automatically and prints a status message. This matches the CLI's opinionated-defaults philosophy of doing the right thing by default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish.go | 88 +++++++++++--- pkg/cmd/skills/publish/publish_test.go | 154 ++++++++++++++++++++++++- 2 files changed, 227 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 931b9085c01..85a3b68b90b 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -10,6 +10,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "github.com/MakeNowJust/heredoc" @@ -343,14 +344,14 @@ func publishRun(opts *PublishOptions) error { } owner, repo := "", "" if repoInfo != nil { - owner = repoInfo.RepoOwner() - repo = repoInfo.RepoName() + owner = repoInfo.Repo.RepoOwner() + repo = repoInfo.Repo.RepoName() } hasTopic := false var existingTags []tagEntry if owner != "" && repo != "" { if host == "" && repoInfo != nil { - host = repoInfo.RepoHost() + host = repoInfo.Repo.RepoHost() } if host != "" { if err := source.ValidateSupportedHost(host); err != nil { @@ -438,7 +439,7 @@ func publishRun(opts *PublishOptions) error { fmt.Fprintf(opts.IO.ErrOut, "\nPublishing to %s/%s...\n\n", owner, repo) - return runPublishRelease(opts, client, host, owner, repo, dir, hasTopic, existingTags) + return runPublishRelease(opts, client, host, owner, repo, dir, repoInfo.RemoteName, hasTopic, existingTags) } // repoHasTopic checks whether the repo has the agent-skills topic. @@ -473,11 +474,11 @@ func fetchTags(client *api.Client, host, owner, repo string) []tagEntry { } // runPublishRelease handles the interactive publish flow: topic, tag, release, immutability. -func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, repo, dir string, hasTopic bool, existingTags []tagEntry) error { +func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, repo, dir, remoteName string, hasTopic bool, existingTags []tagEntry) error { cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() - // 1. Add topic if missing + // Add topic if missing if !hasTopic { addTopic := true if canPrompt { @@ -498,7 +499,12 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re } } - // 2. Determine tag + // Push unpushed commits (like gh pr create) + if err := ensurePushed(opts, dir, remoteName); err != nil { + return err + } + + // Determine tag tag := opts.Tag if tag == "" { suggested := "v1.0.0" @@ -549,7 +555,7 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re } } - // 3. Offer to enable immutable releases + // Offer to enable immutable releases immutableEnabled := checkImmutableReleases(client, host, owner, repo) if !immutableEnabled && canPrompt { enableImmutable, err := opts.Prompter.Confirm( @@ -567,7 +573,7 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re } } - // 4. Inform if not on default branch + // Inform if not on default branch var currentBranch string if opts.GitClient != nil { branchGitClient := opts.GitClient.Copy() @@ -581,7 +587,7 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re fmt.Fprintf(opts.IO.ErrOut, "%s Publishing from branch %q (default is %q)\n", cs.WarningIcon(), currentBranch, defaultBranch) } - // 5. Confirm and create release + // Confirm and create release if canPrompt { confirmed, err := opts.Prompter.Confirm( fmt.Sprintf("Create release %s with auto-generated notes?", tag), true) @@ -622,6 +628,56 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re return nil } +// ensurePushed checks whether the current branch has unpushed commits and +// pushes them automatically, consistent with how gh pr create behaves. +func ensurePushed(opts *PublishOptions, dir, remoteName string) error { + if opts.GitClient == nil { + return nil + } + + cs := opts.IO.ColorScheme() + gitClient := opts.GitClient.Copy() + gitClient.RepoDir = dir + + ctx := context.Background() + currentBranch, err := gitClient.CurrentBranch(ctx) + if err != nil { + return nil //nolint:nilerr // not on a branch (detached HEAD); skip push check + } + + // Count commits ahead of the push target (remote tracking branch). + // If the branch has no upstream, rev-list will fail; we treat that as + // "everything is unpushed" and push the whole branch. + unpushed := 0 + revCmd, err := gitClient.Command(ctx, "rev-list", "--count", "@{push}..HEAD") + if err != nil { + return fmt.Errorf("could not check unpushed commits: %w", err) + } + out, revErr := revCmd.Output() + if revErr != nil { + // @{push} not resolvable; branch has never been pushed + unpushed = -1 + } else { + n, parseErr := strconv.Atoi(strings.TrimSpace(string(out))) + if parseErr != nil { + return fmt.Errorf("could not parse unpushed commit count: %w", parseErr) + } + unpushed = n + } + + if unpushed == 0 { + return nil + } + + ref := fmt.Sprintf("HEAD:refs/heads/%s", currentBranch) + fmt.Fprintf(opts.IO.ErrOut, "%s Pushing %s to %s\n", cs.SuccessIcon(), currentBranch, remoteName) + if err := gitClient.Push(ctx, remoteName, ref); err != nil { + return fmt.Errorf("failed to push branch %s: %w", currentBranch, err) + } + + return nil +} + // detectDefaultBranch returns the default branch of the remote repo via the API. func detectDefaultBranch(client *api.Client, host, owner, repo string) string { if client == nil { @@ -866,9 +922,15 @@ func suggestNextTag(latest string) string { return fmt.Sprintf("%s%s.%s.%d", prefix, major, minor, patch+1) } +// gitHubRemote holds a detected GitHub remote and its local name. +type gitHubRemote struct { + Repo ghrepo.Interface + RemoteName string +} + // detectGitHubRemote attempts to detect the GitHub owner/repo from git remotes // in the given directory. -func detectGitHubRemote(gitClient *git.Client, dir string) (ghrepo.Interface, error) { +func detectGitHubRemote(gitClient *git.Client, dir string) (*gitHubRemote, error) { if gitClient == nil { return nil, nil } @@ -883,7 +945,7 @@ func detectGitHubRemote(gitClient *git.Client, dir string) (ghrepo.Interface, er return nil, parseErr } if repo != nil { - return repo, nil + return &gitHubRemote{Repo: repo, RemoteName: "origin"}, nil } } @@ -902,7 +964,7 @@ func detectGitHubRemote(gitClient *git.Client, dir string) (ghrepo.Interface, er return nil, parseErr } if repo != nil { - return repo, nil + return &gitHubRemote{Repo: repo, RemoteName: r.Name}, nil } } } diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 6da0e01a4c8..82164a96a55 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/MakeNowJust/heredoc" @@ -22,13 +23,28 @@ import ( // initGitRepo initializes a git repo in the given directory and adds remotes. // Use this when the git repo must live in the same directory as the skill files. +// A local bare repo is created as the push target so that ensurePushed can work +// during publish tests, while the fetch URL remains the GitHub URL so that +// detectGitHubRemote still resolves the correct owner/repo. func initGitRepo(t *testing.T, dir string, remoteURLs map[string]string) { t.Helper() + + bareDir := filepath.Join(t.TempDir(), "upstream.git") + require.NoError(t, os.MkdirAll(bareDir, 0o755)) + runGitInDir(t, bareDir, "init", "--bare", "--initial-branch=main") + runGitInDir(t, dir, "init", "--initial-branch=main") runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") for name, url := range remoteURLs { runGitInDir(t, dir, "remote", "add", name, url) + runGitInDir(t, dir, "remote", "set-url", "--push", name, bareDir) + } + + runGitInDir(t, dir, "add", ".") + runGitInDir(t, dir, "commit", "--allow-empty", "-m", "init") + if _, ok := remoteURLs["origin"]; ok { + runGitInDir(t, dir, "push", "origin", "main") } } @@ -1392,8 +1408,8 @@ func TestDetectGitHubRemote_UsesDir(t *testing.T) { repo, err := detectGitHubRemote(gitClient, targetRepo) require.NoError(t, err) require.NotNil(t, repo) - assert.Equal(t, "monalisa", repo.RepoOwner()) - assert.Equal(t, "target-repo", repo.RepoName()) + assert.Equal(t, "monalisa", repo.Repo.RepoOwner()) + assert.Equal(t, "target-repo", repo.Repo.RepoName()) } func TestPublishRun_DirArgUsesTargetRemote(t *testing.T) { @@ -1467,3 +1483,137 @@ func runGitInDir(t *testing.T, dir string, args ...string) { out, err := cmd.CombinedOutput() require.NoError(t, err, "git %v: %s", args, out) } + +// newTestGitClientWithUpstream creates a git repo with a local bare "remote" +// and an initial commit, so we can test push/rev-list behavior realistically. +// It returns the git client and the working directory path. +func newTestGitClientWithUpstream(t *testing.T) (*git.Client, string) { + t.Helper() + parentDir := t.TempDir() + bareDir := filepath.Join(parentDir, "upstream.git") + workDir := filepath.Join(parentDir, "work") + + gitEnv := append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+parentDir) + + run := func(dir string, args ...string) { + t.Helper() + c := exec.Command("git", append([]string{"-C", dir}, args...)...) + c.Env = gitEnv + out, err := c.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, out) + } + + // Create bare upstream + require.NoError(t, os.MkdirAll(bareDir, 0o755)) + run(bareDir, "init", "--bare", "--initial-branch=main") + + // Clone into working dir + c := exec.Command("git", "clone", bareDir, workDir) + c.Env = gitEnv + out, err := c.CombinedOutput() + require.NoError(t, err, "git clone: %s", out) + + run(workDir, "config", "user.email", "monalisa@github.com") + run(workDir, "config", "user.name", "Monalisa Octocat") + + // Create initial commit and push + require.NoError(t, os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Test"), 0o644)) + run(workDir, "add", ".") + run(workDir, "commit", "-m", "initial commit") + run(workDir, "push", "origin", "main") + + return &git.Client{ + RepoDir: workDir, + GitPath: "git", + Stderr: &bytes.Buffer{}, + Stdin: &bytes.Buffer{}, + Stdout: &bytes.Buffer{}, + }, workDir +} + +func TestEnsurePushed(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, workDir string) + verify func(t *testing.T, workDir string) + wantErr string + wantStderr string + }{ + { + name: "no unpushed commits is a no-op", + setup: func(_ *testing.T, _ string) { + // initial commit already pushed by helper + }, + }, + { + name: "unpushed commits are pushed automatically", + setup: func(t *testing.T, workDir string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "new.txt"), []byte("new"), 0o644)) + runGitInDir(t, workDir, "add", ".") + runGitInDir(t, workDir, "commit", "-m", "unpushed change") + }, + verify: func(t *testing.T, workDir string) { + t.Helper() + // After push, rev-list should show 0 unpushed commits + cmd := exec.Command("git", "-C", workDir, "rev-list", "--count", "@{push}..HEAD") + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+workDir) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "rev-list: %s", out) + assert.Equal(t, "0", strings.TrimSpace(string(out))) + }, + wantStderr: "Pushing main to origin", + }, + { + name: "new branch never pushed is pushed automatically", + setup: func(t *testing.T, workDir string) { + t.Helper() + runGitInDir(t, workDir, "checkout", "-b", "feature") + require.NoError(t, os.WriteFile(filepath.Join(workDir, "feat.txt"), []byte("feat"), 0o644)) + runGitInDir(t, workDir, "add", ".") + runGitInDir(t, workDir, "commit", "-m", "new branch commit") + }, + verify: func(t *testing.T, workDir string) { + t.Helper() + // After push, the branch should exist on the remote + cmd := exec.Command("git", "-C", workDir, "rev-list", "--count", "@{push}..HEAD") + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+workDir) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "rev-list: %s", out) + assert.Equal(t, "0", strings.TrimSpace(string(out))) + }, + wantStderr: "Pushing feature to origin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gitClient, workDir := newTestGitClientWithUpstream(t) + tt.setup(t, workDir) + + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + opts := &PublishOptions{ + IO: ios, + GitClient: gitClient, + } + + err := ensurePushed(opts, workDir, "origin") + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + if tt.verify != nil { + tt.verify(t, workDir) + } + }) + } +} From 29e55fe5b9d578ca6b050d170c542b32eca5f569 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 16 Apr 2026 15:52:53 +0200 Subject: [PATCH 032/182] refactor: remove redundant nil-client fallback in skills publish (#13168) Remove the dead code block that silently swallowed errors from opts.HttpClient() and opts.Config() when creating an API client. Instead, create the client with proper error propagation inside the remote-checks block where it is actually needed. Changes: - Remove the error-swallowing client == nil fallback (lines 363-376) - Create the API client inside the remote repo checks block with proper error returns from HttpClient() and Config() - Resolve host from repoInfo first, then fall back to config - Remove the now-unreachable 'client == nil' early exit before publish Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish.go | 71 ++++++++++++++----------------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 9f981104f61..213afeba53d 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -178,7 +178,10 @@ func publishRun(opts *PublishOptions) error { canPrompt := opts.IO.CanPrompt() - // Use injected client or create one from the factory HttpClient + // Use injected client or create one from the factory HttpClient. + // Initialization is deferred until after local validation so that + // simple errors (missing skills/, bad SKILL.md, etc.) are reported + // without requiring an HTTP client. client := opts.client host := opts.host @@ -336,52 +339,49 @@ func publishRun(opts *PublishOptions) error { owner = repoInfo.Repo.RepoOwner() repo = repoInfo.Repo.RepoName() } + hasTopic := false var existingTags []tagEntry if owner != "" && repo != "" { + // Create API client from factory if not already injected (tests inject directly). + if client == nil { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + client = api.NewClientFromHTTP(httpClient) + } + if host == "" && repoInfo != nil { host = repoInfo.Repo.RepoHost() } - if host != "" { - if err := source.ValidateSupportedHost(host); err != nil { + if host == "" { + cfg, err := opts.Config() + if err != nil { return err } + host, _ = cfg.Authentication().DefaultHost() } - - // Create API client for remote checks if not already injected - if client == nil { - httpClient, httpErr := opts.HttpClient() - if httpErr == nil { - apiClient := api.NewClientFromHTTP(httpClient) - cfg, cfgErr := opts.Config() - if cfgErr == nil { - host, _ = cfg.Authentication().DefaultHost() - if err := source.ValidateSupportedHost(host); err != nil { - return err - } - client = apiClient - } - } + if err := source.ValidateSupportedHost(host); err != nil { + return err } - if client != nil { - // Security and ruleset checks (advisory, always shown) - var skillAbsDirs []string - for _, skill := range skills { - skillAbsDirs = append(skillAbsDirs, filepath.Join(dir, filepath.FromSlash(skill.Path))) - } - securityDiags := checkSecuritySettings(client, host, owner, repo, skillAbsDirs) - diagnostics = append(diagnostics, securityDiags...) + // Security and ruleset checks (advisory, always shown) + var skillAbsDirs []string + for _, skill := range skills { + skillAbsDirs = append(skillAbsDirs, filepath.Join(dir, filepath.FromSlash(skill.Path))) + } + securityDiags := checkSecuritySettings(client, host, owner, repo, skillAbsDirs) + diagnostics = append(diagnostics, securityDiags...) - rulesetDiags := checkTagProtection(client, host, owner, repo) - diagnostics = append(diagnostics, rulesetDiags...) + rulesetDiags := checkTagProtection(client, host, owner, repo) + diagnostics = append(diagnostics, rulesetDiags...) - // Check topic (needed for publish flow, not a blocking error) - hasTopic = repoHasTopic(client, host, owner, repo) + // Check topic (needed for publish flow, not a blocking error) + hasTopic = repoHasTopic(client, host, owner, repo) - // Fetch existing tags (needed for version suggestion) - existingTags = fetchTags(client, host, owner, repo) - } + // Fetch existing tags (needed for version suggestion) + existingTags = fetchTags(client, host, owner, repo) } else { diagnostics = append(diagnostics, detectMissingRepoDiagnostic(opts.GitClient, dir)...) } @@ -425,11 +425,6 @@ func publishRun(opts *PublishOptions) error { return nil } - if client == nil { - fmt.Fprintf(opts.IO.ErrOut, "\nValidation passed but could not create API client. Check your authentication configuration.\n") - return nil - } - fmt.Fprintf(opts.IO.ErrOut, "\nPublishing to %s/%s...\n\n", owner, repo) return runPublishRelease(opts, client, host, owner, repo, dir, repoInfo.RemoteName, hasTopic, existingTags) From 8b115d2c2355159b9fe0ba123b81f2e5aec0fbd1 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 16 Apr 2026 16:19:59 +0200 Subject: [PATCH 033/182] fix: address post-merge review feedback for skills commands - Remove direct opts.client injection in publish; use HttpClient factory pattern (PR #13168 feedback) - Rename testName to name in discovery test struct (PR #13170 feedback) - Use typed struct keys for dedup map with case-insensitive comparison in deduplicateResults (PR #13170 feedback) - Simplify remote selection to use Remotes() ordering instead of manual origin-first logic (PR #13171 feedback) - Fix push icon timing: show no icon before push, SuccessIcon after success (PR #13171 feedback) Closes #13184 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/skills/discovery/discovery_test.go | 18 ++++----- pkg/cmd/skills/publish/publish.go | 41 ++++++--------------- pkg/cmd/skills/publish/publish_test.go | 39 ++++++++++---------- pkg/cmd/skills/search/search.go | 17 ++++++++- 4 files changed, 55 insertions(+), 60 deletions(-) diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 7f74c299876..f9abc593cf8 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -925,21 +925,21 @@ func TestMatchesSkillPath(t *testing.T) { func TestMatchSkillPath(t *testing.T) { tests := []struct { - testName string + name string path string wantName string wantNamespace string }{ - {testName: "skills convention", path: "skills/code-review/SKILL.md", wantName: "code-review", wantNamespace: ""}, - {testName: "namespaced convention", path: "skills/monalisa/issue-triage/SKILL.md", wantName: "issue-triage", wantNamespace: "monalisa"}, - {testName: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary", wantNamespace: "hubot"}, - {testName: "non-skill file", path: "README.md", wantName: "", wantNamespace: ""}, - {testName: "same name different namespace 1", path: "skills/kynan/commit/SKILL.md", wantName: "commit", wantNamespace: "kynan"}, - {testName: "same name different namespace 2", path: "skills/will/commit/SKILL.md", wantName: "commit", wantNamespace: "will"}, - {testName: "root convention", path: "my-skill/SKILL.md", wantName: "my-skill", wantNamespace: ""}, + {name: "skills convention", path: "skills/code-review/SKILL.md", wantName: "code-review", wantNamespace: ""}, + {name: "namespaced convention", path: "skills/monalisa/issue-triage/SKILL.md", wantName: "issue-triage", wantNamespace: "monalisa"}, + {name: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary", wantNamespace: "hubot"}, + {name: "non-skill file", path: "README.md", wantName: "", wantNamespace: ""}, + {name: "same name different namespace 1", path: "skills/kynan/commit/SKILL.md", wantName: "commit", wantNamespace: "kynan"}, + {name: "same name different namespace 2", path: "skills/will/commit/SKILL.md", wantName: "commit", wantNamespace: "will"}, + {name: "root convention", path: "my-skill/SKILL.md", wantName: "my-skill", wantNamespace: ""}, } for _, tt := range tests { - t.Run(tt.testName, func(t *testing.T) { + t.Run(tt.name, func(t *testing.T) { name, namespace := MatchSkillPath(tt.path) assert.Equal(t, tt.wantName, name) assert.Equal(t, tt.wantNamespace, namespace) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 213afeba53d..d8278187694 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -42,8 +42,7 @@ type PublishOptions struct { DryRun bool Tag string - client *api.Client // injectable for tests; nil means use factory - host string // resolved from config in production + host string // resolved from config in production } // publishDiagnostic is a single validation finding. @@ -178,11 +177,10 @@ func publishRun(opts *PublishOptions) error { canPrompt := opts.IO.CanPrompt() - // Use injected client or create one from the factory HttpClient. - // Initialization is deferred until after local validation so that + // Client initialization is deferred until after local validation so that // simple errors (missing skills/, bad SKILL.md, etc.) are reported // without requiring an HTTP client. - client := opts.client + var client *api.Client host := opts.host var diagnostics []publishDiagnostic @@ -343,14 +341,11 @@ func publishRun(opts *PublishOptions) error { hasTopic := false var existingTags []tagEntry if owner != "" && repo != "" { - // Create API client from factory if not already injected (tests inject directly). - if client == nil { - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - client = api.NewClientFromHTTP(httpClient) + httpClient, err := opts.HttpClient() + if err != nil { + return err } + client = api.NewClientFromHTTP(httpClient) if host == "" && repoInfo != nil { host = repoInfo.Repo.RepoHost() @@ -658,10 +653,11 @@ func ensurePushed(opts *PublishOptions, dir, remoteName string) error { } ref := fmt.Sprintf("HEAD:refs/heads/%s", currentBranch) - fmt.Fprintf(opts.IO.ErrOut, "%s Pushing %s to %s\n", cs.SuccessIcon(), currentBranch, remoteName) + fmt.Fprintf(opts.IO.ErrOut, "Pushing %s to %s...\n", currentBranch, remoteName) if err := gitClient.Push(ctx, remoteName, ref); err != nil { return fmt.Errorf("failed to push branch %s: %w", currentBranch, err) } + fmt.Fprintf(opts.IO.ErrOut, "%s Pushed %s to %s\n", cs.SuccessIcon(), currentBranch, remoteName) return nil } @@ -924,7 +920,9 @@ type gitHubRemote struct { } // detectGitHubRemote attempts to detect the GitHub owner/repo from git remotes -// in the given directory. +// in the given directory. Remotes are tried in the order returned by +// gitClient.Remotes (upstream > github > origin > rest), so the first +// GitHub-pointing remote wins. func detectGitHubRemote(gitClient *git.Client, dir string) (*gitHubRemote, error) { if gitClient == nil { return nil, nil @@ -933,26 +931,11 @@ func detectGitHubRemote(gitClient *git.Client, dir string) (*gitHubRemote, error dirClient := gitClient.Copy() dirClient.RepoDir = dir - // Try origin first - if url, err := dirClient.RemoteURL(context.Background(), "origin"); err == nil { - repo, parseErr := parseGitHubURL(url) - if parseErr != nil { - return nil, parseErr - } - if repo != nil { - return &gitHubRemote{Repo: repo, RemoteName: "origin"}, nil - } - } - - // Fall back to any remote that points to GitHub remotes, err := dirClient.Remotes(context.Background()) if err != nil { return nil, nil //nolint:nilerr // failing to list remotes is not an error; it just means no repo detected } for _, r := range remotes { - if r.Name == "origin" { - continue - } if url, err := dirClient.RemoteURL(context.Background(), r.Name); err == nil { repo, parseErr := parseGitHubURL(url) if parseErr != nil { diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 8c7205a3deb..27bdfb7684c 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -10,7 +10,6 @@ import ( "testing" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" @@ -165,7 +164,7 @@ func TestPublishRun_UnsupportedHost(t *testing.T) { IO: ios, Dir: dir, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{}), + HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, host: "acme.ghes.com", }) require.ErrorContains(t, err, "supports only github.com") @@ -290,7 +289,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, DryRun: true, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -327,7 +326,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, DryRun: true, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -361,7 +360,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, DryRun: true, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -417,7 +416,7 @@ func TestPublishRun(t *testing.T) { ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, }, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -567,7 +566,7 @@ func TestPublishRun(t *testing.T) { IO: ios, Dir: dir, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -620,7 +619,7 @@ func TestPublishRun(t *testing.T) { IO: ios, Dir: dir, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -684,7 +683,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, DryRun: true, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -749,7 +748,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, DryRun: true, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -899,7 +898,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, DryRun: true, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -988,7 +987,7 @@ func TestPublishRun(t *testing.T) { ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, }, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -1063,7 +1062,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, Tag: "v2.3.5", GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -1095,7 +1094,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -1127,7 +1126,7 @@ func TestPublishRun(t *testing.T) { IO: ios, Dir: dir, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -1238,7 +1237,7 @@ func TestPublishRun(t *testing.T) { }, }, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -1295,7 +1294,7 @@ func TestPublishRun(t *testing.T) { }, }, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -1351,7 +1350,7 @@ func TestPublishRun(t *testing.T) { }, }, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -1415,7 +1414,7 @@ func TestPublishRun(t *testing.T) { }, }, GitClient: &git.Client{}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, @@ -1532,7 +1531,7 @@ func TestPublishRun_DirArgUsesTargetRemote(t *testing.T) { Dir: targetRepo, DryRun: true, GitClient: &git.Client{RepoDir: cwdRepo}, - client: api.NewClientFromHTTP(&http.Client{Transport: reg}), + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", }) diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index f7d4975a7ee..18471cc954f 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -770,9 +770,18 @@ func fetchPrimaryPages(client *api.Client, host, query string, displayPage, disp return allItems, totalCount, nil } +// skillResultKey is a typed map key for deduplicating code search results +// by (repo, namespace, skill name). All fields are lowercased for +// case-insensitive comparison. +type skillResultKey struct { + repo string + namespace string + skillName string +} + // deduplicateResults extracts unique (repo, namespace, skill name) triples from code search hits. func deduplicateResults(items []codeSearchItem) []skillResult { - seen := make(map[string]struct{}) + seen := make(map[skillResultKey]struct{}) var results []skillResult for _, item := range items { @@ -780,7 +789,11 @@ func deduplicateResults(items []codeSearchItem) []skillResult { if skillName == "" { continue } - key := item.Repository.FullName + "/" + namespace + "/" + skillName + key := skillResultKey{ + repo: strings.ToLower(item.Repository.FullName), + namespace: strings.ToLower(namespace), + skillName: strings.ToLower(skillName), + } if _, ok := seen[key]; ok { continue } From 08a0c11d779a65e361c14bf85f116a04c21a84e4 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 16 Apr 2026 16:37:34 +0200 Subject: [PATCH 034/182] fix: update acceptance test to match current error message The skills-publish-dry-run acceptance test expected 'no skills/ directory found' on stderr, but the actual error message from discovery is 'no skills found in '. Update the stderr matcher accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- acceptance/testdata/skills/skills-publish-dry-run.txtar | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/testdata/skills/skills-publish-dry-run.txtar b/acceptance/testdata/skills/skills-publish-dry-run.txtar index 2dea21d678d..cb32fa7e26e 100644 --- a/acceptance/testdata/skills/skills-publish-dry-run.txtar +++ b/acceptance/testdata/skills/skills-publish-dry-run.txtar @@ -1,6 +1,6 @@ # Publish dry-run from a directory with no skills/ should fail gracefully ! exec gh skill publish --dry-run $WORK -stderr 'no skills/ directory found' +stderr 'no skills found in' # Publish dry-run against a valid skill directory should succeed exec gh skill publish --dry-run $WORK/test-repo From 9d00ecc24831eff164cae77fd146a0a0e9b7356e Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 16 Apr 2026 17:04:12 +0200 Subject: [PATCH 035/182] style: fix gofmt alignment in publish tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish_test.go | 146 ++++++++++++------------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 27bdfb7684c..04f400733ef 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -161,11 +161,11 @@ func TestPublishRun_UnsupportedHost(t *testing.T) { ios, _, _, _ := iostreams.Test() initGitRepo(t, dir, map[string]string{"origin": "https://github.com/monalisa/skills-repo.git"}) err := publishRun(&PublishOptions{ - IO: ios, - Dir: dir, - GitClient: &git.Client{}, + IO: ios, + Dir: dir, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, - host: "acme.ghes.com", + host: "acme.ghes.com", }) require.ErrorContains(t, err, "supports only github.com") } @@ -285,12 +285,12 @@ func TestPublishRun(t *testing.T) { opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() return &PublishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: &git.Client{}, + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStdout: "1 skill(s) validated successfully", @@ -322,12 +322,12 @@ func TestPublishRun(t *testing.T) { opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() return &PublishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: &git.Client{}, + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStdout: "1 skill(s) validated successfully", @@ -356,12 +356,12 @@ func TestPublishRun(t *testing.T) { "origin": "https://github.com/monalisa/skills-repo.git", }) return &PublishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: &git.Client{}, + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStdout: "1 skill(s) validated successfully", @@ -415,9 +415,9 @@ func TestPublishRun(t *testing.T) { Prompter: &prompter.PrompterMock{ ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, }, - GitClient: &git.Client{}, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStdout: "Published v1.0.1", @@ -563,11 +563,11 @@ func TestPublishRun(t *testing.T) { "origin": "https://github.com/octocat/secure-repo.git", }) return &PublishOptions{ - IO: ios, - Dir: dir, - GitClient: &git.Client{}, + IO: ios, + Dir: dir, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStdout: "secret scanning is not enabled", @@ -616,11 +616,11 @@ func TestPublishRun(t *testing.T) { "origin": "https://github.com/octocat/tag-repo.git", }) return &PublishOptions{ - IO: ios, - Dir: dir, - GitClient: &git.Client{}, + IO: ios, + Dir: dir, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStdout: "tag protection", @@ -679,12 +679,12 @@ func TestPublishRun(t *testing.T) { "origin": "https://github.com/octocat/code-repo.git", }) return &PublishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: &git.Client{}, + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStderr: "code scanning", @@ -744,12 +744,12 @@ func TestPublishRun(t *testing.T) { "origin": "https://github.com/octocat/dep-repo.git", }) return &PublishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: &git.Client{}, + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStderr: "Dependabot", @@ -894,12 +894,12 @@ func TestPublishRun(t *testing.T) { "upstream": "git@github.com:octocat/repo.git", }) return &PublishOptions{ - IO: ios, - Dir: dir, - DryRun: true, - GitClient: &git.Client{}, + IO: ios, + Dir: dir, + DryRun: true, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStderr: "octocat/repo", @@ -986,9 +986,9 @@ func TestPublishRun(t *testing.T) { Prompter: &prompter.PrompterMock{ ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, }, - GitClient: &git.Client{}, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStdout: "Added \"agent-skills\" topic", @@ -1058,12 +1058,12 @@ func TestPublishRun(t *testing.T) { "origin": "https://github.com/monalisa/skills-repo.git", }) return &PublishOptions{ - IO: ios, - Dir: dir, - Tag: "v2.3.5", - GitClient: &git.Client{}, + IO: ios, + Dir: dir, + Tag: "v2.3.5", + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStdout: "Published v2.3.5", @@ -1090,12 +1090,12 @@ func TestPublishRun(t *testing.T) { "origin": "https://github.com/monalisa/skills-repo.git", }) return &PublishOptions{ - IO: ios, - Dir: dir, - Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag - GitClient: &git.Client{}, + IO: ios, + Dir: dir, + Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantErr: "tag v1.0.0 already exists", @@ -1123,11 +1123,11 @@ func TestPublishRun(t *testing.T) { "origin": "https://github.com/monalisa/skills-repo.git", }) return &PublishOptions{ - IO: ios, - Dir: dir, - GitClient: &git.Client{}, + IO: ios, + Dir: dir, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStdout: "ok", @@ -1236,9 +1236,9 @@ func TestPublishRun(t *testing.T) { return "v1.0.0", nil // accept suggested tag }, }, - GitClient: &git.Client{}, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStdout: "Published v1.0.0", @@ -1293,9 +1293,9 @@ func TestPublishRun(t *testing.T) { return "beta-1", nil }, }, - GitClient: &git.Client{}, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStdout: "Published beta-1", @@ -1349,9 +1349,9 @@ func TestPublishRun(t *testing.T) { return "v1.0.1", nil }, }, - GitClient: &git.Client{}, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantErr: "CancelError", @@ -1413,9 +1413,9 @@ func TestPublishRun(t *testing.T) { return "v1.0.1", nil }, }, - GitClient: &git.Client{}, + GitClient: &git.Client{}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", } }, wantStdout: "Enabled immutable releases", @@ -1527,12 +1527,12 @@ func TestPublishRun_DirArgUsesTargetRemote(t *testing.T) { stubAllSecureRemote(reg, "monalisa", "target-repo") err := publishRun(&PublishOptions{ - IO: ios, - Dir: targetRepo, - DryRun: true, - GitClient: &git.Client{RepoDir: cwdRepo}, + IO: ios, + Dir: targetRepo, + DryRun: true, + GitClient: &git.Client{RepoDir: cwdRepo}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - host: "github.com", + host: "github.com", }) require.NoError(t, err) From a38e81ea5ecf612bb6294136090a79e7fbf997fd Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:05:38 -0600 Subject: [PATCH 036/182] Add cli/skill-reviewers as CODEOWNERS for skills packages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/CODEOWNERS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 84fa9f45187..8925ce55d56 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,3 +13,6 @@ pkg/cmd/release/shared/ @cli/package-security test/integration/attestation-cmd @cli/package-security pkg/cmd/attestation/verification/embed/tuf-repo.github.com/ @cli/tuf-root-reviewers + +pkg/cmd/skills/ @cli/skill-reviewers +internal/skills/ @cli/skill-reviewers From d88705ea965756f3eda360e18b998b015165452d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:47:40 -0600 Subject: [PATCH 037/182] Add @cli/code-reviewers to all CODEOWNERS rules This ensures that an approval from @cli/code-reviewers can satisfy the CODEOWNERS requirement for any path, not just the catch-all wildcard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/CODEOWNERS | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8925ce55d56..4a23f3901f5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,18 +1,18 @@ * @cli/code-reviewers -pkg/cmd/codespace/ @cli/codespaces -internal/codespaces/ @cli/codespaces +pkg/cmd/codespace/ @cli/codespaces @cli/code-reviewers +internal/codespaces/ @cli/codespaces @cli/code-reviewers # Limit Package Security team ownership to the attestation command package and related integration tests -pkg/cmd/attestation/ @cli/package-security -pkg/cmd/release/attestation/ @cli/package-security -pkg/cmd/release/verify/ @cli/package-security -pkg/cmd/release/verify-asset/ @cli/package-security -pkg/cmd/release/shared/ @cli/package-security +pkg/cmd/attestation/ @cli/package-security @cli/code-reviewers +pkg/cmd/release/attestation/ @cli/package-security @cli/code-reviewers +pkg/cmd/release/verify/ @cli/package-security @cli/code-reviewers +pkg/cmd/release/verify-asset/ @cli/package-security @cli/code-reviewers +pkg/cmd/release/shared/ @cli/package-security @cli/code-reviewers -test/integration/attestation-cmd @cli/package-security +test/integration/attestation-cmd @cli/package-security @cli/code-reviewers -pkg/cmd/attestation/verification/embed/tuf-repo.github.com/ @cli/tuf-root-reviewers +pkg/cmd/attestation/verification/embed/tuf-repo.github.com/ @cli/tuf-root-reviewers @cli/code-reviewers -pkg/cmd/skills/ @cli/skill-reviewers -internal/skills/ @cli/skill-reviewers +pkg/cmd/skills/ @cli/skill-reviewers @cli/code-reviewers +internal/skills/ @cli/skill-reviewers @cli/code-reviewers From 7ad1d7c0a16be0585f018e4bfa170336d61b8c8e Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 16 Apr 2026 13:14:10 +0200 Subject: [PATCH 038/182] Suggest and install official extensions via stub commands Register hidden stub commands for official GitHub extensions (gh-aw, gh-stack) that offer to install the extension when invoked. This replaces the error-string-matching approach from the original PR with proper cobra commands that: - Avoid false-positive matches on flag values or post-'--' args - Eliminate conflicting cobra 'Did you mean?' suggestions - Properly propagate prompt/install errors for correct exit codes - Are hidden from help output and shell completions - Use GroupID "extension" so checkValidExtension allows installing over them - Are registered after extensions and aliases so both take priority Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/ghcmd/cmd.go | 53 -------- pkg/cmd/root/official_extension.go | 76 +++++++++++ pkg/cmd/root/official_extension_test.go | 168 ++++++++++++++++++++++++ pkg/cmd/root/root.go | 12 ++ pkg/extensions/official.go | 24 +--- pkg/extensions/official_test.go | 26 ---- 6 files changed, 261 insertions(+), 98 deletions(-) create mode 100644 pkg/cmd/root/official_extension.go create mode 100644 pkg/cmd/root/official_extension_test.go diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 3bf0f5f4a9b..8690078c66e 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -14,18 +14,15 @@ import ( surveyCore "github.com/AlecAivazis/survey/v2/core" "github.com/AlecAivazis/survey/v2/terminal" - "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/agents" "github.com/cli/cli/v2/internal/build" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/config/migration" - "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/update" "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmd/root" "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/utils" "github.com/cli/safeexec" @@ -143,18 +140,6 @@ func Main() exitCode { return exitCode(extError.ExitCode()) } - // Check if any of the provided args match a known official extension. - // We scan all args rather than just the first because global flags - // (e.g. --repo) may precede the unknown command name. - if strings.HasPrefix(err.Error(), "unknown command ") { - for _, arg := range expandedArgs { - if ext := extensions.FindOfficialExtension(arg); ext != nil { - handleOfficialExtension(cmdFactory.IOStreams, cmdFactory.Prompter, cmdFactory.ExtensionManager, ext, err) - return exitError - } - } - } - printError(stderr, err, cmd, hasDebug) if strings.Contains(err.Error(), "Incorrect function") { @@ -260,41 +245,3 @@ func isUnderHomebrew(ghBinary string) bool { brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator) return strings.HasPrefix(ghBinary, brewBinPrefix) } - -// handleOfficialExtension prints a suggestion for the matched official extension -// and, in interactive TTY sessions, prompts the user to install it. -func handleOfficialExtension(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension, err error) { - stderr := io.ErrOut - - fmt.Fprintln(stderr, err) - - if !io.CanPrompt() { - fmt.Fprint(stderr, heredoc.Docf(` - %q is also available as an official extension. - To install it, run: - gh extension install github.com/%s/%s - `, fmt.Sprintf("gh %s", ext.Name), ext.Owner, ext.Repo)) - return - } - - prompt := heredoc.Docf(` - %q is also available as an official extension. - Would you like to install it now? - `, fmt.Sprintf("gh %s", ext.Name)) - confirmed, promptErr := p.Confirm(prompt, true) - if promptErr != nil || !confirmed { - return - } - - repo := ext.Repository() - io.StartProgressIndicatorWithLabel(fmt.Sprintf("Installing %s/%s...", ext.Owner, ext.Repo)) - defer io.StopProgressIndicator() - installErr := em.Install(repo, "") - io.StopProgressIndicator() - if installErr != nil { - fmt.Fprintf(stderr, "Failed to install extension: %s\n", installErr) - return - } - - fmt.Fprintf(stderr, "Successfully installed %s/%s\n", ext.Owner, ext.Repo) -} diff --git a/pkg/cmd/root/official_extension.go b/pkg/cmd/root/official_extension.go new file mode 100644 index 00000000000..cde6bb6da40 --- /dev/null +++ b/pkg/cmd/root/official_extension.go @@ -0,0 +1,76 @@ +package root + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +// NewCmdOfficialExtension creates a hidden stub command for an official +// extension that has not yet been installed. When invoked, it suggests +// installing the extension and, in interactive sessions, offers to do so +// immediately. After a successful install, the extension is dispatched with +// the original arguments. +func NewCmdOfficialExtension(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) *cobra.Command { + return &cobra.Command{ + Use: ext.Name, + Short: fmt.Sprintf("Install the official %s extension", ext.Name), + Hidden: true, + GroupID: "extension", + Annotations: map[string]string{ + "skipAuthCheck": "true", + }, + // Accept any args/flags the user may have passed so we don't get + // cobra validation errors before reaching RunE. + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + return officialExtensionRun(io, p, em, ext, args) + }, + } +} + +func officialExtensionRun(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension, args []string) error { + stderr := io.ErrOut + + if !io.CanPrompt() { + fmt.Fprint(stderr, heredoc.Docf(` + %[1]s is available as an official extension. + To install it, run: + gh extension install %[2]s/%[3]s + `, fmt.Sprintf("gh %s", ext.Name), ext.Owner, ext.Repo)) + return nil + } + + prompt := heredoc.Docf(` + %[1]s is available as an official extension. + Would you like to install it now? + `, fmt.Sprintf("gh %s", ext.Name)) + confirmed, err := p.Confirm(prompt, true) + if err != nil { + return err + } + if !confirmed { + return nil + } + + repo := ext.Repository() + io.StartProgressIndicatorWithLabel(fmt.Sprintf("Installing %s/%s...", ext.Owner, ext.Repo)) + installErr := em.Install(repo, "") + io.StopProgressIndicator() + if installErr != nil { + return fmt.Errorf("failed to install extension: %w", installErr) + } + + fmt.Fprintf(stderr, "Successfully installed %s/%s\n", ext.Owner, ext.Repo) + + // Dispatch the newly installed extension with the original arguments. + dispatchArgs := append([]string{ext.Name}, args...) + if _, dispatchErr := em.Dispatch(dispatchArgs, io.In, io.Out, stderr); dispatchErr != nil { + return dispatchErr + } + return nil +} diff --git a/pkg/cmd/root/official_extension_test.go b/pkg/cmd/root/official_extension_test.go new file mode 100644 index 00000000000..8b6aaa3ae14 --- /dev/null +++ b/pkg/cmd/root/official_extension_test.go @@ -0,0 +1,168 @@ +package root + +import ( + "fmt" + "io" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOfficialExtensionRun_NonTTY(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + // non-TTY by default + + ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + em := &extensions.ExtensionManagerMock{} + p := &prompter.PrompterMock{} + + err := officialExtensionRun(ios, p, em, ext, nil) + require.NoError(t, err) + + assert.Contains(t, stderr.String(), "gh stack") + assert.Contains(t, stderr.String(), "gh extension install github/gh-stack") +} + +func TestOfficialExtensionRun_TTY_Confirmed(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + var installedRepo ghrepo.Interface + var dispatchedArgs []string + em := &extensions.ExtensionManagerMock{ + InstallFunc: func(repo ghrepo.Interface, pin string) error { + installedRepo = repo + return nil + }, + DispatchFunc: func(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) { + dispatchedArgs = args + return true, nil + }, + } + p := &prompter.PrompterMock{ + ConfirmFunc: func(_ string, _ bool) (bool, error) { + return true, nil + }, + } + + err := officialExtensionRun(ios, p, em, ext, []string{"--help"}) + require.NoError(t, err) + + require.NotNil(t, installedRepo) + assert.Equal(t, "github", installedRepo.RepoOwner()) + assert.Equal(t, "gh-stack", installedRepo.RepoName()) + assert.Equal(t, "github.com", installedRepo.RepoHost()) + assert.Contains(t, stderr.String(), "Successfully installed github/gh-stack") + assert.Equal(t, []string{"stack", "--help"}, dispatchedArgs) +} + +func TestOfficialExtensionRun_TTY_Declined(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + em := &extensions.ExtensionManagerMock{} + p := &prompter.PrompterMock{ + ConfirmFunc: func(_ string, _ bool) (bool, error) { + return false, nil + }, + } + + err := officialExtensionRun(ios, p, em, ext, nil) + require.NoError(t, err) + + assert.Empty(t, em.InstallCalls()) +} + +func TestOfficialExtensionRun_TTY_PromptError(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + em := &extensions.ExtensionManagerMock{} + p := &prompter.PrompterMock{ + ConfirmFunc: func(_ string, _ bool) (bool, error) { + return false, fmt.Errorf("prompt interrupted") + }, + } + + err := officialExtensionRun(ios, p, em, ext, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "prompt interrupted") +} + +func TestOfficialExtensionRun_TTY_InstallError(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + em := &extensions.ExtensionManagerMock{ + InstallFunc: func(_ ghrepo.Interface, _ string) error { + return fmt.Errorf("network error") + }, + } + p := &prompter.PrompterMock{ + ConfirmFunc: func(_ string, _ bool) (bool, error) { + return true, nil + }, + } + + err := officialExtensionRun(ios, p, em, ext, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "network error") +} + +func TestOfficialExtensionRun_TTY_DispatchError(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + + ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + em := &extensions.ExtensionManagerMock{ + InstallFunc: func(_ ghrepo.Interface, _ string) error { + return nil + }, + DispatchFunc: func(_ []string, _ io.Reader, _, _ io.Writer) (bool, error) { + return false, fmt.Errorf("dispatch failed") + }, + } + p := &prompter.PrompterMock{ + ConfirmFunc: func(_ string, _ bool) (bool, error) { + return true, nil + }, + } + + err := officialExtensionRun(ios, p, em, ext, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "dispatch failed") +} + +func TestNewCmdOfficialExtension_Properties(t *testing.T) { + ios, _, _, _ := iostreams.Test() + ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + em := &extensions.ExtensionManagerMock{} + p := &prompter.PrompterMock{} + + cmd := NewCmdOfficialExtension(ios, p, em, ext) + + assert.Equal(t, "stack", cmd.Use) + assert.True(t, cmd.Hidden) + assert.Equal(t, "extension", cmd.GroupID) + assert.True(t, cmd.DisableFlagParsing) + assert.Equal(t, "true", cmd.Annotations["skipAuthCheck"]) +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index ed33f568ed3..793b3bc8320 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -44,6 +44,7 @@ import ( versionCmd "github.com/cli/cli/v2/pkg/cmd/version" workflowCmd "github.com/cli/cli/v2/pkg/cmd/workflow" "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/extensions" "github.com/google/shlex" "github.com/spf13/cobra" ) @@ -229,6 +230,17 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, } } + // Official extension stubs — hidden commands that suggest installing + // GitHub-owned extensions when invoked. Registered after real extensions + // and aliases so that both take priority over stubs. + for i := range extensions.OfficialExtensions { + ext := &extensions.OfficialExtensions[i] + if _, _, err := cmd.Find([]string{ext.Name}); err == nil { + continue + } + cmd.AddCommand(NewCmdOfficialExtension(io, f.Prompter, em, ext)) + } + cmdutil.DisableAuthCheck(cmd) // The reference command produces paged output that displays information on every other command. diff --git a/pkg/extensions/official.go b/pkg/extensions/official.go index a1e6996db13..a07c426df91 100644 --- a/pkg/extensions/official.go +++ b/pkg/extensions/official.go @@ -12,29 +12,15 @@ type OfficialExtension struct { Repo string } -// Repository returns a ghrepo.Interface pinned to github.com for use with -// ExtensionManager.Install. +// Repository returns a ghrepo.Interface pinned to github.com so that GHES +// users install from github.com rather than their enterprise host. func (e *OfficialExtension) Repository() ghrepo.Interface { return ghrepo.NewWithHost(e.Owner, e.Repo, "github.com") } -// officialExtensions is the hard-coded registry of GitHub-owned extensions -// that gh will suggest installing when the user invokes an unknown command -// matching one of their names. -// Install suggestions include the "github.com/" host prefix so that GHES users -// install from github.com rather than their enterprise host. -var officialExtensions = []OfficialExtension{ +// OfficialExtensions is the registry of GitHub-owned extensions that gh will +// offer to install when the user invokes the corresponding command name. +var OfficialExtensions = []OfficialExtension{ {Name: "aw", Owner: "github", Repo: "gh-aw"}, {Name: "stack", Owner: "github", Repo: "gh-stack"}, } - -// FindOfficialExtension returns the matching official extension for -// commandName, or nil if none matches. -func FindOfficialExtension(commandName string) *OfficialExtension { - for _, ext := range officialExtensions { - if ext.Name == commandName { - return &ext - } - } - return nil -} diff --git a/pkg/extensions/official_test.go b/pkg/extensions/official_test.go index 0a0b5ec52ea..047af580af1 100644 --- a/pkg/extensions/official_test.go +++ b/pkg/extensions/official_test.go @@ -4,34 +4,8 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -func TestFindOfficialExtension(t *testing.T) { - tests := []struct { - name string - commandName string - wantNil bool - wantRepo string - }{ - {name: "found", commandName: "stack", wantNil: false, wantRepo: "gh-stack"}, - {name: "not found", commandName: "xyzzy", wantNil: true}, - {name: "empty", commandName: "", wantNil: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ext := FindOfficialExtension(tt.commandName) - if tt.wantNil { - assert.Nil(t, ext) - } else { - require.NotNil(t, ext) - assert.Equal(t, tt.wantRepo, ext.Repo) - } - }) - } -} - func TestOfficialExtension_Repository(t *testing.T) { ext := &OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} repo := ext.Repository() From 9596f99e565fa76a087f00c605bf93963ffb27d6 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 16 Apr 2026 18:09:16 +0200 Subject: [PATCH 039/182] Add no em dash rule to AGENTS.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index b04e6b77557..a9e3ab10951 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,6 +135,7 @@ for _, tt := range tests { - Add godoc comments to all exported functions, types, and constants - Avoid unnecessary code comments — only comment when the *why* isn't obvious from the code - Do not comment just to restate what the code does +- Never use em dashes (—) in code, comments, or documentation; use regular dashes (-) or rewrite the sentence instead ## Error Handling From abc2a50b4c00ea1ef93907214b909f16306c9308 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 16 Apr 2026 18:11:28 +0200 Subject: [PATCH 040/182] Address PR review feedback - Use cmdutil.DisableAuthCheck instead of raw annotation map - Convert tests to table-driven format - Use generic extension name in tests (gh-cool) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/root/official_extension.go | 10 +- pkg/cmd/root/official_extension_test.go | 243 +++++++++++------------- 2 files changed, 118 insertions(+), 135 deletions(-) diff --git a/pkg/cmd/root/official_extension.go b/pkg/cmd/root/official_extension.go index cde6bb6da40..bc1f21893b9 100644 --- a/pkg/cmd/root/official_extension.go +++ b/pkg/cmd/root/official_extension.go @@ -5,6 +5,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -16,14 +17,11 @@ import ( // immediately. After a successful install, the extension is dispatched with // the original arguments. func NewCmdOfficialExtension(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: ext.Name, Short: fmt.Sprintf("Install the official %s extension", ext.Name), Hidden: true, GroupID: "extension", - Annotations: map[string]string{ - "skipAuthCheck": "true", - }, // Accept any args/flags the user may have passed so we don't get // cobra validation errors before reaching RunE. DisableFlagParsing: true, @@ -31,6 +29,10 @@ func NewCmdOfficialExtension(io *iostreams.IOStreams, p prompter.Prompter, em ex return officialExtensionRun(io, p, em, ext, args) }, } + + cmdutil.DisableAuthCheck(cmd) + + return cmd } func officialExtensionRun(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension, args []string) error { diff --git a/pkg/cmd/root/official_extension_test.go b/pkg/cmd/root/official_extension_test.go index 8b6aaa3ae14..6986c4cb5b1 100644 --- a/pkg/cmd/root/official_extension_test.go +++ b/pkg/cmd/root/official_extension_test.go @@ -13,156 +13,137 @@ import ( "github.com/stretchr/testify/require" ) -func TestOfficialExtensionRun_NonTTY(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - // non-TTY by default - - ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} - em := &extensions.ExtensionManagerMock{} - p := &prompter.PrompterMock{} - - err := officialExtensionRun(ios, p, em, ext, nil) - require.NoError(t, err) - - assert.Contains(t, stderr.String(), "gh stack") - assert.Contains(t, stderr.String(), "gh extension install github/gh-stack") -} - -func TestOfficialExtensionRun_TTY_Confirmed(t *testing.T) { - ios, _, _, stderr := iostreams.Test() - ios.SetStdinTTY(true) - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} - var installedRepo ghrepo.Interface - var dispatchedArgs []string - em := &extensions.ExtensionManagerMock{ - InstallFunc: func(repo ghrepo.Interface, pin string) error { - installedRepo = repo - return nil +func TestOfficialExtensionRun(t *testing.T) { + ext := &extensions.OfficialExtension{Name: "cool", Owner: "github", Repo: "gh-cool"} + + tests := []struct { + name string + isTTY bool + confirmResult bool + confirmErr error + installErr error + dispatchErr error + args []string + wantErr string + wantStderr string + wantInstalled bool + wantDispatched bool + wantDispArgs []string + }{ + { + name: "non-TTY prints install instructions", + isTTY: false, + wantStderr: "gh extension install github/gh-cool", }, - DispatchFunc: func(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) { - dispatchedArgs = args - return true, nil + { + name: "TTY confirmed installs and dispatches", + isTTY: true, + confirmResult: true, + args: []string{"--help"}, + wantStderr: "Successfully installed github/gh-cool", + wantInstalled: true, + wantDispatched: true, + wantDispArgs: []string{"cool", "--help"}, }, - } - p := &prompter.PrompterMock{ - ConfirmFunc: func(_ string, _ bool) (bool, error) { - return true, nil + { + name: "TTY declined does not install", + isTTY: true, + confirmResult: false, }, - } - - err := officialExtensionRun(ios, p, em, ext, []string{"--help"}) - require.NoError(t, err) - - require.NotNil(t, installedRepo) - assert.Equal(t, "github", installedRepo.RepoOwner()) - assert.Equal(t, "gh-stack", installedRepo.RepoName()) - assert.Equal(t, "github.com", installedRepo.RepoHost()) - assert.Contains(t, stderr.String(), "Successfully installed github/gh-stack") - assert.Equal(t, []string{"stack", "--help"}, dispatchedArgs) -} - -func TestOfficialExtensionRun_TTY_Declined(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdinTTY(true) - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} - em := &extensions.ExtensionManagerMock{} - p := &prompter.PrompterMock{ - ConfirmFunc: func(_ string, _ bool) (bool, error) { - return false, nil - }, - } - - err := officialExtensionRun(ios, p, em, ext, nil) - require.NoError(t, err) - - assert.Empty(t, em.InstallCalls()) -} - -func TestOfficialExtensionRun_TTY_PromptError(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdinTTY(true) - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} - em := &extensions.ExtensionManagerMock{} - p := &prompter.PrompterMock{ - ConfirmFunc: func(_ string, _ bool) (bool, error) { - return false, fmt.Errorf("prompt interrupted") + { + name: "TTY prompt error is propagated", + isTTY: true, + confirmErr: fmt.Errorf("prompt interrupted"), + wantErr: "prompt interrupted", }, - } - - err := officialExtensionRun(ios, p, em, ext, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "prompt interrupted") -} - -func TestOfficialExtensionRun_TTY_InstallError(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdinTTY(true) - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} - em := &extensions.ExtensionManagerMock{ - InstallFunc: func(_ ghrepo.Interface, _ string) error { - return fmt.Errorf("network error") + { + name: "TTY install error is propagated", + isTTY: true, + confirmResult: true, + installErr: fmt.Errorf("network error"), + wantErr: "network error", + wantInstalled: true, }, - } - p := &prompter.PrompterMock{ - ConfirmFunc: func(_ string, _ bool) (bool, error) { - return true, nil + { + name: "TTY dispatch error is propagated", + isTTY: true, + confirmResult: true, + dispatchErr: fmt.Errorf("dispatch failed"), + wantErr: "dispatch failed", + wantInstalled: true, + wantDispatched: true, }, } - err := officialExtensionRun(ios, p, em, ext, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "network error") -} - -func TestOfficialExtensionRun_TTY_DispatchError(t *testing.T) { - ios, _, _, _ := iostreams.Test() - ios.SetStdinTTY(true) - ios.SetStdoutTTY(true) - ios.SetStderrTTY(true) - - ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} - em := &extensions.ExtensionManagerMock{ - InstallFunc: func(_ ghrepo.Interface, _ string) error { - return nil - }, - DispatchFunc: func(_ []string, _ io.Reader, _, _ io.Writer) (bool, error) { - return false, fmt.Errorf("dispatch failed") - }, + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + if tt.isTTY { + ios.SetStdinTTY(true) + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + } + + em := &extensions.ExtensionManagerMock{ + InstallFunc: func(_ ghrepo.Interface, _ string) error { + return tt.installErr + }, + DispatchFunc: func(_ []string, _ io.Reader, _, _ io.Writer) (bool, error) { + if tt.dispatchErr != nil { + return false, tt.dispatchErr + } + return true, nil + }, + } + p := &prompter.PrompterMock{ + ConfirmFunc: func(_ string, _ bool) (bool, error) { + return tt.confirmResult, tt.confirmErr + }, + } + + err := officialExtensionRun(ios, p, em, ext, tt.args) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + + if tt.wantInstalled { + require.NotEmpty(t, em.InstallCalls()) + repo := em.InstallCalls()[0].InterfaceMoqParam + assert.Equal(t, "github", repo.RepoOwner()) + assert.Equal(t, "gh-cool", repo.RepoName()) + assert.Equal(t, "github.com", repo.RepoHost()) + } else if tt.isTTY && !tt.confirmResult && tt.confirmErr == nil { + assert.Empty(t, em.InstallCalls()) + } + + if tt.wantDispatched { + require.NotEmpty(t, em.DispatchCalls()) + if tt.wantDispArgs != nil { + assert.Equal(t, tt.wantDispArgs, em.DispatchCalls()[0].Args) + } + } + }) } - p := &prompter.PrompterMock{ - ConfirmFunc: func(_ string, _ bool) (bool, error) { - return true, nil - }, - } - - err := officialExtensionRun(ios, p, em, ext, nil) - require.Error(t, err) - assert.Contains(t, err.Error(), "dispatch failed") } func TestNewCmdOfficialExtension_Properties(t *testing.T) { ios, _, _, _ := iostreams.Test() - ext := &extensions.OfficialExtension{Name: "stack", Owner: "github", Repo: "gh-stack"} + ext := &extensions.OfficialExtension{Name: "cool", Owner: "github", Repo: "gh-cool"} em := &extensions.ExtensionManagerMock{} p := &prompter.PrompterMock{} cmd := NewCmdOfficialExtension(ios, p, em, ext) - assert.Equal(t, "stack", cmd.Use) + assert.Equal(t, "cool", cmd.Use) assert.True(t, cmd.Hidden) assert.Equal(t, "extension", cmd.GroupID) assert.True(t, cmd.DisableFlagParsing) - assert.Equal(t, "true", cmd.Annotations["skipAuthCheck"]) } From 1d59334964454df2e3a6360c76e322af24b69d1b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:30:47 -0600 Subject: [PATCH 041/182] Replace em-dashes with regular dashes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/root/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 793b3bc8320..700ca570dd1 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -230,7 +230,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, } } - // Official extension stubs — hidden commands that suggest installing + // Official extension stubs: hidden commands that suggest installing // GitHub-owned extensions when invoked. Registered after real extensions // and aliases so that both take priority over stubs. for i := range extensions.OfficialExtensions { From b8d504cbd9de92825440ce7b130467eba2b06698 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:34:48 -0600 Subject: [PATCH 042/182] Rename official extension files and types to stubs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../{official_extension.go => official_extension_stub.go} | 8 ++++---- ..._extension_test.go => official_extension_stub_test.go} | 8 ++++---- pkg/cmd/root/root.go | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) rename pkg/cmd/root/{official_extension.go => official_extension_stub.go} (81%) rename pkg/cmd/root/{official_extension_test.go => official_extension_stub_test.go} (94%) diff --git a/pkg/cmd/root/official_extension.go b/pkg/cmd/root/official_extension_stub.go similarity index 81% rename from pkg/cmd/root/official_extension.go rename to pkg/cmd/root/official_extension_stub.go index bc1f21893b9..2e5a69bec41 100644 --- a/pkg/cmd/root/official_extension.go +++ b/pkg/cmd/root/official_extension_stub.go @@ -11,12 +11,12 @@ import ( "github.com/spf13/cobra" ) -// NewCmdOfficialExtension creates a hidden stub command for an official +// NewCmdOfficialExtensionStub creates a hidden stub command for an official // extension that has not yet been installed. When invoked, it suggests // installing the extension and, in interactive sessions, offers to do so // immediately. After a successful install, the extension is dispatched with // the original arguments. -func NewCmdOfficialExtension(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) *cobra.Command { +func NewCmdOfficialExtensionStub(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) *cobra.Command { cmd := &cobra.Command{ Use: ext.Name, Short: fmt.Sprintf("Install the official %s extension", ext.Name), @@ -26,7 +26,7 @@ func NewCmdOfficialExtension(io *iostreams.IOStreams, p prompter.Prompter, em ex // cobra validation errors before reaching RunE. DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - return officialExtensionRun(io, p, em, ext, args) + return officialExtensionStubRun(io, p, em, ext, args) }, } @@ -35,7 +35,7 @@ func NewCmdOfficialExtension(io *iostreams.IOStreams, p prompter.Prompter, em ex return cmd } -func officialExtensionRun(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension, args []string) error { +func officialExtensionStubRun(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension, args []string) error { stderr := io.ErrOut if !io.CanPrompt() { diff --git a/pkg/cmd/root/official_extension_test.go b/pkg/cmd/root/official_extension_stub_test.go similarity index 94% rename from pkg/cmd/root/official_extension_test.go rename to pkg/cmd/root/official_extension_stub_test.go index 6986c4cb5b1..4fadd67d318 100644 --- a/pkg/cmd/root/official_extension_test.go +++ b/pkg/cmd/root/official_extension_stub_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestOfficialExtensionRun(t *testing.T) { +func TestOfficialExtensionStubRun(t *testing.T) { ext := &extensions.OfficialExtension{Name: "cool", Owner: "github", Repo: "gh-cool"} tests := []struct { @@ -101,7 +101,7 @@ func TestOfficialExtensionRun(t *testing.T) { }, } - err := officialExtensionRun(ios, p, em, ext, tt.args) + err := officialExtensionStubRun(ios, p, em, ext, tt.args) if tt.wantErr != "" { require.Error(t, err) @@ -134,13 +134,13 @@ func TestOfficialExtensionRun(t *testing.T) { } } -func TestNewCmdOfficialExtension_Properties(t *testing.T) { +func TestNewCmdOfficialExtensionStub_Properties(t *testing.T) { ios, _, _, _ := iostreams.Test() ext := &extensions.OfficialExtension{Name: "cool", Owner: "github", Repo: "gh-cool"} em := &extensions.ExtensionManagerMock{} p := &prompter.PrompterMock{} - cmd := NewCmdOfficialExtension(ios, p, em, ext) + cmd := NewCmdOfficialExtensionStub(ios, p, em, ext) assert.Equal(t, "cool", cmd.Use) assert.True(t, cmd.Hidden) diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 700ca570dd1..068129ba23f 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -238,7 +238,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, if _, _, err := cmd.Find([]string{ext.Name}); err == nil { continue } - cmd.AddCommand(NewCmdOfficialExtension(io, f.Prompter, em, ext)) + cmd.AddCommand(NewCmdOfficialExtensionStub(io, f.Prompter, em, ext)) } cmdutil.DisableAuthCheck(cmd) From 32b8af1017ae39c605aed85d7b13acff0721939b Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:38:33 -0600 Subject: [PATCH 043/182] Remove dispatch after install, install only Removes post-install extension dispatch to keep the stub focused on installation. Also removes unused args parameter from the run function. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/root/official_extension_stub.go | 10 +--- pkg/cmd/root/official_extension_stub_test.go | 58 +++++--------------- 2 files changed, 16 insertions(+), 52 deletions(-) diff --git a/pkg/cmd/root/official_extension_stub.go b/pkg/cmd/root/official_extension_stub.go index 2e5a69bec41..af52e43663e 100644 --- a/pkg/cmd/root/official_extension_stub.go +++ b/pkg/cmd/root/official_extension_stub.go @@ -26,7 +26,7 @@ func NewCmdOfficialExtensionStub(io *iostreams.IOStreams, p prompter.Prompter, e // cobra validation errors before reaching RunE. DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - return officialExtensionStubRun(io, p, em, ext, args) + return officialExtensionStubRun(io, p, em, ext) }, } @@ -35,7 +35,7 @@ func NewCmdOfficialExtensionStub(io *iostreams.IOStreams, p prompter.Prompter, e return cmd } -func officialExtensionStubRun(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension, args []string) error { +func officialExtensionStubRun(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) error { stderr := io.ErrOut if !io.CanPrompt() { @@ -68,11 +68,5 @@ func officialExtensionStubRun(io *iostreams.IOStreams, p prompter.Prompter, em e } fmt.Fprintf(stderr, "Successfully installed %s/%s\n", ext.Owner, ext.Repo) - - // Dispatch the newly installed extension with the original arguments. - dispatchArgs := append([]string{ext.Name}, args...) - if _, dispatchErr := em.Dispatch(dispatchArgs, io.In, io.Out, stderr); dispatchErr != nil { - return dispatchErr - } return nil } diff --git a/pkg/cmd/root/official_extension_stub_test.go b/pkg/cmd/root/official_extension_stub_test.go index 4fadd67d318..d2fd4624204 100644 --- a/pkg/cmd/root/official_extension_stub_test.go +++ b/pkg/cmd/root/official_extension_stub_test.go @@ -2,7 +2,6 @@ package root import ( "fmt" - "io" "testing" "github.com/cli/cli/v2/internal/ghrepo" @@ -17,18 +16,14 @@ func TestOfficialExtensionStubRun(t *testing.T) { ext := &extensions.OfficialExtension{Name: "cool", Owner: "github", Repo: "gh-cool"} tests := []struct { - name string - isTTY bool - confirmResult bool - confirmErr error - installErr error - dispatchErr error - args []string - wantErr string - wantStderr string - wantInstalled bool - wantDispatched bool - wantDispArgs []string + name string + isTTY bool + confirmResult bool + confirmErr error + installErr error + wantErr string + wantStderr string + wantInstalled bool }{ { name: "non-TTY prints install instructions", @@ -36,14 +31,11 @@ func TestOfficialExtensionStubRun(t *testing.T) { wantStderr: "gh extension install github/gh-cool", }, { - name: "TTY confirmed installs and dispatches", - isTTY: true, - confirmResult: true, - args: []string{"--help"}, - wantStderr: "Successfully installed github/gh-cool", - wantInstalled: true, - wantDispatched: true, - wantDispArgs: []string{"cool", "--help"}, + name: "TTY confirmed installs", + isTTY: true, + confirmResult: true, + wantStderr: "Successfully installed github/gh-cool", + wantInstalled: true, }, { name: "TTY declined does not install", @@ -64,15 +56,6 @@ func TestOfficialExtensionStubRun(t *testing.T) { wantErr: "network error", wantInstalled: true, }, - { - name: "TTY dispatch error is propagated", - isTTY: true, - confirmResult: true, - dispatchErr: fmt.Errorf("dispatch failed"), - wantErr: "dispatch failed", - wantInstalled: true, - wantDispatched: true, - }, } for _, tt := range tests { @@ -88,12 +71,6 @@ func TestOfficialExtensionStubRun(t *testing.T) { InstallFunc: func(_ ghrepo.Interface, _ string) error { return tt.installErr }, - DispatchFunc: func(_ []string, _ io.Reader, _, _ io.Writer) (bool, error) { - if tt.dispatchErr != nil { - return false, tt.dispatchErr - } - return true, nil - }, } p := &prompter.PrompterMock{ ConfirmFunc: func(_ string, _ bool) (bool, error) { @@ -101,7 +78,7 @@ func TestOfficialExtensionStubRun(t *testing.T) { }, } - err := officialExtensionStubRun(ios, p, em, ext, tt.args) + err := officialExtensionStubRun(ios, p, em, ext) if tt.wantErr != "" { require.Error(t, err) @@ -123,13 +100,6 @@ func TestOfficialExtensionStubRun(t *testing.T) { } else if tt.isTTY && !tt.confirmResult && tt.confirmErr == nil { assert.Empty(t, em.InstallCalls()) } - - if tt.wantDispatched { - require.NotEmpty(t, em.DispatchCalls()) - if tt.wantDispArgs != nil { - assert.Equal(t, tt.wantDispArgs, em.DispatchCalls()[0].Args) - } - } }) } } From de204f85186f29189fd4ec584bd1d2fcc57f890e Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 16 Apr 2026 18:49:54 +0200 Subject: [PATCH 044/182] =?UTF-8?q?fix:=20apply=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20nil=20HttpClient,=20local=20dedup=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Return nil instead of real http.Client in unsupported host test - Move skillResultKey type inside deduplicateResults function scope Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish_test.go | 2 +- pkg/cmd/skills/search/search.go | 16 +++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 04f400733ef..97795a61712 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -164,7 +164,7 @@ func TestPublishRun_UnsupportedHost(t *testing.T) { IO: ios, Dir: dir, GitClient: &git.Client{}, - HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, + HttpClient: func() (*http.Client, error) { return nil, nil }, host: "acme.ghes.com", }) require.ErrorContains(t, err, "supports only github.com") diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 18471cc954f..05511484eae 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -770,17 +770,15 @@ func fetchPrimaryPages(client *api.Client, host, query string, displayPage, disp return allItems, totalCount, nil } -// skillResultKey is a typed map key for deduplicating code search results -// by (repo, namespace, skill name). All fields are lowercased for -// case-insensitive comparison. -type skillResultKey struct { - repo string - namespace string - skillName string -} - // deduplicateResults extracts unique (repo, namespace, skill name) triples from code search hits. func deduplicateResults(items []codeSearchItem) []skillResult { + // skillResultKey is a typed map key that deduplicates by (repo, namespace, + // skill name). All fields are lowercased for case-insensitive comparison. + type skillResultKey struct { + repo string + namespace string + skillName string + } seen := make(map[skillResultKey]struct{}) var results []skillResult From 5ae5d1db7f7f09416b2aa5f00b42ef6cc8c8034a Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:02:53 +0200 Subject: [PATCH 045/182] refactor: replace real git with run.CommandStubber in publish tests Replace all exec.Command("git", ...), initGitRepo, runGitInDir, and newTestGitClientWithUpstream with run.Stub()/run.CommandStubber stubs. Changes: - Remove os/exec and strings imports; add fmt, regexp, internal/run - Add newTestGitClient(), stubGitRemote(), stubEnsurePushed() helpers - Remove initGitRepo, runGitInDir, newTestGitClientWithUpstream helpers - Add cmdStubs field to TestPublishRun table struct - Convert all test cases to use stub-based git interactions - Use regexp.QuoteMeta for remote name patterns - Use %[1]s/%[2]s format args in stubGitRemote - Initialize git.Client with explicit GitPath to avoid real git resolution - Rewrite TestEnsurePushed with stub-based tests - Update TestDetectGitHubRemote_UsesDir and TestPublishRun_DirArgUsesTargetRemote Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish_test.go | 461 ++++++++++++------------- 1 file changed, 217 insertions(+), 244 deletions(-) diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 97795a61712..29142d44f47 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -2,16 +2,17 @@ package publish import ( "bytes" + "fmt" "net/http" "os" - "os/exec" "path/filepath" - "strings" + "regexp" "testing" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -20,33 +21,31 @@ import ( "github.com/stretchr/testify/require" ) -// initGitRepo initializes a git repo in the given directory and adds remotes. -// Use this when the git repo must live in the same directory as the skill files. -// A local bare repo is created as the push target so that ensurePushed can work -// during publish tests, while the fetch URL remains the GitHub URL so that -// detectGitHubRemote still resolves the correct owner/repo. -func initGitRepo(t *testing.T, dir string, remoteURLs map[string]string) { - t.Helper() - - bareDir := filepath.Join(t.TempDir(), "upstream.git") - require.NoError(t, os.MkdirAll(bareDir, 0o755)) - runGitInDir(t, bareDir, "init", "--bare", "--initial-branch=main") +// newTestGitClient returns a git.Client with a fake git path to avoid real git resolution. +func newTestGitClient() *git.Client { + return &git.Client{GitPath: "some/path/git"} +} - runGitInDir(t, dir, "init", "--initial-branch=main") - runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") - runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") +// stubGitRemote registers CommandStubber stubs for git remote detection. +func stubGitRemote(cs *run.CommandStubber, remoteURLs map[string]string) { + var remoteLines string for name, url := range remoteURLs { - runGitInDir(t, dir, "remote", "add", name, url) - runGitInDir(t, dir, "remote", "set-url", "--push", name, bareDir) + remoteLines += fmt.Sprintf("%[1]s\t%[2]s (fetch)\n%[1]s\t%[2]s (push)\n", name, url) } - - runGitInDir(t, dir, "add", ".") - runGitInDir(t, dir, "commit", "--allow-empty", "-m", "init") - if _, ok := remoteURLs["origin"]; ok { - runGitInDir(t, dir, "push", "origin", "main") + cs.Register(`git( .+)? remote -v`, 0, remoteLines) + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + for name, url := range remoteURLs { + cs.Register(fmt.Sprintf(`git( .+)? remote get-url -- %s`, regexp.QuoteMeta(name)), 0, url+"\n") } } +// stubEnsurePushed registers stubs for ensurePushed + runPublishRelease CurrentBranch calls. +func stubEnsurePushed(cs *run.CommandStubber, branch string) { + cs.Register(`git( .+)? symbolic-ref --quiet HEAD`, 0, "refs/heads/"+branch+"\n") + cs.Register(`git( .+)? rev-list --count @\{push\}\.\.HEAD`, 0, "0\n") + cs.Register(`git( .+)? symbolic-ref --quiet HEAD`, 0, "refs/heads/"+branch+"\n") +} + // stubAllSecureRemote registers the standard stubs for a fully-configured remote // repo (topics, tags, rulesets, security) so publishRun skips all remote warnings. func stubAllSecureRemote(reg *httpmock.Registry, owner, repo string) { @@ -158,12 +157,15 @@ func TestPublishRun_UnsupportedHost(t *testing.T) { Body. `)) + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + stubGitRemote(cs, map[string]string{"origin": "https://github.com/monalisa/skills-repo.git"}) + ios, _, _, _ := iostreams.Test() - initGitRepo(t, dir, map[string]string{"origin": "https://github.com/monalisa/skills-repo.git"}) err := publishRun(&PublishOptions{ IO: ios, Dir: dir, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return nil, nil }, host: "acme.ghes.com", }) @@ -176,6 +178,7 @@ func TestPublishRun(t *testing.T) { isTTY bool setup func(t *testing.T, dir string) stubs func(*httpmock.Registry) + cmdStubs func(*run.CommandStubber) opts func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions verify func(t *testing.T, dir string) wantErr string @@ -275,20 +278,22 @@ func TestPublishRun(t *testing.T) { --- Body. `)), 0o644)) - initGitRepo(t, dir, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }) }, stubs: func(reg *httpmock.Registry) { stubAllSecureRemote(reg, "monalisa", "skills-repo") }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() return &PublishOptions{ IO: ios, Dir: dir, DryRun: true, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -312,20 +317,22 @@ func TestPublishRun(t *testing.T) { --- Body. `)), 0o644)) - initGitRepo(t, dir, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }) }, stubs: func(reg *httpmock.Registry) { stubAllSecureRemote(reg, "monalisa", "skills-repo") }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() return &PublishOptions{ IO: ios, Dir: dir, DryRun: true, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -350,16 +357,18 @@ func TestPublishRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { stubAllSecureRemote(reg, "monalisa", "skills-repo") }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { - t.Helper() - initGitRepo(t, dir, map[string]string{ + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ "origin": "https://github.com/monalisa/skills-repo.git", }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() return &PublishOptions{ IO: ios, Dir: dir, DryRun: true, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -403,11 +412,14 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { - t.Helper() - initGitRepo(t, dir, map[string]string{ + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ "origin": "https://github.com/monalisa/skills-repo.git", }) + stubEnsurePushed(cs, "main") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() return &PublishOptions{ IO: ios, Dir: dir, @@ -415,7 +427,7 @@ func TestPublishRun(t *testing.T) { Prompter: &prompter.PrompterMock{ ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, }, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -557,15 +569,17 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { - t.Helper() - initGitRepo(t, dir, map[string]string{ + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ "origin": "https://github.com/octocat/secure-repo.git", }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() return &PublishOptions{ IO: ios, Dir: dir, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -610,15 +624,17 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { - t.Helper() - initGitRepo(t, dir, map[string]string{ + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ "origin": "https://github.com/octocat/tag-repo.git", }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() return &PublishOptions{ IO: ios, Dir: dir, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -673,16 +689,18 @@ func TestPublishRun(t *testing.T) { httpmock.StatusStringResponse(404, "not found"), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { - t.Helper() - initGitRepo(t, dir, map[string]string{ + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ "origin": "https://github.com/octocat/code-repo.git", }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() return &PublishOptions{ IO: ios, Dir: dir, DryRun: true, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -738,16 +756,18 @@ func TestPublishRun(t *testing.T) { httpmock.StatusStringResponse(404, "not found"), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { - t.Helper() - initGitRepo(t, dir, map[string]string{ + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ "origin": "https://github.com/octocat/dep-repo.git", }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() return &PublishOptions{ IO: ios, Dir: dir, DryRun: true, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -767,21 +787,25 @@ func TestPublishRun(t *testing.T) { --- Body. `)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) + }, + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git( .+)? check-ignore -q -- .agents/skills`, 1, "") + cs.Register(`git( .+)? remote -v`, 0, "") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + cs.Register(`git( .+)? rev-parse --git-dir`, 0, ".git\n") + cs.Register(`git( .+)? remote -v`, 0, "") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") }, opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() - require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) - runGitInDir(t, dir, "init", "--initial-branch=main") - runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") - runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") - return &PublishOptions{ IO: ios, Dir: dir, - GitClient: &git.Client{RepoDir: dir}, + GitClient: &git.Client{GitPath: "some/path/git", RepoDir: dir}, } }, - wantStdout: ".gitignore", + wantStdout: "may contain installed skills that are not gitignored", }, { name: "installed skill dirs gitignored no warning", @@ -796,20 +820,21 @@ func TestPublishRun(t *testing.T) { Body. `)) require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) - - runGitInDir(t, dir, "init", "--initial-branch=main") - runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") - runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") - require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".agents/skills\n"), 0o644)) - runGitInDir(t, dir, "add", ".gitignore") - runGitInDir(t, dir, "commit", "-m", "init") + }, + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git( .+)? check-ignore -q -- .agents/skills`, 0, "") + cs.Register(`git( .+)? remote -v`, 0, "") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + cs.Register(`git( .+)? rev-parse --git-dir`, 0, ".git\n") + cs.Register(`git( .+)? remote -v`, 0, "") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") }, opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() return &PublishOptions{ IO: ios, Dir: dir, - GitClient: &git.Client{RepoDir: dir}, + GitClient: &git.Client{GitPath: "some/path/git", RepoDir: dir}, } }, wantStdout: "no git remote", @@ -830,15 +855,22 @@ func TestPublishRun(t *testing.T) { --- Body. `)) - // Create install dir but do NOT init git so check-ignore will fail require.NoError(t, os.MkdirAll(filepath.Join(dir, ".agents", "skills", "installed"), 0o755)) }, + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git( .+)? check-ignore -q -- .agents/skills`, 128, "") + cs.Register(`git( .+)? remote -v`, 0, "") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + cs.Register(`git( .+)? rev-parse --git-dir`, 0, ".git\n") + cs.Register(`git( .+)? remote -v`, 0, "") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + }, opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() return &PublishOptions{ IO: ios, Dir: dir, - GitClient: &git.Client{RepoDir: dir}, + GitClient: &git.Client{GitPath: "some/path/git", RepoDir: dir}, } }, wantStdout: "may contain installed skills that are not gitignored", @@ -855,17 +887,22 @@ func TestPublishRun(t *testing.T) { --- Body. `)) - runGitInDir(t, dir, "init", "--initial-branch=main") - runGitInDir(t, dir, "config", "user.email", "monalisa@github.com") - runGitInDir(t, dir, "config", "user.name", "Monalisa Octocat") - runGitInDir(t, dir, "remote", "add", "origin", "https://gitlab.com/hubot/bar.git") + }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://gitlab.com/hubot/bar.git", + }) + cs.Register(`git( .+)? rev-parse --git-dir`, 0, ".git\n") + cs.Register(`git( .+)? remote -v`, 0, "origin\thttps://gitlab.com/hubot/bar.git (fetch)\norigin\thttps://gitlab.com/hubot/bar.git (push)\n") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + cs.Register(fmt.Sprintf(`git( .+)? remote get-url -- %s`, regexp.QuoteMeta("origin")), 0, "https://gitlab.com/hubot/bar.git\n") }, opts: func(ios *iostreams.IOStreams, dir string, _ *httpmock.Registry) *PublishOptions { t.Helper() return &PublishOptions{ IO: ios, Dir: dir, - GitClient: &git.Client{RepoDir: dir}, + GitClient: &git.Client{GitPath: "some/path/git", RepoDir: dir}, } }, wantStdout: "not a GitHub repository", @@ -887,17 +924,19 @@ func TestPublishRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { stubAllSecureRemote(reg, "octocat", "repo") }, + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git( .+)? remote -v`, 0, "origin\thttps://gitlab.com/hubot/bar.git (fetch)\norigin\thttps://gitlab.com/hubot/bar.git (push)\nupstream\tgit@github.com:octocat/repo.git (fetch)\nupstream\tgit@github.com:octocat/repo.git (push)\n") + cs.Register(`git( .+)? config --get-regexp \^remote\\\.`, 1, "") + // upstream sorts first (score 3 > 1), so only upstream's get-url is called + cs.Register(fmt.Sprintf(`git( .+)? remote get-url -- %s`, regexp.QuoteMeta("upstream")), 0, "git@github.com:octocat/repo.git\n") + }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() - initGitRepo(t, dir, map[string]string{ - "origin": "https://gitlab.com/hubot/bar.git", - "upstream": "git@github.com:octocat/repo.git", - }) return &PublishOptions{ IO: ios, Dir: dir, DryRun: true, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -974,11 +1013,14 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { - t.Helper() - initGitRepo(t, dir, map[string]string{ + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ "origin": "https://github.com/monalisa/skills-repo.git", }) + stubEnsurePushed(cs, "main") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() return &PublishOptions{ IO: ios, Dir: dir, @@ -986,12 +1028,11 @@ func TestPublishRun(t *testing.T) { Prompter: &prompter.PrompterMock{ ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, }, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } }, - wantStdout: "Added \"agent-skills\" topic", }, { name: "tag suggestion uses existing tags", @@ -1052,16 +1093,19 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { - t.Helper() - initGitRepo(t, dir, map[string]string{ + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ "origin": "https://github.com/monalisa/skills-repo.git", }) + stubEnsurePushed(cs, "main") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() return &PublishOptions{ IO: ios, Dir: dir, Tag: "v2.3.5", - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -1084,16 +1128,20 @@ func TestPublishRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { stubAllSecureRemote(reg, "monalisa", "skills-repo") }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { - t.Helper() - initGitRepo(t, dir, map[string]string{ + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ "origin": "https://github.com/monalisa/skills-repo.git", }) + cs.Register(`git( .+)? symbolic-ref --quiet HEAD`, 0, "refs/heads/main\n") + cs.Register(`git( .+)? rev-list --count @\{push\}\.\.HEAD`, 0, "0\n") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() return &PublishOptions{ IO: ios, Dir: dir, Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -1117,15 +1165,17 @@ func TestPublishRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { stubAllSecureRemote(reg, "monalisa", "skills-repo") }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { - t.Helper() - initGitRepo(t, dir, map[string]string{ + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ "origin": "https://github.com/monalisa/skills-repo.git", }) + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() return &PublishOptions{ IO: ios, Dir: dir, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -1215,12 +1265,15 @@ func TestPublishRun(t *testing.T) { }), ) }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + stubEnsurePushed(cs, "main") + }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() confirmCall := 0 - initGitRepo(t, dir, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }) return &PublishOptions{ IO: ios, Dir: dir, @@ -1236,7 +1289,7 @@ func TestPublishRun(t *testing.T) { return "v1.0.0", nil // accept suggested tag }, }, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -1274,11 +1327,14 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { - t.Helper() - initGitRepo(t, dir, map[string]string{ + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ "origin": "https://github.com/monalisa/skills-repo.git", }) + stubEnsurePushed(cs, "main") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() return &PublishOptions{ IO: ios, Dir: dir, @@ -1293,7 +1349,7 @@ func TestPublishRun(t *testing.T) { return "beta-1", nil }, }, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -1325,12 +1381,15 @@ func TestPublishRun(t *testing.T) { httpmock.JSONResponse(map[string]interface{}{"default_branch": "main"}), ) }, + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/skills-repo.git", + }) + stubEnsurePushed(cs, "main") + }, opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { t.Helper() confirmCall := 0 - initGitRepo(t, dir, map[string]string{ - "origin": "https://github.com/monalisa/skills-repo.git", - }) return &PublishOptions{ IO: ios, Dir: dir, @@ -1349,7 +1408,7 @@ func TestPublishRun(t *testing.T) { return "v1.0.1", nil }, }, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -1394,11 +1453,14 @@ func TestPublishRun(t *testing.T) { }), ) }, - opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { - t.Helper() - initGitRepo(t, dir, map[string]string{ + cmdStubs: func(cs *run.CommandStubber) { + stubGitRemote(cs, map[string]string{ "origin": "https://github.com/monalisa/skills-repo.git", }) + stubEnsurePushed(cs, "main") + }, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *PublishOptions { + t.Helper() return &PublishOptions{ IO: ios, Dir: dir, @@ -1413,7 +1475,7 @@ func TestPublishRun(t *testing.T) { return "v1.0.1", nil }, }, - GitClient: &git.Client{}, + GitClient: newTestGitClient(), HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", } @@ -1438,6 +1500,12 @@ func TestPublishRun(t *testing.T) { tt.stubs(reg) } + if tt.cmdStubs != nil { + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + tt.cmdStubs(cs) + } + opts := tt.opts(ios, dir, reg) err := publishRun(opts) @@ -1461,22 +1529,17 @@ func TestPublishRun(t *testing.T) { } func TestDetectGitHubRemote_UsesDir(t *testing.T) { - // Create two separate git repos: "cwd-repo" simulates the working directory - // and "target-repo" simulates the directory argument passed to publish. - cwdRepo := t.TempDir() - initGitRepo(t, cwdRepo, map[string]string{ - "origin": "https://github.com/monalisa/cwd-repo.git", + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/target-repo.git", }) + cwdRepo := t.TempDir() targetRepo := t.TempDir() - initGitRepo(t, targetRepo, map[string]string{ - "origin": "https://github.com/monalisa/target-repo.git", - }) - // gitClient points at cwd-repo (simulating factory-provided client) - gitClient := &git.Client{RepoDir: cwdRepo} + gitClient := &git.Client{GitPath: "some/path/git", RepoDir: cwdRepo} - // detectGitHubRemote should use targetRepo's remotes, not cwdRepo's repo, err := detectGitHubRemote(gitClient, targetRepo) require.NoError(t, err) require.NotNil(t, repo) @@ -1485,24 +1548,14 @@ func TestDetectGitHubRemote_UsesDir(t *testing.T) { } func TestPublishRun_DirArgUsesTargetRemote(t *testing.T) { - // Regression test: when a directory argument is provided, remote detection - // must use that directory's git remotes, not the factory client's directory. - // - // Scenario: - // 1. User is in cwd-repo (has remote → monalisa/cwd-repo) - // 2. User runs: gh skill publish /path/to/target-repo - // 3. target-repo has remote → monalisa/target-repo - // 4. API calls must go to target-repo, NOT cwd-repo - - cwdRepo := t.TempDir() - initGitRepo(t, cwdRepo, map[string]string{ - "origin": "https://github.com/monalisa/cwd-repo.git", + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + stubGitRemote(cs, map[string]string{ + "origin": "https://github.com/monalisa/target-repo.git", }) + cwdRepo := t.TempDir() targetRepo := t.TempDir() - initGitRepo(t, targetRepo, map[string]string{ - "origin": "https://github.com/monalisa/target-repo.git", - }) writeSkill(t, targetRepo, "my-skill", heredoc.Doc(` --- @@ -1520,17 +1573,13 @@ func TestPublishRun_DirArgUsesTargetRemote(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) - - // Stub API calls for target-repo (the correct repo). - // If the bug is present, these stubs won't be called because the code - // would try to hit cwd-repo endpoints instead, and reg.Verify would fail. stubAllSecureRemote(reg, "monalisa", "target-repo") err := publishRun(&PublishOptions{ IO: ios, Dir: targetRepo, DryRun: true, - GitClient: &git.Client{RepoDir: cwdRepo}, + GitClient: &git.Client{GitPath: "some/path/git", RepoDir: cwdRepo}, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, host: "github.com", }) @@ -1547,112 +1596,36 @@ func writeSkill(t *testing.T, dir, name, content string) { require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) } -// runGitInDir runs a git command in the given directory with isolation env vars. -func runGitInDir(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) - cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+dir) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "git %v: %s", args, out) -} - -// newTestGitClientWithUpstream creates a git repo with a local bare "remote" -// and an initial commit, so we can test push/rev-list behavior realistically. -// It returns the git client and the working directory path. -func newTestGitClientWithUpstream(t *testing.T) (*git.Client, string) { - t.Helper() - parentDir := t.TempDir() - bareDir := filepath.Join(parentDir, "upstream.git") - workDir := filepath.Join(parentDir, "work") - - gitEnv := append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+parentDir) - - run := func(dir string, args ...string) { - t.Helper() - c := exec.Command("git", append([]string{"-C", dir}, args...)...) - c.Env = gitEnv - out, err := c.CombinedOutput() - require.NoError(t, err, "git %v: %s", args, out) - } - - // Create bare upstream - require.NoError(t, os.MkdirAll(bareDir, 0o755)) - run(bareDir, "init", "--bare", "--initial-branch=main") - - // Clone into working dir - c := exec.Command("git", "clone", bareDir, workDir) - c.Env = gitEnv - out, err := c.CombinedOutput() - require.NoError(t, err, "git clone: %s", out) - - run(workDir, "config", "user.email", "monalisa@github.com") - run(workDir, "config", "user.name", "Monalisa Octocat") - - // Create initial commit and push - require.NoError(t, os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Test"), 0o644)) - run(workDir, "add", ".") - run(workDir, "commit", "-m", "initial commit") - run(workDir, "push", "origin", "main") - - return &git.Client{ - RepoDir: workDir, - GitPath: "git", - Stderr: &bytes.Buffer{}, - Stdin: &bytes.Buffer{}, - Stdout: &bytes.Buffer{}, - }, workDir -} - func TestEnsurePushed(t *testing.T) { tests := []struct { name string - setup func(t *testing.T, workDir string) - verify func(t *testing.T, workDir string) + cmdStubs func(*run.CommandStubber) wantErr string wantStderr string }{ { name: "no unpushed commits is a no-op", - setup: func(_ *testing.T, _ string) { - // initial commit already pushed by helper + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git( .+)? symbolic-ref --quiet HEAD`, 0, "refs/heads/main\n") + cs.Register(`git( .+)? rev-list --count @\{push\}\.\.HEAD`, 0, "0\n") }, }, { name: "unpushed commits are pushed automatically", - setup: func(t *testing.T, workDir string) { - t.Helper() - require.NoError(t, os.WriteFile(filepath.Join(workDir, "new.txt"), []byte("new"), 0o644)) - runGitInDir(t, workDir, "add", ".") - runGitInDir(t, workDir, "commit", "-m", "unpushed change") - }, - verify: func(t *testing.T, workDir string) { - t.Helper() - // After push, rev-list should show 0 unpushed commits - cmd := exec.Command("git", "-C", workDir, "rev-list", "--count", "@{push}..HEAD") - cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+workDir) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "rev-list: %s", out) - assert.Equal(t, "0", strings.TrimSpace(string(out))) + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git( .+)? symbolic-ref --quiet HEAD`, 0, "refs/heads/main\n") + cs.Register(`git( .+)? rev-list --count @\{push\}\.\.HEAD`, 0, "1\n") + cs.Register(`git( .+)? push --set-upstream origin HEAD:refs/heads/main`, 0, "") }, wantStderr: "Pushing main to origin", }, { - name: "new branch never pushed is pushed automatically", - setup: func(t *testing.T, workDir string) { - t.Helper() - runGitInDir(t, workDir, "checkout", "-b", "feature") - require.NoError(t, os.WriteFile(filepath.Join(workDir, "feat.txt"), []byte("feat"), 0o644)) - runGitInDir(t, workDir, "add", ".") - runGitInDir(t, workDir, "commit", "-m", "new branch commit") - }, - verify: func(t *testing.T, workDir string) { - t.Helper() - // After push, the branch should exist on the remote - cmd := exec.Command("git", "-C", workDir, "rev-list", "--count", "@{push}..HEAD") - cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+workDir) - out, err := cmd.CombinedOutput() - require.NoError(t, err, "rev-list: %s", out) - assert.Equal(t, "0", strings.TrimSpace(string(out))) + name: "new branch is not pushed is pushed automatically", + cmdStubs: func(cs *run.CommandStubber) { + cs.Register(`git( .+)? symbolic-ref --quiet HEAD`, 0, "refs/heads/feature\n") + // rev-list fails when branch is not pushed + cs.Register(`git( .+)? rev-list --count @\{push\}\.\.HEAD`, 1, "") + cs.Register(`git( .+)? push --set-upstream origin HEAD:refs/heads/feature`, 0, "") }, wantStderr: "Pushing feature to origin", }, @@ -1660,8 +1633,11 @@ func TestEnsurePushed(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gitClient, workDir := newTestGitClientWithUpstream(t) - tt.setup(t, workDir) + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + tt.cmdStubs(cs) + + workDir := t.TempDir() ios, _, _, stderr := iostreams.Test() ios.SetStdoutTTY(true) @@ -1669,7 +1645,7 @@ func TestEnsurePushed(t *testing.T) { opts := &PublishOptions{ IO: ios, - GitClient: gitClient, + GitClient: &git.Client{GitPath: "some/path/git", RepoDir: workDir}, } err := ensurePushed(opts, workDir, "origin") @@ -1683,9 +1659,6 @@ func TestEnsurePushed(t *testing.T) { if tt.wantStderr != "" { assert.Contains(t, stderr.String(), tt.wantStderr) } - if tt.verify != nil { - tt.verify(t, workDir) - } }) } } From c5c7d790c5e19b2d68997d2799f15c7684687ab0 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 16 Apr 2026 19:21:35 +0200 Subject: [PATCH 046/182] Update publish_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 29142d44f47..f83117b5b5e 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -1620,7 +1620,7 @@ func TestEnsurePushed(t *testing.T) { wantStderr: "Pushing main to origin", }, { - name: "new branch is not pushed is pushed automatically", + name: "new branch that has not been pushed is pushed automatically", cmdStubs: func(cs *run.CommandStubber) { cs.Register(`git( .+)? symbolic-ref --quiet HEAD`, 0, "refs/heads/feature\n") // rev-list fails when branch is not pushed From 18dc5e77f0d74c83978705b5640c6e4912b18bad Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 14 Apr 2026 17:52:05 +0200 Subject: [PATCH 047/182] Add sampled command telemetry --- .gitignore | 4 + Makefile | 4 + acceptance/acceptance_test.go | 11 + .../telemetry/command-invocation.txtar | 9 + .../no-telemetry-for-completion.txtar | 7 + .../no-telemetry-for-extension.txtar | 27 + .../no-telemetry-for-send-telemetry.txtar | 14 + ...metry-failure-does-not-break-command.txtar | 8 + cmd/gen-docs/main.go | 3 +- go.mod | 3 +- go.sum | 2 + .../barista/observability/telemetry.pb.go | 289 +++++ .../barista/observability/telemetry.twirp.go | 1117 +++++++++++++++++ internal/config/config.go | 15 + internal/config/config_test.go | 31 + internal/config/stub.go | 3 + internal/gh/gh.go | 2 + internal/gh/ghtelemetry/telemetry.go | 27 + internal/gh/mock/config.go | 40 +- internal/ghcmd/cmd.go | 56 +- internal/telemetry/detach_unix.go | 12 + internal/telemetry/detach_windows.go | 16 + internal/telemetry/fake.go | 13 + internal/telemetry/telemetry.go | 384 ++++++ internal/telemetry/telemetry_test.go | 624 +++++++++ pkg/cmd/auth/auth.go | 2 + pkg/cmd/completion/completion.go | 1 + pkg/cmd/config/list/list_test.go | 1 + pkg/cmd/root/extension.go | 13 +- pkg/cmd/root/extension_registration_test.go | 3 +- pkg/cmd/root/help_test.go | 3 +- pkg/cmd/root/help_topic.go | 6 + pkg/cmd/root/root.go | 7 +- pkg/cmd/send-telemetry/send_telemetry.go | 135 ++ pkg/cmd/send-telemetry/send_telemetry_test.go | 226 ++++ pkg/cmd/version/version.go | 3 +- pkg/cmdutil/telemetry.go | 66 + pkg/cmdutil/telemetry_test.go | 168 +++ 38 files changed, 3337 insertions(+), 18 deletions(-) create mode 100644 acceptance/testdata/telemetry/command-invocation.txtar create mode 100644 acceptance/testdata/telemetry/no-telemetry-for-completion.txtar create mode 100644 acceptance/testdata/telemetry/no-telemetry-for-extension.txtar create mode 100644 acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar create mode 100644 acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar create mode 100644 internal/barista/observability/telemetry.pb.go create mode 100644 internal/barista/observability/telemetry.twirp.go create mode 100644 internal/gh/ghtelemetry/telemetry.go create mode 100644 internal/telemetry/detach_unix.go create mode 100644 internal/telemetry/detach_windows.go create mode 100644 internal/telemetry/fake.go create mode 100644 internal/telemetry/telemetry.go create mode 100644 internal/telemetry/telemetry_test.go create mode 100644 pkg/cmd/send-telemetry/send_telemetry.go create mode 100644 pkg/cmd/send-telemetry/send_telemetry_test.go create mode 100644 pkg/cmdutil/telemetry.go create mode 100644 pkg/cmdutil/telemetry_test.go diff --git a/.gitignore b/.gitignore index ffcbbb6c505..461b6a5a0e2 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ vendor/ gh + +# Test coverage artifacts +coverage.out +lcov.info diff --git a/Makefile b/Makefile index 4efdbfbed1b..fb8bf40911b 100644 --- a/Makefile +++ b/Makefile @@ -38,6 +38,10 @@ completions: bin/gh$(EXE) bin/gh$(EXE) completion -s fish > ./share/fish/vendor_completions.d/gh.fish bin/gh$(EXE) completion -s zsh > ./share/zsh/site-functions/_gh +.PHONY: lint +lint: + golangci-lint run ./... + # just convenience tasks around `go test` .PHONY: test test: diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 7c3c6f6ce2e..f978cc732be 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -182,6 +182,15 @@ func TestWorkflows(t *testing.T) { testscript.Run(t, testScriptParamsFor(tsEnv, "workflow")) } +func TestTelemetry(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "telemetry")) +} + func testScriptParamsFor(tsEnv testScriptEnv, command string) testscript.Params { var files []string if tsEnv.script != "" { @@ -226,6 +235,8 @@ func sharedSetup(tsEnv testScriptEnv) func(ts *testscript.Env) error { ts.Setenv("RANDOM_STRING", randomString(10)) + ts.Setenv("GH_TELEMETRY", "false") + // The sandbox overrides HOME, so git cannot find the user's global // config. Write a minimal identity so commits inside the sandbox // don't fail with "Author identity unknown". diff --git a/acceptance/testdata/telemetry/command-invocation.txtar b/acceptance/testdata/telemetry/command-invocation.txtar new file mode 100644 index 00000000000..86d668da5bf --- /dev/null +++ b/acceptance/testdata/telemetry/command-invocation.txtar @@ -0,0 +1,9 @@ +# Telemetry log mode outputs command invocation event to stderr +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +exec gh version +stderr 'Telemetry payload:' +stderr '"type": "command_invocation"' +stderr '"command": "gh version"' diff --git a/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar new file mode 100644 index 00000000000..ffde6e6059f --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar @@ -0,0 +1,7 @@ +# The completion command should not generate a telemetry event +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +exec gh completion -s bash +! stderr 'Telemetry payload:' diff --git a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar new file mode 100644 index 00000000000..b87b3e252ed --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar @@ -0,0 +1,27 @@ +# Extensions should not generate telemetry events +[!exec:bash] skip + +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +# Create a local shell extension repository +exec git init gh-hello +cp gh-hello.sh gh-hello/gh-hello +chmod 755 gh-hello/gh-hello +exec git -C gh-hello add gh-hello +exec git -C gh-hello commit -m 'init' + +# Install it locally +cd gh-hello +exec gh ext install . +cd $WORK + +# Run the extension and verify no telemetry is logged +exec gh hello +stdout 'hello from extension' +! stderr 'Telemetry payload:' + +-- gh-hello.sh -- +#!/usr/bin/env bash +echo "hello from extension" diff --git a/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar new file mode 100644 index 00000000000..7f9d0457aa9 --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar @@ -0,0 +1,14 @@ +# The send-telemetry command should not itself generate a telemetry event +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 +env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1 + +# Provide a minimal valid payload on stdin so the command can run. +# It will fail to connect but that's fine — we only care about telemetry logging. +stdin payload.json +! exec gh send-telemetry +! stderr 'Telemetry payload:' + +-- payload.json -- +{"events":[{"type":"test","dimensions":{},"measures":{}}]} diff --git a/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar b/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar new file mode 100644 index 00000000000..ca1fc4b4ad2 --- /dev/null +++ b/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar @@ -0,0 +1,8 @@ +# Command completes successfully even when telemetry endpoint is unreachable +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=enabled +env GH_TELEMETRY_SAMPLE_RATE=100 +env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1 + +exec gh version +stdout 'gh version' diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 60fd8af58d6..cb76f422087 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/internal/docs" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmd/root" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" @@ -53,7 +54,7 @@ func run(args []string) error { return config.NewFromString(""), nil }, ExtensionManager: &em{}, - }, "", "") + }, &telemetry.NoOpService{}, "", "") rootCmd.InitDefaultHelpCmd() if err := os.MkdirAll(*dir, 0755); err != nil { diff --git a/go.mod b/go.mod index 0fc0b1a5e68..78f0185df8a 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.21.4 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-version v1.9.0 github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec @@ -52,6 +53,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 github.com/theupdateframework/go-tuf/v2 v2.4.1 + github.com/twitchtv/twirp v8.1.3+incompatible github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/yuin/goldmark v1.8.2 github.com/zalando/go-keyring v0.2.8 @@ -129,7 +131,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/certificate-transparency-go v1.3.2 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect diff --git a/go.sum b/go.sum index b6e6f64ed51..681ee5b463c 100644 --- a/go.sum +++ b/go.sum @@ -526,6 +526,8 @@ github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c h1:5a2XDQ github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c/go.mod h1:g85IafeFJZLxlzZCDRu4JLpfS7HKzR+Hw9qRh3bVzDI= github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= +github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU= +github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= diff --git a/internal/barista/observability/telemetry.pb.go b/internal/barista/observability/telemetry.pb.go new file mode 100644 index 00000000000..db5a7d8f31c --- /dev/null +++ b/internal/barista/observability/telemetry.pb.go @@ -0,0 +1,289 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.4 +// protoc v5.29.3 +// source: observability/v1/telemetry.proto + +package observability + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// TelemetryEvent represents a single telemetry event from a client application. +type TelemetryEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required. The client application that generated the event (e.g. "github-cli", "vscode"). + App string `protobuf:"bytes,1,opt,name=app,proto3" json:"app,omitempty"` + // Required. The type of event (e.g. "usage", "lifecycle", "error"). + EventType string `protobuf:"bytes,2,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"` + // Key-value string dimensions describing the event (e.g. command, os, architecture). + Dimensions map[string]string `protobuf:"bytes,3,rep,name=dimensions,proto3" json:"dimensions,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // Key-value numeric measures associated with the event (e.g. duration_ms, api_calls). + Measures map[string]int64 `protobuf:"bytes,4,rep,name=measures,proto3" json:"measures,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TelemetryEvent) Reset() { + *x = TelemetryEvent{} + mi := &file_observability_v1_telemetry_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TelemetryEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TelemetryEvent) ProtoMessage() {} + +func (x *TelemetryEvent) ProtoReflect() protoreflect.Message { + mi := &file_observability_v1_telemetry_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TelemetryEvent.ProtoReflect.Descriptor instead. +func (*TelemetryEvent) Descriptor() ([]byte, []int) { + return file_observability_v1_telemetry_proto_rawDescGZIP(), []int{0} +} + +func (x *TelemetryEvent) GetApp() string { + if x != nil { + return x.App + } + return "" +} + +func (x *TelemetryEvent) GetEventType() string { + if x != nil { + return x.EventType + } + return "" +} + +func (x *TelemetryEvent) GetDimensions() map[string]string { + if x != nil { + return x.Dimensions + } + return nil +} + +func (x *TelemetryEvent) GetMeasures() map[string]int64 { + if x != nil { + return x.Measures + } + return nil +} + +// RecordEventsRequest contains a batch of telemetry events. +type RecordEventsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required. One or more telemetry events to record. + Events []*TelemetryEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordEventsRequest) Reset() { + *x = RecordEventsRequest{} + mi := &file_observability_v1_telemetry_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordEventsRequest) ProtoMessage() {} + +func (x *RecordEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_observability_v1_telemetry_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RecordEventsRequest.ProtoReflect.Descriptor instead. +func (*RecordEventsRequest) Descriptor() ([]byte, []int) { + return file_observability_v1_telemetry_proto_rawDescGZIP(), []int{1} +} + +func (x *RecordEventsRequest) GetEvents() []*TelemetryEvent { + if x != nil { + return x.Events + } + return nil +} + +// RecordEventsResponse is intentionally empty. +type RecordEventsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RecordEventsResponse) Reset() { + *x = RecordEventsResponse{} + mi := &file_observability_v1_telemetry_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RecordEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RecordEventsResponse) ProtoMessage() {} + +func (x *RecordEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_observability_v1_telemetry_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RecordEventsResponse.ProtoReflect.Descriptor instead. +func (*RecordEventsResponse) Descriptor() ([]byte, []int) { + return file_observability_v1_telemetry_proto_rawDescGZIP(), []int{2} +} + +var File_observability_v1_telemetry_proto protoreflect.FileDescriptor + +var file_observability_v1_telemetry_proto_rawDesc = string([]byte{ + 0x0a, 0x20, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2f, + 0x76, 0x31, 0x2f, 0x74, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x2e, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x12, 0x1d, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, + 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, + 0x31, 0x22, 0xf5, 0x02, 0x0a, 0x0e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x45, + 0x76, 0x65, 0x6e, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x61, 0x70, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x61, 0x70, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, + 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x65, 0x76, 0x65, 0x6e, + 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x5d, 0x0a, 0x0a, 0x64, 0x69, 0x6d, 0x65, 0x6e, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3d, 0x2e, 0x63, 0x6c, 0x69, 0x65, + 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, + 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, + 0x74, 0x72, 0x79, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x44, 0x69, 0x6d, 0x65, 0x6e, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0a, 0x64, 0x69, 0x6d, 0x65, 0x6e, 0x73, + 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x57, 0x0a, 0x08, 0x6d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, + 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3b, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, + 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, + 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, + 0x45, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x4d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, 0x1a, 0x3d, 0x0a, + 0x0f, 0x44, 0x69, 0x6d, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, + 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, + 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x3b, 0x0a, 0x0d, + 0x4d, 0x65, 0x61, 0x73, 0x75, 0x72, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, + 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, + 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x5c, 0x0a, 0x13, 0x52, 0x65, 0x63, + 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x45, 0x0a, 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, + 0x32, 0x2d, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e, + 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, + 0x2e, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x52, + 0x06, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x22, 0x16, 0x0a, 0x14, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, + 0x87, 0x01, 0x0a, 0x0c, 0x54, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x74, 0x72, 0x79, 0x41, 0x50, 0x49, + 0x12, 0x77, 0x0a, 0x0c, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, + 0x12, 0x32, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2e, + 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, + 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, + 0x73, 0x66, 0x65, 0x2e, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, + 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x45, 0x76, 0x65, 0x6e, 0x74, + 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, + 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2f, 0x63, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x61, 0x70, 0x70, 0x73, 0x66, 0x65, 0x2f, 0x70, 0x6b, 0x67, 0x2f, + 0x61, 0x70, 0x69, 0x2f, 0x74, 0x77, 0x69, 0x72, 0x70, 0x2f, 0x6f, 0x62, 0x73, 0x65, 0x72, 0x76, + 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x2f, 0x76, 0x31, 0x3b, 0x6f, 0x62, 0x73, 0x65, 0x72, + 0x76, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +}) + +var ( + file_observability_v1_telemetry_proto_rawDescOnce sync.Once + file_observability_v1_telemetry_proto_rawDescData []byte +) + +func file_observability_v1_telemetry_proto_rawDescGZIP() []byte { + file_observability_v1_telemetry_proto_rawDescOnce.Do(func() { + file_observability_v1_telemetry_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_observability_v1_telemetry_proto_rawDesc), len(file_observability_v1_telemetry_proto_rawDesc))) + }) + return file_observability_v1_telemetry_proto_rawDescData +} + +var file_observability_v1_telemetry_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_observability_v1_telemetry_proto_goTypes = []any{ + (*TelemetryEvent)(nil), // 0: clientappsfe.observability.v1.TelemetryEvent + (*RecordEventsRequest)(nil), // 1: clientappsfe.observability.v1.RecordEventsRequest + (*RecordEventsResponse)(nil), // 2: clientappsfe.observability.v1.RecordEventsResponse + nil, // 3: clientappsfe.observability.v1.TelemetryEvent.DimensionsEntry + nil, // 4: clientappsfe.observability.v1.TelemetryEvent.MeasuresEntry +} +var file_observability_v1_telemetry_proto_depIdxs = []int32{ + 3, // 0: clientappsfe.observability.v1.TelemetryEvent.dimensions:type_name -> clientappsfe.observability.v1.TelemetryEvent.DimensionsEntry + 4, // 1: clientappsfe.observability.v1.TelemetryEvent.measures:type_name -> clientappsfe.observability.v1.TelemetryEvent.MeasuresEntry + 0, // 2: clientappsfe.observability.v1.RecordEventsRequest.events:type_name -> clientappsfe.observability.v1.TelemetryEvent + 1, // 3: clientappsfe.observability.v1.TelemetryAPI.RecordEvents:input_type -> clientappsfe.observability.v1.RecordEventsRequest + 2, // 4: clientappsfe.observability.v1.TelemetryAPI.RecordEvents:output_type -> clientappsfe.observability.v1.RecordEventsResponse + 4, // [4:5] is the sub-list for method output_type + 3, // [3:4] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_observability_v1_telemetry_proto_init() } +func file_observability_v1_telemetry_proto_init() { + if File_observability_v1_telemetry_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_observability_v1_telemetry_proto_rawDesc), len(file_observability_v1_telemetry_proto_rawDesc)), + NumEnums: 0, + NumMessages: 5, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_observability_v1_telemetry_proto_goTypes, + DependencyIndexes: file_observability_v1_telemetry_proto_depIdxs, + MessageInfos: file_observability_v1_telemetry_proto_msgTypes, + }.Build() + File_observability_v1_telemetry_proto = out.File + file_observability_v1_telemetry_proto_goTypes = nil + file_observability_v1_telemetry_proto_depIdxs = nil +} diff --git a/internal/barista/observability/telemetry.twirp.go b/internal/barista/observability/telemetry.twirp.go new file mode 100644 index 00000000000..0068d6ca212 --- /dev/null +++ b/internal/barista/observability/telemetry.twirp.go @@ -0,0 +1,1117 @@ +// Code generated by protoc-gen-twirp v8.1.3, DO NOT EDIT. +// source: observability/v1/telemetry.proto + +package observability + +import context "context" +import fmt "fmt" +import http "net/http" +import io "io" +import json "encoding/json" +import strconv "strconv" +import strings "strings" + +import protojson "google.golang.org/protobuf/encoding/protojson" +import proto "google.golang.org/protobuf/proto" +import twirp "github.com/twitchtv/twirp" +import ctxsetters "github.com/twitchtv/twirp/ctxsetters" + +import bytes "bytes" +import errors "errors" +import path "path" +import url "net/url" + +// Version compatibility assertion. +// If the constant is not defined in the package, that likely means +// the package needs to be updated to work with this generated code. +// See https://twitchtv.github.io/twirp/docs/version_matrix.html +const _ = twirp.TwirpPackageMinVersion_8_1_0 + +// ====================== +// TelemetryAPI Interface +// ====================== + +// TelemetryAPI receives telemetry events from client applications. +// This endpoint is unauthenticated to support anonymous telemetry collection. +type TelemetryAPI interface { + // RecordEvents records a batch of telemetry events from a client application. + RecordEvents(context.Context, *RecordEventsRequest) (*RecordEventsResponse, error) +} + +// ============================ +// TelemetryAPI Protobuf Client +// ============================ + +type telemetryAPIProtobufClient struct { + client HTTPClient + urls [1]string + interceptor twirp.Interceptor + opts twirp.ClientOptions +} + +// NewTelemetryAPIProtobufClient creates a Protobuf client that implements the TelemetryAPI interface. +// It communicates using Protobuf and can be configured with a custom HTTPClient. +func NewTelemetryAPIProtobufClient(baseURL string, client HTTPClient, opts ...twirp.ClientOption) TelemetryAPI { + if c, ok := client.(*http.Client); ok { + client = withoutRedirects(c) + } + + clientOpts := twirp.ClientOptions{} + for _, o := range opts { + o(&clientOpts) + } + + // Using ReadOpt allows backwards and forwards compatibility with new options in the future + literalURLs := false + _ = clientOpts.ReadOpt("literalURLs", &literalURLs) + var pathPrefix string + if ok := clientOpts.ReadOpt("pathPrefix", &pathPrefix); !ok { + pathPrefix = "/twirp" // default prefix + } + + // Build method URLs: []/./ + serviceURL := sanitizeBaseURL(baseURL) + serviceURL += baseServicePath(pathPrefix, "clientappsfe.observability.v1", "TelemetryAPI") + urls := [1]string{ + serviceURL + "RecordEvents", + } + + return &telemetryAPIProtobufClient{ + client: client, + urls: urls, + interceptor: twirp.ChainInterceptors(clientOpts.Interceptors...), + opts: clientOpts, + } +} + +func (c *telemetryAPIProtobufClient) RecordEvents(ctx context.Context, in *RecordEventsRequest) (*RecordEventsResponse, error) { + ctx = ctxsetters.WithPackageName(ctx, "clientappsfe.observability.v1") + ctx = ctxsetters.WithServiceName(ctx, "TelemetryAPI") + ctx = ctxsetters.WithMethodName(ctx, "RecordEvents") + caller := c.callRecordEvents + if c.interceptor != nil { + caller = func(ctx context.Context, req *RecordEventsRequest) (*RecordEventsResponse, error) { + resp, err := c.interceptor( + func(ctx context.Context, req interface{}) (interface{}, error) { + typedReq, ok := req.(*RecordEventsRequest) + if !ok { + return nil, twirp.InternalError("failed type assertion req.(*RecordEventsRequest) when calling interceptor") + } + return c.callRecordEvents(ctx, typedReq) + }, + )(ctx, req) + if resp != nil { + typedResp, ok := resp.(*RecordEventsResponse) + if !ok { + return nil, twirp.InternalError("failed type assertion resp.(*RecordEventsResponse) when calling interceptor") + } + return typedResp, err + } + return nil, err + } + } + return caller(ctx, in) +} + +func (c *telemetryAPIProtobufClient) callRecordEvents(ctx context.Context, in *RecordEventsRequest) (*RecordEventsResponse, error) { + out := new(RecordEventsResponse) + ctx, err := doProtobufRequest(ctx, c.client, c.opts.Hooks, c.urls[0], in, out) + if err != nil { + twerr, ok := err.(twirp.Error) + if !ok { + twerr = twirp.InternalErrorWith(err) + } + callClientError(ctx, c.opts.Hooks, twerr) + return nil, err + } + + callClientResponseReceived(ctx, c.opts.Hooks) + + return out, nil +} + +// ======================== +// TelemetryAPI JSON Client +// ======================== + +type telemetryAPIJSONClient struct { + client HTTPClient + urls [1]string + interceptor twirp.Interceptor + opts twirp.ClientOptions +} + +// NewTelemetryAPIJSONClient creates a JSON client that implements the TelemetryAPI interface. +// It communicates using JSON and can be configured with a custom HTTPClient. +func NewTelemetryAPIJSONClient(baseURL string, client HTTPClient, opts ...twirp.ClientOption) TelemetryAPI { + if c, ok := client.(*http.Client); ok { + client = withoutRedirects(c) + } + + clientOpts := twirp.ClientOptions{} + for _, o := range opts { + o(&clientOpts) + } + + // Using ReadOpt allows backwards and forwards compatibility with new options in the future + literalURLs := false + _ = clientOpts.ReadOpt("literalURLs", &literalURLs) + var pathPrefix string + if ok := clientOpts.ReadOpt("pathPrefix", &pathPrefix); !ok { + pathPrefix = "/twirp" // default prefix + } + + // Build method URLs: []/./ + serviceURL := sanitizeBaseURL(baseURL) + serviceURL += baseServicePath(pathPrefix, "clientappsfe.observability.v1", "TelemetryAPI") + urls := [1]string{ + serviceURL + "RecordEvents", + } + + return &telemetryAPIJSONClient{ + client: client, + urls: urls, + interceptor: twirp.ChainInterceptors(clientOpts.Interceptors...), + opts: clientOpts, + } +} + +func (c *telemetryAPIJSONClient) RecordEvents(ctx context.Context, in *RecordEventsRequest) (*RecordEventsResponse, error) { + ctx = ctxsetters.WithPackageName(ctx, "clientappsfe.observability.v1") + ctx = ctxsetters.WithServiceName(ctx, "TelemetryAPI") + ctx = ctxsetters.WithMethodName(ctx, "RecordEvents") + caller := c.callRecordEvents + if c.interceptor != nil { + caller = func(ctx context.Context, req *RecordEventsRequest) (*RecordEventsResponse, error) { + resp, err := c.interceptor( + func(ctx context.Context, req interface{}) (interface{}, error) { + typedReq, ok := req.(*RecordEventsRequest) + if !ok { + return nil, twirp.InternalError("failed type assertion req.(*RecordEventsRequest) when calling interceptor") + } + return c.callRecordEvents(ctx, typedReq) + }, + )(ctx, req) + if resp != nil { + typedResp, ok := resp.(*RecordEventsResponse) + if !ok { + return nil, twirp.InternalError("failed type assertion resp.(*RecordEventsResponse) when calling interceptor") + } + return typedResp, err + } + return nil, err + } + } + return caller(ctx, in) +} + +func (c *telemetryAPIJSONClient) callRecordEvents(ctx context.Context, in *RecordEventsRequest) (*RecordEventsResponse, error) { + out := new(RecordEventsResponse) + ctx, err := doJSONRequest(ctx, c.client, c.opts.Hooks, c.urls[0], in, out) + if err != nil { + twerr, ok := err.(twirp.Error) + if !ok { + twerr = twirp.InternalErrorWith(err) + } + callClientError(ctx, c.opts.Hooks, twerr) + return nil, err + } + + callClientResponseReceived(ctx, c.opts.Hooks) + + return out, nil +} + +// =========================== +// TelemetryAPI Server Handler +// =========================== + +type telemetryAPIServer struct { + TelemetryAPI + interceptor twirp.Interceptor + hooks *twirp.ServerHooks + pathPrefix string // prefix for routing + jsonSkipDefaults bool // do not include unpopulated fields (default values) in the response + jsonCamelCase bool // JSON fields are serialized as lowerCamelCase rather than keeping the original proto names +} + +// NewTelemetryAPIServer builds a TwirpServer that can be used as an http.Handler to handle +// HTTP requests that are routed to the right method in the provided svc implementation. +// The opts are twirp.ServerOption modifiers, for example twirp.WithServerHooks(hooks). +func NewTelemetryAPIServer(svc TelemetryAPI, opts ...interface{}) TwirpServer { + serverOpts := newServerOpts(opts) + + // Using ReadOpt allows backwards and forwards compatibility with new options in the future + jsonSkipDefaults := false + _ = serverOpts.ReadOpt("jsonSkipDefaults", &jsonSkipDefaults) + jsonCamelCase := false + _ = serverOpts.ReadOpt("jsonCamelCase", &jsonCamelCase) + var pathPrefix string + if ok := serverOpts.ReadOpt("pathPrefix", &pathPrefix); !ok { + pathPrefix = "/twirp" // default prefix + } + + return &telemetryAPIServer{ + TelemetryAPI: svc, + hooks: serverOpts.Hooks, + interceptor: twirp.ChainInterceptors(serverOpts.Interceptors...), + pathPrefix: pathPrefix, + jsonSkipDefaults: jsonSkipDefaults, + jsonCamelCase: jsonCamelCase, + } +} + +// writeError writes an HTTP response with a valid Twirp error format, and triggers hooks. +// If err is not a twirp.Error, it will get wrapped with twirp.InternalErrorWith(err) +func (s *telemetryAPIServer) writeError(ctx context.Context, resp http.ResponseWriter, err error) { + writeError(ctx, resp, err, s.hooks) +} + +// handleRequestBodyError is used to handle error when the twirp server cannot read request +func (s *telemetryAPIServer) handleRequestBodyError(ctx context.Context, resp http.ResponseWriter, msg string, err error) { + if context.Canceled == ctx.Err() { + s.writeError(ctx, resp, twirp.NewError(twirp.Canceled, "failed to read request: context canceled")) + return + } + if context.DeadlineExceeded == ctx.Err() { + s.writeError(ctx, resp, twirp.NewError(twirp.DeadlineExceeded, "failed to read request: deadline exceeded")) + return + } + s.writeError(ctx, resp, twirp.WrapError(malformedRequestError(msg), err)) +} + +// TelemetryAPIPathPrefix is a convenience constant that may identify URL paths. +// Should be used with caution, it only matches routes generated by Twirp Go clients, +// with the default "/twirp" prefix and default CamelCase service and method names. +// More info: https://twitchtv.github.io/twirp/docs/routing.html +const TelemetryAPIPathPrefix = "/twirp/clientappsfe.observability.v1.TelemetryAPI/" + +func (s *telemetryAPIServer) ServeHTTP(resp http.ResponseWriter, req *http.Request) { + ctx := req.Context() + ctx = ctxsetters.WithPackageName(ctx, "clientappsfe.observability.v1") + ctx = ctxsetters.WithServiceName(ctx, "TelemetryAPI") + ctx = ctxsetters.WithResponseWriter(ctx, resp) + + var err error + ctx, err = callRequestReceived(ctx, s.hooks) + if err != nil { + s.writeError(ctx, resp, err) + return + } + + if req.Method != "POST" { + msg := fmt.Sprintf("unsupported method %q (only POST is allowed)", req.Method) + s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path)) + return + } + + // Verify path format: []/./ + prefix, pkgService, method := parseTwirpPath(req.URL.Path) + if pkgService != "clientappsfe.observability.v1.TelemetryAPI" { + msg := fmt.Sprintf("no handler for path %q", req.URL.Path) + s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path)) + return + } + if prefix != s.pathPrefix { + msg := fmt.Sprintf("invalid path prefix %q, expected %q, on path %q", prefix, s.pathPrefix, req.URL.Path) + s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path)) + return + } + + switch method { + case "RecordEvents": + s.serveRecordEvents(ctx, resp, req) + return + default: + msg := fmt.Sprintf("no handler for path %q", req.URL.Path) + s.writeError(ctx, resp, badRouteError(msg, req.Method, req.URL.Path)) + return + } +} + +func (s *telemetryAPIServer) serveRecordEvents(ctx context.Context, resp http.ResponseWriter, req *http.Request) { + header := req.Header.Get("Content-Type") + i := strings.Index(header, ";") + if i == -1 { + i = len(header) + } + switch strings.TrimSpace(strings.ToLower(header[:i])) { + case "application/json": + s.serveRecordEventsJSON(ctx, resp, req) + case "application/protobuf": + s.serveRecordEventsProtobuf(ctx, resp, req) + default: + msg := fmt.Sprintf("unexpected Content-Type: %q", req.Header.Get("Content-Type")) + twerr := badRouteError(msg, req.Method, req.URL.Path) + s.writeError(ctx, resp, twerr) + } +} + +func (s *telemetryAPIServer) serveRecordEventsJSON(ctx context.Context, resp http.ResponseWriter, req *http.Request) { + var err error + ctx = ctxsetters.WithMethodName(ctx, "RecordEvents") + ctx, err = callRequestRouted(ctx, s.hooks) + if err != nil { + s.writeError(ctx, resp, err) + return + } + + d := json.NewDecoder(req.Body) + rawReqBody := json.RawMessage{} + if err := d.Decode(&rawReqBody); err != nil { + s.handleRequestBodyError(ctx, resp, "the json request could not be decoded", err) + return + } + reqContent := new(RecordEventsRequest) + unmarshaler := protojson.UnmarshalOptions{DiscardUnknown: true} + if err = unmarshaler.Unmarshal(rawReqBody, reqContent); err != nil { + s.handleRequestBodyError(ctx, resp, "the json request could not be decoded", err) + return + } + + handler := s.TelemetryAPI.RecordEvents + if s.interceptor != nil { + handler = func(ctx context.Context, req *RecordEventsRequest) (*RecordEventsResponse, error) { + resp, err := s.interceptor( + func(ctx context.Context, req interface{}) (interface{}, error) { + typedReq, ok := req.(*RecordEventsRequest) + if !ok { + return nil, twirp.InternalError("failed type assertion req.(*RecordEventsRequest) when calling interceptor") + } + return s.TelemetryAPI.RecordEvents(ctx, typedReq) + }, + )(ctx, req) + if resp != nil { + typedResp, ok := resp.(*RecordEventsResponse) + if !ok { + return nil, twirp.InternalError("failed type assertion resp.(*RecordEventsResponse) when calling interceptor") + } + return typedResp, err + } + return nil, err + } + } + + // Call service method + var respContent *RecordEventsResponse + func() { + defer ensurePanicResponses(ctx, resp, s.hooks) + respContent, err = handler(ctx, reqContent) + }() + + if err != nil { + s.writeError(ctx, resp, err) + return + } + if respContent == nil { + s.writeError(ctx, resp, twirp.InternalError("received a nil *RecordEventsResponse and nil error while calling RecordEvents. nil responses are not supported")) + return + } + + ctx = callResponsePrepared(ctx, s.hooks) + + marshaler := &protojson.MarshalOptions{UseProtoNames: !s.jsonCamelCase, EmitUnpopulated: !s.jsonSkipDefaults} + respBytes, err := marshaler.Marshal(respContent) + if err != nil { + s.writeError(ctx, resp, wrapInternal(err, "failed to marshal json response")) + return + } + + ctx = ctxsetters.WithStatusCode(ctx, http.StatusOK) + resp.Header().Set("Content-Type", "application/json") + resp.Header().Set("Content-Length", strconv.Itoa(len(respBytes))) + resp.WriteHeader(http.StatusOK) + + if n, err := resp.Write(respBytes); err != nil { + msg := fmt.Sprintf("failed to write response, %d of %d bytes written: %s", n, len(respBytes), err.Error()) + twerr := twirp.NewError(twirp.Unknown, msg) + ctx = callError(ctx, s.hooks, twerr) + } + callResponseSent(ctx, s.hooks) +} + +func (s *telemetryAPIServer) serveRecordEventsProtobuf(ctx context.Context, resp http.ResponseWriter, req *http.Request) { + var err error + ctx = ctxsetters.WithMethodName(ctx, "RecordEvents") + ctx, err = callRequestRouted(ctx, s.hooks) + if err != nil { + s.writeError(ctx, resp, err) + return + } + + buf, err := io.ReadAll(req.Body) + if err != nil { + s.handleRequestBodyError(ctx, resp, "failed to read request body", err) + return + } + reqContent := new(RecordEventsRequest) + if err = proto.Unmarshal(buf, reqContent); err != nil { + s.writeError(ctx, resp, malformedRequestError("the protobuf request could not be decoded")) + return + } + + handler := s.TelemetryAPI.RecordEvents + if s.interceptor != nil { + handler = func(ctx context.Context, req *RecordEventsRequest) (*RecordEventsResponse, error) { + resp, err := s.interceptor( + func(ctx context.Context, req interface{}) (interface{}, error) { + typedReq, ok := req.(*RecordEventsRequest) + if !ok { + return nil, twirp.InternalError("failed type assertion req.(*RecordEventsRequest) when calling interceptor") + } + return s.TelemetryAPI.RecordEvents(ctx, typedReq) + }, + )(ctx, req) + if resp != nil { + typedResp, ok := resp.(*RecordEventsResponse) + if !ok { + return nil, twirp.InternalError("failed type assertion resp.(*RecordEventsResponse) when calling interceptor") + } + return typedResp, err + } + return nil, err + } + } + + // Call service method + var respContent *RecordEventsResponse + func() { + defer ensurePanicResponses(ctx, resp, s.hooks) + respContent, err = handler(ctx, reqContent) + }() + + if err != nil { + s.writeError(ctx, resp, err) + return + } + if respContent == nil { + s.writeError(ctx, resp, twirp.InternalError("received a nil *RecordEventsResponse and nil error while calling RecordEvents. nil responses are not supported")) + return + } + + ctx = callResponsePrepared(ctx, s.hooks) + + respBytes, err := proto.Marshal(respContent) + if err != nil { + s.writeError(ctx, resp, wrapInternal(err, "failed to marshal proto response")) + return + } + + ctx = ctxsetters.WithStatusCode(ctx, http.StatusOK) + resp.Header().Set("Content-Type", "application/protobuf") + resp.Header().Set("Content-Length", strconv.Itoa(len(respBytes))) + resp.WriteHeader(http.StatusOK) + if n, err := resp.Write(respBytes); err != nil { + msg := fmt.Sprintf("failed to write response, %d of %d bytes written: %s", n, len(respBytes), err.Error()) + twerr := twirp.NewError(twirp.Unknown, msg) + ctx = callError(ctx, s.hooks, twerr) + } + callResponseSent(ctx, s.hooks) +} + +func (s *telemetryAPIServer) ServiceDescriptor() ([]byte, int) { + return twirpFileDescriptor0, 0 +} + +func (s *telemetryAPIServer) ProtocGenTwirpVersion() string { + return "v8.1.3" +} + +// PathPrefix returns the base service path, in the form: "//./" +// that is everything in a Twirp route except for the . This can be used for routing, +// for example to identify the requests that are targeted to this service in a mux. +func (s *telemetryAPIServer) PathPrefix() string { + return baseServicePath(s.pathPrefix, "clientappsfe.observability.v1", "TelemetryAPI") +} + +// ===== +// Utils +// ===== + +// HTTPClient is the interface used by generated clients to send HTTP requests. +// It is fulfilled by *(net/http).Client, which is sufficient for most users. +// Users can provide their own implementation for special retry policies. +// +// HTTPClient implementations should not follow redirects. Redirects are +// automatically disabled if *(net/http).Client is passed to client +// constructors. See the withoutRedirects function in this file for more +// details. +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// TwirpServer is the interface generated server structs will support: they're +// HTTP handlers with additional methods for accessing metadata about the +// service. Those accessors are a low-level API for building reflection tools. +// Most people can think of TwirpServers as just http.Handlers. +type TwirpServer interface { + http.Handler + + // ServiceDescriptor returns gzipped bytes describing the .proto file that + // this service was generated from. Once unzipped, the bytes can be + // unmarshalled as a + // google.golang.org/protobuf/types/descriptorpb.FileDescriptorProto. + // + // The returned integer is the index of this particular service within that + // FileDescriptorProto's 'Service' slice of ServiceDescriptorProtos. This is a + // low-level field, expected to be used for reflection. + ServiceDescriptor() ([]byte, int) + + // ProtocGenTwirpVersion is the semantic version string of the version of + // twirp used to generate this file. + ProtocGenTwirpVersion() string + + // PathPrefix returns the HTTP URL path prefix for all methods handled by this + // service. This can be used with an HTTP mux to route Twirp requests. + // The path prefix is in the form: "//./" + // that is, everything in a Twirp route except for the at the end. + PathPrefix() string +} + +func newServerOpts(opts []interface{}) *twirp.ServerOptions { + serverOpts := &twirp.ServerOptions{} + for _, opt := range opts { + switch o := opt.(type) { + case twirp.ServerOption: + o(serverOpts) + case *twirp.ServerHooks: // backwards compatibility, allow to specify hooks as an argument + twirp.WithServerHooks(o)(serverOpts) + case nil: // backwards compatibility, allow nil value for the argument + continue + default: + panic(fmt.Sprintf("Invalid option type %T, please use a twirp.ServerOption", o)) + } + } + return serverOpts +} + +// WriteError writes an HTTP response with a valid Twirp error format (code, msg, meta). +// Useful outside of the Twirp server (e.g. http middleware), but does not trigger hooks. +// If err is not a twirp.Error, it will get wrapped with twirp.InternalErrorWith(err) +func WriteError(resp http.ResponseWriter, err error) { + writeError(context.Background(), resp, err, nil) +} + +// writeError writes Twirp errors in the response and triggers hooks. +func writeError(ctx context.Context, resp http.ResponseWriter, err error, hooks *twirp.ServerHooks) { + // Convert to a twirp.Error. Non-twirp errors are converted to internal errors. + var twerr twirp.Error + if !errors.As(err, &twerr) { + twerr = twirp.InternalErrorWith(err) + } + + statusCode := twirp.ServerHTTPStatusFromErrorCode(twerr.Code()) + ctx = ctxsetters.WithStatusCode(ctx, statusCode) + ctx = callError(ctx, hooks, twerr) + + respBody := marshalErrorToJSON(twerr) + + resp.Header().Set("Content-Type", "application/json") // Error responses are always JSON + resp.Header().Set("Content-Length", strconv.Itoa(len(respBody))) + resp.WriteHeader(statusCode) // set HTTP status code and send response + + _, writeErr := resp.Write(respBody) + if writeErr != nil { + // We have three options here. We could log the error, call the Error + // hook, or just silently ignore the error. + // + // Logging is unacceptable because we don't have a user-controlled + // logger; writing out to stderr without permission is too rude. + // + // Calling the Error hook would confuse users: it would mean the Error + // hook got called twice for one request, which is likely to lead to + // duplicated log messages and metrics, no matter how well we document + // the behavior. + // + // Silently ignoring the error is our least-bad option. It's highly + // likely that the connection is broken and the original 'err' says + // so anyway. + _ = writeErr + } + + callResponseSent(ctx, hooks) +} + +// sanitizeBaseURL parses the the baseURL, and adds the "http" scheme if needed. +// If the URL is unparsable, the baseURL is returned unchanged. +func sanitizeBaseURL(baseURL string) string { + u, err := url.Parse(baseURL) + if err != nil { + return baseURL // invalid URL will fail later when making requests + } + if u.Scheme == "" { + u.Scheme = "http" + } + return u.String() +} + +// baseServicePath composes the path prefix for the service (without ). +// e.g.: baseServicePath("/twirp", "my.pkg", "MyService") +// +// returns => "/twirp/my.pkg.MyService/" +// +// e.g.: baseServicePath("", "", "MyService") +// +// returns => "/MyService/" +func baseServicePath(prefix, pkg, service string) string { + fullServiceName := service + if pkg != "" { + fullServiceName = pkg + "." + service + } + return path.Join("/", prefix, fullServiceName) + "/" +} + +// parseTwirpPath extracts path components form a valid Twirp route. +// Expected format: "[]/./" +// e.g.: prefix, pkgService, method := parseTwirpPath("/twirp/pkg.Svc/MakeHat") +func parseTwirpPath(path string) (string, string, string) { + parts := strings.Split(path, "/") + if len(parts) < 2 { + return "", "", "" + } + method := parts[len(parts)-1] + pkgService := parts[len(parts)-2] + prefix := strings.Join(parts[0:len(parts)-2], "/") + return prefix, pkgService, method +} + +// getCustomHTTPReqHeaders retrieves a copy of any headers that are set in +// a context through the twirp.WithHTTPRequestHeaders function. +// If there are no headers set, or if they have the wrong type, nil is returned. +func getCustomHTTPReqHeaders(ctx context.Context) http.Header { + header, ok := twirp.HTTPRequestHeaders(ctx) + if !ok || header == nil { + return nil + } + copied := make(http.Header) + for k, vv := range header { + if vv == nil { + copied[k] = nil + continue + } + copied[k] = make([]string, len(vv)) + copy(copied[k], vv) + } + return copied +} + +// newRequest makes an http.Request from a client, adding common headers. +func newRequest(ctx context.Context, url string, reqBody io.Reader, contentType string) (*http.Request, error) { + req, err := http.NewRequest("POST", url, reqBody) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if customHeader := getCustomHTTPReqHeaders(ctx); customHeader != nil { + req.Header = customHeader + } + req.Header.Set("Accept", contentType) + req.Header.Set("Content-Type", contentType) + req.Header.Set("Twirp-Version", "v8.1.3") + return req, nil +} + +// JSON serialization for errors +type twerrJSON struct { + Code string `json:"code"` + Msg string `json:"msg"` + Meta map[string]string `json:"meta,omitempty"` +} + +// marshalErrorToJSON returns JSON from a twirp.Error, that can be used as HTTP error response body. +// If serialization fails, it will use a descriptive Internal error instead. +func marshalErrorToJSON(twerr twirp.Error) []byte { + // make sure that msg is not too large + msg := twerr.Msg() + if len(msg) > 1e6 { + msg = msg[:1e6] + } + + tj := twerrJSON{ + Code: string(twerr.Code()), + Msg: msg, + Meta: twerr.MetaMap(), + } + + buf, err := json.Marshal(&tj) + if err != nil { + buf = []byte("{\"type\": \"" + twirp.Internal + "\", \"msg\": \"There was an error but it could not be serialized into JSON\"}") // fallback + } + + return buf +} + +// errorFromResponse builds a twirp.Error from a non-200 HTTP response. +// If the response has a valid serialized Twirp error, then it's returned. +// If not, the response status code is used to generate a similar twirp +// error. See twirpErrorFromIntermediary for more info on intermediary errors. +func errorFromResponse(resp *http.Response) twirp.Error { + statusCode := resp.StatusCode + statusText := http.StatusText(statusCode) + + if isHTTPRedirect(statusCode) { + // Unexpected redirect: it must be an error from an intermediary. + // Twirp clients don't follow redirects automatically, Twirp only handles + // POST requests, redirects should only happen on GET and HEAD requests. + location := resp.Header.Get("Location") + msg := fmt.Sprintf("unexpected HTTP status code %d %q received, Location=%q", statusCode, statusText, location) + return twirpErrorFromIntermediary(statusCode, msg, location) + } + + respBodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return wrapInternal(err, "failed to read server error response body") + } + + var tj twerrJSON + dec := json.NewDecoder(bytes.NewReader(respBodyBytes)) + dec.DisallowUnknownFields() + if err := dec.Decode(&tj); err != nil || tj.Code == "" { + // Invalid JSON response; it must be an error from an intermediary. + msg := fmt.Sprintf("Error from intermediary with HTTP status code %d %q", statusCode, statusText) + return twirpErrorFromIntermediary(statusCode, msg, string(respBodyBytes)) + } + + errorCode := twirp.ErrorCode(tj.Code) + if !twirp.IsValidErrorCode(errorCode) { + msg := "invalid type returned from server error response: " + tj.Code + return twirp.InternalError(msg).WithMeta("body", string(respBodyBytes)) + } + + twerr := twirp.NewError(errorCode, tj.Msg) + for k, v := range tj.Meta { + twerr = twerr.WithMeta(k, v) + } + return twerr +} + +// twirpErrorFromIntermediary maps HTTP errors from non-twirp sources to twirp errors. +// The mapping is similar to gRPC: https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md. +// Returned twirp Errors have some additional metadata for inspection. +func twirpErrorFromIntermediary(status int, msg string, bodyOrLocation string) twirp.Error { + var code twirp.ErrorCode + if isHTTPRedirect(status) { // 3xx + code = twirp.Internal + } else { + switch status { + case 400: // Bad Request + code = twirp.Internal + case 401: // Unauthorized + code = twirp.Unauthenticated + case 403: // Forbidden + code = twirp.PermissionDenied + case 404: // Not Found + code = twirp.BadRoute + case 429: // Too Many Requests + code = twirp.ResourceExhausted + case 502, 503, 504: // Bad Gateway, Service Unavailable, Gateway Timeout + code = twirp.Unavailable + default: // All other codes + code = twirp.Unknown + } + } + + twerr := twirp.NewError(code, msg) + twerr = twerr.WithMeta("http_error_from_intermediary", "true") // to easily know if this error was from intermediary + twerr = twerr.WithMeta("status_code", strconv.Itoa(status)) + if isHTTPRedirect(status) { + twerr = twerr.WithMeta("location", bodyOrLocation) + } else { + twerr = twerr.WithMeta("body", bodyOrLocation) + } + return twerr +} + +func isHTTPRedirect(status int) bool { + return status >= 300 && status <= 399 +} + +// wrapInternal wraps an error with a prefix as an Internal error. +// The original error cause is accessible by github.com/pkg/errors.Cause. +func wrapInternal(err error, prefix string) twirp.Error { + return twirp.InternalErrorWith(&wrappedError{prefix: prefix, cause: err}) +} + +type wrappedError struct { + prefix string + cause error +} + +func (e *wrappedError) Error() string { return e.prefix + ": " + e.cause.Error() } +func (e *wrappedError) Unwrap() error { return e.cause } // for go1.13 + errors.Is/As +func (e *wrappedError) Cause() error { return e.cause } // for github.com/pkg/errors + +// ensurePanicResponses makes sure that rpc methods causing a panic still result in a Twirp Internal +// error response (status 500), and error hooks are properly called with the panic wrapped as an error. +// The panic is re-raised so it can be handled normally with middleware. +func ensurePanicResponses(ctx context.Context, resp http.ResponseWriter, hooks *twirp.ServerHooks) { + if r := recover(); r != nil { + // Wrap the panic as an error so it can be passed to error hooks. + // The original error is accessible from error hooks, but not visible in the response. + err := errFromPanic(r) + twerr := &internalWithCause{msg: "Internal service panic", cause: err} + // Actually write the error + writeError(ctx, resp, twerr, hooks) + // If possible, flush the error to the wire. + f, ok := resp.(http.Flusher) + if ok { + f.Flush() + } + + panic(r) + } +} + +// errFromPanic returns the typed error if the recovered panic is an error, otherwise formats as error. +func errFromPanic(p interface{}) error { + if err, ok := p.(error); ok { + return err + } + return fmt.Errorf("panic: %v", p) +} + +// internalWithCause is a Twirp Internal error wrapping an original error cause, +// but the original error message is not exposed on Msg(). The original error +// can be checked with go1.13+ errors.Is/As, and also by (github.com/pkg/errors).Unwrap +type internalWithCause struct { + msg string + cause error +} + +func (e *internalWithCause) Unwrap() error { return e.cause } // for go1.13 + errors.Is/As +func (e *internalWithCause) Cause() error { return e.cause } // for github.com/pkg/errors +func (e *internalWithCause) Error() string { return e.msg + ": " + e.cause.Error() } +func (e *internalWithCause) Code() twirp.ErrorCode { return twirp.Internal } +func (e *internalWithCause) Msg() string { return e.msg } +func (e *internalWithCause) Meta(key string) string { return "" } +func (e *internalWithCause) MetaMap() map[string]string { return nil } +func (e *internalWithCause) WithMeta(key string, val string) twirp.Error { return e } + +// malformedRequestError is used when the twirp server cannot unmarshal a request +func malformedRequestError(msg string) twirp.Error { + return twirp.NewError(twirp.Malformed, msg) +} + +// badRouteError is used when the twirp server cannot route a request +func badRouteError(msg string, method, url string) twirp.Error { + err := twirp.NewError(twirp.BadRoute, msg) + err = err.WithMeta("twirp_invalid_route", method+" "+url) + return err +} + +// withoutRedirects makes sure that the POST request can not be redirected. +// The standard library will, by default, redirect requests (including POSTs) if it gets a 302 or +// 303 response, and also 301s in go1.8. It redirects by making a second request, changing the +// method to GET and removing the body. This produces very confusing error messages, so instead we +// set a redirect policy that always errors. This stops Go from executing the redirect. +// +// We have to be a little careful in case the user-provided http.Client has its own CheckRedirect +// policy - if so, we'll run through that policy first. +// +// Because this requires modifying the http.Client, we make a new copy of the client and return it. +func withoutRedirects(in *http.Client) *http.Client { + copy := *in + copy.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if in.CheckRedirect != nil { + // Run the input's redirect if it exists, in case it has side effects, but ignore any error it + // returns, since we want to use ErrUseLastResponse. + err := in.CheckRedirect(req, via) + _ = err // Silly, but this makes sure generated code passes errcheck -blank, which some people use. + } + return http.ErrUseLastResponse + } + return © +} + +// doProtobufRequest makes a Protobuf request to the remote Twirp service. +func doProtobufRequest(ctx context.Context, client HTTPClient, hooks *twirp.ClientHooks, url string, in, out proto.Message) (_ context.Context, err error) { + reqBodyBytes, err := proto.Marshal(in) + if err != nil { + return ctx, wrapInternal(err, "failed to marshal proto request") + } + reqBody := bytes.NewBuffer(reqBodyBytes) + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + req, err := newRequest(ctx, url, reqBody, "application/protobuf") + if err != nil { + return ctx, wrapInternal(err, "could not build request") + } + ctx, err = callClientRequestPrepared(ctx, hooks, req) + if err != nil { + return ctx, err + } + + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return ctx, wrapInternal(err, "failed to do request") + } + defer func() { _ = resp.Body.Close() }() + + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + if resp.StatusCode != 200 { + return ctx, errorFromResponse(resp) + } + + respBodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return ctx, wrapInternal(err, "failed to read response body") + } + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + if err = proto.Unmarshal(respBodyBytes, out); err != nil { + return ctx, wrapInternal(err, "failed to unmarshal proto response") + } + return ctx, nil +} + +// doJSONRequest makes a JSON request to the remote Twirp service. +func doJSONRequest(ctx context.Context, client HTTPClient, hooks *twirp.ClientHooks, url string, in, out proto.Message) (_ context.Context, err error) { + marshaler := &protojson.MarshalOptions{UseProtoNames: true} + reqBytes, err := marshaler.Marshal(in) + if err != nil { + return ctx, wrapInternal(err, "failed to marshal json request") + } + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + req, err := newRequest(ctx, url, bytes.NewReader(reqBytes), "application/json") + if err != nil { + return ctx, wrapInternal(err, "could not build request") + } + ctx, err = callClientRequestPrepared(ctx, hooks, req) + if err != nil { + return ctx, err + } + + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return ctx, wrapInternal(err, "failed to do request") + } + + defer func() { + cerr := resp.Body.Close() + if err == nil && cerr != nil { + err = wrapInternal(cerr, "failed to close response body") + } + }() + + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + + if resp.StatusCode != 200 { + return ctx, errorFromResponse(resp) + } + + d := json.NewDecoder(resp.Body) + rawRespBody := json.RawMessage{} + if err := d.Decode(&rawRespBody); err != nil { + return ctx, wrapInternal(err, "failed to unmarshal json response") + } + unmarshaler := protojson.UnmarshalOptions{DiscardUnknown: true} + if err = unmarshaler.Unmarshal(rawRespBody, out); err != nil { + return ctx, wrapInternal(err, "failed to unmarshal json response") + } + if err = ctx.Err(); err != nil { + return ctx, wrapInternal(err, "aborted because context was done") + } + return ctx, nil +} + +// Call twirp.ServerHooks.RequestReceived if the hook is available +func callRequestReceived(ctx context.Context, h *twirp.ServerHooks) (context.Context, error) { + if h == nil || h.RequestReceived == nil { + return ctx, nil + } + return h.RequestReceived(ctx) +} + +// Call twirp.ServerHooks.RequestRouted if the hook is available +func callRequestRouted(ctx context.Context, h *twirp.ServerHooks) (context.Context, error) { + if h == nil || h.RequestRouted == nil { + return ctx, nil + } + return h.RequestRouted(ctx) +} + +// Call twirp.ServerHooks.ResponsePrepared if the hook is available +func callResponsePrepared(ctx context.Context, h *twirp.ServerHooks) context.Context { + if h == nil || h.ResponsePrepared == nil { + return ctx + } + return h.ResponsePrepared(ctx) +} + +// Call twirp.ServerHooks.ResponseSent if the hook is available +func callResponseSent(ctx context.Context, h *twirp.ServerHooks) { + if h == nil || h.ResponseSent == nil { + return + } + h.ResponseSent(ctx) +} + +// Call twirp.ServerHooks.Error if the hook is available +func callError(ctx context.Context, h *twirp.ServerHooks, err twirp.Error) context.Context { + if h == nil || h.Error == nil { + return ctx + } + return h.Error(ctx, err) +} + +func callClientResponseReceived(ctx context.Context, h *twirp.ClientHooks) { + if h == nil || h.ResponseReceived == nil { + return + } + h.ResponseReceived(ctx) +} + +func callClientRequestPrepared(ctx context.Context, h *twirp.ClientHooks, req *http.Request) (context.Context, error) { + if h == nil || h.RequestPrepared == nil { + return ctx, nil + } + return h.RequestPrepared(ctx, req) +} + +func callClientError(ctx context.Context, h *twirp.ClientHooks, err twirp.Error) { + if h == nil || h.Error == nil { + return + } + h.Error(ctx, err) +} + +var twirpFileDescriptor0 = []byte{ + // 353 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x94, 0x92, 0x4d, 0x4b, 0x02, 0x41, + 0x18, 0xc7, 0x59, 0xb7, 0x24, 0x9f, 0xec, 0x85, 0x49, 0x62, 0x11, 0x04, 0xf1, 0xe4, 0xa5, 0x1d, + 0xd4, 0x4b, 0x24, 0x1e, 0x8a, 0x3c, 0x44, 0x08, 0xb1, 0x08, 0x41, 0x14, 0xb1, 0xab, 0x4f, 0x36, + 0xb8, 0x2f, 0xd3, 0xce, 0xec, 0xca, 0x7c, 0x82, 0x3e, 0x71, 0xf7, 0x70, 0x56, 0x65, 0x57, 0x22, + 0xf1, 0x36, 0x3b, 0x33, 0xbf, 0xdf, 0xff, 0xf9, 0x2f, 0x03, 0xcd, 0xc8, 0x13, 0x18, 0xa7, 0xae, + 0xc7, 0x7c, 0x26, 0x15, 0x4d, 0x3b, 0x54, 0xa2, 0x8f, 0x01, 0xca, 0x58, 0xd9, 0x3c, 0x8e, 0x64, + 0x44, 0x1a, 0x13, 0x9f, 0x61, 0x28, 0x5d, 0xce, 0xc5, 0x07, 0xda, 0x85, 0xeb, 0x76, 0xda, 0x69, + 0xfd, 0x94, 0xe0, 0x74, 0xbc, 0x46, 0x86, 0x29, 0x86, 0x92, 0x9c, 0x83, 0xe9, 0x72, 0x6e, 0x19, + 0x4d, 0xa3, 0x5d, 0x71, 0x96, 0x4b, 0xd2, 0x00, 0xc0, 0xe5, 0xd1, 0xbb, 0x54, 0x1c, 0xad, 0x92, + 0x3e, 0xa8, 0xe8, 0x9d, 0xb1, 0xe2, 0x48, 0xde, 0x00, 0xa6, 0x2c, 0xc0, 0x50, 0xb0, 0x28, 0x14, + 0x96, 0xd9, 0x34, 0xdb, 0xc7, 0xdd, 0x81, 0xfd, 0x6f, 0xae, 0x5d, 0xcc, 0xb4, 0xef, 0x37, 0xfc, + 0x30, 0x94, 0xb1, 0x72, 0x72, 0x42, 0xf2, 0x0c, 0x47, 0x01, 0xba, 0x22, 0x89, 0x51, 0x58, 0x07, + 0x5a, 0xde, 0xdf, 0x4f, 0x3e, 0x5a, 0xd1, 0x99, 0x7a, 0x23, 0xab, 0x0f, 0xe0, 0x6c, 0x2b, 0x77, + 0xd9, 0x7d, 0x8e, 0x6a, 0xdd, 0x7d, 0x8e, 0x8a, 0xd4, 0xe0, 0x30, 0x75, 0xfd, 0x64, 0x5d, 0x3b, + 0xfb, 0xb8, 0x29, 0x5d, 0x1b, 0xf5, 0x3e, 0x9c, 0x14, 0xcc, 0xbb, 0x60, 0x33, 0x07, 0xb7, 0x5e, + 0xe1, 0xc2, 0xc1, 0x49, 0x14, 0x4f, 0xf5, 0x88, 0xc2, 0xc1, 0xaf, 0x04, 0x85, 0x24, 0x43, 0x28, + 0xeb, 0xff, 0x2a, 0x2c, 0x43, 0x37, 0xbd, 0xda, 0xab, 0xa9, 0xb3, 0x82, 0x5b, 0x97, 0x50, 0x2b, + 0xda, 0x05, 0x8f, 0x42, 0x81, 0xdd, 0x6f, 0x03, 0xaa, 0x1b, 0xe4, 0xf6, 0xe9, 0x81, 0x2c, 0xa0, + 0x9a, 0xbf, 0x48, 0xba, 0x3b, 0xf2, 0xfe, 0x98, 0xb9, 0xde, 0xdb, 0x8b, 0xc9, 0x26, 0xb9, 0x1b, + 0xbd, 0x3c, 0xce, 0x98, 0xfc, 0x4c, 0x3c, 0x7b, 0x12, 0x05, 0x34, 0x5b, 0xd2, 0xbc, 0x87, 0xf2, + 0xf9, 0x8c, 0xba, 0x9c, 0x51, 0xb9, 0x60, 0x31, 0xa7, 0xdb, 0xef, 0xbc, 0x5f, 0xd8, 0xf0, 0xca, + 0xfa, 0xb1, 0xf7, 0x7e, 0x03, 0x00, 0x00, 0xff, 0xff, 0xff, 0x5b, 0x87, 0x22, 0x10, 0x03, 0x00, + 0x00, +} diff --git a/internal/config/config.go b/internal/config/config.go index 4d83bd4e5a1..a694ca65461 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,6 +31,7 @@ const ( promptKey = "prompt" preferEditorPromptKey = "prefer_editor_prompt" spinnerKey = "spinner" + telemetryKey = "telemetry" userKey = "user" usersKey = "users" versionKey = "version" @@ -169,6 +170,11 @@ func (c *cfg) Spinner(hostname string) gh.ConfigEntry { return c.GetOrDefault(hostname, spinnerKey).Unwrap() } +func (c *cfg) Telemetry() gh.ConfigEntry { + // Intentionally panic if there is no user provided value or default value (which would be a programmer error) + return c.GetOrDefault("", telemetryKey).Unwrap() +} + func (c *cfg) Version() o.Option[string] { return c.get("", versionKey) } @@ -682,6 +688,15 @@ var Options = []ConfigOption{ return c.Spinner(hostname).Value }, }, + { + Key: telemetryKey, + Description: "whether telemetry is enabled, disabled, or logging", + DefaultValue: "enabled", + AllowedValues: []string{"enabled", "disabled", "log"}, + CurrentValue: func(c gh.Config, hostname string) string { + return c.Telemetry().Value + }, + }, } func HomeDirPath(subdir string) (string, error) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 67a9a98d1ab..57cca23740f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -182,3 +182,34 @@ func TestSetUserSpecificKeyNoUserPresent(t *testing.T) { requireKeyWithValue(t, c.cfg, []string{hostsKey, host, key}, val) requireNoKey(t, c.cfg, []string{hostsKey, host, usersKey}) } + +func TestTelemetry(t *testing.T) { + t.Run("returns default when not configured", func(t *testing.T) { + c := newTestConfig() + + entry := c.Telemetry() + + require.Equal(t, "enabled", entry.Value) + require.Equal(t, gh.ConfigDefaultProvided, entry.Source) + }) + + t.Run("returns user configured value", func(t *testing.T) { + c := newTestConfig() + c.Set("", telemetryKey, "disabled") + + entry := c.Telemetry() + + require.Equal(t, "disabled", entry.Value) + require.Equal(t, gh.ConfigUserProvided, entry.Source) + }) + + t.Run("returns log when configured", func(t *testing.T) { + c := newTestConfig() + c.Set("", telemetryKey, "log") + + entry := c.Telemetry() + + require.Equal(t, "log", entry.Value) + require.Equal(t, gh.ConfigUserProvided, entry.Source) + }) +} diff --git a/internal/config/stub.go b/internal/config/stub.go index ea60254db85..fe5e277b62b 100644 --- a/internal/config/stub.go +++ b/internal/config/stub.go @@ -61,6 +61,9 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock { mock.BrowserFunc = func(hostname string) gh.ConfigEntry { return cfg.Browser(hostname) } + mock.TelemetryFunc = func() gh.ConfigEntry { + return cfg.Telemetry() + } mock.ColorLabelsFunc = func(hostname string) gh.ConfigEntry { return cfg.ColorLabels(hostname) } diff --git a/internal/gh/gh.go b/internal/gh/gh.go index aa90a5268b6..759a931f2b7 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -57,6 +57,8 @@ type Config interface { PreferEditorPrompt(hostname string) ConfigEntry // Spinner returns the configured spinner setting, optionally scoped by host. Spinner(hostname string) ConfigEntry + // Telemetry returns the configured telemetry setting, ignoring host scoping since telemetry is a global setting. + Telemetry() ConfigEntry // Aliases provides persistent storage and modification of command aliases. Aliases() AliasConfig diff --git a/internal/gh/ghtelemetry/telemetry.go b/internal/gh/ghtelemetry/telemetry.go new file mode 100644 index 00000000000..c9256361b59 --- /dev/null +++ b/internal/gh/ghtelemetry/telemetry.go @@ -0,0 +1,27 @@ +package ghtelemetry + +type Dimensions map[string]string + +type Measures map[string]int64 + +type Event struct { + Type string + Dimensions Dimensions + Measures Measures +} + +type EventRecorder interface { + Record(event Event) +} + +type CommandRecorder interface { + EventRecorder + SetSampleRate(rate int) +} + +type Service interface { + CommandRecorder + Flush() +} + +const SAMPLE_ALL = 100 diff --git a/internal/gh/mock/config.go b/internal/gh/mock/config.go index 9f3f807993b..31e35cb1899 100644 --- a/internal/gh/mock/config.go +++ b/internal/gh/mock/config.go @@ -4,9 +4,10 @@ package ghmock import ( + "sync" + "github.com/cli/cli/v2/internal/gh" o "github.com/cli/cli/v2/pkg/option" - "sync" ) // Ensure, that ConfigMock does implement gh.Config. @@ -70,6 +71,9 @@ var _ gh.Config = &ConfigMock{} // SpinnerFunc: func(hostname string) gh.ConfigEntry { // panic("mock out the Spinner method") // }, +// TelemetryFunc: func() gh.ConfigEntry { +// panic("mock out the Telemetry method") +// }, // VersionFunc: func() o.Option[string] { // panic("mock out the Version method") // }, @@ -134,6 +138,9 @@ type ConfigMock struct { // SpinnerFunc mocks the Spinner method. SpinnerFunc func(hostname string) gh.ConfigEntry + // TelemetryFunc mocks the Telemetry method. + TelemetryFunc func() gh.ConfigEntry + // VersionFunc mocks the Version method. VersionFunc func() o.Option[string] @@ -227,6 +234,9 @@ type ConfigMock struct { // Hostname is the hostname argument value. Hostname string } + // Telemetry holds details about calls to the Telemetry method. + Telemetry []struct { + } // Version holds details about calls to the Version method. Version []struct { } @@ -251,6 +261,7 @@ type ConfigMock struct { lockPrompt sync.RWMutex lockSet sync.RWMutex lockSpinner sync.RWMutex + lockTelemetry sync.RWMutex lockVersion sync.RWMutex lockWrite sync.RWMutex } @@ -796,6 +807,33 @@ func (mock *ConfigMock) SpinnerCalls() []struct { return calls } +// Telemetry calls TelemetryFunc. +func (mock *ConfigMock) Telemetry() gh.ConfigEntry { + if mock.TelemetryFunc == nil { + panic("ConfigMock.TelemetryFunc: method is nil but Config.Telemetry was just called") + } + callInfo := struct { + }{} + mock.lockTelemetry.Lock() + mock.calls.Telemetry = append(mock.calls.Telemetry, callInfo) + mock.lockTelemetry.Unlock() + return mock.TelemetryFunc() +} + +// TelemetryCalls gets all the calls that were made to Telemetry. +// Check the length with: +// +// len(mockedConfig.TelemetryCalls()) +func (mock *ConfigMock) TelemetryCalls() []struct { +} { + var calls []struct { + } + mock.lockTelemetry.RLock() + calls = mock.calls.Telemetry + mock.lockTelemetry.RUnlock() + return calls +} + // Version calls VersionFunc. func (mock *ConfigMock) Version() o.Option[string] { if mock.VersionFunc == nil { diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 8690078c66e..9112d428372 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "time" @@ -19,6 +20,8 @@ import ( "github.com/cli/cli/v2/internal/build" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/config/migration" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/internal/update" "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmd/root" @@ -48,16 +51,57 @@ func Main() exitCode { cmdFactory := factory.New(buildVersion, string(agents.Detect())) stderr := cmdFactory.IOStreams.ErrOut - ctx := context.Background() + cfg, err := cmdFactory.Config() + if err != nil { + fmt.Fprintf(stderr, "failed to load config: %s\n", err) + return exitError + } - if cfg, err := cmdFactory.Config(); err == nil { - var m migration.MultiAccount - if err := cfg.Migrate(m); err != nil { - fmt.Fprintln(stderr, err) + additionalCommonDimensions := ghtelemetry.Dimensions{ + "version": strings.TrimPrefix(buildVersion, "v"), + "is_tty": strconv.FormatBool(cmdFactory.IOStreams.IsStdoutTTY()), + "agent": string(agents.Detect()), + } + + var telemetryService ghtelemetry.Service + if os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" { + telemetryService = &telemetry.NoOpService{} + } else { + + telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value) + switch telemetryState { + case telemetry.Disabled: + telemetryService = &telemetry.NoOpService{} + case telemetry.Logged: + telemetryService = telemetry.NewService( + telemetry.LogFlusher(cmdFactory.IOStreams.ErrOut, cmdFactory.IOStreams.ColorEnabled()), + telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), + ) + case telemetry.Enabled: + sampleRate := 1 + if v, err := strconv.Atoi(os.Getenv("GH_TELEMETRY_SAMPLE_RATE")); err == nil && v >= 0 && v <= 100 { + sampleRate = v + } + additionalCommonDimensions["sample_rate"] = strconv.Itoa(sampleRate) + telemetryService = telemetry.NewService( + telemetry.GitHubFlusher(cmdFactory.Executable()), + telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), + telemetry.WithSampleRate(sampleRate), + ) + default: + fmt.Fprintf(stderr, "invalid telemetry configuration: %q\n", cfg.Telemetry().Value) return exitError } } + defer telemetryService.Flush() + + var m migration.MultiAccount + if err := cfg.Migrate(m); err != nil { + fmt.Fprintln(stderr, err) + return exitError + } + ctx := context.Background() updateCtx, updateCancel := context.WithCancel(ctx) defer updateCancel() updateMessageChan := make(chan *update.ReleaseInfo) @@ -90,7 +134,7 @@ func Main() exitCode { cobra.MousetrapHelpText = "" } - rootCmd, err := root.NewCmdRoot(cmdFactory, buildVersion, buildDate) + rootCmd, err := root.NewCmdRoot(cmdFactory, telemetryService, buildVersion, buildDate) if err != nil { fmt.Fprintf(stderr, "failed to create root command: %s\n", err) return exitError diff --git a/internal/telemetry/detach_unix.go b/internal/telemetry/detach_unix.go new file mode 100644 index 00000000000..f2f6011bcd9 --- /dev/null +++ b/internal/telemetry/detach_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package telemetry + +import "syscall" + +// detachAttrs returns SysProcAttr configured to place the child in its own +// process group so that terminal signals delivered to the parent's group +// (SIGINT, SIGHUP) are not forwarded to the child. +func detachAttrs() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setpgid: true} +} diff --git a/internal/telemetry/detach_windows.go b/internal/telemetry/detach_windows.go new file mode 100644 index 00000000000..eb610163b5d --- /dev/null +++ b/internal/telemetry/detach_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package telemetry + +import ( + "syscall" + + "golang.org/x/sys/windows" +) + +// detachAttrs returns SysProcAttr configured to place the child in its own +// process group so that console signals (Ctrl+C) delivered to the parent's +// group are not forwarded to the child. +func detachAttrs() *syscall.SysProcAttr { + return &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS} +} diff --git a/internal/telemetry/fake.go b/internal/telemetry/fake.go new file mode 100644 index 00000000000..ee38262d9d5 --- /dev/null +++ b/internal/telemetry/fake.go @@ -0,0 +1,13 @@ +package telemetry + +import "github.com/cli/cli/v2/internal/gh/ghtelemetry" + +type EventRecorderSpy struct { + Events []ghtelemetry.Event +} + +func (r *EventRecorderSpy) Record(event ghtelemetry.Event) { + r.Events = append(r.Events, event) +} + +func (r *EventRecorderSpy) Flush() {} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 00000000000..f8698706ac3 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,384 @@ +// Package telemetry provides best-effort usage telemetry for gh commands. +package telemetry + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "time" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/pkg/jsoncolor" + "github.com/google/uuid" + "github.com/mgutz/ansi" +) + +const deviceIDFileName = "device-id" + +// stateDirFunc returns the state directory path. Can be replaced in tests. +var stateDirFunc = config.StateDir + +// deviceIDFunc returns a per-user device identifier stored in the state directory. +// It generates and persists a UUID on first call. Can be replaced in tests. +var deviceIDFunc = getOrCreateDeviceID + +func getOrCreateDeviceID() (string, error) { + stateDir := stateDirFunc() + idPath := filepath.Join(stateDir, deviceIDFileName) + + data, err := os.ReadFile(idPath) + if err == nil { + return strings.TrimSpace(string(data)), nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", err + } + + id := uuid.New().String() + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return "", err + } + + // Write the ID to a temp file in the same directory, then hard-link it + // to the target path. os.Link fails atomically if the target already + // exists, so exactly one concurrent caller wins. Losers read the + // winner's ID. The temp file is always cleaned up. + tmpFile, err := os.CreateTemp(stateDir, deviceIDFileName+".tmp.*") + if err != nil { + return "", err + } + tmpPath := tmpFile.Name() + + if _, err := tmpFile.WriteString(id); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return "", err + } + if err := tmpFile.Close(); err != nil { + os.Remove(tmpPath) + return "", err + } + + linkErr := os.Link(tmpPath, idPath) + os.Remove(tmpPath) + + if linkErr != nil { + // Another caller won — read their ID. + data, readErr := os.ReadFile(idPath) + if readErr != nil { + return "", linkErr + } + return strings.TrimSpace(string(data)), nil + } + + return id, nil +} + +var falseyValues = []string{"", "0", "false", "no", "disabled", "off"} + +// lookupEnvFunc wraps os.LookupEnv. Can be replaced in tests. +var lookupEnvFunc = os.LookupEnv + +type TelemetryState string + +const ( + Enabled TelemetryState = "enabled" + Disabled TelemetryState = "disabled" + Logged TelemetryState = "log" +) + +// ParseTelemetryState determines the telemetry state based on environment variables and configuration values. +// The GH_TELEMETRY environment variable takes precedence, followed by DO_NOT_TRACK, then the configuration value. +// Recognized values for GH_TELEMETRY and config are "enabled", "disabled", "log", or any falsey value (e.g. "0", "false", "no") to disable telemetry. +func ParseTelemetryState(configValue string) TelemetryState { + // GH_TELEMETRY env var takes highest precedence + if envVal, ok := lookupEnvFunc("GH_TELEMETRY"); ok { + envVal = strings.TrimSpace(strings.ToLower(envVal)) + + // If falsey, telemetry is disabled. + if slices.Contains(falseyValues, envVal) { + return Disabled + } + + // If logged, telemetry is logged instead of sent. + if envVal == "log" { + return Logged + } + + // Any other value (including "enabled") is treated as enabled. + return Enabled + } + + // DO_NOT_TRACK takes precedence over config + if envVal, ok := lookupEnvFunc("DO_NOT_TRACK"); ok { + envVal = strings.TrimSpace(strings.ToLower(envVal)) + if envVal == "1" || envVal == "true" { + return Disabled + } + } + + // Then check the config values with the same rules. + configValue = strings.TrimSpace(strings.ToLower(configValue)) + + if slices.Contains(falseyValues, configValue) { + return Disabled + } + + if configValue == "log" { + return Logged + } + + return Enabled +} + +type telemetryServiceOpts struct { + additionalDimensions ghtelemetry.Dimensions + sampleRate int +} + +type telemetryServiceOption func(*telemetryServiceOpts) + +// WithAdditionalCommonDimensions allows setting additional common dimensions that will be included with every telemetry event recorded by the service. +func WithAdditionalCommonDimensions(dimensions ghtelemetry.Dimensions) telemetryServiceOption { + return func(s *telemetryServiceOpts) { + maps.Copy(s.additionalDimensions, dimensions) + } +} + +// WithSampleRate allows setting a sample rate (0-100) for telemetry events. Events recorded with the Unsampled option will be sent regardless of the sample rate. +// Sampling is based on invocation ID, so an entire invocation will be included or excluded as a whole. This ensures that related events are not split between sampled and unsampled, +// which could lead to incomplete data and incorrect assumptions. +func WithSampleRate(rate int) telemetryServiceOption { + return func(s *telemetryServiceOpts) { + s.sampleRate = rate + } +} + +// LogFlusher returns a flush function that writes telemetry payloads to the provided log writer. This is used for the "log" telemetry mode, which is intended for debugging and development. +var LogFlusher = func(log io.Writer, colorEnabled bool) func(payload SendTelemetryPayload) { + return func(payload SendTelemetryPayload) { + payloadBytes, err := json.Marshal(payload) + if err != nil { + return + } + + header := "Telemetry payload:" + if colorEnabled { + header = ansi.Color(header, "cyan+b") + } + fmt.Fprintf(log, "%s\n", header) + + if colorEnabled { + _ = jsoncolor.Write(log, bytes.NewReader(payloadBytes), " ") + } else { + var indented bytes.Buffer + _ = json.Indent(&indented, payloadBytes, "", " ") + fmt.Fprintln(log, indented.String()) + } + } +} + +// GitHubFlusher returns a flush function that sends telemetry payloads to a child `gh send-telemetry` process. This is used for the "enabled" telemetry mode. +var GitHubFlusher = func(executable string) func(payload SendTelemetryPayload) { + return func(payload SendTelemetryPayload) { + SpawnSendTelemetry(executable, payload) + } +} + +// NewService creates a new telemetry service with the provided flush function and options. +func NewService(flusher func(SendTelemetryPayload), opts ...telemetryServiceOption) ghtelemetry.Service { + telemetryServiceOpts := telemetryServiceOpts{ + additionalDimensions: make(ghtelemetry.Dimensions), + } + for _, opt := range opts { + opt(&telemetryServiceOpts) + } + + deviceID, err := deviceIDFunc() + if err != nil { + deviceID = "" + } + + invocationID := uuid.NewString() + + var commonDimensions = ghtelemetry.Dimensions{ + "device_id": deviceID, + "invocation_id": invocationID, + "os": runtime.GOOS, + "architecture": runtime.GOARCH, + } + maps.Copy(commonDimensions, telemetryServiceOpts.additionalDimensions) + + hash := uuid.NewSHA1(uuid.Nil, []byte(invocationID)) + sampleBucket := hash[0] % 100 + + s := &service{ + flush: flusher, + commonDimensions: commonDimensions, + sampleRate: telemetryServiceOpts.sampleRate, + sampleBucket: sampleBucket, + } + + return s +} + +type recordedEvent struct { + event ghtelemetry.Event + recordedAt time.Time +} + +type service struct { + mu sync.RWMutex + flush func(payload SendTelemetryPayload) + previouslyCalled bool + + commonDimensions ghtelemetry.Dimensions + sampleRate int + sampleBucket byte + + events []recordedEvent +} + +func (s *service) Record(event ghtelemetry.Event) { + s.mu.Lock() + defer s.mu.Unlock() + + s.events = append(s.events, recordedEvent{event: event, recordedAt: time.Now()}) +} + +func (s *service) SetSampleRate(rate int) { + s.mu.Lock() + defer s.mu.Unlock() + + s.sampleRate = rate +} + +func (s *service) Flush() { + // This shouldn't really be required since flush should only be called once, but just in case... + s.mu.Lock() + defer s.mu.Unlock() + + if s.previouslyCalled { + return + } + s.previouslyCalled = true + + if len(s.events) == 0 { + return + } + + if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate { + return + } + + payload := SendTelemetryPayload{ + Events: make([]PayloadEvent, len(s.events)), + } + + for i, recorded := range s.events { + dimensions := map[string]string{ + "timestamp": recorded.recordedAt.UTC().Format("2006-01-02T15:04:05.000Z"), + } + maps.Copy(dimensions, s.commonDimensions) + maps.Copy(dimensions, recorded.event.Dimensions) + + payload.Events[i] = PayloadEvent{ + Type: recorded.event.Type, + Dimensions: dimensions, + Measures: recorded.event.Measures, + } + } + + s.flush(payload) +} + +// maxPayloadSize is a safety limit for the telemetry payload written to the +// child process stdin pipe. This bounds the data transferred to a reasonable +// size and avoids blocking on pipe buffer capacity (typically 16-64 KB). +const maxPayloadSize = 16 * 1024 + +// PayloadEvent represents a single telemetry event in the wire format. +type PayloadEvent struct { + Type string `json:"type"` + Dimensions map[string]string `json:"dimensions,omitempty"` + Measures map[string]int64 `json:"measures,omitempty"` +} + +type SendTelemetryPayload struct { + Events []PayloadEvent `json:"events"` +} + +// SpawnSendTelemetry spawns a detached subprocess to send telemetry. +// The payload is written to the child's stdin via a pipe so that it is not +// visible to other users through process argument inspection (e.g. ps aux). +// The parent writes the full payload and closes the pipe before returning, +// so no long-lived pipe is needed and the parent can exit immediately. +// +// Note: the payload is bounded by maxPayloadSize (16 KB). On macOS the +// default pipe buffer is also 16 KB, so in theory a write could block +// briefly if the child hasn't started reading yet. In practice the child +// is already running after cmd.Start(), so this is unlikely. +// +// All errors are silently ignored since telemetry is best-effort. +func SpawnSendTelemetry(executable string, payload SendTelemetryPayload) { + payloadBytes, err := json.Marshal(payload) + if err != nil { + return + } + + if len(payloadBytes) > maxPayloadSize { + return + } + + cmd := exec.Command(executable, "send-telemetry") + + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + + // Set the working directory to a stable directory elsewhere so that the subprocess doesn't + // hold a reference to the parent's current working directory, avoiding any weirdness around + // deleting the parent process's current working directory while the child is still running. + cmd.Dir = os.TempDir() + + // Configure the child process to be detached from the parent so that it can continue running + // after the parent exits, and so that it doesn't receive any signals sent to the parent. + cmd.SysProcAttr = detachAttrs() + + // Get the write end of the stdin pipe before starting. + stdin, err := cmd.StdinPipe() + if err != nil { + return + } + + if err := cmd.Start(); err != nil { + _ = stdin.Close() + return + } + + // Write the payload synchronously into the kernel pipe buffer, then close + // the pipe to signal EOF. The child reads the complete payload from stdin. + _, _ = stdin.Write(payloadBytes) + _ = stdin.Close() + + // Release resources associated with the child process since we will never Wait for it. + _ = cmd.Process.Release() +} + +type NoOpService struct{} + +func (s *NoOpService) Record(event ghtelemetry.Event) {} + +func (s *NoOpService) SetSampleRate(rate int) {} + +func (s *NoOpService) Flush() {} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go new file mode 100644 index 00000000000..0142d4d1611 --- /dev/null +++ b/internal/telemetry/telemetry_test.go @@ -0,0 +1,624 @@ +package telemetry + +import ( + "bytes" + "errors" + "maps" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func stubStateDir(dir string) func() { + orig := stateDirFunc + stateDirFunc = func() string { return dir } + return func() { stateDirFunc = orig } +} + +func stubDeviceID(id string) func() { + orig := deviceIDFunc + deviceIDFunc = func() (string, error) { return id, nil } + return func() { deviceIDFunc = orig } +} + +func stubDeviceIDError(err error) func() { + orig := deviceIDFunc + deviceIDFunc = func() (string, error) { return "", err } + return func() { deviceIDFunc = orig } +} + +func stubLookupEnv(fn func(string) (string, bool)) func() { + orig := lookupEnvFunc + lookupEnvFunc = fn + return func() { lookupEnvFunc = orig } +} + +// newService is a test helper that constructs the internal service struct +// directly, bypassing the config/env parsing of NewService but still +// resolving common dimensions like device_id and invocation_id. +func newService(flusher func(SendTelemetryPayload), additionalDimensions ghtelemetry.Dimensions) *service { + deviceID, err := deviceIDFunc() + if err != nil { + deviceID = "" + } + + commonDimensions := ghtelemetry.Dimensions{ + "device_id": deviceID, + "invocation_id": uuid.NewString(), + } + maps.Copy(commonDimensions, additionalDimensions) + + return &service{ + flush: flusher, + commonDimensions: commonDimensions, + } +} + +func TestGetOrCreateDeviceID(t *testing.T) { + t.Run("creates new ID on first call", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + id, err := getOrCreateDeviceID() + require.NoError(t, err) + require.NotEmpty(t, id) + + data, err := os.ReadFile(filepath.Join(tmpDir, deviceIDFileName)) + require.NoError(t, err) + assert.Equal(t, id, string(data)) + }) + + t.Run("returns same ID on subsequent calls", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + id1, err := getOrCreateDeviceID() + require.NoError(t, err) + + id2, err := getOrCreateDeviceID() + require.NoError(t, err) + + assert.Equal(t, id1, id2) + }) + + t.Run("trims whitespace from stored ID", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + err := os.WriteFile(filepath.Join(tmpDir, deviceIDFileName), []byte(" some-device-id\n"), 0o600) + require.NoError(t, err) + + id, err := getOrCreateDeviceID() + require.NoError(t, err) + assert.Equal(t, "some-device-id", id) + }) + + t.Run("returns error for non-ErrNotExist read failures", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + // Create device-id as a directory so ReadFile fails with a non-ErrNotExist error. + err := os.Mkdir(filepath.Join(tmpDir, deviceIDFileName), 0o755) + require.NoError(t, err) + + _, err = getOrCreateDeviceID() + require.Error(t, err) + assert.False(t, errors.Is(err, os.ErrNotExist)) + }) + + t.Run("creates state directory if missing", func(t *testing.T) { + tmpDir := t.TempDir() + nestedDir := filepath.Join(tmpDir, "nested", "state") + t.Cleanup(stubStateDir(nestedDir)) + + id, err := getOrCreateDeviceID() + require.NoError(t, err) + require.NotEmpty(t, id) + + data, err := os.ReadFile(filepath.Join(nestedDir, deviceIDFileName)) + require.NoError(t, err) + assert.Equal(t, id, string(data)) + }) + + t.Run("concurrent callers converge on the same ID", func(t *testing.T) { + tmpDir := t.TempDir() + t.Cleanup(stubStateDir(tmpDir)) + + const goroutines = 10 + ids := make([]string, goroutines) + errs := make([]error, goroutines) + var wg sync.WaitGroup + wg.Add(goroutines) + for i := range goroutines { + go func() { + defer wg.Done() + ids[i], errs[i] = getOrCreateDeviceID() + }() + } + wg.Wait() + + for i := range goroutines { + require.NoError(t, errs[i]) + } + for i := 1; i < goroutines; i++ { + assert.Equal(t, ids[0], ids[i], "goroutine %d returned a different ID", i) + } + }) +} + +func TestParseTelemetryState(t *testing.T) { + envSet := func(val string) func(string) (string, bool) { + return func(string) (string, bool) { return val, true } + } + envUnset := func(string) (string, bool) { return "", false } + + // envMap allows setting multiple environment variables for testing DO_NOT_TRACK + GH_TELEMETRY interactions. + envMap := func(m map[string]string) func(string) (string, bool) { + return func(key string) (string, bool) { + val, ok := m[key] + return val, ok + } + } + + tests := []struct { + name string + lookupEnv func(string) (string, bool) + configValue string + want TelemetryState + }{ + { + name: "env unset, config empty string disables", + lookupEnv: envUnset, + configValue: "", + want: Disabled, + }, + { + name: "env unset, config enabled", + lookupEnv: envUnset, + configValue: "enabled", + want: Enabled, + }, + { + name: "env unset, config disabled", + lookupEnv: envUnset, + configValue: "disabled", + want: Disabled, + }, + { + name: "env unset, config log", + lookupEnv: envUnset, + configValue: "log", + want: Logged, + }, + { + name: "env unset, config false", + lookupEnv: envUnset, + configValue: "false", + want: Disabled, + }, + { + name: "env unset, config any truthy value", + lookupEnv: envUnset, + configValue: "anything", + want: Enabled, + }, + { + name: "env enabled takes precedence over config disabled", + lookupEnv: envSet("enabled"), + configValue: "disabled", + want: Enabled, + }, + { + name: "env disabled takes precedence over config enabled", + lookupEnv: envSet("disabled"), + configValue: "enabled", + want: Disabled, + }, + { + name: "env log takes precedence over config enabled", + lookupEnv: envSet("log"), + configValue: "enabled", + want: Logged, + }, + { + name: "env false disables", + lookupEnv: envSet("false"), + configValue: "enabled", + want: Disabled, + }, + { + name: "env empty string disables", + lookupEnv: envSet(""), + configValue: "enabled", + want: Disabled, + }, + { + name: "env any truthy value enables", + lookupEnv: envSet("yes"), + configValue: "disabled", + want: Enabled, + }, + { + name: "env FALSE (uppercase) disables", + lookupEnv: envSet("FALSE"), + configValue: "enabled", + want: Disabled, + }, + { + name: "env LOG (uppercase) logs", + lookupEnv: envSet("LOG"), + configValue: "enabled", + want: Logged, + }, + { + name: "env value with whitespace is trimmed", + lookupEnv: envSet(" false "), + configValue: "enabled", + want: Disabled, + }, + { + name: "DO_NOT_TRACK=1 disables telemetry", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "1"}), + configValue: "enabled", + want: Disabled, + }, + { + name: "DO_NOT_TRACK=true disables telemetry", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "true"}), + configValue: "enabled", + want: Disabled, + }, + { + name: "DO_NOT_TRACK=TRUE disables telemetry (case insensitive)", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "TRUE"}), + configValue: "enabled", + want: Disabled, + }, + { + name: "DO_NOT_TRACK=0 does not disable telemetry", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "0"}), + configValue: "enabled", + want: Enabled, + }, + { + name: "DO_NOT_TRACK with whitespace is trimmed", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": " 1 "}), + configValue: "enabled", + want: Disabled, + }, + { + name: "GH_TELEMETRY takes precedence over DO_NOT_TRACK", + lookupEnv: envMap(map[string]string{"GH_TELEMETRY": "enabled", "DO_NOT_TRACK": "1"}), + configValue: "", + want: Enabled, + }, + { + name: "DO_NOT_TRACK takes precedence over config", + lookupEnv: envMap(map[string]string{"DO_NOT_TRACK": "1"}), + configValue: "log", + want: Disabled, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(stubLookupEnv(tt.lookupEnv)) + got := ParseTelemetryState(tt.configValue) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestNewServiceLogModeFlushesToWriter(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var buf bytes.Buffer + svc := NewService(LogFlusher(&buf, false)) + + svc.Record(ghtelemetry.Event{ + Type: "test_event", + Dimensions: map[string]string{"key": "value"}, + }) + svc.Flush() + + output := buf.String() + assert.Contains(t, output, "Telemetry payload:") + assert.Contains(t, output, "test_event") + assert.Contains(t, output, `"key"`) + assert.Contains(t, output, `"value"`) +} + +func TestNewServiceLogModeWithColorLogsToWriter(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var buf bytes.Buffer + svc := NewService(LogFlusher(&buf, true)) + + svc.Record(ghtelemetry.Event{Type: "color_event"}) + svc.Flush() + + output := buf.String() + assert.Contains(t, output, "color_event") + // Verify ANSI color codes are present in the output + assert.Contains(t, output, "\033[", "expected ANSI escape sequences when color is enabled") +} + +func TestServiceDeviceIDFallback(t *testing.T) { + t.Cleanup(stubDeviceIDError(errors.New("no device id"))) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + assert.Equal(t, "", captured.Events[0].Dimensions["device_id"]) +} + +func TestServiceFlush(t *testing.T) { + t.Run("does nothing when no events recorded", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc.Flush() + + assert.False(t, called, "flusher should not be called with no events") + }) + + t.Run("flushes events with merged dimensions", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{"version": "2.45.0"}) + + svc.Record(ghtelemetry.Event{ + Type: "command_invocation", + Dimensions: map[string]string{"command": "gh pr list"}, + Measures: map[string]int64{"duration_ms": 150}, + }) + svc.Flush() + + require.Len(t, captured.Events, 1) + event := captured.Events[0] + assert.Equal(t, "command_invocation", event.Type) + assert.Equal(t, "gh pr list", event.Dimensions["command"]) + assert.Equal(t, "2.45.0", event.Dimensions["version"]) + assert.Equal(t, "test-device", event.Dimensions["device_id"]) + assert.NotEmpty(t, event.Dimensions["timestamp"]) + assert.NotEmpty(t, event.Dimensions["invocation_id"]) + assert.Equal(t, int64(150), event.Measures["duration_ms"]) + }) + + t.Run("flushes multiple events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + + svc.Record(ghtelemetry.Event{Type: "event1"}) + svc.Record(ghtelemetry.Event{Type: "event2"}) + svc.Flush() + + require.Len(t, captured.Events, 2) + assert.Equal(t, "event1", captured.Events[0].Type) + assert.Equal(t, "event2", captured.Events[1].Type) + }) + + t.Run("is idempotent", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + callCount := 0 + svc := newService(func(SendTelemetryPayload) { callCount++ }, nil) + svc.Record(ghtelemetry.Event{Type: "test"}) + + svc.Flush() + svc.Flush() + svc.Flush() + + assert.Equal(t, 1, callCount, "flusher should only be called once") + }) + + t.Run("event dimensions override common dimensions", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{"shared": "common"}) + + svc.Record(ghtelemetry.Event{ + Type: "test", + Dimensions: map[string]string{"shared": "event-level"}, + }) + svc.Flush() + + require.Len(t, captured.Events, 1) + // Event dimensions are copied last via maps.Copy, so they override common + assert.Equal(t, "event-level", captured.Events[0].Dimensions["shared"]) + }) + + t.Run("timestamps reflect record time not flush time", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + + svc.Record(ghtelemetry.Event{Type: "early"}) + time.Sleep(50 * time.Millisecond) + svc.Record(ghtelemetry.Event{Type: "late"}) + svc.Flush() + + require.Len(t, captured.Events, 2) + ts1 := captured.Events[0].Dimensions["timestamp"] + ts2 := captured.Events[1].Dimensions["timestamp"] + require.NotEmpty(t, ts1) + require.NotEmpty(t, ts2) + + t1, err := time.Parse("2006-01-02T15:04:05.000Z", ts1) + require.NoError(t, err) + t2, err := time.Parse("2006-01-02T15:04:05.000Z", ts2) + require.NoError(t, err) + + assert.True(t, t2.After(t1), "second event timestamp %s should be after first %s", ts2, ts1) + }) +} + +func TestServiceSampling(t *testing.T) { + t.Run("sampleRate 0 sends all events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + svc.sampleRate = 0 + svc.sampleBucket = 99 + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + }) + + t.Run("sampleRate 100 sends all events regardless of bucket", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + svc.sampleRate = 100 + svc.sampleBucket = 99 + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + }) + + t.Run("bucket below sampleRate sends events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, nil) + svc.sampleRate = 50 + svc.sampleBucket = 49 // below rate, should be included + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + }) + + t.Run("bucket at sampleRate drops events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc.sampleRate = 50 + svc.sampleBucket = 50 // at rate boundary, should be excluded + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + assert.False(t, called, "flusher should not be called when bucket >= sampleRate") + }) + + t.Run("bucket above sampleRate drops events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc.sampleRate = 1 + svc.sampleBucket = 50 + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + assert.False(t, called, "flusher should not be called when bucket >= sampleRate") + }) + + t.Run("SetSampleRate changes flush behavior", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc.sampleBucket = 50 + + // Initially rate=0, which sends everything + svc.SetSampleRate(10) // Now bucket=50 >= rate=10, should drop + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + assert.False(t, called, "flusher should not be called after SetSampleRate reduced the rate") + }) + + t.Run("WithSampleRate option sets rate on construction", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := NewService(func(SendTelemetryPayload) { called = true }, WithSampleRate(1)) + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + // We can't control the bucket from NewService, so we just verify + // the service was created without error and Flush doesn't panic. + // The actual sampling behavior is tested via direct struct manipulation above. + _ = called + }) +} + +func TestWithAdditionalCommonDimensions(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := NewService( + func(p SendTelemetryPayload) { captured = p }, + WithAdditionalCommonDimensions(ghtelemetry.Dimensions{ + "version": "2.45.0", + "agent": "none", + }), + ) + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + assert.Equal(t, "2.45.0", captured.Events[0].Dimensions["version"]) + assert.Equal(t, "none", captured.Events[0].Dimensions["agent"]) + // Standard common dimensions should also be present + assert.Equal(t, "test-device", captured.Events[0].Dimensions["device_id"]) + assert.NotEmpty(t, captured.Events[0].Dimensions["invocation_id"]) + assert.NotEmpty(t, captured.Events[0].Dimensions["os"]) + assert.NotEmpty(t, captured.Events[0].Dimensions["architecture"]) +} + +func TestNoOpService(t *testing.T) { + svc := &NoOpService{} + // All methods should be safe to call without panicking + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.SetSampleRate(50) + svc.Flush() +} + +func TestSpawnSendTelemetryRejectsOversizedPayload(t *testing.T) { + // Build a payload larger than maxPayloadSize (16KB) + largeDimensions := map[string]string{ + "data": strings.Repeat("x", maxPayloadSize), + } + payload := SendTelemetryPayload{ + Events: []PayloadEvent{ + {Type: "test", Dimensions: largeDimensions}, + }, + } + + // This should not panic or spawn a process - it silently returns. + // We can't easily assert the subprocess wasn't started, but we verify + // the function doesn't crash. + SpawnSendTelemetry("/nonexistent/binary", payload) +} diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go index 70e01653f14..e8154f42495 100644 --- a/pkg/cmd/auth/auth.go +++ b/pkg/cmd/auth/auth.go @@ -31,5 +31,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(authTokenCmd.NewCmdToken(f, nil)) cmd.AddCommand(authSwitchCmd.NewCmdSwitch(f, nil)) + cmdutil.DisableTelemetryForSubcommands(cmd) + return cmd } diff --git a/pkg/cmd/completion/completion.go b/pkg/cmd/completion/completion.go index b34bf7abfb5..b703fa24952 100644 --- a/pkg/cmd/completion/completion.go +++ b/pkg/cmd/completion/completion.go @@ -93,6 +93,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command { cmdutil.DisableAuthCheck(cmd) cmdutil.StringEnumFlag(cmd, &shellType, "shell", "s", "", []string{"bash", "zsh", "fish", "powershell"}, "Shell type") + cmdutil.DisableTelemetry(cmd) return cmd } diff --git a/pkg/cmd/config/list/list_test.go b/pkg/cmd/config/list/list_test.go index 27260e85783..61d3db35981 100644 --- a/pkg/cmd/config/list/list_test.go +++ b/pkg/cmd/config/list/list_test.go @@ -104,6 +104,7 @@ func Test_listRun(t *testing.T) { accessible_colors=disabled accessible_prompter=disabled spinner=enabled + telemetry=enabled `), }, } diff --git a/pkg/cmd/root/extension.go b/pkg/cmd/root/extension.go index 7e2d7aca75f..45a60332b8d 100644 --- a/pkg/cmd/root/extension.go +++ b/pkg/cmd/root/extension.go @@ -8,6 +8,7 @@ import ( "time" "github.com/cli/cli/v2/internal/update" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/utils" @@ -26,7 +27,7 @@ func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ex checkExtensionReleaseInfo = checkForExtensionUpdate } - return &cobra.Command{ + cmd := &cobra.Command{ Use: ext.Name(), Short: fmt.Sprintf("Extension %s", ext.Name()), // PreRun handles looking up whether extension has a latest version only when the command is ran. @@ -73,12 +74,14 @@ func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ex // This is being handled in non-blocking default as there is no context to cancel like in gh update checks. } }, - GroupID: "extension", - Annotations: map[string]string{ - "skipAuthCheck": "true", - }, + GroupID: "extension", DisableFlagParsing: true, } + + cmdutil.DisableAuthCheck(cmd) + cmdutil.DisableTelemetry(cmd) + + return cmd } func checkForExtensionUpdate(em extensions.ExtensionManager, ext extensions.Extension) (*update.ReleaseInfo, error) { diff --git a/pkg/cmd/root/extension_registration_test.go b/pkg/cmd/root/extension_registration_test.go index 90b836e4a47..828f51ca066 100644 --- a/pkg/cmd/root/extension_registration_test.go +++ b/pkg/cmd/root/extension_registration_test.go @@ -6,6 +6,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" @@ -74,7 +75,7 @@ func TestNewCmdRoot_ExtensionRegistration(t *testing.T) { ExtensionManager: em, } - cmd, err := NewCmdRoot(f, "", "") + cmd, err := NewCmdRoot(f, &telemetry.NoOpService{}, "", "") require.NoError(t, err) // Verify skipped extensions (should find core command registered, not extension) diff --git a/pkg/cmd/root/help_test.go b/pkg/cmd/root/help_test.go index 40f333159de..e7f04375845 100644 --- a/pkg/cmd/root/help_test.go +++ b/pkg/cmd/root/help_test.go @@ -7,6 +7,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" @@ -74,7 +75,7 @@ func TestKramdownCompatibleDocs(t *testing.T) { }, } - cmd, err := NewCmdRoot(f, "N/A", "") + cmd, err := NewCmdRoot(f, &telemetry.NoOpService{}, "N/A", "") require.NoError(t, err) var walk func(*cobra.Command) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index a375d9e2050..fbacef356cc 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -117,6 +117,12 @@ var HelpTopics = []helpTopic{ %[1]sGH_ACCESSIBLE_PROMPTER%[1]s (preview): set to a truthy value to enable prompts that are more compatible with speech synthesis and braille screen readers. + %[1]sGH_TELEMETRY%[1]s: set to %[1]slog%[1]s to print telemetry data to standard error instead of sending it. + Set to %[1]sfalse%[1]s or %[1]s0%[1]s to disable telemetry that would have been printed when set to %[1]slog%[1]s. + + %[1]sDO_NOT_TRACK%[1]s: set to %[1]strue%[1]s or %[1]s1%[1]s to disable telemetry that would have been printed + when %[1]sGH_TELEMETRY%[1]s is set to %[1]slog%[1]s. %[1]sGH_TELEMETRY%[1]s takes precedence if both are set. + %[1]sGH_SPINNER_DISABLED%[1]s: set to a truthy value to replace the spinner animation with a textual progress indicator. `, "`"), diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 37684b40c8c..7df9d2986ea 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" accessibilityCmd "github.com/cli/cli/v2/pkg/cmd/accessibility" actionsCmd "github.com/cli/cli/v2/pkg/cmd/actions" agentTaskCmd "github.com/cli/cli/v2/pkg/cmd/agent-task" @@ -38,6 +39,7 @@ import ( runCmd "github.com/cli/cli/v2/pkg/cmd/run" searchCmd "github.com/cli/cli/v2/pkg/cmd/search" secretCmd "github.com/cli/cli/v2/pkg/cmd/secret" + sendTelemetryCmd "github.com/cli/cli/v2/pkg/cmd/send-telemetry" skillsCmd "github.com/cli/cli/v2/pkg/cmd/skills" sshKeyCmd "github.com/cli/cli/v2/pkg/cmd/ssh-key" statusCmd "github.com/cli/cli/v2/pkg/cmd/status" @@ -58,7 +60,7 @@ func (ae *AuthError) Error() string { return ae.err.Error() } -func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, error) { +func NewCmdRoot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, version, buildDate string) (*cobra.Command, error) { io := f.IOStreams cfg, err := f.Config() if err != nil { @@ -88,6 +90,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, } return &AuthError{} } + return nil }, } @@ -153,6 +156,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, cmd.AddCommand(statusCmd.NewCmdStatus(f, nil)) cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil)) cmd.AddCommand(licensesCmd.NewCmdLicenses(f)) + cmd.AddCommand(sendTelemetryCmd.NewCmdSendTelemetry(f)) // below here at the commands that require the "intelligent" BaseRepo resolver repoResolvingCmdFactory := *f @@ -244,6 +248,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) (*cobra.Command, } cmdutil.DisableAuthCheck(cmd) + cmdutil.RecordTelemetryForSubcommands(cmd, telemetry) // The reference command produces paged output that displays information on every other command. // Therefore, we explicitly set the Long text and HelpFunc here after all other commands are registered. diff --git a/pkg/cmd/send-telemetry/send_telemetry.go b/pkg/cmd/send-telemetry/send_telemetry.go new file mode 100644 index 00000000000..fce6dff8391 --- /dev/null +++ b/pkg/cmd/send-telemetry/send_telemetry.go @@ -0,0 +1,135 @@ +package sendtelemetry + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "time" + + "github.com/cli/cli/v2/internal/barista/observability" + "github.com/cli/cli/v2/internal/build" + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +const defaultTelemetryEndpointURL = "https://cafe.github.com" + +type SendTelemetryOptions struct { + TelemetryEndpointURL string + PayloadJSON string + HTTPUnixSocket string +} + +func NewCmdSendTelemetry(f *cmdutil.Factory) *cobra.Command { + return newCmdSendTelemetry(f, nil) +} + +func newCmdSendTelemetry(f *cmdutil.Factory, runF func(*SendTelemetryOptions) error) *cobra.Command { + cmd := &cobra.Command{ + Use: "send-telemetry", + Short: "Send telemetry event to GitHub", + Hidden: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := f.Config() + if err != nil { + return err + } + + payloadJSON, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return fmt.Errorf("reading payload from stdin: %w", err) + } + if len(payloadJSON) == 0 { + return fmt.Errorf("no payload provided on stdin") + } + + opts := &SendTelemetryOptions{ + TelemetryEndpointURL: cmp.Or(os.Getenv("GH_TELEMETRY_ENDPOINT_URL"), defaultTelemetryEndpointURL), + PayloadJSON: string(payloadJSON), + // This is a best effort to use a Unix Socket if configured. In most cases, if there is one configured + // it will be at the global level. However, since the telemetry service is not related to a specific host, we can't + // know that the socket we choose will work. + HTTPUnixSocket: cfg.HTTPUnixSocket("").Value, + } + + if runF != nil { + return runF(opts) + } + + return runSendTelemetry(cmd.Context(), opts) + }, + } + + cmdutil.DisableAuthCheck(cmd) + cmdutil.DisableTelemetry(cmd) + + return cmd +} + +func runSendTelemetry(ctx context.Context, opts *SendTelemetryOptions) error { + httpClient := &http.Client{ + Timeout: 2 * time.Second, + Transport: &userAgentTransport{ + base: handleUnixDomainSocket(opts.HTTPUnixSocket), + userAgent: fmt.Sprintf("GitHub CLI %s", build.Version), + }, + } + + client := observability.NewTelemetryAPIProtobufClient(opts.TelemetryEndpointURL, httpClient) + + var payload telemetry.SendTelemetryPayload + if err := json.Unmarshal([]byte(opts.PayloadJSON), &payload); err != nil { + return fmt.Errorf("parsing payload JSON: %w", err) + } + + if len(payload.Events) == 0 { + return nil + } + + events := make([]*observability.TelemetryEvent, len(payload.Events)) + for i, event := range payload.Events { + events[i] = &observability.TelemetryEvent{ + App: "github-cli", + EventType: event.Type, + Dimensions: event.Dimensions, + Measures: event.Measures, + } + } + + _, err := client.RecordEvents(ctx, &observability.RecordEventsRequest{ + Events: events, + }) + return err +} + +type userAgentTransport struct { + base http.RoundTripper + userAgent string +} + +func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("User-Agent", t.userAgent) + return t.base.RoundTrip(req) +} + +func handleUnixDomainSocket(socketPath string) http.RoundTripper { + if socketPath == "" { + return http.DefaultTransport + } + + dialContext := func(ctx context.Context, network, addr string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socketPath) + } + + return &http.Transport{ + DialContext: dialContext, + DisableKeepAlives: true, + } +} diff --git a/pkg/cmd/send-telemetry/send_telemetry_test.go b/pkg/cmd/send-telemetry/send_telemetry_test.go new file mode 100644 index 00000000000..8ec2f83c555 --- /dev/null +++ b/pkg/cmd/send-telemetry/send_telemetry_test.go @@ -0,0 +1,226 @@ +package sendtelemetry + +import ( + "context" + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/cli/cli/v2/internal/barista/observability" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockTelemetryAPI struct { + request *observability.RecordEventsRequest + err error +} + +func (m *mockTelemetryAPI) RecordEvents(_ context.Context, req *observability.RecordEventsRequest) (*observability.RecordEventsResponse, error) { + m.request = req + return &observability.RecordEventsResponse{}, m.err +} + +func TestNewCmdSendTelemetry(t *testing.T) { + tests := []struct { + name string + stdin string + env map[string]string + wantOpts SendTelemetryOptions + wantErr string + }{ + { + name: "reads payload from stdin", + stdin: `{"events":[{"type":"usage","dimensions":{"command":"gh pr list"}}]}`, + wantOpts: SendTelemetryOptions{ + TelemetryEndpointURL: defaultTelemetryEndpointURL, + PayloadJSON: `{"events":[{"type":"usage","dimensions":{"command":"gh pr list"}}]}`, + }, + }, + { + name: "uses GH_TELEMETRY_ENDPOINT_URL env var", + stdin: `{"events":[]}`, + env: map[string]string{"GH_TELEMETRY_ENDPOINT_URL": "https://custom.endpoint"}, + wantOpts: SendTelemetryOptions{ + TelemetryEndpointURL: "https://custom.endpoint", + PayloadJSON: `{"events":[]}`, + }, + }, + { + name: "defaults endpoint when env var not set", + stdin: `{}`, + wantOpts: SendTelemetryOptions{ + TelemetryEndpointURL: defaultTelemetryEndpointURL, + PayloadJSON: `{}`, + }, + }, + { + name: "errors on empty stdin", + stdin: "", + wantErr: "no payload provided on stdin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: ios, + Config: func() (gh.Config, error) { + return config.NewBlankConfig(), nil + }, + } + + var gotOpts *SendTelemetryOptions + cmd := newCmdSendTelemetry(f, func(opts *SendTelemetryOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{}) + cmd.SetIn(strings.NewReader(tt.stdin)) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err := cmd.ExecuteC() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + require.NotNil(t, gotOpts) + assert.Equal(t, tt.wantOpts.TelemetryEndpointURL, gotOpts.TelemetryEndpointURL) + assert.Equal(t, tt.wantOpts.PayloadJSON, gotOpts.PayloadJSON) + }) + } +} + +func TestRunSendTelemetry(t *testing.T) { + tests := []struct { + name string + payload telemetry.SendTelemetryPayload + serverErr error + wantErr bool + assertFunc func(t *testing.T, req *observability.RecordEventsRequest) + }{ + { + name: "posts single event to endpoint", + payload: telemetry.SendTelemetryPayload{ + Events: []telemetry.PayloadEvent{ + { + Type: "command_invocation", + Dimensions: map[string]string{ + "command": "gh pr create", + "device_id": "abc123", + "os": "darwin", + }, + Measures: map[string]int64{"duration_ms": 150}, + }, + }, + }, + assertFunc: func(t *testing.T, req *observability.RecordEventsRequest) { + t.Helper() + require.Len(t, req.Events, 1) + event := req.Events[0] + assert.Equal(t, "github-cli", event.App) + assert.Equal(t, "command_invocation", event.EventType) + assert.Equal(t, "gh pr create", event.Dimensions["command"]) + assert.Equal(t, "abc123", event.Dimensions["device_id"]) + assert.Equal(t, "darwin", event.Dimensions["os"]) + }, + }, + { + name: "posts multiple events in single batch request", + payload: telemetry.SendTelemetryPayload{ + Events: []telemetry.PayloadEvent{ + {Type: "event1", Dimensions: map[string]string{"a": "1"}}, + {Type: "event2", Dimensions: map[string]string{"b": "2"}}, + }, + }, + assertFunc: func(t *testing.T, req *observability.RecordEventsRequest) { + t.Helper() + require.Len(t, req.Events, 2) + assert.Equal(t, "1", req.Events[0].Dimensions["a"]) + assert.Equal(t, "2", req.Events[1].Dimensions["b"]) + assert.Equal(t, "github-cli", req.Events[0].App) + assert.Equal(t, "event1", req.Events[0].EventType) + assert.Equal(t, "github-cli", req.Events[1].App) + assert.Equal(t, "event2", req.Events[1].EventType) + }, + }, + { + name: "empty events list produces no request", + payload: telemetry.SendTelemetryPayload{ + Events: []telemetry.PayloadEvent{}, + }, + assertFunc: func(t *testing.T, req *observability.RecordEventsRequest) { + t.Helper() + assert.Nil(t, req) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockTelemetryAPI{err: tt.serverErr} + handler := observability.NewTelemetryAPIServer(mock) + server := httptest.NewServer(handler) + defer server.Close() + + opts := &SendTelemetryOptions{ + TelemetryEndpointURL: server.URL, + PayloadJSON: mustMarshal(t, tt.payload), + } + + err := runSendTelemetry(context.Background(), opts) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + if tt.assertFunc != nil { + tt.assertFunc(t, mock.request) + } + }) + } +} + +func TestRunSendTelemetryInvalidPayload(t *testing.T) { + err := runSendTelemetry(context.Background(), &SendTelemetryOptions{ + TelemetryEndpointURL: "http://localhost:0", + PayloadJSON: "not-json", + }) + require.Error(t, err) +} + +func TestRunSendTelemetryServerError(t *testing.T) { + mock := &mockTelemetryAPI{err: assert.AnError} + handler := observability.NewTelemetryAPIServer(mock) + server := httptest.NewServer(handler) + defer server.Close() + + err := runSendTelemetry(context.Background(), &SendTelemetryOptions{ + TelemetryEndpointURL: server.URL, + PayloadJSON: `{"events":[{"type":"test","dimensions":{"a":"1"}}]}`, + }) + require.Error(t, err) +} + +func mustMarshal(t *testing.T, v any) string { + t.Helper() + data, err := json.Marshal(v) + require.NoError(t, err) + return string(data) +} diff --git a/pkg/cmd/version/version.go b/pkg/cmd/version/version.go index 11d4a0271fd..4f68fbe61be 100644 --- a/pkg/cmd/version/version.go +++ b/pkg/cmd/version/version.go @@ -13,8 +13,9 @@ func NewCmdVersion(f *cmdutil.Factory, version, buildDate string) *cobra.Command cmd := &cobra.Command{ Use: "version", Hidden: true, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { fmt.Fprint(f.IOStreams.Out, cmd.Root().Annotations["versionInfo"]) + return nil }, } diff --git a/pkg/cmdutil/telemetry.go b/pkg/cmdutil/telemetry.go new file mode 100644 index 00000000000..42169beecec --- /dev/null +++ b/pkg/cmdutil/telemetry.go @@ -0,0 +1,66 @@ +package cmdutil + +import ( + "slices" + "strings" + + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +func RecordTelemetry(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) { + if isTelemetryDisabled(cmd) { + return + } + + if cmd.RunE == nil { + return + } + + currentRunE := cmd.RunE + cmd.RunE = func(cmd *cobra.Command, args []string) error { + runErr := currentRunE(cmd, args) + + var flags []string + cmd.Flags().Visit(func(f *pflag.Flag) { + flags = append(flags, f.Name) + }) + slices.Sort(flags) + + telemetry.Record(ghtelemetry.Event{ + Type: "command_invocation", + Dimensions: map[string]string{ + "command": cmd.CommandPath(), + "flags": strings.Join(flags, ","), + }, + }) + + return runErr + } +} + +func RecordTelemetryForSubcommands(cmd *cobra.Command, telemetry ghtelemetry.EventRecorder) { + for _, c := range cmd.Commands() { + RecordTelemetry(c, telemetry) + RecordTelemetryForSubcommands(c, telemetry) + } +} + +func DisableTelemetry(cmd *cobra.Command) { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations["telemetry"] = "disabled" +} + +func DisableTelemetryForSubcommands(cmd *cobra.Command) { + for _, c := range cmd.Commands() { + DisableTelemetry(c) + DisableTelemetryForSubcommands(c) + } +} + +func isTelemetryDisabled(cmd *cobra.Command) bool { + return cmd.Annotations["telemetry"] == "disabled" +} diff --git a/pkg/cmdutil/telemetry_test.go b/pkg/cmdutil/telemetry_test.go new file mode 100644 index 00000000000..bfe4c420ca0 --- /dev/null +++ b/pkg/cmdutil/telemetry_test.go @@ -0,0 +1,168 @@ +package cmdutil_test + +import ( + "fmt" + "testing" + + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRecordTelemetry(t *testing.T) { + t.Run("records command path and flags", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{ + Use: "list", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmd.Flags().Bool("web", false, "") + cmd.Flags().String("repo", "", "") + + parent := &cobra.Command{Use: "pr"} + root := &cobra.Command{Use: "gh"} + root.AddCommand(parent) + parent.AddCommand(cmd) + + cmdutil.RecordTelemetry(cmd, recorder) + + require.NoError(t, cmd.Flags().Set("web", "true")) + require.NoError(t, cmd.Flags().Set("repo", "cli/cli")) + require.NoError(t, cmd.RunE(cmd, nil)) + + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "command_invocation", event.Type) + assert.Equal(t, "gh pr list", event.Dimensions["command"]) + assert.Equal(t, "repo,web", event.Dimensions["flags"]) + }) + + t.Run("is a no-op when original RunE is nil", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{Use: "test"} + + cmdutil.RecordTelemetry(cmd, recorder) + + assert.Nil(t, cmd.RunE, "RunE should remain nil when it was nil before") + assert.Empty(t, recorder.Events, "no telemetry should be recorded") + }) + + t.Run("propagates error from original RunE", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + expectedErr := fmt.Errorf("something went wrong") + cmd := &cobra.Command{ + Use: "fail", + RunE: func(cmd *cobra.Command, args []string) error { return expectedErr }, + } + + cmdutil.RecordTelemetry(cmd, recorder) + + err := cmd.RunE(cmd, nil) + assert.ErrorIs(t, err, expectedErr) + // Telemetry is still recorded even on error + require.Len(t, recorder.Events, 1) + assert.Equal(t, "command_invocation", recorder.Events[0].Type) + }) + + t.Run("flags are sorted alphabetically", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmd.Flags().Bool("zebra", false, "") + cmd.Flags().Bool("alpha", false, "") + cmd.Flags().Bool("middle", false, "") + + cmdutil.RecordTelemetry(cmd, recorder) + + require.NoError(t, cmd.Flags().Set("zebra", "true")) + require.NoError(t, cmd.Flags().Set("alpha", "true")) + require.NoError(t, cmd.Flags().Set("middle", "true")) + require.NoError(t, cmd.RunE(cmd, nil)) + + require.Len(t, recorder.Events, 1) + assert.Equal(t, "alpha,middle,zebra", recorder.Events[0].Dimensions["flags"]) + }) + + t.Run("no flags set records empty flags string", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{ + Use: "test", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmd.Flags().Bool("unused", false, "") + + cmdutil.RecordTelemetry(cmd, recorder) + require.NoError(t, cmd.RunE(cmd, nil)) + + require.Len(t, recorder.Events, 1) + assert.Equal(t, "", recorder.Events[0].Dimensions["flags"]) + }) + + t.Run("skips commands with telemetry disabled", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + cmd := &cobra.Command{ + Use: "internal", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmdutil.DisableTelemetry(cmd) + cmdutil.RecordTelemetry(cmd, recorder) + + require.NoError(t, cmd.RunE(cmd, nil)) + assert.Empty(t, recorder.Events, "telemetry should not be recorded for disabled commands") + }) +} + +func TestRecordTelemetryForSubcommands(t *testing.T) { + t.Run("instruments nested subcommands", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + + root := &cobra.Command{Use: "gh"} + parent := &cobra.Command{Use: "pr"} + child := &cobra.Command{ + Use: "list", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + root.AddCommand(parent) + parent.AddCommand(child) + + cmdutil.RecordTelemetryForSubcommands(root, recorder) + require.NoError(t, child.RunE(child, nil)) + + require.Len(t, recorder.Events, 1) + assert.Equal(t, "command_invocation", recorder.Events[0].Type) + assert.Equal(t, "gh pr list", recorder.Events[0].Dimensions["command"]) + }) + + t.Run("skips subcommands with nil RunE", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + + root := &cobra.Command{Use: "gh"} + child := &cobra.Command{Use: "help"} // no RunE + root.AddCommand(child) + + cmdutil.RecordTelemetryForSubcommands(root, recorder) + + assert.Nil(t, child.RunE, "nil RunE should remain nil") + }) + + t.Run("skips subcommands with telemetry disabled", func(t *testing.T) { + recorder := &telemetry.EventRecorderSpy{} + + root := &cobra.Command{Use: "gh"} + child := &cobra.Command{ + Use: "send-telemetry", + RunE: func(cmd *cobra.Command, args []string) error { return nil }, + } + cmdutil.DisableTelemetry(child) + root.AddCommand(child) + + cmdutil.RecordTelemetryForSubcommands(root, recorder) + require.NoError(t, child.RunE(child, nil)) + + assert.Empty(t, recorder.Events, "disabled commands should not record telemetry") + }) +} From d333093efd29de0a17353b37d336ce32d889e43a Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Fri, 17 Apr 2026 10:26:03 +0100 Subject: [PATCH 048/182] remove misleading text --- pkg/cmd/skills/search/search.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 05511484eae..4e0d21e9a21 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -540,7 +540,7 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { } indices, err := opts.Prompter.MultiSelect( - "Select skills to install (press Enter to skip):", + "Select skills to install:", nil, options, ) From 3ed389d664d9820f73bd02c3d77575f43299cd62 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 15 Apr 2026 14:55:35 +0200 Subject: [PATCH 049/182] Disable telemetry for GHES --- .../no-telemetry-for-ghes-user.txtar | 8 + api/http_client.go | 21 + api/http_client_test.go | 74 +++ internal/gh/ghtelemetry/telemetry.go | 5 + internal/ghcmd/cmd.go | 174 ++++++- internal/ghcmd/cmd_test.go | 435 ++++++++++++++++++ .../ghcmd/executable_test.go | 11 +- internal/telemetry/fake.go | 2 + internal/telemetry/telemetry.go | 15 + internal/telemetry/telemetry_test.go | 44 ++ pkg/cmd/api/api.go | 2 +- .../verify/verify_integration_test.go | 78 ++-- pkg/cmd/auth/login/login.go | 2 +- pkg/cmd/auth/refresh/refresh.go | 2 +- pkg/cmd/auth/setupgit/setupgit.go | 2 +- pkg/cmd/codespace/root.go | 10 +- pkg/cmd/factory/default.go | 149 ++---- pkg/cmd/factory/default_test.go | 403 +--------------- pkg/cmd/search/shared/shared_test.go | 13 +- pkg/cmd/skills/preview/preview.go | 18 +- pkg/cmd/skills/search/search.go | 24 +- pkg/cmdutil/factory.go | 78 +--- pkg/cmdutil/repo_override.go | 6 +- 23 files changed, 920 insertions(+), 656 deletions(-) create mode 100644 acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar rename pkg/cmdutil/factory_test.go => internal/ghcmd/executable_test.go (94%) diff --git a/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar new file mode 100644 index 00000000000..0fe6f4bb210 --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar @@ -0,0 +1,8 @@ +# GHES users should not get telemetry even when telemetry is enabled +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 +env GH_ENTERPRISE_TOKEN=fake-enterprise-token + +exec gh version +! stderr 'Telemetry payload:' diff --git a/api/http_client.go b/api/http_client.go index 532f79c7f9d..be7a6b8a71c 100644 --- a/api/http_client.go +++ b/api/http_client.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/utils" ghAPI "github.com/cli/go-gh/v2/pkg/api" ghauth "github.com/cli/go-gh/v2/pkg/auth" @@ -26,6 +27,7 @@ type HTTPClientOptions struct { LogColorize bool LogVerboseHTTP bool SkipDefaultHeaders bool + TelemetryDisabler ghtelemetry.Disabler } func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) { @@ -74,6 +76,13 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) { client.Transport = AddAuthTokenHeader(client.Transport, opts.Config) } + if opts.TelemetryDisabler != nil { + client.Transport = telemetryDisablerTransport{ + wrappedTransport: client.Transport, + telemetryDisabler: opts.TelemetryDisabler, + } + } + return client, nil } @@ -147,3 +156,15 @@ func getHost(r *http.Request) string { } return r.URL.Host } + +type telemetryDisablerTransport struct { + wrappedTransport http.RoundTripper + telemetryDisabler ghtelemetry.Disabler +} + +func (t telemetryDisablerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if ghauth.IsEnterprise(getHost(req)) { + t.telemetryDisabler.Disable() + } + return t.wrappedTransport.RoundTrip(req) +} diff --git a/api/http_client_test.go b/api/http_client_test.go index 1c81b4aa7a5..198c0849118 100644 --- a/api/http_client_test.go +++ b/api/http_client_test.go @@ -315,6 +315,80 @@ func TestHTTPClientSanitizeControlCharactersC1(t *testing.T) { assert.Equal(t, "monalisa¡", issue.Author.Login) } +func TestNewHTTPClientTelemetryDisabler(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer ts.Close() + + tests := []struct { + name string + host string + wantDisabled bool + }{ + { + name: "enterprise host triggers disable", + host: "ghes.example.com", + wantDisabled: true, + }, + { + name: "github.com does not trigger disable", + host: "github.com", + wantDisabled: false, + }, + { + name: "tenancy host does not trigger disable", + host: "my-company.ghe.com", + wantDisabled: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + disabler := &fakeTelemetryDisabler{} + client, err := NewHTTPClient(HTTPClientOptions{ + TelemetryDisabler: disabler, + }) + require.NoError(t, err) + + req, err := http.NewRequest("GET", ts.URL, nil) + require.NoError(t, err) + req.Host = tt.host + + res, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, 204, res.StatusCode) + assert.Equal(t, tt.wantDisabled, disabler.disabled, "Disable() called") + }) + } +} + +func TestNewHTTPClientWithoutTelemetryDisabler(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer ts.Close() + + client, err := NewHTTPClient(HTTPClientOptions{}) + require.NoError(t, err) + + req, err := http.NewRequest("GET", ts.URL, nil) + require.NoError(t, err) + req.Host = "ghes.example.com" + + res, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, 204, res.StatusCode) +} + +type fakeTelemetryDisabler struct { + disabled bool +} + +func (f *fakeTelemetryDisabler) Disable() { + f.disabled = true +} + type tinyConfig map[string]string func (c tinyConfig) ActiveToken(host string) (string, string) { diff --git a/internal/gh/ghtelemetry/telemetry.go b/internal/gh/ghtelemetry/telemetry.go index c9256361b59..197b955b4c1 100644 --- a/internal/gh/ghtelemetry/telemetry.go +++ b/internal/gh/ghtelemetry/telemetry.go @@ -10,8 +10,13 @@ type Event struct { Measures Measures } +type Disabler interface { + Disable() +} + type EventRecorder interface { Record(event Event) + Disabler } type CommandRecorder interface { diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 9112d428372..eab842c5a79 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strconv" "strings" "time" @@ -20,6 +21,7 @@ import ( "github.com/cli/cli/v2/internal/build" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/config/migration" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/internal/update" @@ -28,6 +30,8 @@ import ( "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/utils" + ghauth "github.com/cli/go-gh/v2/pkg/auth" + xcolor "github.com/cli/go-gh/v2/pkg/x/color" "github.com/cli/safeexec" "github.com/mgutz/ansi" "github.com/spf13/cobra" @@ -48,33 +52,34 @@ func Main() exitCode { buildVersion := build.Version hasDebug, _ := utils.IsDebugEnabled() - cmdFactory := factory.New(buildVersion, string(agents.Detect())) - stderr := cmdFactory.IOStreams.ErrOut - - cfg, err := cmdFactory.Config() + cfg, err := config.NewConfig() if err != nil { - fmt.Fprintf(stderr, "failed to load config: %s\n", err) + fmt.Fprintf(os.Stderr, "failed to load config: %s\n", err) return exitError } + ioStreams := newIOStreams(cfg) + stderr := ioStreams.ErrOut + + ghExecutablePath := executablePath("gh") + additionalCommonDimensions := ghtelemetry.Dimensions{ "version": strings.TrimPrefix(buildVersion, "v"), - "is_tty": strconv.FormatBool(cmdFactory.IOStreams.IsStdoutTTY()), + "is_tty": strconv.FormatBool(ioStreams.IsStdoutTTY()), "agent": string(agents.Detect()), } var telemetryService ghtelemetry.Service - if os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" { + if os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" || mightBeGHESUser(cfg) { telemetryService = &telemetry.NoOpService{} } else { - telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value) switch telemetryState { case telemetry.Disabled: telemetryService = &telemetry.NoOpService{} case telemetry.Logged: telemetryService = telemetry.NewService( - telemetry.LogFlusher(cmdFactory.IOStreams.ErrOut, cmdFactory.IOStreams.ColorEnabled()), + telemetry.LogFlusher(ioStreams.ErrOut, ioStreams.ColorEnabled()), telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), ) case telemetry.Enabled: @@ -84,7 +89,7 @@ func Main() exitCode { } additionalCommonDimensions["sample_rate"] = strconv.Itoa(sampleRate) telemetryService = telemetry.NewService( - telemetry.GitHubFlusher(cmdFactory.Executable()), + telemetry.GitHubFlusher(ghExecutablePath), telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), telemetry.WithSampleRate(sampleRate), ) @@ -95,6 +100,8 @@ func Main() exitCode { } defer telemetryService.Flush() + cmdFactory := factory.New(buildVersion, string(agents.Detect()), cfg, ioStreams, ghExecutablePath, telemetryService) + var m migration.MultiAccount if err := cfg.Migrate(m); err != nil { fmt.Fprintln(stderr, err) @@ -211,7 +218,7 @@ func Main() exitCode { updateCancel() // if the update checker hasn't completed by now, abort it newRelease := <-updateMessageChan if newRelease != nil { - isHomebrew := isUnderHomebrew(cmdFactory.Executable()) + isHomebrew := isUnderHomebrew(cmdFactory.ExecutablePath) if isHomebrew && isRecentRelease(newRelease.PublishedAt) { // do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core return exitOK @@ -289,3 +296,148 @@ func isUnderHomebrew(ghBinary string) bool { brewBinPrefix := filepath.Join(strings.TrimSpace(string(brewPrefixBytes)), "bin") + string(filepath.Separator) return strings.HasPrefix(ghBinary, brewBinPrefix) } + +func newIOStreams(cfg gh.Config) *iostreams.IOStreams { + io := iostreams.System() + + if _, ghPromptDisabled := os.LookupEnv("GH_PROMPT_DISABLED"); ghPromptDisabled { + io.SetNeverPrompt(true) + } else if prompt := cfg.Prompt(""); prompt.Value == "disabled" { + io.SetNeverPrompt(true) + } + + falseyValues := []string{"false", "0", "no", ""} + + accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER") + if accessiblePrompterIsSet { + if !slices.Contains(falseyValues, accessiblePrompterValue) { + io.SetAccessiblePrompterEnabled(true) + } + } else if prompt := cfg.AccessiblePrompter(""); prompt.Value == "enabled" { + io.SetAccessiblePrompterEnabled(true) + } + + experimentalPrompterValue, experimentalPrompterIsSet := os.LookupEnv("GH_EXPERIMENTAL_PROMPTER") + if experimentalPrompterIsSet { + if !slices.Contains(falseyValues, experimentalPrompterValue) { + io.SetExperimentalPrompterEnabled(true) + } + } + + ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED") + if ghSpinnerDisabledIsSet { + if !slices.Contains(falseyValues, ghSpinnerDisabledValue) { + io.SetSpinnerDisabled(true) + } + } else if spinnerDisabled := cfg.Spinner(""); spinnerDisabled.Value == "disabled" { + io.SetSpinnerDisabled(true) + } + + // Pager precedence + // 1. GH_PAGER + // 2. pager from config + // 3. PAGER + if ghPager, ghPagerExists := os.LookupEnv("GH_PAGER"); ghPagerExists { + io.SetPager(ghPager) + } else if pager := cfg.Pager(""); pager.Value != "" { + io.SetPager(pager.Value) + } + + if ghColorLabels, ghColorLabelsExists := os.LookupEnv("GH_COLOR_LABELS"); ghColorLabelsExists { + switch ghColorLabels { + case "", "0", "false", "no": + io.SetColorLabels(false) + default: + io.SetColorLabels(true) + } + } else if prompt := cfg.ColorLabels(""); prompt.Value == "enabled" { + io.SetColorLabels(true) + } + + io.SetAccessibleColorsEnabled(xcolor.IsAccessibleColorsEnabled()) + + return io +} + +// Executable is the path to the currently invoked binary +func executablePath(executableName string) string { + ghPath := os.Getenv("GH_PATH") + if ghPath != "" { + return ghPath + } + + if strings.ContainsRune(executableName, os.PathSeparator) { + return executableName + } + + return executable(executableName) +} + +// Finds the location of the executable for the current process as it's found in PATH, respecting symlinks. +// If the process couldn't determine its location, return fallbackName. If the executable wasn't found in +// PATH, return the absolute location to the program. +// +// The idea is that the result of this function is callable in the future and refers to the same +// installation of gh, even across upgrades. This is needed primarily for Homebrew, which installs software +// under a location such as `/usr/local/Cellar/gh/1.13.1/bin/gh` and symlinks it from `/usr/local/bin/gh`. +// When the version is upgraded, Homebrew will often delete older versions, but keep the symlink. Because of +// this, we want to refer to the `gh` binary as `/usr/local/bin/gh` and not as its internal Homebrew +// location. +// +// None of this would be needed if we could just refer to GitHub CLI as `gh`, i.e. without using an absolute +// path. However, for some reason Homebrew does not include `/usr/local/bin` in PATH when it invokes git +// commands to update its taps. If `gh` (no path) is being used as git credential helper, as set up by `gh +// auth login`, running `brew update` will print out authentication errors as git is unable to locate +// Homebrew-installed `gh` +func executable(fallback string) string { + exe, err := os.Executable() + if err != nil { + return fallback + } + + base := filepath.Base(exe) + path := os.Getenv("PATH") + for _, dir := range filepath.SplitList(path) { + p, err := filepath.Abs(filepath.Join(dir, base)) + if err != nil { + continue + } + f, err := os.Lstat(p) + if err != nil { + continue + } + + if p == exe { + return p + } else if f.Mode()&os.ModeSymlink != 0 { + realP, err := filepath.EvalSymlinks(p) + if err != nil { + continue + } + realExe, err := filepath.EvalSymlinks(exe) + if err != nil { + continue + } + if realP == realExe { + return p + } + } + } + + return exe +} + +func mightBeGHESUser(cfg gh.Config) bool { + if os.Getenv("GH_ENTERPRISE_TOKEN") != "" || os.Getenv("GITHUB_ENTERPRISE_TOKEN") != "" { + return true + } + + if host := os.Getenv("GH_HOST"); host != "" && ghauth.IsEnterprise(host) { + return true + } + + // If any targeted host is Enterprise, then the user is likely a GHES user. + return slices.ContainsFunc(cfg.Authentication().Hosts(), func(host string) bool { + return ghauth.IsEnterprise(host) + }) +} diff --git a/internal/ghcmd/cmd_test.go b/internal/ghcmd/cmd_test.go index 08bbceb8532..65bcc0f288e 100644 --- a/internal/ghcmd/cmd_test.go +++ b/internal/ghcmd/cmd_test.go @@ -7,8 +7,11 @@ import ( "net" "testing" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" ) func Test_printError(t *testing.T) { @@ -76,3 +79,435 @@ check your internet connection or https://githubstatus.com }) } } + +func Test_newIOStreams_pager(t *testing.T) { + tests := []struct { + name string + env map[string]string + config gh.Config + wantPager string + }{ + { + name: "GH_PAGER and PAGER set", + env: map[string]string{ + "GH_PAGER": "GH_PAGER", + "PAGER": "PAGER", + }, + wantPager: "GH_PAGER", + }, + { + name: "GH_PAGER and config pager set", + env: map[string]string{ + "GH_PAGER": "GH_PAGER", + }, + config: pagerConfig(), + wantPager: "GH_PAGER", + }, + { + name: "config pager and PAGER set", + env: map[string]string{ + "PAGER": "PAGER", + }, + config: pagerConfig(), + wantPager: "CONFIG_PAGER", + }, + { + name: "only PAGER set", + env: map[string]string{ + "PAGER": "PAGER", + }, + wantPager: "PAGER", + }, + { + name: "GH_PAGER set to blank string", + env: map[string]string{ + "GH_PAGER": "", + "PAGER": "PAGER", + }, + wantPager: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != nil { + for k, v := range tt.env { + t.Setenv(k, v) + } + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.wantPager, io.GetPager()) + }) + } +} + +func Test_newIOStreams_prompt(t *testing.T) { + tests := []struct { + name string + config gh.Config + promptDisabled bool + env map[string]string + }{ + { + name: "default config", + promptDisabled: false, + }, + { + name: "config with prompt disabled", + config: disablePromptConfig(), + promptDisabled: true, + }, + { + name: "prompt disabled via GH_PROMPT_DISABLED env var", + env: map[string]string{"GH_PROMPT_DISABLED": "1"}, + promptDisabled: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != nil { + for k, v := range tt.env { + t.Setenv(k, v) + } + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.promptDisabled, io.GetNeverPrompt()) + }) + } +} + +func Test_newIOStreams_spinnerDisabled(t *testing.T) { + tests := []struct { + name string + config gh.Config + spinnerDisabled bool + env map[string]string + }{ + { + name: "default config", + spinnerDisabled: false, + }, + { + name: "config with spinner disabled", + config: disableSpinnersConfig(), + spinnerDisabled: true, + }, + { + name: "config with spinner enabled", + config: enableSpinnersConfig(), + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = 0", + env: map[string]string{"GH_SPINNER_DISABLED": "0"}, + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = false", + env: map[string]string{"GH_SPINNER_DISABLED": "false"}, + spinnerDisabled: false, + }, + { + name: "spinner disabled via GH_SPINNER_DISABLED env var = no", + env: map[string]string{"GH_SPINNER_DISABLED": "no"}, + spinnerDisabled: false, + }, + { + name: "spinner enabled via GH_SPINNER_DISABLED env var = 1", + env: map[string]string{"GH_SPINNER_DISABLED": "1"}, + spinnerDisabled: true, + }, + { + name: "spinner enabled via GH_SPINNER_DISABLED env var = true", + env: map[string]string{"GH_SPINNER_DISABLED": "true"}, + spinnerDisabled: true, + }, + { + name: "config enabled but env disabled, respects env", + config: enableSpinnersConfig(), + env: map[string]string{"GH_SPINNER_DISABLED": "true"}, + spinnerDisabled: true, + }, + { + name: "config disabled but env enabled, respects env", + config: disableSpinnersConfig(), + env: map[string]string{"GH_SPINNER_DISABLED": "false"}, + spinnerDisabled: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.spinnerDisabled, io.GetSpinnerDisabled()) + }) + } +} + +func Test_newIOStreams_accessiblePrompterEnabled(t *testing.T) { + tests := []struct { + name string + config gh.Config + accessiblePrompterEnabled bool + env map[string]string + }{ + { + name: "default config", + accessiblePrompterEnabled: false, + }, + { + name: "config with accessible prompter enabled", + config: enableAccessiblePrompterConfig(), + accessiblePrompterEnabled: true, + }, + { + name: "config with accessible prompter disabled", + config: disableAccessiblePrompterConfig(), + accessiblePrompterEnabled: false, + }, + { + name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = 1", + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "1"}, + accessiblePrompterEnabled: true, + }, + { + name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = true", + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"}, + accessiblePrompterEnabled: true, + }, + { + name: "accessible prompter disabled via GH_ACCESSIBLE_PROMPTER env var = 0", + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "0"}, + accessiblePrompterEnabled: false, + }, + { + name: "config disabled but env enabled, respects env", + config: disableAccessiblePrompterConfig(), + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"}, + accessiblePrompterEnabled: true, + }, + { + name: "config enabled but env disabled, respects env", + config: enableAccessiblePrompterConfig(), + env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "false"}, + accessiblePrompterEnabled: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.accessiblePrompterEnabled, io.AccessiblePrompterEnabled()) + }) + } +} + +func Test_newIOStreams_colorLabels(t *testing.T) { + tests := []struct { + name string + config gh.Config + colorLabelsEnabled bool + env map[string]string + }{ + { + name: "default config", + colorLabelsEnabled: false, + }, + { + name: "config with colorLabels enabled", + config: enableColorLabelsConfig(), + colorLabelsEnabled: true, + }, + { + name: "config with colorLabels disabled", + config: disableColorLabelsConfig(), + colorLabelsEnabled: false, + }, + { + name: "colorLabels enabled via `1` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "1"}, + colorLabelsEnabled: true, + }, + { + name: "colorLabels enabled via `true` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "true"}, + colorLabelsEnabled: true, + }, + { + name: "colorLabels enabled via `yes` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "yes"}, + colorLabelsEnabled: true, + }, + { + name: "colorLabels disable via empty string in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": ""}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `0` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "0"}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `false` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "false"}, + colorLabelsEnabled: false, + }, + { + name: "colorLabels disabled via `no` in GH_COLOR_LABELS env var", + env: map[string]string{"GH_COLOR_LABELS": "no"}, + colorLabelsEnabled: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.env != nil { + for k, v := range tt.env { + t.Setenv(k, v) + } + } + var cfg gh.Config + if tt.config != nil { + cfg = tt.config + } else { + cfg = config.NewBlankConfig() + } + io := newIOStreams(cfg) + assert.Equal(t, tt.colorLabelsEnabled, io.ColorLabels()) + }) + } +} + +func Test_mightBeGHESUser(t *testing.T) { + tests := []struct { + name string + env map[string]string + config gh.Config + want bool + }{ + { + name: "GH_ENTERPRISE_TOKEN set", + env: map[string]string{"GH_ENTERPRISE_TOKEN": "some-token"}, + config: config.NewBlankConfig(), + want: true, + }, + { + name: "GITHUB_ENTERPRISE_TOKEN set", + env: map[string]string{"GITHUB_ENTERPRISE_TOKEN": "some-token"}, + config: config.NewBlankConfig(), + want: true, + }, + { + name: "no env vars, config has enterprise host", + config: config.NewFromString("hosts:\n ghes.example.com:\n oauth_token: abc123\n"), + want: true, + }, + { + name: "no env vars, config has only github.com", + config: config.NewFromString("hosts:\n github.com:\n oauth_token: abc123\n"), + want: false, + }, + { + name: "no env vars, config has no hosts", + config: config.NewBlankConfig(), + want: false, + }, + { + name: "no env vars, config has github.com and enterprise host", + config: config.NewFromString("hosts:\n github.com:\n oauth_token: abc123\n ghes.example.com:\n oauth_token: def456\n"), + want: true, + }, + { + name: "no env vars, config has tenancy host", + config: config.NewFromString("hosts:\n my-company.ghe.com:\n oauth_token: abc123\n"), + want: false, + }, + { + name: "GH_HOST set to enterprise host", + env: map[string]string{"GH_HOST": "ghes.example.com"}, + config: config.NewBlankConfig(), + want: true, + }, + { + name: "GH_HOST set to github.com", + env: map[string]string{"GH_HOST": "github.com"}, + config: config.NewBlankConfig(), + want: false, + }, + { + name: "GH_HOST set to tenancy host", + env: map[string]string{"GH_HOST": "my-company.ghe.com"}, + config: config.NewBlankConfig(), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for k, v := range tt.env { + t.Setenv(k, v) + } + got := mightBeGHESUser(tt.config) + assert.Equal(t, tt.want, got) + }) + } +} + +func pagerConfig() gh.Config { + return config.NewFromString("pager: CONFIG_PAGER") +} + +func disablePromptConfig() gh.Config { + return config.NewFromString("prompt: disabled") +} + +func enableAccessiblePrompterConfig() gh.Config { + return config.NewFromString("accessible_prompter: enabled") +} + +func disableAccessiblePrompterConfig() gh.Config { + return config.NewFromString("accessible_prompter: disabled") +} + +func disableSpinnersConfig() gh.Config { + return config.NewFromString("spinner: disabled") +} + +func enableSpinnersConfig() gh.Config { + return config.NewFromString("spinner: enabled") +} + +func disableColorLabelsConfig() gh.Config { + return config.NewFromString("color_labels: disabled") +} + +func enableColorLabelsConfig() gh.Config { + return config.NewFromString("color_labels: enabled") +} diff --git a/pkg/cmdutil/factory_test.go b/internal/ghcmd/executable_test.go similarity index 94% rename from pkg/cmdutil/factory_test.go rename to internal/ghcmd/executable_test.go index 0103a04f1b5..f0374429bcd 100644 --- a/pkg/cmdutil/factory_test.go +++ b/internal/ghcmd/executable_test.go @@ -1,10 +1,12 @@ -package cmdutil +package ghcmd import ( "os" "path/filepath" "strings" "testing" + + "github.com/stretchr/testify/require" ) func Test_executable(t *testing.T) { @@ -113,11 +115,8 @@ func Test_executable_relative(t *testing.T) { } } -func Test_Executable_override(t *testing.T) { +func TestExecutablePath(t *testing.T) { override := strings.Join([]string{"C:", "cygwin64", "home", "gh.exe"}, string(os.PathSeparator)) t.Setenv("GH_PATH", override) - f := Factory{} - if got := f.Executable(); got != override { - t.Errorf("executable() = %q, want %q", got, override) - } + require.Equal(t, override, executablePath("gh")) } diff --git a/internal/telemetry/fake.go b/internal/telemetry/fake.go index ee38262d9d5..1dc45ab2692 100644 --- a/internal/telemetry/fake.go +++ b/internal/telemetry/fake.go @@ -10,4 +10,6 @@ func (r *EventRecorderSpy) Record(event ghtelemetry.Event) { r.Events = append(r.Events, event) } +func (r *EventRecorderSpy) Disable() {} + func (r *EventRecorderSpy) Flush() {} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index f8698706ac3..b046ec77d84 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -248,6 +248,15 @@ type service struct { sampleBucket byte events []recordedEvent + + disabled bool +} + +func (s *service) Disable() { + s.mu.Lock() + defer s.mu.Unlock() + + s.disabled = true } func (s *service) Record(event ghtelemetry.Event) { @@ -269,6 +278,10 @@ func (s *service) Flush() { s.mu.Lock() defer s.mu.Unlock() + if s.disabled { + return + } + if s.previouslyCalled { return } @@ -379,6 +392,8 @@ type NoOpService struct{} func (s *NoOpService) Record(event ghtelemetry.Event) {} +func (s *NoOpService) Disable() {} + func (s *NoOpService) SetSampleRate(rate int) {} func (s *NoOpService) Flush() {} diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 0142d4d1611..207d611eeca 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -598,10 +598,54 @@ func TestWithAdditionalCommonDimensions(t *testing.T) { assert.NotEmpty(t, captured.Events[0].Dimensions["architecture"]) } +func TestServiceDisable(t *testing.T) { + t.Run("prevents flush from sending events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Disable() + svc.Flush() + + assert.False(t, called, "flusher should not be called after Disable()") + }) + + t.Run("prevents flush even with multiple recorded events", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + + svc.Record(ghtelemetry.Event{Type: "event1"}) + svc.Record(ghtelemetry.Event{Type: "event2"}) + svc.Record(ghtelemetry.Event{Type: "event3"}) + svc.Disable() + svc.Flush() + + assert.False(t, called, "flusher should not be called after Disable()") + }) + + t.Run("can be called before any events are recorded", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + called := false + svc := newService(func(SendTelemetryPayload) { called = true }, nil) + + svc.Disable() + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + assert.False(t, called, "flusher should not be called when disabled before recording") + }) +} + func TestNoOpService(t *testing.T) { svc := &NoOpService{} // All methods should be safe to call without panicking svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Disable() svc.SetSampleRate(50) svc.Flush() } diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index fb641457f0f..4a87e0f8cb9 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -223,7 +223,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command }, Args: cobra.ExactArgs(1), PreRun: func(c *cobra.Command, args []string) { - opts.BaseRepo = cmdutil.OverrideBaseRepoFunc(f, "") + opts.BaseRepo = cmdutil.OverrideBaseRepoFunc(f.BaseRepo, "") }, RunE: func(c *cobra.Command, args []string) error { opts.RequestPath = args[0] diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index ec64cefa7a3..10a1e521657 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -1,17 +1,19 @@ -//go:build integration - package verify import ( "net/http" "testing" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact/oci" "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/test" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" "github.com/cli/cli/v2/pkg/cmd/factory" + "github.com/cli/cli/v2/pkg/iostreams" o "github.com/cli/cli/v2/pkg/option" "github.com/cli/go-gh/v2/pkg/auth" "github.com/stretchr/testify/require" @@ -26,12 +28,15 @@ func TestVerifyIntegration(t *testing.T) { TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test", "") - - hc, err := cmdFactory.HttpClient() - if err != nil { - t.Fatal(err) - } + ios, _, _, _ := iostreams.Test() + hc, err := factory.HttpClientFunc( + &config.AuthConfig{}, + ios, + "test", + "", + &telemetry.NoOpService{}, + )() + require.NoError(t, err) host, _ := auth.DefaultHost() @@ -143,12 +148,15 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test", "") - - hc, err := cmdFactory.HttpClient() - if err != nil { - t.Fatal(err) - } + ios, _, _, _ := iostreams.Test() + hc, err := factory.HttpClientFunc( + &config.AuthConfig{}, + ios, + "test", + "", + &telemetry.NoOpService{}, + )() + require.NoError(t, err) host, _ := auth.DefaultHost() @@ -217,12 +225,16 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test", "") - - hc, err := cmdFactory.HttpClient() - if err != nil { - t.Fatal(err) - } + cfg := config.NewBlankConfig() + ios, _, _, _ := iostreams.Test() + hc, err := factory.HttpClientFunc( + cfg.Authentication(), + ios, + "test", + "", + &telemetry.NoOpService{}, + )() + require.NoError(t, err) host, _ := auth.DefaultHost() @@ -310,22 +322,28 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test", "") - - hc, err := cmdFactory.HttpClient() - if err != nil { - t.Fatal(err) - } + cfg := config.NewBlankConfig() + ios, _, _, _ := iostreams.Test() + hc, err := factory.HttpClientFunc( + cfg.Authentication(), + ios, + "test", + "", + &telemetry.NoOpService{}, + )() + require.NoError(t, err) host, _ := auth.DefaultHost() sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) require.NoError(t, err) baseOpts := Options{ - APIClient: api.NewLiveClient(hc, host, logger), - ArtifactPath: artifactPath, - BundlePath: bundlePath, - Config: cmdFactory.Config, + APIClient: api.NewLiveClient(hc, host, logger), + ArtifactPath: artifactPath, + BundlePath: bundlePath, + Config: func() (gh.Config, error) { + return cfg, nil + }, DigestAlgorithm: "sha256", Logger: logger, OCIClient: oci.NewLiveClient(), diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 88bc09f63a0..24d30c56244 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -138,7 +138,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm opts.Hostname, _ = ghauth.DefaultHost() } - opts.MainExecutable = f.Executable() + opts.MainExecutable = f.ExecutablePath if runF != nil { return runF(opts) } diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index c025df465b8..842902502cd 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -101,7 +101,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. return cmdutil.FlagErrorf("--hostname required when not running interactively") } - opts.MainExecutable = f.Executable() + opts.MainExecutable = f.ExecutablePath if runF != nil { return runF(opts) } diff --git a/pkg/cmd/auth/setupgit/setupgit.go b/pkg/cmd/auth/setupgit/setupgit.go index 0ff7b690360..a146a579fb9 100644 --- a/pkg/cmd/auth/setupgit/setupgit.go +++ b/pkg/cmd/auth/setupgit/setupgit.go @@ -53,7 +53,7 @@ func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobr `), RunE: func(cmd *cobra.Command, args []string) error { opts.CredentialsHelperConfig = &gitcredentials.HelperConfig{ - SelfExecutablePath: f.Executable(), + SelfExecutablePath: f.ExecutablePath, GitClient: f.GitClient, } if opts.Hostname == "" && opts.Force { diff --git a/pkg/cmd/codespace/root.go b/pkg/cmd/codespace/root.go index d1675a8f742..5d3bff3d6d8 100644 --- a/pkg/cmd/codespace/root.go +++ b/pkg/cmd/codespace/root.go @@ -7,6 +7,14 @@ import ( "github.com/spf13/cobra" ) +type ghExecutable struct { + executablePath string +} + +func (e *ghExecutable) Executable() string { + return e.executablePath +} + func NewCmdCodespace(f *cmdutil.Factory) *cobra.Command { root := &cobra.Command{ Use: "codespace", @@ -17,7 +25,7 @@ func NewCmdCodespace(f *cmdutil.Factory) *cobra.Command { app := NewApp( f.IOStreams, - f, + &ghExecutable{executablePath: f.ExecutablePath}, codespacesAPI.New(f), f.Browser, f.Remotes, diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 7afc6baa758..bf203bd4314 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -4,46 +4,46 @@ import ( "context" "fmt" "net/http" - "os" "regexp" - "slices" "time" "github.com/cli/cli/v2/api" ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/browser" - "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/extension" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" - xcolor "github.com/cli/go-gh/v2/pkg/x/color" ) var ssoHeader string var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`) -func New(appVersion string, invokingAgent string) *cmdutil.Factory { +func New(appVersion string, invokingAgent string, cfg gh.Config, ios *iostreams.IOStreams, executablePath string, telemetryDisabler ghtelemetry.Disabler) *cmdutil.Factory { f := &cmdutil.Factory{ - AppVersion: appVersion, - InvokingAgent: invokingAgent, - Config: configFunc(), // No factory dependencies - ExecutableName: "gh", + AppVersion: appVersion, + InvokingAgent: invokingAgent, + Cfg: cfg, + Config: func() (gh.Config, error) { + return cfg, nil + }, // No factory dependencies + ExecutablePath: executablePath, } - f.IOStreams = ioStreams(f) // Depends on Config - f.HttpClient = httpClientFunc(f, appVersion, invokingAgent) // Depends on Config, IOStreams, appVersion, and invokingAgent - f.PlainHttpClient = plainHttpClientFunc(f, appVersion, invokingAgent) // Depends on IOStreams, appVersion, and invokingAgent - f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable - f.Remotes = remotesFunc(f) // Depends on Config, and GitClient - f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes - f.Prompter = newPrompter(f) // Depends on Config and IOStreams - f.Browser = newBrowser(f) // Depends on Config, and IOStreams - f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams - f.Branch = branchFunc(f) // Depends on GitClient + f.IOStreams = ios + f.HttpClient = HttpClientFunc(cfg.Authentication(), ios, appVersion, invokingAgent, telemetryDisabler) + f.PlainHttpClient = plainHttpClientFunc(ios, appVersion, invokingAgent, telemetryDisabler) + f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable + f.Remotes = remotesFunc(f) // Depends on Config, and GitClient + f.BaseRepo = BaseRepoFunc(f.Remotes) + f.Prompter = newPrompter(f) // Depends on Config and IOStreams + f.Browser = newBrowser(f) // Depends on Config, and IOStreams + f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams + f.Branch = branchFunc(f) // Depends on GitClient return f } @@ -73,9 +73,9 @@ func New(appVersion string, invokingAgent string) *cmdutil.Factory { // origin https://github.com/cli/cli-fork.git (push) // // With this resolution function, the upstream will always be chosen (assuming we have authenticated with github.com). -func BaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) { +func BaseRepoFunc(remotesFunc func() (ghContext.Remotes, error)) func() (ghrepo.Interface, error) { return func() (ghrepo.Interface, error) { - remotes, err := f.Remotes() + remotes, err := remotesFunc() if err != nil { return nil, err } @@ -187,19 +187,15 @@ func remotesFunc(f *cmdutil.Factory) func() (ghContext.Remotes, error) { return rr.Resolver() } -func httpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent string) func() (*http.Client, error) { +func HttpClientFunc(authCfg gh.AuthConfig, ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler) func() (*http.Client, error) { return func() (*http.Client, error) { - io := f.IOStreams - cfg, err := f.Config() - if err != nil { - return nil, err - } opts := api.HTTPClientOptions{ - Config: cfg.Authentication(), - Log: io.ErrOut, - LogColorize: io.ColorEnabled(), - AppVersion: appVersion, - InvokingAgent: invokingAgent, + Config: authCfg, + Log: ios.ErrOut, + LogColorize: ios.ColorEnabled(), + AppVersion: appVersion, + InvokingAgent: invokingAgent, + TelemetryDisabler: telemetryDisabler, } client, err := api.NewHTTPClient(opts) if err != nil { @@ -210,16 +206,16 @@ func httpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent string) } } -func plainHttpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent string) func() (*http.Client, error) { +func plainHttpClientFunc(ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler) func() (*http.Client, error) { return func() (*http.Client, error) { - io := f.IOStreams opts := api.HTTPClientOptions{ - Log: io.ErrOut, - LogColorize: io.ColorEnabled(), + Log: ios.ErrOut, + LogColorize: ios.ColorEnabled(), AppVersion: appVersion, InvokingAgent: invokingAgent, // This is required to prevent automatic setting of auth and other headers. SkipDefaultHeaders: true, + TelemetryDisabler: telemetryDisabler, } client, err := api.NewHTTPClient(opts) if err != nil { @@ -231,9 +227,8 @@ func plainHttpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent st func newGitClient(f *cmdutil.Factory) *git.Client { io := f.IOStreams - ghPath := f.Executable() client := &git.Client{ - GhPath: ghPath, + GhPath: f.ExecutablePath, Stderr: io.ErrOut, Stdin: io.In, Stdout: io.Out, @@ -252,18 +247,6 @@ func newPrompter(f *cmdutil.Factory) prompter.Prompter { return prompter.New(editor, io) } -func configFunc() func() (gh.Config, error) { - var cachedConfig gh.Config - var configError error - return func() (gh.Config, error) { - if cachedConfig != nil || configError != nil { - return cachedConfig, configError - } - cachedConfig, configError = config.NewConfig() - return cachedConfig, configError - } -} - func branchFunc(f *cmdutil.Factory) func() (string, error) { return func() (string, error) { currentBranch, err := f.GitClient.CurrentBranch(context.Background()) @@ -293,72 +276,6 @@ func extensionManager(f *cmdutil.Factory) *extension.Manager { return em } -func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams { - io := iostreams.System() - cfg, err := f.Config() - if err != nil { - return io - } - - if _, ghPromptDisabled := os.LookupEnv("GH_PROMPT_DISABLED"); ghPromptDisabled { - io.SetNeverPrompt(true) - } else if prompt := cfg.Prompt(""); prompt.Value == "disabled" { - io.SetNeverPrompt(true) - } - - falseyValues := []string{"false", "0", "no", ""} - - accessiblePrompterValue, accessiblePrompterIsSet := os.LookupEnv("GH_ACCESSIBLE_PROMPTER") - if accessiblePrompterIsSet { - if !slices.Contains(falseyValues, accessiblePrompterValue) { - io.SetAccessiblePrompterEnabled(true) - } - } else if prompt := cfg.AccessiblePrompter(""); prompt.Value == "enabled" { - io.SetAccessiblePrompterEnabled(true) - } - - experimentalPrompterValue, experimentalPrompterIsSet := os.LookupEnv("GH_EXPERIMENTAL_PROMPTER") - if experimentalPrompterIsSet { - if !slices.Contains(falseyValues, experimentalPrompterValue) { - io.SetExperimentalPrompterEnabled(true) - } - } - - ghSpinnerDisabledValue, ghSpinnerDisabledIsSet := os.LookupEnv("GH_SPINNER_DISABLED") - if ghSpinnerDisabledIsSet { - if !slices.Contains(falseyValues, ghSpinnerDisabledValue) { - io.SetSpinnerDisabled(true) - } - } else if spinnerDisabled := cfg.Spinner(""); spinnerDisabled.Value == "disabled" { - io.SetSpinnerDisabled(true) - } - - // Pager precedence - // 1. GH_PAGER - // 2. pager from config - // 3. PAGER - if ghPager, ghPagerExists := os.LookupEnv("GH_PAGER"); ghPagerExists { - io.SetPager(ghPager) - } else if pager := cfg.Pager(""); pager.Value != "" { - io.SetPager(pager.Value) - } - - if ghColorLabels, ghColorLabelsExists := os.LookupEnv("GH_COLOR_LABELS"); ghColorLabelsExists { - switch ghColorLabels { - case "", "0", "false", "no": - io.SetColorLabels(false) - default: - io.SetColorLabels(true) - } - } else if prompt := cfg.ColorLabels(""); prompt.Value == "enabled" { - io.SetColorLabels(true) - } - - io.SetAccessibleColorsEnabled(xcolor.IsAccessibleColorsEnabled()) - - return io -} - // SSOURL returns the URL of a SAML SSO challenge received by the server for clients that use ExtractHeader // to extract the value of the "X-GitHub-SSO" response header. func SSOURL() string { diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index 7d84caa8f26..6f376e48c88 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" ghmock "github.com/cli/cli/v2/internal/gh/mock" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -66,7 +67,6 @@ func Test_BaseRepo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1", "") rr := &remoteResolver{ readRemotes: func() (git.RemoteSet, error) { return tt.remotes, nil @@ -90,8 +90,10 @@ func Test_BaseRepo(t *testing.T) { return cfg, nil }, } - f.Remotes = rr.Resolver() - f.BaseRepo = BaseRepoFunc(f) + remotes := rr.Resolver() + f := &cmdutil.Factory{ + BaseRepo: BaseRepoFunc(remotes), + } repo, err := f.BaseRepo() if tt.wantsErr { assert.Error(t, err) @@ -204,7 +206,7 @@ func Test_SmartBaseRepo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1", "") + f := &cmdutil.Factory{} rr := &remoteResolver{ readRemotes: func() (git.RemoteSet, error) { return tt.remotes, nil @@ -297,7 +299,6 @@ func Test_OverrideBaseRepo(t *testing.T) { if tt.envOverride != "" { t.Setenv("GH_REPO", tt.envOverride) } - f := New("1", "") rr := &remoteResolver{ readRemotes: func() (git.RemoteSet, error) { return tt.remotes, nil @@ -306,8 +307,10 @@ func Test_OverrideBaseRepo(t *testing.T) { return tt.config, nil }, } - f.Remotes = rr.Resolver() - f.BaseRepo = cmdutil.OverrideBaseRepoFunc(f, tt.argOverride) + remotes := rr.Resolver() + f := &cmdutil.Factory{ + BaseRepo: cmdutil.OverrideBaseRepoFunc(BaseRepoFunc(remotes), tt.argOverride), + } repo, err := f.BaseRepo() if tt.wantsErr { assert.Error(t, err) @@ -321,341 +324,6 @@ func Test_OverrideBaseRepo(t *testing.T) { } } -func Test_ioStreams_pager(t *testing.T) { - tests := []struct { - name string - env map[string]string - config gh.Config - wantPager string - }{ - { - name: "GH_PAGER and PAGER set", - env: map[string]string{ - "GH_PAGER": "GH_PAGER", - "PAGER": "PAGER", - }, - wantPager: "GH_PAGER", - }, - { - name: "GH_PAGER and config pager set", - env: map[string]string{ - "GH_PAGER": "GH_PAGER", - }, - config: pagerConfig(), - wantPager: "GH_PAGER", - }, - { - name: "config pager and PAGER set", - env: map[string]string{ - "PAGER": "PAGER", - }, - config: pagerConfig(), - wantPager: "CONFIG_PAGER", - }, - { - name: "only PAGER set", - env: map[string]string{ - "PAGER": "PAGER", - }, - wantPager: "PAGER", - }, - { - name: "GH_PAGER set to blank string", - env: map[string]string{ - "GH_PAGER": "", - "PAGER": "PAGER", - }, - wantPager: "", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.env != nil { - for k, v := range tt.env { - t.Setenv(k, v) - } - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.wantPager, io.GetPager()) - }) - } -} - -func Test_ioStreams_prompt(t *testing.T) { - tests := []struct { - name string - config gh.Config - promptDisabled bool - env map[string]string - }{ - { - name: "default config", - promptDisabled: false, - }, - { - name: "config with prompt disabled", - config: disablePromptConfig(), - promptDisabled: true, - }, - { - name: "prompt disabled via GH_PROMPT_DISABLED env var", - env: map[string]string{"GH_PROMPT_DISABLED": "1"}, - promptDisabled: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.env != nil { - for k, v := range tt.env { - t.Setenv(k, v) - } - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.promptDisabled, io.GetNeverPrompt()) - }) - } -} - -func Test_ioStreams_spinnerDisabled(t *testing.T) { - tests := []struct { - name string - config gh.Config - spinnerDisabled bool - env map[string]string - }{ - { - name: "default config", - spinnerDisabled: false, - }, - { - name: "config with spinner disabled", - config: disableSpinnersConfig(), - spinnerDisabled: true, - }, - { - name: "config with spinner enabled", - config: enableSpinnersConfig(), - spinnerDisabled: false, - }, - { - name: "spinner disabled via GH_SPINNER_DISABLED env var = 0", - env: map[string]string{"GH_SPINNER_DISABLED": "0"}, - spinnerDisabled: false, - }, - { - name: "spinner disabled via GH_SPINNER_DISABLED env var = false", - env: map[string]string{"GH_SPINNER_DISABLED": "false"}, - spinnerDisabled: false, - }, - { - name: "spinner disabled via GH_SPINNER_DISABLED env var = no", - env: map[string]string{"GH_SPINNER_DISABLED": "no"}, - spinnerDisabled: false, - }, - { - name: "spinner enabled via GH_SPINNER_DISABLED env var = 1", - env: map[string]string{"GH_SPINNER_DISABLED": "1"}, - spinnerDisabled: true, - }, - { - name: "spinner enabled via GH_SPINNER_DISABLED env var = true", - env: map[string]string{"GH_SPINNER_DISABLED": "true"}, - spinnerDisabled: true, - }, - { - name: "config enabled but env disabled, respects env", - config: enableSpinnersConfig(), - env: map[string]string{"GH_SPINNER_DISABLED": "true"}, - spinnerDisabled: true, - }, - { - name: "config disabled but env enabled, respects env", - config: disableSpinnersConfig(), - env: map[string]string{"GH_SPINNER_DISABLED": "false"}, - spinnerDisabled: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for k, v := range tt.env { - t.Setenv(k, v) - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.spinnerDisabled, io.GetSpinnerDisabled()) - }) - } -} - -func Test_ioStreams_accessiblePrompterEnabled(t *testing.T) { - tests := []struct { - name string - config gh.Config - accessiblePrompterEnabled bool - env map[string]string - }{ - { - name: "default config", - accessiblePrompterEnabled: false, - }, - { - name: "config with accessible prompter enabled", - config: enableAccessiblePrompterConfig(), - accessiblePrompterEnabled: true, - }, - { - name: "config with accessible prompter disabled", - config: disableAccessiblePrompterConfig(), - accessiblePrompterEnabled: false, - }, - { - name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = 1", - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "1"}, - accessiblePrompterEnabled: true, - }, - { - name: "accessible prompter enabled via GH_ACCESSIBLE_PROMPTER env var = true", - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"}, - accessiblePrompterEnabled: true, - }, - { - name: "accessible prompter disabled via GH_ACCESSIBLE_PROMPTER env var = 0", - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "0"}, - accessiblePrompterEnabled: false, - }, - { - name: "config disabled but env enabled, respects env", - config: disableAccessiblePrompterConfig(), - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "true"}, - accessiblePrompterEnabled: true, - }, - { - name: "config enabled but env disabled, respects env", - config: enableAccessiblePrompterConfig(), - env: map[string]string{"GH_ACCESSIBLE_PROMPTER": "false"}, - accessiblePrompterEnabled: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for k, v := range tt.env { - t.Setenv(k, v) - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.accessiblePrompterEnabled, io.AccessiblePrompterEnabled()) - }) - } -} - -func Test_ioStreams_colorLabels(t *testing.T) { - tests := []struct { - name string - config gh.Config - colorLabelsEnabled bool - env map[string]string - }{ - { - name: "default config", - colorLabelsEnabled: false, - }, - { - name: "config with colorLabels enabled", - config: enableColorLabelsConfig(), - colorLabelsEnabled: true, - }, - { - name: "config with colorLabels disabled", - config: disableColorLabelsConfig(), - colorLabelsEnabled: false, - }, - { - name: "colorLabels enabled via `1` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "1"}, - colorLabelsEnabled: true, - }, - { - name: "colorLabels enabled via `true` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "true"}, - colorLabelsEnabled: true, - }, - { - name: "colorLabels enabled via `yes` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "yes"}, - colorLabelsEnabled: true, - }, - { - name: "colorLabels disable via empty string in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": ""}, - colorLabelsEnabled: false, - }, - { - name: "colorLabels disabled via `0` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "0"}, - colorLabelsEnabled: false, - }, - { - name: "colorLabels disabled via `false` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "false"}, - colorLabelsEnabled: false, - }, - { - name: "colorLabels disabled via `no` in GH_COLOR_LABELS env var", - env: map[string]string{"GH_COLOR_LABELS": "no"}, - colorLabelsEnabled: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.env != nil { - for k, v := range tt.env { - t.Setenv(k, v) - } - } - f := New("1", "") - f.Config = func() (gh.Config, error) { - if tt.config == nil { - return config.NewBlankConfig(), nil - } else { - return tt.config, nil - } - } - io := ioStreams(f) - assert.Equal(t, tt.colorLabelsEnabled, io.ColorLabels()) - }) - } -} - func TestSSOURL(t *testing.T) { tests := []struct { name string @@ -683,13 +351,9 @@ func TestSSOURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1", "") - f.Config = func() (gh.Config, error) { - return config.NewBlankConfig(), nil - } + cfg := config.NewBlankConfig() ios, _, _, stderr := iostreams.Test() - f.IOStreams = ios - client, err := httpClientFunc(f, "v1.2.3", "")() + client, err := HttpClientFunc(cfg.Authentication(), ios, "v1.2.3", "", &telemetry.NoOpService{})() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) if tt.sso != "" { @@ -718,13 +382,8 @@ func TestPlainHttpClient(t *testing.T) { })) defer ts.Close() - f := New("1", "") - f.Config = func() (gh.Config, error) { - return config.NewBlankConfig(), nil - } ios, _, _, _ := iostreams.Test() - f.IOStreams = ios - client, err := plainHttpClientFunc(f, "v1.2.3", "")() + client, err := plainHttpClientFunc(ios, "v1.2.3", "", &telemetry.NoOpService{})() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) @@ -759,7 +418,7 @@ func TestNewGitClient(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1", "") + f := &cmdutil.Factory{} f.Config = func() (gh.Config, error) { if tt.config == nil { return config.NewBlankConfig(), nil @@ -767,7 +426,7 @@ func TestNewGitClient(t *testing.T) { return tt.config, nil } } - f.ExecutableName = tt.executable + f.ExecutablePath = tt.executable ios, _, _, _ := iostreams.Test() f.IOStreams = ios c := newGitClient(f) @@ -784,35 +443,3 @@ func defaultConfig() *ghmock.ConfigMock { cfg.Set("nonsense.com", "oauth_token", "BLAH") return cfg } - -func pagerConfig() gh.Config { - return config.NewFromString("pager: CONFIG_PAGER") -} - -func disablePromptConfig() gh.Config { - return config.NewFromString("prompt: disabled") -} - -func enableAccessiblePrompterConfig() gh.Config { - return config.NewFromString("accessible_prompter: enabled") -} - -func disableAccessiblePrompterConfig() gh.Config { - return config.NewFromString("accessible_prompter: disabled") -} - -func disableSpinnersConfig() gh.Config { - return config.NewFromString("spinner: disabled") -} - -func enableSpinnersConfig() gh.Config { - return config.NewFromString("spinner: enabled") -} - -func disableColorLabelsConfig() gh.Config { - return config.NewFromString("color_labels: disabled") -} - -func enableColorLabelsConfig() gh.Config { - return config.NewFromString("color_labels: enabled") -} diff --git a/pkg/cmd/search/shared/shared_test.go b/pkg/cmd/search/shared/shared_test.go index c66e0908f52..bd8060943c2 100644 --- a/pkg/cmd/search/shared/shared_test.go +++ b/pkg/cmd/search/shared/shared_test.go @@ -2,22 +2,27 @@ package shared import ( "fmt" + "net/http" "testing" "time" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" - "github.com/cli/cli/v2/pkg/cmd/factory" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/search" "github.com/stretchr/testify/assert" ) func TestSearcher(t *testing.T) { - f := factory.New("1", "") - f.Config = func() (gh.Config, error) { - return config.NewBlankConfig(), nil + f := &cmdutil.Factory{ + Config: func() (gh.Config, error) { + return config.NewBlankConfig(), nil + }, + HttpClient: func() (*http.Client, error) { + return &http.Client{}, nil + }, } _, err := Searcher(f) assert.NoError(t, err) diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index e39886ecda9..4bdfdd416b4 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -22,11 +22,11 @@ import ( ) type PreviewOptions struct { - IO *iostreams.IOStreams - HttpClient func() (*http.Client, error) - Prompter prompter.Prompter - Executable func() string - RenderFile func(string, string) string + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Prompter prompter.Prompter + ExecutablePath string + RenderFile func(string, string) string RepoArg string SkillName string @@ -38,10 +38,10 @@ type PreviewOptions struct { // NewCmdPreview creates the "skills preview" command. func NewCmdPreview(f *cmdutil.Factory, runF func(*PreviewOptions) error) *cobra.Command { opts := &PreviewOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - Prompter: f.Prompter, - Executable: f.Executable, + IO: f.IOStreams, + HttpClient: f.HttpClient, + Prompter: f.Prompter, + ExecutablePath: f.ExecutablePath, } opts.RenderFile = func(filePath, content string) string { return renderMarkdownPreview(opts.IO, filePath, content) diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 05511484eae..2542b9d904f 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -47,12 +47,12 @@ var SkillSearchFields = []string{ } type SearchOptions struct { - IO *iostreams.IOStreams - HttpClient func() (*http.Client, error) - Config func() (gh.Config, error) - Prompter prompter.Prompter - Executable string // path to the current gh binary for install subprocess - Exporter cmdutil.Exporter + IO *iostreams.IOStreams + HttpClient func() (*http.Client, error) + Config func() (gh.Config, error) + Prompter prompter.Prompter + ExecutablePath string // path to the current gh binary for install subprocess + Exporter cmdutil.Exporter // User inputs Query string @@ -64,11 +64,11 @@ type SearchOptions struct { // NewCmdSearch creates the "skills search" command. func NewCmdSearch(f *cmdutil.Factory, runF func(*SearchOptions) error) *cobra.Command { opts := &SearchOptions{ - IO: f.IOStreams, - HttpClient: f.HttpClient, - Config: f.Config, - Prompter: f.Prompter, - Executable: f.Executable(), + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + Prompter: f.Prompter, + ExecutablePath: f.ExecutablePath, } cmd := &cobra.Command{ @@ -585,7 +585,7 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { } //nolint:gosec // arguments are from user-selected search results, not arbitrary input - cmd := exec.Command(opts.Executable, "skills", "install", s.Repo, installArg, + cmd := exec.Command(opts.ExecutablePath, "skills", "install", s.Repo, installArg, "--agent", host.ID, "--scope", scope) cmd.Stdin = os.Stdin cmd.Stdout = opts.IO.Out diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index f746ec8978d..9ea8ce4a682 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -2,9 +2,6 @@ package cmdutil import ( "net/http" - "os" - "path/filepath" - "strings" "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" @@ -18,7 +15,7 @@ import ( type Factory struct { AppVersion string - ExecutableName string + ExecutablePath string InvokingAgent string Browser browser.Browser @@ -27,8 +24,11 @@ type Factory struct { IOStreams *iostreams.IOStreams Prompter prompter.Prompter - BaseRepo func() (ghrepo.Interface, error) - Branch func() (string, error) + BaseRepo func() (ghrepo.Interface, error) + Branch func() (string, error) + Cfg gh.Config + // TODO: Config should be removed in favour of cfg being passed to the right place, + // but this is going to be very invasive and shouldn't be done as part of a feature change. Config func() (gh.Config, error) HttpClient func() (*http.Client, error) // PlainHttpClient is a special HTTP client that does not automatically set @@ -37,69 +37,3 @@ type Factory struct { PlainHttpClient func() (*http.Client, error) Remotes func() (context.Remotes, error) } - -// Executable is the path to the currently invoked binary -func (f *Factory) Executable() string { - ghPath := os.Getenv("GH_PATH") - if ghPath != "" { - return ghPath - } - if !strings.ContainsRune(f.ExecutableName, os.PathSeparator) { - f.ExecutableName = executable(f.ExecutableName) - } - return f.ExecutableName -} - -// Finds the location of the executable for the current process as it's found in PATH, respecting symlinks. -// If the process couldn't determine its location, return fallbackName. If the executable wasn't found in -// PATH, return the absolute location to the program. -// -// The idea is that the result of this function is callable in the future and refers to the same -// installation of gh, even across upgrades. This is needed primarily for Homebrew, which installs software -// under a location such as `/usr/local/Cellar/gh/1.13.1/bin/gh` and symlinks it from `/usr/local/bin/gh`. -// When the version is upgraded, Homebrew will often delete older versions, but keep the symlink. Because of -// this, we want to refer to the `gh` binary as `/usr/local/bin/gh` and not as its internal Homebrew -// location. -// -// None of this would be needed if we could just refer to GitHub CLI as `gh`, i.e. without using an absolute -// path. However, for some reason Homebrew does not include `/usr/local/bin` in PATH when it invokes git -// commands to update its taps. If `gh` (no path) is being used as git credential helper, as set up by `gh -// auth login`, running `brew update` will print out authentication errors as git is unable to locate -// Homebrew-installed `gh`. -func executable(fallbackName string) string { - exe, err := os.Executable() - if err != nil { - return fallbackName - } - - base := filepath.Base(exe) - path := os.Getenv("PATH") - for _, dir := range filepath.SplitList(path) { - p, err := filepath.Abs(filepath.Join(dir, base)) - if err != nil { - continue - } - f, err := os.Lstat(p) - if err != nil { - continue - } - - if p == exe { - return p - } else if f.Mode()&os.ModeSymlink != 0 { - realP, err := filepath.EvalSymlinks(p) - if err != nil { - continue - } - realExe, err := filepath.EvalSymlinks(exe) - if err != nil { - continue - } - if realP == realExe { - return p - } - } - } - - return exe -} diff --git a/pkg/cmdutil/repo_override.go b/pkg/cmdutil/repo_override.go index 791dd919a21..b037859e737 100644 --- a/pkg/cmdutil/repo_override.go +++ b/pkg/cmdutil/repo_override.go @@ -52,12 +52,12 @@ func EnableRepoOverride(cmd *cobra.Command, f *Factory) { return err } repoOverride, _ := cmd.Flags().GetString("repo") - f.BaseRepo = OverrideBaseRepoFunc(f, repoOverride) + f.BaseRepo = OverrideBaseRepoFunc(f.BaseRepo, repoOverride) return nil } } -func OverrideBaseRepoFunc(f *Factory, override string) func() (ghrepo.Interface, error) { +func OverrideBaseRepoFunc(baseRepoFunc func() (ghrepo.Interface, error), override string) func() (ghrepo.Interface, error) { if override == "" { override = os.Getenv("GH_REPO") } @@ -66,5 +66,5 @@ func OverrideBaseRepoFunc(f *Factory, override string) func() (ghrepo.Interface, return ghrepo.FromFullName(override) } } - return f.BaseRepo + return baseRepoFunc } From 17776cafc17df463501c7aeb030584d57da6650a Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 17 Apr 2026 11:45:44 +0200 Subject: [PATCH 050/182] Apply review feedback - Harden SpawnSendTelemetry against relative executable paths - Use io.Copy for telemetry subprocess stdin write - Clean up GH_TELEMETRY/DO_NOT_TRACK help text - Fall back to built-in defaults (NoOp telemetry) on config load failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/ghcmd/cmd.go | 35 ++++++++++++------- internal/telemetry/telemetry.go | 15 ++++++-- .../verify/verify_integration_test.go | 8 ++--- pkg/cmd/factory/default.go | 21 +++++------ pkg/cmd/factory/default_test.go | 2 +- pkg/cmd/root/help_topic.go | 8 ++--- pkg/cmdutil/factory.go | 10 ++++-- 7 files changed, 63 insertions(+), 36 deletions(-) diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index eab842c5a79..7350437dfb4 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -52,13 +52,18 @@ func Main() exitCode { buildVersion := build.Version hasDebug, _ := utils.IsDebugEnabled() - cfg, err := config.NewConfig() - if err != nil { - fmt.Fprintf(os.Stderr, "failed to load config: %s\n", err) - return exitError + cfg, cfgErr := config.NewConfig() + if cfgErr != nil { + fmt.Fprintf(os.Stderr, "warning: failed to load config: %s\n", cfgErr) } + cfgFunc := func() (gh.Config, error) { return cfg, cfgErr } - ioStreams := newIOStreams(cfg) + var ioStreams *iostreams.IOStreams + if cfgErr == nil { + ioStreams = newIOStreams(cfg) + } else { + ioStreams = iostreams.System() + } stderr := ioStreams.ErrOut ghExecutablePath := executablePath("gh") @@ -70,9 +75,13 @@ func Main() exitCode { } var telemetryService ghtelemetry.Service - if os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" || mightBeGHESUser(cfg) { + switch { + case cfgErr != nil: + // Without a valid on-disk config we can't honour user telemetry preferences, so disable it to be safe. telemetryService = &telemetry.NoOpService{} - } else { + case os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" || mightBeGHESUser(cfg): + telemetryService = &telemetry.NoOpService{} + default: telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value) switch telemetryState { case telemetry.Disabled: @@ -100,12 +109,14 @@ func Main() exitCode { } defer telemetryService.Flush() - cmdFactory := factory.New(buildVersion, string(agents.Detect()), cfg, ioStreams, ghExecutablePath, telemetryService) + cmdFactory := factory.New(buildVersion, string(agents.Detect()), cfgFunc, ioStreams, ghExecutablePath, telemetryService) - var m migration.MultiAccount - if err := cfg.Migrate(m); err != nil { - fmt.Fprintln(stderr, err) - return exitError + if cfgErr == nil { + var m migration.MultiAccount + if err := cfg.Migrate(m); err != nil { + fmt.Fprintln(stderr, err) + return exitError + } } ctx := context.Background() diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index b046ec77d84..67fb8b7626e 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -354,6 +354,13 @@ func SpawnSendTelemetry(executable string, payload SendTelemetryPayload) { return } + // Resolve the executable to an absolute path before changing the child's + // working directory. Without this, a relative path (e.g. from GH_PATH) would + // be resolved against cmd.Dir at Start time and fail to spawn. + if abs, err := filepath.Abs(executable); err == nil { + executable = abs + } + cmd := exec.Command(executable, "send-telemetry") cmd.Stdout = io.Discard @@ -362,7 +369,10 @@ func SpawnSendTelemetry(executable string, payload SendTelemetryPayload) { // Set the working directory to a stable directory elsewhere so that the subprocess doesn't // hold a reference to the parent's current working directory, avoiding any weirdness around // deleting the parent process's current working directory while the child is still running. - cmd.Dir = os.TempDir() + // Only do this when we have an absolute executable path so that the child can still be found. + if filepath.IsAbs(executable) { + cmd.Dir = os.TempDir() + } // Configure the child process to be detached from the parent so that it can continue running // after the parent exits, and so that it doesn't receive any signals sent to the parent. @@ -381,7 +391,8 @@ func SpawnSendTelemetry(executable string, payload SendTelemetryPayload) { // Write the payload synchronously into the kernel pipe buffer, then close // the pipe to signal EOF. The child reads the complete payload from stdin. - _, _ = stdin.Write(payloadBytes) + // io.Copy loops until all bytes are written, avoiding any risk of a short write. + _, _ = io.Copy(stdin, bytes.NewReader(payloadBytes)) _ = stdin.Close() // Release resources associated with the child process since we will never Wait for it. diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index 10a1e521657..b9994141313 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -30,7 +30,7 @@ func TestVerifyIntegration(t *testing.T) { ios, _, _, _ := iostreams.Test() hc, err := factory.HttpClientFunc( - &config.AuthConfig{}, + func() (gh.Config, error) { return config.NewBlankConfig(), nil }, ios, "test", "", @@ -150,7 +150,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { ios, _, _, _ := iostreams.Test() hc, err := factory.HttpClientFunc( - &config.AuthConfig{}, + func() (gh.Config, error) { return config.NewBlankConfig(), nil }, ios, "test", "", @@ -228,7 +228,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { cfg := config.NewBlankConfig() ios, _, _, _ := iostreams.Test() hc, err := factory.HttpClientFunc( - cfg.Authentication(), + func() (gh.Config, error) { return cfg, nil }, ios, "test", "", @@ -325,7 +325,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { cfg := config.NewBlankConfig() ios, _, _, _ := iostreams.Test() hc, err := factory.HttpClientFunc( - cfg.Authentication(), + func() (gh.Config, error) { return cfg, nil }, ios, "test", "", diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index bf203bd4314..f61e51b452f 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -23,19 +23,16 @@ import ( var ssoHeader string var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`) -func New(appVersion string, invokingAgent string, cfg gh.Config, ios *iostreams.IOStreams, executablePath string, telemetryDisabler ghtelemetry.Disabler) *cmdutil.Factory { +func New(appVersion string, invokingAgent string, cfgFunc func() (gh.Config, error), ios *iostreams.IOStreams, executablePath string, telemetryDisabler ghtelemetry.Disabler) *cmdutil.Factory { f := &cmdutil.Factory{ - AppVersion: appVersion, - InvokingAgent: invokingAgent, - Cfg: cfg, - Config: func() (gh.Config, error) { - return cfg, nil - }, // No factory dependencies + AppVersion: appVersion, + InvokingAgent: invokingAgent, + Config: cfgFunc, ExecutablePath: executablePath, } f.IOStreams = ios - f.HttpClient = HttpClientFunc(cfg.Authentication(), ios, appVersion, invokingAgent, telemetryDisabler) + f.HttpClient = HttpClientFunc(cfgFunc, ios, appVersion, invokingAgent, telemetryDisabler) f.PlainHttpClient = plainHttpClientFunc(ios, appVersion, invokingAgent, telemetryDisabler) f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable f.Remotes = remotesFunc(f) // Depends on Config, and GitClient @@ -187,10 +184,14 @@ func remotesFunc(f *cmdutil.Factory) func() (ghContext.Remotes, error) { return rr.Resolver() } -func HttpClientFunc(authCfg gh.AuthConfig, ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler) func() (*http.Client, error) { +func HttpClientFunc(cfgFunc func() (gh.Config, error), ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler) func() (*http.Client, error) { return func() (*http.Client, error) { + cfg, err := cfgFunc() + if err != nil { + return nil, err + } opts := api.HTTPClientOptions{ - Config: authCfg, + Config: cfg.Authentication(), Log: ios.ErrOut, LogColorize: ios.ColorEnabled(), AppVersion: appVersion, diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index 6f376e48c88..9cf34f3b0e5 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -353,7 +353,7 @@ func TestSSOURL(t *testing.T) { t.Run(tt.name, func(t *testing.T) { cfg := config.NewBlankConfig() ios, _, _, stderr := iostreams.Test() - client, err := HttpClientFunc(cfg.Authentication(), ios, "v1.2.3", "", &telemetry.NoOpService{})() + client, err := HttpClientFunc(func() (gh.Config, error) { return cfg, nil }, ios, "v1.2.3", "", &telemetry.NoOpService{})() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) if tt.sso != "" { diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index fbacef356cc..3002a351294 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -118,10 +118,10 @@ var HelpTopics = []helpTopic{ more compatible with speech synthesis and braille screen readers. %[1]sGH_TELEMETRY%[1]s: set to %[1]slog%[1]s to print telemetry data to standard error instead of sending it. - Set to %[1]sfalse%[1]s or %[1]s0%[1]s to disable telemetry that would have been printed when set to %[1]slog%[1]s. - - %[1]sDO_NOT_TRACK%[1]s: set to %[1]strue%[1]s or %[1]s1%[1]s to disable telemetry that would have been printed - when %[1]sGH_TELEMETRY%[1]s is set to %[1]slog%[1]s. %[1]sGH_TELEMETRY%[1]s takes precedence if both are set. + Set to %[1]sfalse%[1]s or %[1]s0%[1]s to disable telemetry. Takes precedence over %[1]sDO_NOT_TRACK%[1]s. + + %[1]sDO_NOT_TRACK%[1]s: set to %[1]strue%[1]s or %[1]s1%[1]s to disable telemetry. Ignored when + %[1]sGH_TELEMETRY%[1]s is set, which takes precedence. %[1]sGH_SPINNER_DISABLED%[1]s: set to a truthy value to replace the spinner animation with a textual progress indicator. diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index 9ea8ce4a682..1faf859f00c 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -26,9 +26,13 @@ type Factory struct { BaseRepo func() (ghrepo.Interface, error) Branch func() (string, error) - Cfg gh.Config - // TODO: Config should be removed in favour of cfg being passed to the right place, - // but this is going to be very invasive and shouldn't be done as part of a feature change. + // It would be nice if Config were just loaded once at startup and an error + // were returned, but this would prevent commands like "gh version" from running. + // So for now, we eagerly load the config and don't fail if there is an error, + // and defer the error handling to commands that need it. + // HOWEVER, as an additional point, the root command setup currently DOES call + // this and errors, so we never get to "gh version" anyway. + // We need to revisit that, but I don't want to make it worse. Config func() (gh.Config, error) HttpClient func() (*http.Client, error) // PlainHttpClient is a special HTTP client that does not automatically set From 6709e315df8fac55140e7e281cd6e20262cff906 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 16 Apr 2026 22:12:01 +0200 Subject: [PATCH 051/182] Do not send telemetry for aliases --- .../telemetry/no-telemetry-for-alias.txtar | 18 +++++++++++++ pkg/cmd/root/alias.go | 27 ++++++++++++------- 2 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 acceptance/testdata/telemetry/no-telemetry-for-alias.txtar diff --git a/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar b/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar new file mode 100644 index 00000000000..733bea11f5c --- /dev/null +++ b/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar @@ -0,0 +1,18 @@ +# Aliases should not leak their user-defined names via telemetry, but the +# resolved inner command should still record normally — its path is a core +# gh command and conveys no user-authored identifier. + +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +# Create a regular (non-shell) alias that resolves to an existing command. +exec gh alias set secret-project-alias version + +# Invoking the alias must not produce any event carrying the alias name. +exec gh secret-project-alias +! stderr 'secret-project-alias' + +# The resolved inner command still records telemetry as normal. +stderr 'Telemetry payload:' +stderr '"command": "gh version"' diff --git a/pkg/cmd/root/alias.go b/pkg/cmd/root/alias.go index 4f504f2b8c4..ea4c21d8a97 100644 --- a/pkg/cmd/root/alias.go +++ b/pkg/cmd/root/alias.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/findsh" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" @@ -17,7 +18,7 @@ import ( ) func NewCmdShellAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: aliasName, Short: fmt.Sprintf("Shell alias for %q", text.Truncate(80, aliasValue)), RunE: func(c *cobra.Command, args []string) error { @@ -39,16 +40,19 @@ func NewCmdShellAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *co } return nil }, - GroupID: "alias", - Annotations: map[string]string{ - "skipAuthCheck": "true", - }, + GroupID: "alias", DisableFlagParsing: true, } + cmdutil.DisableAuthCheck(cmd) + // Aliases are user-defined names and must not be reported as telemetry + // dimensions, since the name itself may be sensitive (e.g. project or + // organization names). + cmdutil.DisableTelemetry(cmd) + return cmd } func NewCmdAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.Command { - return &cobra.Command{ + cmd := &cobra.Command{ Use: aliasName, Short: fmt.Sprintf("Alias for %q", text.Truncate(80, aliasValue)), RunE: func(c *cobra.Command, args []string) error { @@ -60,12 +64,15 @@ func NewCmdAlias(io *iostreams.IOStreams, aliasName, aliasValue string) *cobra.C root.SetArgs(expandedArgs) return root.Execute() }, - GroupID: "alias", - Annotations: map[string]string{ - "skipAuthCheck": "true", - }, + GroupID: "alias", DisableFlagParsing: true, } + cmdutil.DisableAuthCheck(cmd) + // Aliases are user-defined names and must not be reported as telemetry + // dimensions, since the name itself may be sensitive (e.g. project or + // organization names). + cmdutil.DisableTelemetry(cmd) + return cmd } // ExpandAlias processes argv to see if it should be rewritten according to a user's aliases. From fd28f058aac2f590ce235a9c50026521f9062bf7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:30:09 +0000 Subject: [PATCH 052/182] chore(deps): bump github.com/google/go-containerregistry Bumps [github.com/google/go-containerregistry](https://github.com/google/go-containerregistry) from 0.21.4 to 0.21.5. - [Release notes](https://github.com/google/go-containerregistry/releases) - [Commits](https://github.com/google/go-containerregistry/compare/v0.21.4...v0.21.5) --- updated-dependencies: - dependency-name: github.com/google/go-containerregistry dependency-version: 0.21.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 78f0185df8a..a84b94bb00b 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/gabriel-vasile/mimetype v1.4.13 github.com/gdamore/tcell/v2 v2.13.8 github.com/google/go-cmp v0.7.0 - github.com/google/go-containerregistry v0.21.4 + github.com/google/go-containerregistry v0.21.5 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 @@ -100,7 +100,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/docker/cli v29.3.1+incompatible // indirect + github.com/docker/cli v29.4.0+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.18.0 // indirect @@ -182,9 +182,9 @@ require ( go.opentelemetry.io/otel/metric v1.42.0 // indirect go.opentelemetry.io/otel/trace v1.42.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.34.0 // indirect + golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect gotest.tools/v3 v3.5.2 // indirect diff --git a/go.sum b/go.sum index 681ee5b463c..907a27a7109 100644 --- a/go.sum +++ b/go.sum @@ -189,8 +189,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/cli v29.3.1+incompatible h1:M04FDj2TRehDacrosh7Vlkgc7AuQoWloQkf1PA5hmoI= -github.com/docker/cli v29.3.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM= +github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -279,8 +279,8 @@ github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCY github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.4 h1:VrhlIQtdhE6riZW//MjPrcJ1snAjPoCCpPHqGOygrv8= -github.com/google/go-containerregistry v0.21.4/go.mod h1:kxgc23zQ2qMY/hAKt0wCbB/7tkeovAP2mE2ienynJUw= +github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM= +github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= @@ -579,8 +579,8 @@ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/y golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -624,8 +624,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= From 998b6212b38f653f997c79fc42e6f13197008581 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 17 Apr 2026 19:58:59 +0200 Subject: [PATCH 053/182] Add skills specific telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add skills specific telemetry * Remove VisibilityFuture, inline goroutine at call sites The VisibilityFuture/FetchRepoVisibilityAsync/Wait wrapper was an unidiomatic async abstraction built for a single pattern used in exactly two call sites. In Go the channel is already the future; wrapping it in a struct with a Wait(timeout) method adds no value. Delete the abstraction and inline a local visResult struct, buffered channel, goroutine, and select at each call site. Behavior is preserved exactly: err -> "unknown", timeout -> "unknown", success+public -> include skill_names. FetchRepoVisibility (synchronous) is kept as-is. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix nonsense copilot tests * Update telemetry tests for public-only dims and search event removal Production telemetry emission changed: - preview: skill_owner/skill_repo/skill_name (renamed from skill_names) are now emitted only when repo_visibility=public. - install: skill_owner/skill_repo/skill_names are now emitted only when repo_visibility=public. - search: the initial skill_search event was removed entirely; the skill_search_install event no longer carries query/owner dims. Update tests to match: rename skill_names -> skill_name in preview, make owner/repo assertions conditional on public visibility in both preview and install, and reduce the search test to a single event with explicit Empty assertions for the removed query/owner dims so a privacy regression cannot pass silently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Test CategorizeHost and switch telemetry to skill_host_type Add TestCategorizeHost covering all four classification branches (github.com, ghes, tenancy, uncategorized) with cases verified against the real ghauth implementation rather than guessed. Update install and preview unit tests to assert the new skill_host_type dimension name, and fix a typo in the preview acceptance txtar (skill_hos_type -> skill_host_type). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Shrink visibility wait and test unknown visibility The 2s visibilityWaitTimeout was wildly overprovisioned: by the time telemetry emission reaches the select, the command has already done several serial GitHub REST calls (and for install, a git sparse-checkout plus possibly interactive prompts), so the one-call visibility fetch has almost always completed. Drop the timeout to 200ms — a short safety net for a stalled REST call, not a wait budget for a healthy one. Also adds a table-driven case to TestFetchRepoVisibility covering an unknown/future visibility value from the API, addressing @babakks' review nitpick. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../testdata/skills/skills-install.txtar | 13 + .../testdata/skills/skills-preview.txtar | 12 + .../skills/skills-publish-dry-run.txtar | 4 - .../testdata/skills/skills-search-page.txtar | 2 +- .../testdata/skills/skills-search.txtar | 4 +- internal/ghinstance/host.go | 16 ++ internal/ghinstance/host_test.go | 54 +++++ internal/skills/discovery/discovery.go | 69 ++++-- internal/skills/discovery/discovery_test.go | 80 +++++++ internal/telemetry/fake.go | 20 ++ pkg/cmd/root/root.go | 2 +- pkg/cmd/skills/install/install.go | 68 +++++- pkg/cmd/skills/install/install_test.go | 188 ++++++++++++++- pkg/cmd/skills/preview/preview.go | 67 +++++- pkg/cmd/skills/preview/preview_test.go | 223 +++++++++++++++++- pkg/cmd/skills/search/search.go | 12 +- pkg/cmd/skills/search/search_test.go | 77 +++++- pkg/cmd/skills/skills.go | 13 +- pkg/cmd/skills/skills_test.go | 19 ++ 19 files changed, 895 insertions(+), 48 deletions(-) create mode 100644 pkg/cmd/skills/skills_test.go diff --git a/acceptance/testdata/skills/skills-install.txtar b/acceptance/testdata/skills/skills-install.txtar index c365cb83389..0311a0db280 100644 --- a/acceptance/testdata/skills/skills-install.txtar +++ b/acceptance/testdata/skills/skills-install.txtar @@ -18,3 +18,16 @@ stdout 'Installed git-commit' # Verify the skill was written to the custom directory exists $WORK/custom-skills/git-commit/SKILL.md grep 'github-repo' $WORK/custom-skills/git-commit/SKILL.md + +# Telemetry: skill_install event records agent hosts, repo identifiers, +# and (for a public repo) the installed skill name. +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 +exec gh skill install github/awesome-copilot git-commit --scope user --force --agent github-copilot +stderr 'Telemetry payload:' +stderr '"type": "skill_install"' +stderr '"agent_hosts": "github-copilot"' +stderr '"skill_host_type": "github.com"' +stderr '"skill_owner": "github"' +stderr '"skill_repo": "awesome-copilot"' diff --git a/acceptance/testdata/skills/skills-preview.txtar b/acceptance/testdata/skills/skills-preview.txtar index be1be5244d7..af1d0bbbe2c 100644 --- a/acceptance/testdata/skills/skills-preview.txtar +++ b/acceptance/testdata/skills/skills-preview.txtar @@ -7,3 +7,15 @@ stdout 'git-commit/' # Preview a skill that doesn't exist should error ! exec gh skill preview github/awesome-copilot nonexistent-skill-xyz stderr 'not found' + +# Telemetry: skill_preview event records repo identifiers and, for a +# public repo, the skill name. +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 +exec gh skill preview github/awesome-copilot git-commit +stderr 'Telemetry payload:' +stderr '"type": "skill_preview"' +stderr '"skill_host_type": "github.com"' +stderr '"skill_owner": "github"' +stderr '"skill_repo": "awesome-copilot"' diff --git a/acceptance/testdata/skills/skills-publish-dry-run.txtar b/acceptance/testdata/skills/skills-publish-dry-run.txtar index cb32fa7e26e..786204951e1 100644 --- a/acceptance/testdata/skills/skills-publish-dry-run.txtar +++ b/acceptance/testdata/skills/skills-publish-dry-run.txtar @@ -6,10 +6,6 @@ stderr 'no skills found in' exec gh skill publish --dry-run $WORK/test-repo stdout 'hello-world' -# Validate alias should work identically -exec gh skill validate --dry-run $WORK/test-repo -stdout 'hello-world' - # Publish dry-run with --tag exec gh skill publish --dry-run --tag v1.0.0 $WORK/test-repo stdout 'hello-world' diff --git a/acceptance/testdata/skills/skills-search-page.txtar b/acceptance/testdata/skills/skills-search-page.txtar index 30c044f78cf..48409c2354d 100644 --- a/acceptance/testdata/skills/skills-search-page.txtar +++ b/acceptance/testdata/skills/skills-search-page.txtar @@ -1,3 +1,3 @@ # Pagination returns results on page 2 -exec gh skill search copilot --page 2 +exec gh skill search --owner github copilot --page 2 stdout 'copilot' diff --git a/acceptance/testdata/skills/skills-search.txtar b/acceptance/testdata/skills/skills-search.txtar index 5e8c7744208..e16936b0d1b 100644 --- a/acceptance/testdata/skills/skills-search.txtar +++ b/acceptance/testdata/skills/skills-search.txtar @@ -1,5 +1,5 @@ # Search for skills matching a query -exec gh skill search copilot +exec gh skill search --owner github copilot stdout 'copilot' # Search with JSON output @@ -9,4 +9,4 @@ stdout '"repo"' # Search with a short query should error ! exec gh skill search a -stderr 'at least' +stderr 'at least' \ No newline at end of file diff --git a/internal/ghinstance/host.go b/internal/ghinstance/host.go index 7abfd83acaf..dfea6dd946b 100644 --- a/internal/ghinstance/host.go +++ b/internal/ghinstance/host.go @@ -96,3 +96,19 @@ func HostPrefix(hostname string) string { } return fmt.Sprintf("https://%s/", hostname) } + +func CategorizeHost(host string) string { + if host == defaultHostname { + return "github.com" + } + + if ghauth.IsEnterprise(host) { + return "ghes" + } + + if ghauth.IsTenancy(host) { + return "tenancy" + } + + return "uncategorized" +} diff --git a/internal/ghinstance/host_test.go b/internal/ghinstance/host_test.go index 1b7e0146d04..c4f447780a9 100644 --- a/internal/ghinstance/host_test.go +++ b/internal/ghinstance/host_test.go @@ -157,3 +157,57 @@ func TestRESTPrefix(t *testing.T) { }) } } + +func TestCategorizeHost(t *testing.T) { + tests := []struct { + name string + host string + want string + }{ + { + name: "github.com returns github.com", + host: "github.com", + want: "github.com", + }, + { + name: "classic GHES hostname returns ghes", + host: "ghe.io", + want: "ghes", + }, + { + name: "arbitrary enterprise hostname returns ghes", + host: "enterprise.example.com", + want: "ghes", + }, + { + name: "tenant subdomain of ghe.com returns tenancy", + host: "tenant.ghe.com", + want: "tenancy", + }, + { + name: "api subdomain under tenant returns tenancy", + host: "api.tenant.ghe.com", + want: "tenancy", + }, + { + name: "bare ghe.com returns ghes", + host: "ghe.com", + want: "ghes", + }, + { + name: "github.localhost returns uncategorized", + host: "github.localhost", + want: "uncategorized", + }, + { + name: "github.com subdomain returns uncategorized", + host: "garage.github.com", + want: "uncategorized", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, CategorizeHost(tt.host)) + }) + } +} diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 93de67faac5..2d6c1ee7256 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -126,18 +126,37 @@ type treeResponse struct { Truncated bool `json:"truncated"` } -type blobResponse struct { - SHA string `json:"sha"` - Content string `json:"content"` - Encoding string `json:"encoding"` -} +type RepoVisibility string + +const ( + RepoVisibilityPublic RepoVisibility = "public" + RepoVisibilityPrivate RepoVisibility = "private" + RepoVisibilityInternal RepoVisibility = "internal" +) -type releaseResponse struct { - TagName string `json:"tag_name"` +func parseRepoVisibility(s string) (RepoVisibility, error) { + switch s { + case "public": + return RepoVisibilityPublic, nil + case "private": + return RepoVisibilityPrivate, nil + case "internal": + return RepoVisibilityInternal, nil + default: + return "", fmt.Errorf("unknown repository visibility: %q", s) + } } -type repoResponse struct { - DefaultBranch string `json:"default_branch"` +// FetchRepoVisibility returns the repository visibility: "public", "private", or "internal". +func FetchRepoVisibility(client *api.Client, host, owner, repo string) (RepoVisibility, error) { + apiPath := fmt.Sprintf("repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo)) + var resp struct { + Visibility string `json:"visibility"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { + return "", err + } + return parseRepoVisibility(resp.Visibility) } // ResolveRef determines the git ref to use for a given owner/repo. @@ -266,8 +285,10 @@ func (e *noReleasesError) Error() string { return e.reason } func resolveLatestRelease(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { apiPath := fmt.Sprintf("repos/%s/%s/releases/latest", url.PathEscape(owner), url.PathEscape(repo)) - var release releaseResponse - if err := client.REST(host, "GET", apiPath, nil, &release); err != nil { + var resp struct { + TagName string `json:"tag_name"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { // A 404 means the repository has no releases. This is the // only case where falling back to the default branch is safe. // Any other HTTP error (403, 500, …) or network failure is @@ -278,19 +299,21 @@ func resolveLatestRelease(client *api.Client, host, owner, repo string) (*Resolv } return nil, fmt.Errorf("could not fetch latest release: %w", err) } - if release.TagName == "" { + if resp.TagName == "" { return nil, &noReleasesError{reason: "latest release has no tag"} } - return resolveTagRef(client, host, owner, repo, release.TagName) + return resolveTagRef(client, host, owner, repo, resp.TagName) } func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { apiPath := fmt.Sprintf("repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo)) - var repoResp repoResponse - if err := client.REST(host, "GET", apiPath, nil, &repoResp); err != nil { + var resp struct { + DefaultBranch string `json:"default_branch"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { return nil, fmt.Errorf("could not determine default branch: %w", err) } - branch := repoResp.DefaultBranch + branch := resp.DefaultBranch if branch == "" { return nil, fmt.Errorf("could not determine default branch for %s/%s", owner, repo) } @@ -657,18 +680,22 @@ func walkTree(client *api.Client, host, owner, repo, sha, prefix string, depth i // FetchBlob retrieves the content of a blob by SHA. func FetchBlob(client *api.Client, host, owner, repo, sha string) (string, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/blobs/%s", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) - var blob blobResponse - if err := client.REST(host, "GET", apiPath, nil, &blob); err != nil { + var resp struct { + SHA string `json:"sha"` + Content string `json:"content"` + Encoding string `json:"encoding"` + } + if err := client.REST(host, "GET", apiPath, nil, &resp); err != nil { return "", fmt.Errorf("could not fetch blob: %w", err) } - if blob.Encoding != "base64" { - return "", fmt.Errorf("unexpected blob encoding: %s", blob.Encoding) + if resp.Encoding != "base64" { + return "", fmt.Errorf("unexpected blob encoding: %s", resp.Encoding) } // GitHub API returns base64 with embedded newlines; use the StdEncoding // decoder via a reader to handle them transparently. - decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(blob.Content))) + decoded, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(resp.Content))) if err != nil { return "", fmt.Errorf("could not decode blob content: %w", err) } diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index f9abc593cf8..8929e17871b 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -561,6 +561,86 @@ func TestFetchBlob(t *testing.T) { } } +func TestFetchRepoVisibility(t *testing.T) { + tests := []struct { + name string + stubs func(*httpmock.Registry) + want RepoVisibility + wantErr string + }{ + { + name: "public repo", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "public", + })) + }, + want: RepoVisibilityPublic, + }, + { + name: "private repo", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "private", + })) + }, + want: RepoVisibilityPrivate, + }, + { + name: "internal repo", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "internal", + })) + }, + want: RepoVisibilityInternal, + }, + { + name: "unknown visibility", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "cool-visibility", + })) + }, + wantErr: `unknown repository visibility: "cool-visibility"`, + }, + { + name: "API error", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.StatusStringResponse(500, "server error")) + }, + wantErr: "HTTP 500", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + got, err := FetchRepoVisibility(client, "github.com", "monalisa", "octocat-skills") + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + func TestDiscoverSkills(t *testing.T) { tests := []struct { name string diff --git a/internal/telemetry/fake.go b/internal/telemetry/fake.go index 1dc45ab2692..4eb22e898a5 100644 --- a/internal/telemetry/fake.go +++ b/internal/telemetry/fake.go @@ -13,3 +13,23 @@ func (r *EventRecorderSpy) Record(event ghtelemetry.Event) { func (r *EventRecorderSpy) Disable() {} func (r *EventRecorderSpy) Flush() {} + +// CommandRecorderSpy is a test double for ghtelemetry.CommandRecorder. +// It captures recorded events and the most recent SetSampleRate call so tests can +// assert on the sampling behavior commands attempt to configure. +type CommandRecorderSpy struct { + Events []ghtelemetry.Event + LastSampleRate int +} + +func (r *CommandRecorderSpy) Record(event ghtelemetry.Event) { + r.Events = append(r.Events, event) +} + +func (r *CommandRecorderSpy) Disable() {} + +func (r *CommandRecorderSpy) SetSampleRate(rate int) { + r.LastSampleRate = rate +} + +func (r *CommandRecorderSpy) Flush() {} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 7df9d2986ea..9f4fa6f5bdc 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -149,7 +149,7 @@ func NewCmdRoot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, versi cmd.AddCommand(codespaceCmd.NewCmdCodespace(f)) cmd.AddCommand(projectCmd.NewCmdProject(f)) cmd.AddCommand(previewCmd.NewCmdPreview(f)) - cmd.AddCommand(skillsCmd.NewCmdSkills(f)) + cmd.AddCommand(skillsCmd.NewCmdSkills(f, telemetry)) // Root commands with standalone functionality and no subcommands cmd.AddCommand(copilotCmd.NewCmdCopilot(f, nil)) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 1b6a7fd8fb4..d4a05440e7b 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -8,11 +8,14 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" @@ -38,6 +41,7 @@ const ( // InstallOptions holds all dependencies and user-provided flags for the install command. type InstallOptions struct { IO *iostreams.IOStreams + Telemetry ghtelemetry.EventRecorder HttpClient func() (*http.Client, error) Prompter prompter.Prompter GitClient *git.Client @@ -59,9 +63,10 @@ type InstallOptions struct { } // NewCmdInstall creates the "skills install" command. -func NewCmdInstall(f *cmdutil.Factory, runF func(*InstallOptions) error) *cobra.Command { +func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*InstallOptions) error) *cobra.Command { opts := &InstallOptions{ IO: f.IOStreams, + Telemetry: telemetry, Prompter: f.Prompter, GitClient: f.GitClient, Remotes: f.Remotes, @@ -232,6 +237,19 @@ func installRun(opts *InstallOptions) error { return err } + // Kick off the visibility fetch in parallel with the install work so + // the extra API roundtrip doesn't add latency on the critical path. + // The result is consumed when the telemetry event is emitted below. + type visResult struct { + vis discovery.RepoVisibility + err error + } + visCh := make(chan visResult, 1) + go func() { + vis, err := discovery.FetchRepoVisibility(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName()) + visCh <- visResult{vis: vis, err: err} + }() + resolved, err := resolveVersion(opts, apiClient, hostname) if err != nil { return err @@ -325,9 +343,57 @@ func installRun(opts *InstallOptions) error { } } + dims := map[string]string{ + "agent_hosts": mapAgentHostsToIDs(selectedHosts), + "skill_host_type": ghinstance.CategorizeHost(opts.repo.RepoHost()), + } + select { + case r := <-visCh: + if r.err == nil { + dims["repo_visibility"] = string(r.vis) + if r.vis == discovery.RepoVisibilityPublic { + dims["skill_owner"] = opts.repo.RepoOwner() + dims["skill_repo"] = opts.repo.RepoName() + dims["skill_names"] = mapSkillsToNames(selectedSkills) + } + } else { + dims["repo_visibility"] = "unknown" + } + case <-time.After(visibilityWaitTimeout): + dims["repo_visibility"] = "unknown" + } + opts.Telemetry.Record(ghtelemetry.Event{ + Type: "skill_install", + Dimensions: dims, + }) + return nil } +// visibilityWaitTimeout is how long to wait at telemetry-emit time for +// the in-flight repo visibility fetch before giving up and emitting +// repo_visibility="unknown". By this point the command has already done +// several serial API calls and (for install) a git sparse-checkout, so +// the fetch has almost always completed; this budget is a short safety +// net for the case where that single REST call has stalled. +const visibilityWaitTimeout = 200 * time.Millisecond + +func mapSkillsToNames(skills []discovery.Skill) string { + names := make([]string, len(skills)) + for i, s := range skills { + names[i] = s.DisplayName() + } + return strings.Join(names, ",") +} + +func mapAgentHostsToIDs(hosts []*registry.AgentHost) string { + agentHostIDs := make([]string, len(hosts)) + for i, h := range hosts { + agentHostIDs[i] = h.ID + } + return strings.Join(agentHostIDs, ",") +} + // runLocalInstall handles installation from a local directory path. func runLocalInstall(opts *InstallOptions) error { cs := opts.IO.ColorScheme() diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 4812275247f..0560625f7fc 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -16,6 +16,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -128,7 +129,7 @@ func TestNewCmdInstall(t *testing.T) { } var gotOpts *InstallOptions - cmd := NewCmdInstall(f, func(opts *InstallOptions) error { + cmd := NewCmdInstall(f, &telemetry.NoOpService{}, func(opts *InstallOptions) error { gotOpts = opts return nil }) @@ -167,7 +168,7 @@ func TestNewCmdInstall(t *testing.T) { t.Run("command metadata", func(t *testing.T) { ios, _, _, _ := iostreams.Test() f := &cmdutil.Factory{IOStreams: ios, Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{}} - cmd := NewCmdInstall(f, nil) + cmd := NewCmdInstall(f, &telemetry.NoOpService{}, nil) assert.Equal(t, "install [] [flags]", cmd.Use) assert.NotEmpty(t, cmd.Short) @@ -1348,6 +1349,9 @@ func TestInstallRun(t *testing.T) { ios.SetStdinTTY(tt.isTTY) ios.SetStderrTTY(tt.isTTY) opts := tt.opts(ios, reg) + if opts.Telemetry == nil { + opts.Telemetry = &telemetry.NoOpService{} + } err := installRun(opts) @@ -1414,6 +1418,7 @@ func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) { SkillSource: "monalisa/octocat-skills", SkillName: "git-commit", Force: true, + Telemetry: &telemetry.NoOpService{}, }) require.NoError(t, err) assert.Equal(t, 1, strings.Count(stdout.String(), "Installed git-commit")) @@ -1980,3 +1985,182 @@ func Test_selectSkillsWithSelector_noDisclaimer(t *testing.T) { require.NoError(t, err) assert.NotContains(t, stderr.String(), "not verified by GitHub") } + +func TestInstallRun_TelemetryVisibility(t *testing.T) { + tests := []struct { + name string + visibility string + visibilityErr bool + wantSkillNames string + }{ + { + name: "public repo includes skill names", + visibility: "public", + wantSkillNames: "git-commit", + }, + { + name: "private repo excludes skill names", + visibility: "private", + }, + { + name: "internal repo excludes skill names", + visibility: "internal", + }, + { + name: "API error omits visibility and skill names", + visibilityErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "octocat-skills", "treeSHA", "blobSHA", gitCommitContent) + if tt.visibilityErr { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.StatusStringResponse(500, "server error"), + ) + } else { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": tt.visibility, + }), + ) + } + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + recorder := &telemetry.EventRecorderSpy{} + + err := installRun(&InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: &prompter.PrompterMock{}, + SkillSource: "monalisa/octocat-skills", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + Force: true, + Telemetry: recorder, + }) + require.NoError(t, err) + + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "skill_install", event.Type) + assert.NotEmpty(t, event.Dimensions["agent_hosts"], "agent_hosts should always be present") + + // skill_host_type is always recorded (categorized, no raw hostname for enterprise/tenancy). + assert.Equal(t, "github.com", event.Dimensions["skill_host_type"]) + + if tt.visibilityErr { + assert.Equal(t, "unknown", event.Dimensions["repo_visibility"], + "visibility fetch errors should emit repo_visibility=\"unknown\" so the fallback is distinguishable from a successful fetch") + } else { + assert.Equal(t, tt.visibility, event.Dimensions["repo_visibility"]) + } + + // Owner, repo, and skill names are only included when the repo + // is public; for private/internal/unknown they are omitted to + // avoid leaking identifiers of non-public repositories. + if tt.wantSkillNames != "" { + assert.Equal(t, "monalisa", event.Dimensions["skill_owner"]) + assert.Equal(t, "octocat-skills", event.Dimensions["skill_repo"]) + assert.Equal(t, tt.wantSkillNames, event.Dimensions["skill_names"]) + } else { + assert.Empty(t, event.Dimensions["skill_owner"]) + assert.Empty(t, event.Dimensions["skill_repo"]) + assert.Empty(t, event.Dimensions["skill_names"]) + } + }) + } +} + +func TestInstallRun_TelemetryMultipleSkills(t *testing.T) { + codeReviewContent := heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + stubResolveVersion(reg, "monalisa", "octocat-skills", "v1.0.0", "abc123") + treeJSON := `{"path": "skills/git-commit", "type": "tree", "sha": "treeGC"}, ` + + `{"path": "skills/git-commit/SKILL.md", "type": "blob", "sha": "blobGC"}, ` + + `{"path": "skills/code-review", "type": "tree", "sha": "treeCR"}, ` + + `{"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blobCR"}` + stubDiscoverTree(reg, "monalisa", "octocat-skills", "abc123", treeJSON) + + // Blob stubs for FetchDescriptionsConcurrent during interactive selection + encGC := base64.StdEncoding.EncodeToString([]byte(gitCommitContent)) + encCR := base64.StdEncoding.EncodeToString([]byte(codeReviewContent)) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blobGC"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobGC", "content": %q, "encoding": "base64"}`, encGC))) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blobCR"), + httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobCR", "content": %q, "encoding": "base64"}`, encCR))) + + stubInstallFiles(reg, "monalisa", "octocat-skills", "treeGC", "blobGC", gitCommitContent) + stubInstallFiles(reg, "monalisa", "octocat-skills", "treeCR", "blobCR", codeReviewContent) + + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "public", + }), + ) + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + pm := &prompter.PrompterMock{ + MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { + return []string{allSkillsKey}, nil + }, + } + + recorder := &telemetry.EventRecorderSpy{} + + err := installRun(&InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: pm, + SkillSource: "monalisa/octocat-skills", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + Telemetry: recorder, + }) + require.NoError(t, err) + + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "skill_install", event.Type) + assert.Equal(t, "public", event.Dimensions["repo_visibility"]) + + // Verify comma-separated skill names (alphabetical order from DiscoverSkills) + names := strings.Split(event.Dimensions["skill_names"], ",") + assert.Len(t, names, 2) + assert.Contains(t, names, "code-review") + assert.Contains(t, names, "git-commit") +} diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 4bdfdd416b4..e9f1e0442ce 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -7,9 +7,12 @@ import ( "path" "sort" "strings" + "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" @@ -23,6 +26,7 @@ import ( type PreviewOptions struct { IO *iostreams.IOStreams + Telemetry ghtelemetry.EventRecorder HttpClient func() (*http.Client, error) Prompter prompter.Prompter ExecutablePath string @@ -36,9 +40,10 @@ type PreviewOptions struct { } // NewCmdPreview creates the "skills preview" command. -func NewCmdPreview(f *cmdutil.Factory, runF func(*PreviewOptions) error) *cobra.Command { +func NewCmdPreview(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*PreviewOptions) error) *cobra.Command { opts := &PreviewOptions{ IO: f.IOStreams, + Telemetry: telemetry, HttpClient: f.HttpClient, Prompter: f.Prompter, ExecutablePath: f.ExecutablePath, @@ -125,6 +130,19 @@ func previewRun(opts *PreviewOptions) error { } apiClient := api.NewClientFromHTTP(httpClient) + // Kick off the visibility fetch in parallel with the preview work so + // the extra API roundtrip doesn't add latency on the critical path. + // The result is consumed when the telemetry event is emitted below. + type visResult struct { + vis discovery.RepoVisibility + err error + } + visCh := make(chan visResult, 1) + go func() { + vis, err := discovery.FetchRepoVisibility(apiClient, hostname, owner, repoName) + visCh <- visResult{vis: vis, err: err} + }() + opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Resolving %s/%s", owner, repoName)) resolved, err := discovery.ResolveRef(apiClient, hostname, owner, repoName, opts.Version) opts.IO.StopProgressIndicator() @@ -177,17 +195,50 @@ func previewRun(opts *PreviewOptions) error { // Non-interactive or skill has only SKILL.md: dump through pager if !canPrompt || len(extraFiles) == 0 { - return renderAllFiles(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) + renderAllFiles(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) + } else { + // Interactive with multiple files: show tree, then file picker + renderInteractive(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) } - // Interactive with multiple files: show tree, then file picker - return renderInteractive(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) + dims := map[string]string{ + "skill_host_type": ghinstance.CategorizeHost(opts.repo.RepoHost()), + } + select { + case r := <-visCh: + if r.err == nil { + dims["repo_visibility"] = string(r.vis) + if r.vis == discovery.RepoVisibilityPublic { + dims["skill_owner"] = opts.repo.RepoOwner() + dims["skill_repo"] = opts.repo.RepoName() + dims["skill_name"] = skill.DisplayName() + } + } else { + dims["repo_visibility"] = "unknown" + } + case <-time.After(visibilityWaitTimeout): + dims["repo_visibility"] = "unknown" + } + opts.Telemetry.Record(ghtelemetry.Event{ + Type: "skill_preview", + Dimensions: dims, + }) + + return nil } +// visibilityWaitTimeout is how long to wait at telemetry-emit time for +// the in-flight repo visibility fetch before giving up and emitting +// repo_visibility="unknown". By this point the command has already done +// several serial API calls and rendering work, so the fetch has almost +// always completed; this budget is a short safety net for the case +// where that single REST call has stalled. +const visibilityWaitTimeout = 200 * time.Millisecond + // renderAllFiles dumps the tree, SKILL.md, and all extra files through the pager. func renderAllFiles(opts *PreviewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, files []discovery.SkillFile, rendered string, extraFiles []discovery.SkillFile, - apiClient *api.Client, hostname, owner, repo string) error { + apiClient *api.Client, hostname, owner, repo string) { opts.IO.DetectTerminalTheme() if err := opts.IO.StartPager(); err != nil { @@ -232,14 +283,12 @@ func renderAllFiles(opts *PreviewOptions, cs *iostreams.ColorScheme, skill disco fmt.Fprintln(out) } } - - return nil } // renderInteractive shows the file tree, then a picker to browse individual files. func renderInteractive(opts *PreviewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, files []discovery.SkillFile, renderedSkillMD string, extraFiles []discovery.SkillFile, - apiClient *api.Client, hostname, owner, repo string) error { + apiClient *api.Client, hostname, owner, repo string) { // Show the file tree to stderr so it persists above the prompt fmt.Fprintf(opts.IO.ErrOut, "\n%s\n", cs.Bold(skill.DisplayName()+"/")) @@ -265,7 +314,7 @@ func renderInteractive(opts *PreviewOptions, cs *iostreams.ColorScheme, skill di idx, err := opts.Prompter.Select("View a file (Esc to exit):", "", choices) if err != nil { - return nil //nolint:nilerr // Prompter returns error on Esc/Ctrl-C; treat as graceful exit + return // Prompter returns error on Esc/Ctrl-C; treat as graceful exit } var content string diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index 474ce88b5aa..a5d5554ffe9 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -11,6 +11,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -74,7 +75,7 @@ func TestNewCmdPreview(t *testing.T) { } var gotOpts *PreviewOptions - cmd := NewCmdPreview(f, func(opts *PreviewOptions) error { + cmd := NewCmdPreview(f, &telemetry.NoOpService{}, func(opts *PreviewOptions) error { gotOpts = opts return nil }) @@ -332,6 +333,7 @@ func TestPreviewRun(t *testing.T) { tt.opts.IO = ios tt.opts.Prompter = &prompter.PrompterMock{} + tt.opts.Telemetry = &telemetry.NoOpService{} err := previewRun(tt.opts) @@ -354,6 +356,7 @@ func TestPreviewRun_UnsupportedHost(t *testing.T) { IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, repo: ghrepo.NewWithHost("github", "awesome-copilot", "acme.ghes.com"), + Telemetry: &telemetry.NoOpService{}, }) require.ErrorContains(t, err, "supports only github.com") } @@ -415,6 +418,7 @@ func TestPreviewRun_Interactive(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, Prompter: pm, repo: ghrepo.New("owner", "repo"), + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -510,6 +514,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { Prompter: pm, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -593,6 +598,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { renderCalls++ return fmt.Sprintf("rendered:%s", filePath) }, + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -618,6 +624,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -724,6 +731,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -761,6 +769,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -793,6 +802,7 @@ func TestPreviewRun_RenderLimits(t *testing.T) { Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, } err := previewRun(opts) @@ -802,3 +812,214 @@ func TestPreviewRun_RenderLimits(t *testing.T) { assert.Contains(t, out, "could not fetch file") }) } + +func TestPreviewRun_InteractiveTelemetryCapturesSelectedSkillName(t *testing.T) { + skillContent := "# Selected Skill\n\nContent here." + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/alpha", "type": "tree", "sha": "tree-a"}, + {"path": "skills/alpha/SKILL.md", "type": "blob", "sha": "blob-a"}, + {"path": "skills/beta", "type": "tree", "sha": "tree-b"}, + {"path": "skills/beta/SKILL.md", "type": "blob", "sha": "blob-b"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/tree-b"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob-b", "size": 40} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob-b"), + httpmock.StringResponse(`{"sha": "blob-b", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": "public", + }), + ) + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + + pm := &prompter.PrompterMock{ + SelectFunc: func(prompt string, defaultValue string, options []string) (int, error) { + return 1, nil // select "beta" + }, + } + + recorder := &telemetry.EventRecorderSpy{} + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: pm, + Telemetry: recorder, + repo: ghrepo.New("owner", "repo"), + // SkillName intentionally left empty to simulate interactive selection + } + + err := previewRun(opts) + require.NoError(t, err) + + // Verify the telemetry event captured the interactively-selected skill name, not empty string + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "skill_preview", event.Type) + assert.Equal(t, "beta", event.Dimensions["skill_name"], "telemetry should capture the selected skill name, not the empty opts.SkillName") +} + +func TestPreviewRun_TelemetryVisibility(t *testing.T) { + skillContent := heredoc.Doc(` + --- + name: my-skill + description: test + --- + # My Skill + Body. + `) + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + tests := []struct { + name string + visibility string + visibilityErr bool + wantSkillNames string + }{ + { + name: "public repo includes skill names", + visibility: "public", + wantSkillNames: "my-skill", + }, + { + name: "private repo excludes skill names", + visibility: "private", + }, + { + name: "internal repo excludes skill names", + visibility: "internal", + }, + { + name: "API error omits visibility and skill names", + visibilityErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blobSKILL"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blobSKILL", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobSKILL"), + httpmock.StringResponse(`{"sha": "blobSKILL", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + if tt.visibilityErr { + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.StatusStringResponse(500, "server error"), + ) + } else { + reg.Register( + httpmock.REST("GET", "repos/owner/repo"), + httpmock.JSONResponse(map[string]interface{}{ + "visibility": tt.visibility, + }), + ) + } + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + recorder := &telemetry.EventRecorderSpy{} + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + Telemetry: recorder, + repo: ghrepo.New("owner", "repo"), + SkillName: "my-skill", + } + + err := previewRun(opts) + require.NoError(t, err) + + require.Len(t, recorder.Events, 1) + event := recorder.Events[0] + assert.Equal(t, "skill_preview", event.Type) + + // skill_host_type is always recorded (categorized, no raw hostname for enterprise/tenancy). + assert.Equal(t, "github.com", event.Dimensions["skill_host_type"]) + + if tt.visibilityErr { + assert.Equal(t, "unknown", event.Dimensions["repo_visibility"], + "visibility fetch errors should emit repo_visibility=\"unknown\" so the fallback is distinguishable from a successful fetch") + } else { + assert.Equal(t, tt.visibility, event.Dimensions["repo_visibility"]) + } + + // Owner, repo, and skill name are only included when the repo + // is public; for private/internal/unknown they are omitted to + // avoid leaking identifiers of non-public repositories. + if tt.wantSkillNames != "" { + assert.Equal(t, "owner", event.Dimensions["skill_owner"]) + assert.Equal(t, "repo", event.Dimensions["skill_repo"]) + assert.Equal(t, tt.wantSkillNames, event.Dimensions["skill_name"]) + } else { + assert.Empty(t, event.Dimensions["skill_owner"]) + assert.Empty(t, event.Dimensions["skill_repo"]) + assert.Empty(t, event.Dimensions["skill_name"]) + } + }) + } +} diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 5bcc15bda3a..5f510aae2d2 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -15,6 +15,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" @@ -48,6 +49,7 @@ var SkillSearchFields = []string{ type SearchOptions struct { IO *iostreams.IOStreams + Telemetry ghtelemetry.EventRecorder HttpClient func() (*http.Client, error) Config func() (gh.Config, error) Prompter prompter.Prompter @@ -62,9 +64,10 @@ type SearchOptions struct { } // NewCmdSearch creates the "skills search" command. -func NewCmdSearch(f *cmdutil.Factory, runF func(*SearchOptions) error) *cobra.Command { +func NewCmdSearch(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*SearchOptions) error) *cobra.Command { opts := &SearchOptions{ IO: f.IOStreams, + Telemetry: telemetry, HttpClient: f.HttpClient, Config: f.Config, Prompter: f.Prompter, @@ -552,6 +555,13 @@ func promptInstall(opts *SearchOptions, skills []skillResult) error { return nil } + opts.Telemetry.Record(ghtelemetry.Event{ + Type: "skill_search_install", + Measures: ghtelemetry.Measures{ + "install_count": int64(len(indices)), + }, + }) + // Prompt for target agent host (once for all selected skills) hostNames := registry.AgentNames() hostIdx, err := opts.Prompter.Select("Select target agent:", "", hostNames) diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index bdfe3ba1913..763ca2124e1 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -8,6 +8,8 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -102,7 +104,7 @@ func TestNewCmdSearch(t *testing.T) { t.Run(tt.name, func(t *testing.T) { f := &cmdutil.Factory{} var gotOpts *SearchOptions - cmd := NewCmdSearch(f, func(opts *SearchOptions) error { + cmd := NewCmdSearch(f, &telemetry.NoOpService{}, func(opts *SearchOptions) error { gotOpts = opts return nil }) @@ -372,6 +374,7 @@ func TestSearchRun(t *testing.T) { ios.SetStdoutTTY(tt.tty) ios.SetStderrTTY(tt.tty) tt.opts.IO = ios + tt.opts.Telemetry = &telemetry.NoOpService{} defer reg.Verify(t) err := searchRun(tt.opts) @@ -576,3 +579,75 @@ func TestDeduplicateByName_Namespaced(t *testing.T) { assert.NotEqual(t, "org/repo6", s.Repo) } } + +// TestSearchRun_TelemetryRecordsInstallFromResults verifies that when a +// user searches, picks one or more results interactively, and proceeds to +// install them, the search command records a telemetry event capturing +// that the search led to an install attempt. This is the key signal for +// measuring the value of search results: of the searches that ran, how +// many converted to an install? +func TestSearchRun_TelemetryRecordsInstallFromResults(t *testing.T) { + codeResponse := `{"total_count": 1, "incomplete_results": false, "items": [ + {"name": "SKILL.md", "path": "skills/terraform/SKILL.md", "sha": "abc123", + "repository": {"full_name": "org/repo"}} + ]}` + + reg := &httpmock.Registry{} + defer reg.Verify(t) + // Keyword search fires path + owner + primary (3 requests). + for range 3 { + reg.Register( + httpmock.REST("GET", "search/code"), + httpmock.StringResponse(codeResponse), + ) + } + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStderrTTY(true) + ios.SetStdinTTY(true) + + pm := &prompter.PrompterMock{ + MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { + // Select the single result. + return []int{0}, nil + }, + SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { + // First Select: target agent (0). Second Select: scope (0). + return 0, nil + }, + } + + recorder := &telemetry.EventRecorderSpy{} + + err := searchRun(&SearchOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + Prompter: pm, + Telemetry: recorder, + ExecutablePath: "/nonexistent/gh", // install subprocess will fail; failures are logged, not fatal. + Query: "terraform", + Page: 1, + Limit: defaultLimit, + }) + require.NoError(t, err) + + // The search command no longer records a separate skill_search event; + // only the follow-up skill_search_install event fires when the user + // proceeds to install from the results. + require.Len(t, recorder.Events, 1) + + installEvent := recorder.Events[0] + assert.Equal(t, "skill_search_install", installEvent.Type, + "an install triggered from search results should be recorded as a distinct event") + assert.Equal(t, int64(1), installEvent.Measures["install_count"], + "install_count captures how many results the user chose to install") + // The skill_search_install event must not carry the query or owner: + // these were intentionally removed so that installs from search are + // not linked back to the search terms at the telemetry layer. + assert.Empty(t, installEvent.Dimensions["query"], + "skill_search_install must not record the search query") + assert.Empty(t, installEvent.Dimensions["owner"], + "skill_search_install must not record the search owner filter") +} diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index 1dadd3b1f4e..05a87c38651 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -2,6 +2,7 @@ package skills import ( "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/pkg/cmd/skills/install" "github.com/cli/cli/v2/pkg/cmd/skills/preview" "github.com/cli/cli/v2/pkg/cmd/skills/publish" @@ -12,7 +13,7 @@ import ( ) // NewCmdSkills returns the top-level "skill" command. -func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { +func NewCmdSkills(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder) *cobra.Command { cmd := &cobra.Command{ Use: "skill ", Short: "Install and manage agent skills (preview)", @@ -40,12 +41,16 @@ func NewCmdSkills(f *cmdutil.Factory) *cobra.Command { # Validate skills for publishing $ gh skill publish --dry-run `), + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + telemetry.SetSampleRate(ghtelemetry.SAMPLE_ALL) + return nil + }, } - cmd.AddCommand(install.NewCmdInstall(f, nil)) - cmd.AddCommand(preview.NewCmdPreview(f, nil)) + cmd.AddCommand(install.NewCmdInstall(f, telemetry, nil)) + cmd.AddCommand(preview.NewCmdPreview(f, telemetry, nil)) cmd.AddCommand(publish.NewCmdPublish(f, nil)) - cmd.AddCommand(search.NewCmdSearch(f, nil)) + cmd.AddCommand(search.NewCmdSearch(f, telemetry, nil)) cmd.AddCommand(update.NewCmdUpdate(f, nil)) return cmd diff --git a/pkg/cmd/skills/skills_test.go b/pkg/cmd/skills/skills_test.go new file mode 100644 index 00000000000..eb8bb465c0e --- /dev/null +++ b/pkg/cmd/skills/skills_test.go @@ -0,0 +1,19 @@ +package skills_test + +import ( + "testing" + + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmd/skills" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/stretchr/testify/require" +) + +func TestSkillCommandsAreSampledAt100(t *testing.T) { + spy := &telemetry.CommandRecorderSpy{} + factory := &cmdutil.Factory{} + cmd := skills.NewCmdSkills(factory, spy) + cmd.PersistentPreRunE(nil, []string{}) + require.Equal(t, ghtelemetry.SAMPLE_ALL, spy.LastSampleRate) +} From 082f15a8fd1419d95d9296161d3203842f3c9794 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Sat, 18 Apr 2026 22:22:09 +0100 Subject: [PATCH 054/182] Add support for installation in multiple agent hosts in `gh skills install` (#13209) * add support for installation in multiple agent host * print correct dir in warning * remove dir as it depends on user vs project installation scope * Move comment closer to assertion in registry test Move the explanatory comment from above the map initialization to directly above the assertions it describes, per review feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * List supported agent names and IDs in help text Replace the self-referencing "run --help" sentence with an inline list of all supported --agent values showing Name (id) pairs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use heredoc.Docf for Kiro CLI post-install hint Replace individual fmt.Fprintln calls with a single heredoc.Docf block for the Kiro CLI post-install guidance, per review feedback. Also shorten the --agent flag usage line by overriding the auto-generated enum list with a reference to the supported values in the help text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/skills/registry/registry.go | 256 +++++++++++++++++++++- internal/skills/registry/registry_test.go | 17 +- pkg/cmd/skills/install/install.go | 85 +++++-- pkg/cmd/skills/install/install_test.go | 92 +++++++- pkg/cmd/skills/publish/publish.go | 5 + 5 files changed, 434 insertions(+), 21 deletions(-) diff --git a/internal/skills/registry/registry.go b/internal/skills/registry/registry.go index b112d361a50..a5e018176cf 100644 --- a/internal/skills/registry/registry.go +++ b/internal/skills/registry/registry.go @@ -34,7 +34,16 @@ const ( ) // Agents contains all known agent hosts. +// +// The slice is ordered so that the most widely used agents appear first, +// followed by the rest in alphabetical order. This order is used for +// interactive selection, help output, and flag enum suggestions. +// +// Agents sharing a ProjectDir (such as the shared .agents/skills directory) +// install skills to the same project-scope location, so selecting multiple +// such agents writes each skill only once. var Agents = []AgentHost{ + // Popular agents, listed first for discoverability. { ID: "github-copilot", Name: "GitHub Copilot", @@ -60,7 +69,7 @@ var Agents = []AgentHost{ UserDir: ".codex/skills", }, { - ID: "gemini", + ID: "gemini-cli", Name: "Gemini CLI", ProjectDir: sharedProjectSkillsDir, UserDir: ".gemini/skills", @@ -71,6 +80,242 @@ var Agents = []AgentHost{ ProjectDir: sharedProjectSkillsDir, UserDir: ".gemini/antigravity/skills", }, + + // All other supported agents, alphabetical by ID. + { + ID: "adal", + Name: "AdaL", + ProjectDir: ".adal/skills", + UserDir: ".adal/skills", + }, + { + ID: "amp", + Name: "Amp", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/agents/skills", + }, + { + ID: "augment", + Name: "Augment", + ProjectDir: ".augment/skills", + UserDir: ".augment/skills", + }, + { + ID: "bob", + Name: "IBM Bob", + ProjectDir: ".bob/skills", + UserDir: ".bob/skills", + }, + { + ID: "cline", + Name: "Cline", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".agents/skills", + }, + { + ID: "codebuddy", + Name: "CodeBuddy", + ProjectDir: ".codebuddy/skills", + UserDir: ".codebuddy/skills", + }, + { + ID: "command-code", + Name: "Command Code", + ProjectDir: ".commandcode/skills", + UserDir: ".commandcode/skills", + }, + { + ID: "continue", + Name: "Continue", + ProjectDir: ".continue/skills", + UserDir: ".continue/skills", + }, + { + ID: "cortex", + Name: "Cortex Code", + ProjectDir: ".cortex/skills", + UserDir: ".snowflake/cortex/skills", + }, + { + ID: "crush", + Name: "Crush", + ProjectDir: ".crush/skills", + UserDir: ".config/crush/skills", + }, + { + ID: "deepagents", + Name: "Deep Agents", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".deepagents/agent/skills", + }, + { + ID: "droid", + Name: "Droid", + ProjectDir: ".factory/skills", + UserDir: ".factory/skills", + }, + { + ID: "firebender", + Name: "Firebender", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".firebender/skills", + }, + { + ID: "goose", + Name: "Goose", + ProjectDir: ".goose/skills", + UserDir: ".config/goose/skills", + }, + { + ID: "iflow-cli", + Name: "iFlow CLI", + ProjectDir: ".iflow/skills", + UserDir: ".iflow/skills", + }, + { + ID: "junie", + Name: "Junie", + ProjectDir: ".junie/skills", + UserDir: ".junie/skills", + }, + { + ID: "kilo", + Name: "Kilo Code", + ProjectDir: ".kilocode/skills", + UserDir: ".kilocode/skills", + }, + { + ID: "kimi-cli", + Name: "Kimi Code CLI", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/agents/skills", + }, + { + ID: "kiro-cli", + Name: "Kiro CLI", + ProjectDir: ".kiro/skills", + UserDir: ".kiro/skills", + }, + { + ID: "kode", + Name: "Kode", + ProjectDir: ".kode/skills", + UserDir: ".kode/skills", + }, + { + ID: "mcpjam", + Name: "MCPJam", + ProjectDir: ".mcpjam/skills", + UserDir: ".mcpjam/skills", + }, + { + ID: "mistral-vibe", + Name: "Mistral Vibe", + ProjectDir: ".vibe/skills", + UserDir: ".vibe/skills", + }, + { + ID: "mux", + Name: "Mux", + ProjectDir: ".mux/skills", + UserDir: ".mux/skills", + }, + { + ID: "neovate", + Name: "Neovate", + ProjectDir: ".neovate/skills", + UserDir: ".neovate/skills", + }, + { + ID: "openclaw", + Name: "OpenClaw", + ProjectDir: "skills", + UserDir: ".openclaw/skills", + }, + { + ID: "opencode", + Name: "OpenCode", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/opencode/skills", + }, + { + ID: "openhands", + Name: "OpenHands", + ProjectDir: ".openhands/skills", + UserDir: ".openhands/skills", + }, + { + ID: "pi", + Name: "Pi", + ProjectDir: ".pi/skills", + UserDir: ".pi/agent/skills", + }, + { + ID: "pochi", + Name: "Pochi", + ProjectDir: ".pochi/skills", + UserDir: ".pochi/skills", + }, + { + ID: "qoder", + Name: "Qoder", + ProjectDir: ".qoder/skills", + UserDir: ".qoder/skills", + }, + { + ID: "qwen-code", + Name: "Qwen Code", + ProjectDir: ".qwen/skills", + UserDir: ".qwen/skills", + }, + { + ID: "replit", + Name: "Replit", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/agents/skills", + }, + { + ID: "roo", + Name: "Roo Code", + ProjectDir: ".roo/skills", + UserDir: ".roo/skills", + }, + { + ID: "trae", + Name: "Trae", + ProjectDir: ".trae/skills", + UserDir: ".trae/skills", + }, + { + ID: "trae-cn", + Name: "Trae CN", + ProjectDir: ".trae/skills", + UserDir: ".trae-cn/skills", + }, + { + ID: "universal", + Name: "Universal", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".config/agents/skills", + }, + { + ID: "warp", + Name: "Warp", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".agents/skills", + }, + { + ID: "windsurf", + Name: "Windsurf", + ProjectDir: ".windsurf/skills", + UserDir: ".codeium/windsurf/skills", + }, + { + ID: "zencoder", + Name: "Zencoder", + ProjectDir: ".zencoder/skills", + UserDir: ".zencoder/skills", + }, } // FindByID returns the agent host with the given ID, or an error if not found. @@ -97,6 +342,15 @@ func AgentIDs() []string { return ids } +// AgentHelpList returns a newline-separated bulleted list of agents for help text. +func AgentHelpList() string { + lines := make([]string, len(Agents)) + for i, h := range Agents { + lines[i] = fmt.Sprintf(" - %s (%s)", h.Name, h.ID) + } + return strings.Join(lines, "\n") +} + // AgentNames returns the display names of all agents for prompting. func AgentNames() []string { names := make([]string, len(Agents)) diff --git a/internal/skills/registry/registry_test.go b/internal/skills/registry/registry_test.go index 003a28afa1f..bd0c4470963 100644 --- a/internal/skills/registry/registry_test.go +++ b/internal/skills/registry/registry_test.go @@ -19,7 +19,7 @@ func TestFindByID(t *testing.T) { {name: "claude-code", id: "claude-code", wantName: "Claude Code"}, {name: "cursor", id: "cursor", wantName: "Cursor"}, {name: "codex", id: "codex", wantName: "Codex"}, - {name: "gemini", id: "gemini", wantName: "Gemini CLI"}, + {name: "gemini-cli", id: "gemini-cli", wantName: "Gemini CLI"}, {name: "antigravity", id: "antigravity", wantName: "Antigravity"}, {name: "unknown agent", id: "nonexistent", wantErr: "unknown agent"}, } @@ -89,7 +89,7 @@ func TestInstallDir(t *testing.T) { }, { name: "gemini project scope", - hostID: "gemini", + hostID: "gemini-cli", scope: ScopeProject, gitRoot: "/tmp/monalisa-repo", homeDir: "/home/monalisa", @@ -167,7 +167,18 @@ func TestRepoNameFromRemote(t *testing.T) { func TestUniqueProjectDirs(t *testing.T) { dirs := UniqueProjectDirs() - assert.Equal(t, []string{".agents/skills", ".claude/skills"}, dirs) + seen := map[string]int{} + for _, d := range dirs { + seen[d]++ + } + // The shared .agents/skills dir and .claude/skills must both be present + // and listed exactly once each. + assert.Equal(t, 1, seen[".agents/skills"], "expected .agents/skills exactly once") + assert.Equal(t, 1, seen[".claude/skills"], "expected .claude/skills exactly once") + // No project dir should appear more than once. + for d, n := range seen { + assert.LessOrEqualf(t, n, 1, "project dir %q appears %d times", d, n) + } } func TestScopeLabels(t *testing.T) { diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index d4a05440e7b..5f715ff7ef9 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -80,24 +80,24 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru Install agent skills from a GitHub repository or local directory into your local environment. Skills are placed in a host-specific directory at either project scope (inside the current git repository) or user - scope (in your home directory, available everywhere). Supported hosts - and their storage directories are (project, user): + scope (in your home directory, available everywhere). - - GitHub Copilot (%[1]s.agents/skills%[1]s, %[1]s~/.copilot/skills%[1]s) - - Claude Code (%[1]s.claude/skills%[1]s, %[1]s~/.claude/skills%[1]s) - - Cursor (%[1]s.agents/skills%[1]s, %[1]s~/.cursor/skills%[1]s) - - Codex (%[1]s.agents/skills%[1]s, %[1]s~/.codex/skills%[1]s) - - Gemini CLI (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/skills%[1]s) - - Antigravity (%[1]s.agents/skills%[1]s, %[1]s~/.gemini/antigravity/skills%[1]s) + A wide range of AI coding agents are supported, including GitHub + Copilot, Claude Code, Cursor, Codex, Gemini CLI, Antigravity, Amp, + Goose, Junie, OpenCode, Windsurf, and many more. + + Supported %[1]s--agent%[1]s values: + + %[2]s Use %[1]s--agent%[1]s and %[1]s--scope%[1]s to control placement, or %[1]s--dir%[1]s for a custom directory. The default scope is %[1]sproject%[1]s, and the default agent is %[1]sgithub-copilot%[1]s (when running non-interactively). - At project scope, GitHub Copilot, Cursor, Codex, Gemini CLI, and - Antigravity all use the shared %[1]s.agents/skills%[1]s directory. If you - select multiple hosts that resolve to the same destination, each skill is - installed there only once. + At project scope, several agents (including GitHub Copilot, Cursor, + Codex, Gemini CLI, Antigravity, Amp, Cline, OpenCode, and Warp) share + the %[1]s.agents/skills%[1]s directory. If you select multiple hosts that + resolve to the same destination, each skill is installed there only once. The first argument is a GitHub repository in %[1]sOWNER/REPO%[1]s format. Use %[1]s--from-local%[1]s to install from a local directory instead. @@ -133,7 +133,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru When run interactively, the command prompts for any missing arguments. When run non-interactively, %[1]srepository%[1]s and a skill name are required. - `, "`"), + `, "`", registry.AgentHelpList()), Example: heredoc.Doc(` # Interactive: choose repo, skill, and agent $ gh skill install @@ -198,7 +198,8 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru }, } - cmdutil.StringEnumFlag(cmd, &opts.Agent, "agent", "", "", registry.AgentIDs(), "Target agent") + agentFlag := cmdutil.StringEnumFlag(cmd, &opts.Agent, "agent", "", "", registry.AgentIDs(), "Target agent") + agentFlag.Usage = "Target agent (see supported values above)" cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope") cmd.Flags().StringVar(&opts.Pin, "pin", "", "Pin to a specific git tag or commit SHA") cmd.Flags().StringVar(&opts.Dir, "dir", "", "Install to a custom directory (overrides --agent and --scope)") @@ -336,6 +337,7 @@ func installRun(opts *InstallOptions) error { printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed) printReviewHint(opts.IO.ErrOut, cs, repoSource, resolved.SHA, result.Installed) + printHostHints(opts.IO.ErrOut, cs, plan.hosts, result.Installed, result.Dir, gitRoot) } if err != nil { @@ -474,6 +476,7 @@ func runLocalInstall(opts *InstallOptions) error { printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed) printReviewHint(opts.IO.ErrOut, cs, "", "", result.Installed) + printHostHints(opts.IO.ErrOut, cs, plan.hosts, result.Installed, result.Dir, gitRoot) } return nil @@ -789,8 +792,18 @@ func resolveHosts(opts *InstallOptions, canPrompt bool) ([]*registry.AgentHost, } fmt.Fprintln(opts.IO.ErrOut) - names := registry.AgentNames() - indices, err := opts.Prompter.MultiSelect("Select target agent(s):", []string{names[0]}, names) + labels := make([]string, len(registry.Agents)) + defaultLabel := "" + for i, h := range registry.Agents { + labels[i] = h.Name + if h.ID == registry.DefaultAgentID { + defaultLabel = labels[i] + } + } + if defaultLabel == "" { + defaultLabel = labels[0] + } + indices, err := opts.Prompter.MultiSelect("Select target agent(s):", []string{defaultLabel}, labels) if err != nil { return nil, err } @@ -1058,3 +1071,43 @@ func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo, sha string, s } fmt.Fprintln(w) } + +// printHostHints prints any agent-specific post-install guidance for the +// hosts that were installed to. Most agents need no extra steps; this is +// currently used for Kiro CLI, which requires skills to be registered as +// resources on a custom agent. The path in the example is derived from +// the actual install directory so it matches the chosen scope or --dir. +func printHostHints(w io.Writer, cs *iostreams.ColorScheme, hosts []*registry.AgentHost, installed []string, installDir, gitRoot string) { + if len(installed) == 0 { + return + } + for _, h := range hosts { + if h.ID == "kiro-cli" { + fmt.Fprintln(w) + fmt.Fprint(w, heredoc.Docf(` + %s Kiro CLI: register these skills on a custom agent by adding them to + .kiro/agents/.json under "resources", for example: + + { + "resources": ["skill://%s/**/SKILL.md"] + } + `, cs.WarningIcon(), kiroResourcePath(installDir, gitRoot))) + fmt.Fprintln(w) + return + } + } +} + +// kiroResourcePath returns a slash-separated path suitable for use in the +// "resources" field of a Kiro agent config. When the install directory is +// inside the current git repository the path is made relative to the repo +// root so the example works for project-scoped agent configs; otherwise +// the absolute install path is used (e.g. for --scope user or --dir). +func kiroResourcePath(installDir, gitRoot string) string { + if gitRoot != "" && installDir != "" { + if rel, err := filepath.Rel(gitRoot, installDir); err == nil && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) { + return filepath.ToSlash(rel) + } + } + return filepath.ToSlash(installDir) +} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 0560625f7fc..120738fd052 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -16,6 +16,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/registry" "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -1403,7 +1404,15 @@ func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) { pm := &prompter.PrompterMock{ MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { - return []int{0, 2}, nil // GitHub Copilot + Cursor share .agents/skills + // Select two agents that share the .agents/skills project dir + // (GitHub Copilot and Cursor) to exercise deduplication. + var indices []int + for i, label := range options { + if label == "GitHub Copilot" || label == "Cursor" { + indices = append(indices, i) + } + } + return indices, nil }, SelectFunc: func(prompt, defaultValue string, options []string) (int, error) { return 0, nil // project scope @@ -1947,6 +1956,87 @@ func Test_printReviewHint(t *testing.T) { } } +func Test_printHostHints(t *testing.T) { + kiro := ®istry.AgentHost{ID: "kiro-cli", Name: "Kiro CLI", ProjectDir: ".kiro/skills", UserDir: ".kiro/skills"} + copilot := ®istry.AgentHost{ID: "copilot-cli", Name: "GitHub Copilot CLI", ProjectDir: ".github/skills"} + + tests := []struct { + name string + hosts []*registry.AgentHost + installed []string + installDir string + gitRoot string + wantSub []string + wantNot []string + }{ + { + name: "no installs produces no output", + hosts: []*registry.AgentHost{kiro}, + installed: nil, + installDir: "/repo/.kiro/skills", + gitRoot: "/repo", + wantNot: []string{"Kiro CLI"}, + }, + { + name: "non-kiro host produces no output", + hosts: []*registry.AgentHost{copilot}, + installed: []string{"s1"}, + installDir: "/repo/.github/skills", + gitRoot: "/repo", + wantNot: []string{"Kiro CLI"}, + }, + { + name: "kiro project scope uses relative path", + hosts: []*registry.AgentHost{kiro}, + installed: []string{"s1"}, + installDir: filepath.Join("/repo", ".kiro", "skills"), + gitRoot: "/repo", + wantSub: []string{"Kiro CLI", `"skill://.kiro/skills/**/SKILL.md"`}, + }, + { + name: "kiro user scope uses absolute install dir", + hosts: []*registry.AgentHost{kiro}, + installed: []string{"s1"}, + installDir: "/home/user/.kiro/skills", + gitRoot: "/repo", + wantSub: []string{`"skill:///home/user/.kiro/skills/**/SKILL.md"`}, + wantNot: []string{`skill://.kiro/skills`}, + }, + { + name: "kiro custom dir outside git root uses absolute path", + hosts: []*registry.AgentHost{kiro}, + installed: []string{"s1"}, + installDir: "/tmp/my-skills", + gitRoot: "/repo", + wantSub: []string{`"skill:///tmp/my-skills/**/SKILL.md"`}, + }, + { + name: "kiro without git root falls back to install dir", + hosts: []*registry.AgentHost{kiro}, + installed: []string{"s1"}, + installDir: "/home/user/.kiro/skills", + gitRoot: "", + wantSub: []string{`"skill:///home/user/.kiro/skills/**/SKILL.md"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + cs := ios.ColorScheme() + var buf strings.Builder + printHostHints(&buf, cs, tt.hosts, tt.installed, tt.installDir, tt.gitRoot) + got := buf.String() + for _, s := range tt.wantSub { + assert.Contains(t, got, s) + } + for _, s := range tt.wantNot { + assert.NotContains(t, got, s) + } + }) + } +} + func Test_printPreInstallDisclaimer(t *testing.T) { ios, _, _, _ := iostreams.Test() cs := ios.ColorScheme() diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index d8278187694..6364846840f 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -859,6 +859,11 @@ func checkInstalledSkillDirs(gitClient *git.Client, repoDir string) []publishDia var diagnostics []publishDiagnostic for _, relPath := range registry.UniqueProjectDirs() { + // Skip non-hidden project dirs (such as "skills") to avoid + // flagging the canonical authoring layout used when publishing. + if !strings.HasPrefix(relPath, ".") { + continue + } absPath := filepath.Join(repoDir, relPath) if _, err := os.Stat(absPath); os.IsNotExist(err) { continue From eaa018545aff23f22744a0faade18b58c5c4aaf2 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 20 Apr 2026 11:06:02 +0200 Subject: [PATCH 055/182] refactor: decouple hidden-dir filtering from discovery layer Move --allow-hidden-dirs filtering logic from the discovery package to the install command, addressing review feedback. Discovery functions now always return all skills (including hidden-dir), and callers decide how to handle them. Changes: - DiscoverSkillsWithOptions/DiscoverLocalSkillsWithOptions always return hidden-dir skills; callers filter using IsHiddenDirConvention() - DiscoverSkills/DiscoverLocalSkills (convenience wrappers) auto-filter hidden-dir skills for backward compatibility with preview/update/publish - Remove --allow-hidden-dirs reference from discovery error messages - Add filterHiddenDirSkills in install.go with caller-side flag logic - Inline warning using heredoc.Docf, remove printHiddenDirWarning - Add inline comments in matchHiddenDirConventions (babakks nitpicks) - Add non-hidden-namespaced dir and no-skills-at-all test cases - Add --allow-hidden-dirs tests in TestNewCmdInstall, TestInstallRun, and TestRunLocalInstall Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/skills/discovery/discovery.go | 136 +++++++++- internal/skills/discovery/discovery_test.go | 283 ++++++++++++++++++++ pkg/cmd/skills/install/install.go | 75 +++++- pkg/cmd/skills/install/install_test.go | 179 +++++++++++++ 4 files changed, 659 insertions(+), 14 deletions(-) diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 2d6c1ee7256..b2c8baaed93 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -66,6 +66,8 @@ func (s Skill) DisplayName() string { return "[plugins] " + name case "root": return "[root] " + name + case "hidden-dir", "hidden-dir-namespaced": + return "[hidden-dir] " + name default: return name } @@ -82,6 +84,23 @@ func (s Skill) InstallName() string { return s.Name } +// IsHiddenDirConvention returns true if the skill was discovered in a hidden +// (dot-prefixed) directory such as .claude/skills/ or .agents/skills/. +func (s Skill) IsHiddenDirConvention() bool { + return s.Convention == "hidden-dir" || s.Convention == "hidden-dir-namespaced" +} + +// HasHiddenDirSkills returns true if any of the given skills were discovered +// in hidden directories. +func HasHiddenDirSkills(skills []Skill) bool { + for _, s := range skills { + if s.IsHiddenDirConvention() { + return true + } + } + return false +} + // ResolvedRef contains the resolved git reference and its SHA. type ResolvedRef struct { Ref string // fully qualified ref (refs/heads/*, refs/tags/*) or commit SHA @@ -393,8 +412,87 @@ func matchSkillConventions(entry treeEntry) *skillMatch { return nil } -// DiscoverSkills finds all skills in a repository at the given commit SHA. +// matchHiddenDirConventions checks if a blob path matches a skill convention +// under a hidden (dot-prefixed) root directory. These patterns mirror the +// standard skills/ conventions but rooted under .{host}/skills/: +// +// - .{host}/skills/*/SKILL.md -> "hidden-dir" +// - .{host}/skills/{scope}/*/SKILL.md -> "hidden-dir-namespaced" +func matchHiddenDirConventions(entry treeEntry) *skillMatch { + if path.Base(entry.Path) != "SKILL.md" { + return nil + } + + // .{host}/skills/* + // .{host}/skills/{scope}/* + dir := path.Dir(entry.Path) + skillName := path.Base(dir) + + if !validateName(skillName) { + return nil + } + + // .{host}/skills + // .{host}/skills/{scope} + parentDir := path.Dir(dir) + + // .{host}/skills/*/SKILL.md + if path.Base(parentDir) == "skills" { + hiddenRoot := path.Dir(parentDir) + if path.Dir(hiddenRoot) == "." && strings.HasPrefix(hiddenRoot, ".") { + return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "hidden-dir"} + } + } + + // .{host}/skills/{scope}/*/SKILL.md + grandparentDir := path.Dir(parentDir) + if path.Base(grandparentDir) == "skills" { + hiddenRoot := path.Dir(grandparentDir) + if path.Dir(hiddenRoot) == "." && strings.HasPrefix(hiddenRoot, ".") { + namespace := path.Base(parentDir) + if !validateName(namespace) { + return nil + } + return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "hidden-dir-namespaced"} + } + } + + return nil +} + +// DiscoverOptions controls optional discovery behaviors. +type DiscoverOptions struct { +} + +// DiscoverSkills finds all non-hidden-dir skills in a repository at the given +// commit SHA. Hidden-dir skills are excluded; use DiscoverSkillsWithOptions to +// retrieve all skills including those in hidden directories. func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([]Skill, error) { + all, err := DiscoverSkillsWithOptions(client, host, owner, repo, commitSHA, DiscoverOptions{}) + if err != nil { + return nil, err + } + var skills []Skill + for _, s := range all { + if !s.IsHiddenDirConvention() { + skills = append(skills, s) + } + } + if len(skills) == 0 { + return nil, fmt.Errorf( + "no skills found in %s/%s\n"+ + " Expected skills in skills/*/SKILL.md, skills/{scope}/*/SKILL.md,\n"+ + " */SKILL.md, or plugins/*/skills/*/SKILL.md\n"+ + " This repository may be a curated list rather than a skills publisher", + owner, repo, + ) + } + return skills, nil +} + +// DiscoverSkillsWithOptions finds all skills in a repository at the given +// commit SHA, with configurable discovery behavior. +func DiscoverSkillsWithOptions(client *api.Client, host, owner, repo, commitSHA string, opts DiscoverOptions) ([]Skill, error) { apiPath := fmt.Sprintf("repos/%s/%s/git/trees/%s?recursive=true", url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(commitSHA)) var tree treeResponse if err := client.REST(host, "GET", apiPath, nil, &tree); err != nil { @@ -419,6 +517,9 @@ func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([] continue } m := matchSkillConventions(entry) + if m == nil { + m = matchHiddenDirConventions(entry) + } if m == nil { continue } @@ -703,9 +804,35 @@ func FetchBlob(client *api.Client, host, owner, repo, sha string) (string, error return string(decoded), nil } -// DiscoverLocalSkills finds skills in a local directory using the same -// conventions as remote discovery. +// DiscoverLocalSkills finds non-hidden-dir skills in a local directory using +// the same conventions as remote discovery. Hidden-dir skills are excluded; use +// DiscoverLocalSkillsWithOptions to retrieve all skills including those in +// hidden directories. func DiscoverLocalSkills(dir string) ([]Skill, error) { + all, err := DiscoverLocalSkillsWithOptions(dir, DiscoverOptions{}) + if err != nil { + return nil, err + } + var skills []Skill + for _, s := range all { + if !s.IsHiddenDirConvention() { + skills = append(skills, s) + } + } + if len(skills) == 0 { + return nil, fmt.Errorf( + "no skills found in %s\n"+ + " Expected SKILL.md in the directory, or skills in skills/*/SKILL.md,\n"+ + " skills/{scope}/*/SKILL.md, */SKILL.md, or plugins/*/skills/*/SKILL.md", + dir, + ) + } + return skills, nil +} + +// DiscoverLocalSkillsWithOptions finds skills in a local directory using the +// same conventions as remote discovery, with configurable discovery behavior. +func DiscoverLocalSkillsWithOptions(dir string, opts DiscoverOptions) ([]Skill, error) { absDir, err := filepath.Abs(dir) if err != nil { return nil, fmt.Errorf("could not resolve path: %w", err) @@ -751,6 +878,9 @@ func DiscoverLocalSkills(dir string) ([]Skill, error) { entry := treeEntry{Path: relPath, Type: "blob"} m := matchSkillConventions(entry) + if m == nil { + m = matchHiddenDirConventions(entry) + } if m == nil { return nil } diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 8929e17871b..41600f21c72 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -116,6 +116,155 @@ func TestMatchSkillConventions(t *testing.T) { } } +func TestMatchHiddenDirConventions(t *testing.T) { + tests := []struct { + name string + path string + wantNil bool + wantName string + wantNamespace string + wantConvention string + }{ + { + name: "claude skills directory", + path: ".claude/skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "hidden-dir", + }, + { + name: "agents skills directory", + path: ".agents/skills/git-commit/SKILL.md", + wantName: "git-commit", + wantConvention: "hidden-dir", + }, + { + name: "github skills directory", + path: ".github/skills/issue-triage/SKILL.md", + wantName: "issue-triage", + wantConvention: "hidden-dir", + }, + { + name: "copilot skills directory", + path: ".copilot/skills/pr-summary/SKILL.md", + wantName: "pr-summary", + wantConvention: "hidden-dir", + }, + { + name: "namespaced hidden dir skill", + path: ".claude/skills/monalisa/code-review/SKILL.md", + wantName: "code-review", + wantNamespace: "monalisa", + wantConvention: "hidden-dir-namespaced", + }, + { + name: "not a SKILL.md file", + path: ".claude/skills/code-review/README.md", + wantNil: true, + }, + { + name: "too shallow - just hidden dir and SKILL.md", + path: ".claude/SKILL.md", + wantNil: true, + }, + { + name: "no skills subdirectory", + path: ".claude/code-review/SKILL.md", + wantNil: true, + }, + { + name: "non-hidden dir does not match", + path: "visible/skills/code-review/SKILL.md", + wantNil: true, + }, + { + name: "non-hidden-namespaced dir does not match", + path: "visible/skills/monalisa/code-review/SKILL.md", + wantNil: true, + }, + { + name: "too deeply nested hidden dir", + path: ".claude/nested/skills/code-review/SKILL.md", + wantNil: true, + }, + { + name: "invalid skill name", + path: ".claude/skills/../SKILL.md", + wantNil: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := matchHiddenDirConventions(treeEntry{Path: tt.path, Type: "blob"}) + if tt.wantNil { + assert.Nil(t, m) + return + } + require.NotNil(t, m) + assert.Equal(t, tt.wantName, m.name) + assert.Equal(t, tt.wantNamespace, m.namespace) + assert.Equal(t, tt.wantConvention, m.convention) + }) + } +} + +func TestHasHiddenDirSkills(t *testing.T) { + tests := []struct { + name string + skills []Skill + want bool + }{ + { + name: "empty list", + skills: nil, + want: false, + }, + { + name: "only standard skills", + skills: []Skill{{Convention: "skills"}, {Convention: "root"}}, + want: false, + }, + { + name: "has hidden-dir skill", + skills: []Skill{{Convention: "skills"}, {Convention: "hidden-dir"}}, + want: true, + }, + { + name: "has hidden-dir-namespaced skill", + skills: []Skill{{Convention: "hidden-dir-namespaced"}}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, HasHiddenDirSkills(tt.skills)) + }) + } +} + +func TestDisplayNameHiddenDir(t *testing.T) { + tests := []struct { + name string + skill Skill + wantName string + }{ + { + name: "hidden-dir skill", + skill: Skill{Name: "code-review", Convention: "hidden-dir"}, + wantName: "[hidden-dir] code-review", + }, + { + name: "hidden-dir-namespaced skill", + skill: Skill{Name: "code-review", Namespace: "monalisa", Convention: "hidden-dir-namespaced"}, + wantName: "[hidden-dir] monalisa/code-review", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantName, tt.skill.DisplayName()) + }) + } +} + func TestValidateName(t *testing.T) { tests := []struct { name string @@ -740,6 +889,82 @@ func TestDiscoverSkills(t *testing.T) { } } +func TestDiscoverSkillsWithOptions(t *testing.T) { + hiddenDirTree := map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": ".claude/skills/code-review", "type": "tree", "sha": "tree-sha-1"}, + {"path": ".claude/skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": ".agents/skills/git-commit", "type": "tree", "sha": "tree-sha-2"}, + {"path": ".agents/skills/git-commit/SKILL.md", "type": "blob", "sha": "blob-2"}, + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + } + + mixedTree := map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/standard-skill", "type": "tree", "sha": "tree-sha-1"}, + {"path": "skills/standard-skill/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": ".claude/skills/hidden-skill", "type": "tree", "sha": "tree-sha-2"}, + {"path": ".claude/skills/hidden-skill/SKILL.md", "type": "blob", "sha": "blob-2"}, + }, + } + + emptyTree := map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + } + + tests := []struct { + name string + tree map[string]interface{} + wantSkills []string + wantErr string + }{ + { + name: "returns hidden-dir skills", + tree: hiddenDirTree, + wantSkills: []string{"code-review", "git-commit"}, + }, + { + name: "mixed tree returns all skills", + tree: mixedTree, + wantSkills: []string{"hidden-skill", "standard-skill"}, + }, + { + name: "no skills at all", + tree: emptyTree, + wantErr: "no skills found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(tt.tree)) + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + + skills, err := DiscoverSkillsWithOptions(client, "github.com", "monalisa", "octocat-skills", "abc123", DiscoverOptions{}) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.Equal(t, tt.wantSkills, names) + }) + } +} + func TestDiscoverSkillByPath(t *testing.T) { tests := []struct { name string @@ -984,6 +1209,64 @@ func TestDiscoverLocalSkills(t *testing.T) { } } +func TestDiscoverLocalSkillsWithOptions(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, dir string) + wantSkills []string + wantErr string + }{ + { + name: "returns hidden dir skills", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, ".claude", "skills", "code-review") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# code-review"), 0o644)) + }, + wantSkills: []string{"code-review"}, + }, + { + name: "mixed standard and hidden returns all", + setup: func(t *testing.T, dir string) { + t.Helper() + for _, p := range []string{"skills/standard", ".agents/skills/hidden"} { + skillDir := filepath.Join(dir, filepath.FromSlash(p)) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + name := filepath.Base(p) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + wantSkills: []string{"standard", "hidden"}, + }, + { + name: "no skills at all", + setup: func(t *testing.T, _ string) { t.Helper() }, + wantErr: "no skills found", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "repo") + require.NoError(t, os.MkdirAll(dir, 0o755)) + tt.setup(t, dir) + + skills, err := DiscoverLocalSkillsWithOptions(dir, DiscoverOptions{}) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + var names []string + for _, s := range skills { + names = append(names, s.Name) + } + assert.ElementsMatch(t, tt.wantSkills, names) + }) + } +} + func TestMatchesSkillPath(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 5f715ff7ef9..88cd2673163 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -47,15 +47,16 @@ type InstallOptions struct { GitClient *git.Client Remotes func() (ghContext.Remotes, error) - SkillSource string // owner/repo or local path (when --from-local is set) - SkillName string // possibly with @version suffix - Agent string - Scope string - ScopeChanged bool // true when --scope was explicitly set - Pin string - Dir string // overrides --agent and --scope - Force bool - FromLocal bool // treat SkillSource as a local directory path + SkillSource string // owner/repo or local path (when --from-local is set) + SkillName string // possibly with @version suffix + Agent string + Scope string + ScopeChanged bool // true when --scope was explicitly set + Pin string + Dir string // overrides --agent and --scope + Force bool + FromLocal bool // treat SkillSource as a local directory path + AllowHiddenDirs bool // include skills in dot-prefixed directories repo ghrepo.Interface // set when SkillSource is a GitHub repository localPath string // set when FromLocal is true @@ -161,6 +162,9 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru # Pin to a specific git ref $ gh skill install github/awesome-copilot git-commit --pin v2.0.0 + + # Install skills from hidden directories (e.g. .claude/skills/) + $ gh skill install owner/repo --allow-hidden-dirs `), Aliases: []string{"add"}, Args: cobra.MaximumNArgs(2), @@ -205,6 +209,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru cmd.Flags().StringVar(&opts.Dir, "dir", "", "Install to a custom directory (overrides --agent and --scope)") cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting") cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository") + cmd.Flags().BoolVar(&opts.AllowHiddenDirs, "allow-hidden-dirs", false, "Include skills in hidden directories (e.g. .claude/skills/, .agents/skills/)") cmdutil.DisableAuthCheckFlag(cmd.Flags().Lookup("from-local")) return cmd @@ -417,12 +422,17 @@ func runLocalInstall(opts *InstallOptions) error { } opts.IO.StartProgressIndicatorWithLabel("Discovering skills") - skills, err := discovery.DiscoverLocalSkills(absSource) + allSkills, err := discovery.DiscoverLocalSkillsWithOptions(absSource, discovery.DiscoverOptions{}) opts.IO.StopProgressIndicator() if err != nil { return err } + skills, err := filterHiddenDirSkills(opts, allSkills) + if err != nil { + return err + } + if canPrompt { fmt.Fprintf(opts.IO.ErrOut, "Found %d skill(s)\n", len(skills)) } @@ -553,7 +563,7 @@ func resolveVersion(opts *InstallOptions, client *api.Client, hostname string) ( func discoverSkills(opts *InstallOptions, client *api.Client, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { opts.IO.StartProgressIndicatorWithLabel("Discovering skills") - skills, err := discovery.DiscoverSkills(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA) + allSkills, err := discovery.DiscoverSkillsWithOptions(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, discovery.DiscoverOptions{}) opts.IO.StopProgressIndicator() if err != nil { var treeTooLarge *discovery.TreeTooLargeError @@ -564,6 +574,10 @@ func discoverSkills(opts *InstallOptions, client *api.Client, hostname string, r } return nil, err } + skills, filterErr := filterHiddenDirSkills(opts, allSkills) + if filterErr != nil { + return nil, filterErr + } logConventions(opts.IO, skills) for _, s := range skills { if !discovery.IsSpecCompliant(s.Name) { @@ -1111,3 +1125,42 @@ func kiroResourcePath(installDir, gitRoot string) string { } return filepath.ToSlash(installDir) } + +// filterHiddenDirSkills separates hidden-dir skills from the full list and +// applies the --allow-hidden-dirs flag logic. When the flag is set, all skills +// are returned and a warning is printed. When the flag is not set, hidden-dir +// skills are excluded and an error is returned if no standard skills remain. +func filterHiddenDirSkills(opts *InstallOptions, allSkills []discovery.Skill) ([]discovery.Skill, error) { + cs := opts.IO.ColorScheme() + + if opts.AllowHiddenDirs { + if discovery.HasHiddenDirSkills(allSkills) { + fmt.Fprint(opts.IO.ErrOut, heredoc.Docf(` + %[1]s Skills in hidden directories (e.g. .claude/, .agents/) may be installed + copies from another publisher. Verify the skill's origin and check for a + canonical source. + `, cs.WarningIcon())) + } + return allSkills, nil + } + + var standard []discovery.Skill + var hiddenCount int + for _, s := range allSkills { + if s.IsHiddenDirConvention() { + hiddenCount++ + } else { + standard = append(standard, s) + } + } + + if len(standard) == 0 && hiddenCount > 0 { + return nil, fmt.Errorf( + "no standard skills found, but %d skill(s) exist in hidden directories\n"+ + " Use --allow-hidden-dirs to include them", + hiddenCount, + ) + } + + return standard, nil +} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 120738fd052..c557c93e7fa 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -119,6 +119,11 @@ func TestNewCmdInstall(t *testing.T) { cli: "--from-local ./local-dir --pin v1.0.0", wantErr: true, }, + { + name: "allow-hidden-dirs flag", + cli: "monalisa/skills-repo --allow-hidden-dirs", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project", AllowHiddenDirs: true}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -157,6 +162,7 @@ func TestNewCmdInstall(t *testing.T) { assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) assert.Equal(t, tt.wantOpts.FromLocal, gotOpts.FromLocal) + assert.Equal(t, tt.wantOpts.AllowHiddenDirs, gotOpts.AllowHiddenDirs) if tt.wantLocalPath { assert.NotEmpty(t, gotOpts.localPath, "expected localPath to be set") } else { @@ -256,6 +262,14 @@ func singleSkillTreeJSON(name, treeSHA, blobSHA string) string { ) } +// hiddenDirSkillTreeJSON returns tree entries for a hidden-dir skill under .claude/skills/. +func hiddenDirSkillTreeJSON(name, treeSHA, blobSHA string) string { + return fmt.Sprintf( + `{"path": ".claude/skills/%s", "type": "tree", "sha": %q}, {"path": ".claude/skills/%s/SKILL.md", "type": "blob", "sha": %q}`, + name, treeSHA, name, blobSHA, + ) +} + func TestInstallRun(t *testing.T) { tests := []struct { name string @@ -1327,6 +1341,110 @@ func TestInstallRun(t *testing.T) { wantStdout: "Installed git-commit", wantStderr: "Installing to", }, + { + name: "hidden-dir skills excluded without --allow-hidden-dirs", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + hiddenDirSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + } + }, + wantErr: "no standard skills found, but 1 skill(s) exist in hidden directories", + }, + { + name: "hidden-dir skills included with --allow-hidden-dirs", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + hiddenDirSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + AllowHiddenDirs: true, + } + }, + wantStdout: "Installed git-commit", + wantStderr: "Skills in hidden directories", + }, + { + name: "mixed tree without --allow-hidden-dirs returns only standard", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")+", "+ + hiddenDirSkillTreeJSON("hidden-skill", "treeSHA2", "blobSHA2")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, + { + name: "mixed tree with --allow-hidden-dirs returns all", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")+", "+ + hiddenDirSkillTreeJSON("hidden-skill", "treeSHA2", "blobSHA2")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA2", "blobSHA2", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "hidden-skill", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + AllowHiddenDirs: true, + } + }, + wantStdout: "Installed hidden-skill", + wantStderr: "Skills in hidden directories", + }, } for _, tt := range tests { @@ -1853,6 +1971,67 @@ func TestRunLocalInstall(t *testing.T) { }, wantErr: "not found in local directory", }, + { + name: "local hidden-dir skills excluded without --allow-hidden-dirs", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join(".claude", "skills", "code-review"), heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "code-review", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantErr: "no standard skills found, but 1 skill(s) exist in hidden directories", + }, + { + name: "local hidden-dir skills included with --allow-hidden-dirs", + isTTY: false, + setup: func(t *testing.T, sourceDir, _ string) { + t.Helper() + writeLocalTestSkill(t, sourceDir, filepath.Join(".claude", "skills", "code-review"), heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review + `)) + }, + opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + SkillSource: sourceDir, + localPath: sourceDir, + SkillName: "code-review", + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + AllowHiddenDirs: true, + GitClient: &git.Client{RepoDir: t.TempDir()}, + } + }, + wantStdout: "Installed code-review", + wantStderr: "Skills in hidden directories", + }, } for _, tt := range tests { From 78f1ad537c034d31f43c29be0e76e7dc120b44df Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 17 Apr 2026 16:56:28 +0200 Subject: [PATCH 056/182] Include CI context in telemetry --- internal/ci/ci.go | 19 +++++++++++++ internal/ci/ci_test.go | 56 ++++++++++++++++++++++++++++++++++++++ internal/ghcmd/cmd.go | 9 ++++-- internal/update/update.go | 13 ++------- pkg/cmd/copilot/copilot.go | 4 +-- 5 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 internal/ci/ci.go create mode 100644 internal/ci/ci_test.go diff --git a/internal/ci/ci.go b/internal/ci/ci.go new file mode 100644 index 00000000000..6438127b093 --- /dev/null +++ b/internal/ci/ci.go @@ -0,0 +1,19 @@ +// Package ci provides helpers for detecting CI/CD execution environments. +package ci + +import "os" + +// IsCI determines if the current execution context is within a known CI/CD system. +// This is based on https://github.com/watson/ci-info/blob/HEAD/index.js. +func IsCI() bool { + return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari + os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity + os.Getenv("RUN_ID") != "" // TaskCluster, dsari +} + +// IsGitHubActions determines if the current execution context is within GitHub Actions. +// GitHub Actions sets the GITHUB_ACTIONS environment variable to "true" for all steps. +// See https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables. +func IsGitHubActions() bool { + return os.Getenv("GITHUB_ACTIONS") == "true" +} diff --git a/internal/ci/ci_test.go b/internal/ci/ci_test.go new file mode 100644 index 00000000000..6b2a28b54af --- /dev/null +++ b/internal/ci/ci_test.go @@ -0,0 +1,56 @@ +package ci + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsCI(t *testing.T) { + tests := []struct { + name string + env map[string]string + want bool + }{ + {name: "no CI env vars", env: map[string]string{}, want: false}, + {name: "CI set", env: map[string]string{"CI": "true"}, want: true}, + {name: "BUILD_NUMBER set", env: map[string]string{"BUILD_NUMBER": "42"}, want: true}, + {name: "RUN_ID set", env: map[string]string{"RUN_ID": "abc"}, want: true}, + {name: "CI empty string", env: map[string]string{"CI": ""}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("BUILD_NUMBER", "") + t.Setenv("RUN_ID", "") + for k, v := range tt.env { + t.Setenv(k, v) + } + assert.Equal(t, tt.want, IsCI()) + }) + } +} + +func TestIsGitHubActions(t *testing.T) { + tests := []struct { + name string + value string + set bool + want bool + }{ + {name: "unset", set: false, want: false}, + {name: "true", value: "true", set: true, want: true}, + {name: "false", value: "false", set: true, want: false}, + {name: "empty", value: "", set: true, want: false}, + {name: "other value", value: "yes", set: true, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "") + if tt.set { + t.Setenv("GITHUB_ACTIONS", tt.value) + } + assert.Equal(t, tt.want, IsGitHubActions()) + }) + } +} diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 7350437dfb4..34806f87449 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -19,6 +19,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/agents" "github.com/cli/cli/v2/internal/build" + "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/config/migration" "github.com/cli/cli/v2/internal/gh" @@ -69,9 +70,11 @@ func Main() exitCode { ghExecutablePath := executablePath("gh") additionalCommonDimensions := ghtelemetry.Dimensions{ - "version": strings.TrimPrefix(buildVersion, "v"), - "is_tty": strconv.FormatBool(ioStreams.IsStdoutTTY()), - "agent": string(agents.Detect()), + "version": strings.TrimPrefix(buildVersion, "v"), + "is_tty": strconv.FormatBool(ioStreams.IsStdoutTTY()), + "agent": string(agents.Detect()), + "ci": strconv.FormatBool(ci.IsCI()), + "github_actions": strconv.FormatBool(ci.IsGitHubActions()), } var telemetryService ghtelemetry.Service diff --git a/internal/update/update.go b/internal/update/update.go index a4a15ea17cc..20cd09606c8 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/pkg/extensions" "github.com/hashicorp/go-version" "github.com/mattn/go-isatty" @@ -42,7 +43,7 @@ func ShouldCheckForExtensionUpdate() bool { if os.Getenv("CODESPACES") != "" { return false } - return !IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) + return !ci.IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) } // CheckForExtensionUpdate checks whether an update exists for a specific extension based on extension type and recency of last check within past 24 hours. @@ -83,7 +84,7 @@ func ShouldCheckForUpdate() bool { if os.Getenv("CODESPACES") != "" { return false } - return !IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) + return !ci.IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) } // CheckForUpdate checks whether an update exists for the GitHub CLI based on recency of last check within past 24 hours. @@ -182,11 +183,3 @@ func versionGreaterThan(v, w string) bool { func IsTerminal(f *os.File) bool { return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) } - -// IsCI determines if the current execution context is within a known CI/CD system. -// This is based on https://github.com/watson/ci-info/blob/HEAD/index.js. -func IsCI() bool { - return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari - os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity - os.Getenv("RUN_ID") != "" // TaskCluster, dsari -} diff --git a/pkg/cmd/copilot/copilot.go b/pkg/cmd/copilot/copilot.go index 4ab840709f1..1f2b7779858 100644 --- a/pkg/cmd/copilot/copilot.go +++ b/pkg/cmd/copilot/copilot.go @@ -18,10 +18,10 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safepaths" - "github.com/cli/cli/v2/internal/update" ghzip "github.com/cli/cli/v2/internal/zip" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -150,7 +150,7 @@ func runCopilot(opts *CopilotOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s Copilot CLI was not installed", opts.IO.ColorScheme().WarningIcon()) return cmdutil.SilentError } - } else if !update.IsCI() { + } else if !ci.IsCI() { fmt.Fprintf(opts.IO.ErrOut, "%s Copilot CLI not installed", opts.IO.ColorScheme().WarningIcon()) return cmdutil.SilentError } From 2e64043d55bb6b4ad6c543f95a1f781675155929 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 20 Apr 2026 12:22:55 +0200 Subject: [PATCH 057/182] fix(skills): stop publish --fix from publishing When --fix is used, return early after applying fixes instead of continuing to the publish flow. The fixed files need to be committed before publishing, so proceeding would fail anyway since the repo would not be in sync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish.go | 16 +++++++++++++--- pkg/cmd/skills/publish/publish_test.go | 1 + 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 6364846840f..73343f1716a 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -126,7 +126,8 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. Use %[1]s--dry-run%[1]s to validate without publishing. Use %[1]s--tag%[1]s to publish non-interactively with a specific tag. - Use %[1]s--fix%[1]s to automatically strip install metadata from committed files. + Use %[1]s--fix%[1]s to automatically strip install metadata from committed files + without publishing. Review and commit the changes, then run publish again. `, "`"), Example: heredoc.Doc(` # Validate and publish interactively @@ -138,7 +139,7 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. # Validate only (no publish) $ gh skill publish --dry-run - # Validate and strip install metadata + # Strip install metadata without publishing $ gh skills publish --fix `), Args: cobra.MaximumNArgs(1), @@ -153,7 +154,7 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. }, } - cmd.Flags().BoolVar(&opts.Fix, "fix", false, "Auto-fix issues where possible (e.g. strip install metadata)") + cmd.Flags().BoolVar(&opts.Fix, "fix", false, "Auto-fix issues where possible without publishing (e.g. strip install metadata)") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Validate without publishing") cmd.Flags().StringVar(&opts.Tag, "tag", "", "Version tag for the release (e.g. v1.0.0)") @@ -410,6 +411,15 @@ func publishRun(opts *PublishOptions) error { return nil } + if opts.Fix { + if fixes > 0 { + fmt.Fprintf(opts.IO.ErrOut, "\nFixed %d file(s). Review and commit the changes, then run %s to publish.\n", fixes, "gh skills publish") + } else { + fmt.Fprintf(opts.IO.ErrOut, "\nNo issues to fix.\n") + } + return nil + } + if owner == "" || repo == "" { fmt.Fprintf(opts.IO.ErrOut, "\nValidation passed. Set up a GitHub remote to publish.\n") return nil diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index f83117b5b5e..2eac240bc65 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -457,6 +457,7 @@ func TestPublishRun(t *testing.T) { return &PublishOptions{IO: ios, Dir: dir, Fix: true} }, wantStdout: "stripped install metadata", + wantStderr: "Fixed 1 file(s). Review and commit the changes", verify: func(t *testing.T, dir string) { t.Helper() fixed, err := os.ReadFile(filepath.Join(dir, "skills", "test-skill", "SKILL.md")) From c703294fbe8022e256b0b3999901efc4d785621a Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 20 Apr 2026 12:34:59 +0200 Subject: [PATCH 058/182] fix(skills): use canonical 'gh skill' not 'gh skills' alias Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 73343f1716a..d7aeddd6503 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -140,7 +140,7 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. $ gh skill publish --dry-run # Strip install metadata without publishing - $ gh skills publish --fix + $ gh skill publish --fix `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -413,7 +413,7 @@ func publishRun(opts *PublishOptions) error { if opts.Fix { if fixes > 0 { - fmt.Fprintf(opts.IO.ErrOut, "\nFixed %d file(s). Review and commit the changes, then run %s to publish.\n", fixes, "gh skills publish") + fmt.Fprintf(opts.IO.ErrOut, "\nFixed %d file(s). Review and commit the changes, then run %s to publish.\n", fixes, "gh skill publish") } else { fmt.Fprintf(opts.IO.ErrOut, "\nNo issues to fix.\n") } From f88a2a671c72341aaed3646c803d06f89201efa9 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 20 Apr 2026 12:36:57 +0200 Subject: [PATCH 059/182] fix(skills): make --fix and --dry-run mutually exclusive, suppress publish prompt - --fix and --dry-run now error when combined - "Ready to publish!" is suppressed when --fix is set since user must commit first Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/publish/publish.go | 5 ++++- pkg/cmd/skills/publish/publish_test.go | 14 ++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index d7aeddd6503..3a27fc5a2cd 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -147,6 +147,9 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. if len(args) == 1 { opts.Dir = args[0] } + if err := cmdutil.MutuallyExclusive("specify only one of `--fix` or `--dry-run`", opts.Fix, opts.DryRun); err != nil { + return err + } if runF != nil { return runF(opts) } @@ -1069,7 +1072,7 @@ func renderDiagnosticsTTY(opts *PublishOptions, skillCount int, diagnostics []pu fmt.Fprintf(opts.IO.ErrOut, "\n%s\n", d.message) } - if errors == 0 { + if errors == 0 && !opts.Fix { if owner != "" && repo != "" { fmt.Fprintf(opts.IO.ErrOut, "\n%s Repository: %s/%s\n", cs.Green("Ready to publish!"), owner, repo) } else { diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 2eac240bc65..a4f48dfe6e0 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -86,13 +86,15 @@ func TestNewCmdPublish(t *testing.T) { wantsOpts PublishOptions }{ { - name: "all flags", - cli: "./monalisa-skills --dry-run --fix --tag v1.0.0", + name: "fix and dry-run are mutually exclusive", + cli: "./monalisa-skills --dry-run --fix --tag v1.0.0", + wantsErr: true, + }, + { + name: "fix flag only", + cli: "--fix", wantsOpts: PublishOptions{ - Dir: "./monalisa-skills", - DryRun: true, - Fix: true, - Tag: "v1.0.0", + Fix: true, }, }, { From a656271f26a591c958f561874ddced4aace18bed Mon Sep 17 00:00:00 2001 From: Fredric Silberberg Date: Mon, 30 Mar 2026 11:58:53 -0700 Subject: [PATCH 060/182] Print `gh auth refresh` for 401 returns `gh auth refresh` exists to make it simpler for users to refresh their tokens on expiration/scope mismatch, but help messages only suggest using it in limited scenarios, and not in a common case of a token expiring and the user receiving a 401 error. Now, the auth flow will detect this case, and for refreshable tokens (namely, tokens created by logging in with `gh auth login` in the first place), it will suggest using `gh auth refresh` for these cases. --- internal/ghcmd/cmd.go | 21 ++++++++- internal/ghcmd/cmd_test.go | 75 ++++++++++++++++++++++++++++++ pkg/cmd/auth/shared/writeable.go | 6 +++ pkg/cmd/auth/status/status.go | 3 ++ pkg/cmd/auth/status/status_test.go | 8 ++-- 5 files changed, 108 insertions(+), 5 deletions(-) diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 34806f87449..b76bc146fb5 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -26,6 +26,7 @@ import ( "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/internal/update" + "github.com/cli/cli/v2/pkg/cmd/auth/shared" "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmd/root" "github.com/cli/cli/v2/pkg/cmdutil" @@ -215,7 +216,11 @@ func Main() exitCode { var httpErr api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == 401 { - fmt.Fprintln(stderr, "Try authenticating with: gh auth login") + authCommand := "gh auth login" + if cfg, cfgErr := cmdFactory.Config(); cfgErr == nil { + authCommand = authRecoveryCommand(cfg, httpErr) + } + fmt.Fprintf(stderr, "Try authenticating with: %s\n", authCommand) } else if u := factory.SSOURL(); u != "" { // handles organization SAML enforcement error fmt.Fprintf(stderr, "Authorize in your web browser: %s\n", u) @@ -279,6 +284,20 @@ func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) { } } +func authRecoveryCommand(cfg gh.Config, httpErr api.HTTPError) string { + if httpErr.RequestURL == nil { + return "gh auth login" + } + + hostname := ghauth.NormalizeHostname(httpErr.RequestURL.Hostname()) + token, source := cfg.Authentication().ActiveToken(hostname) + if shared.AuthTokenRefreshable(token, source) { + return fmt.Sprintf("gh auth refresh -h %s", hostname) + } + + return "gh auth login" +} + func checkForUpdate(ctx context.Context, f *cmdutil.Factory, currentVersion string) (*update.ReleaseInfo, error) { if updaterEnabled == "" || !update.ShouldCheckForUpdate() { return nil, nil diff --git a/internal/ghcmd/cmd_test.go b/internal/ghcmd/cmd_test.go index 65bcc0f288e..f7443511b84 100644 --- a/internal/ghcmd/cmd_test.go +++ b/internal/ghcmd/cmd_test.go @@ -5,11 +5,15 @@ import ( "errors" "fmt" "net" + "net/url" "testing" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + ghmock "github.com/cli/cli/v2/internal/gh/mock" "github.com/cli/cli/v2/pkg/cmdutil" + ghAPI "github.com/cli/go-gh/v2/pkg/api" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" ) @@ -511,3 +515,74 @@ func disableColorLabelsConfig() gh.Config { func enableColorLabelsConfig() gh.Config { return config.NewFromString("color_labels: enabled") } + +func Test_authRecoveryCommand(t *testing.T) { + tests := []struct { + name string + token string + source string + requestURL string + want string + }{ + { + name: "stored oauth token", + token: "gho_abc123", + source: "oauth_token", + requestURL: "https://api.github.com/graphql", + want: "gh auth refresh -h github.com", + }, + { + name: "stored pat", + token: "github_pat_abc123", + source: "oauth_token", + requestURL: "https://api.github.com/graphql", + want: "gh auth login", + }, + { + name: "env token", + token: "gho_abc123", + source: "GH_TOKEN", + requestURL: "https://api.github.com/graphql", + want: "gh auth login", + }, + { + name: "missing request url", + token: "gho_abc123", + source: "oauth_token", + want: "gh auth login", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authCfg := config.NewBlankConfig().Authentication() + authCfg.SetActiveToken(tt.token, tt.source) + cfg := &ghmock.ConfigMock{ + AuthenticationFunc: func() gh.AuthConfig { + return authCfg + }, + } + + var requestURL *url.URL + if tt.requestURL != "" { + var err error + requestURL, err = url.Parse(tt.requestURL) + if err != nil { + t.Fatalf("failed to parse request URL: %v", err) + } + } + + httpErr := api.HTTPError{ + HTTPError: &ghAPI.HTTPError{ + RequestURL: requestURL, + StatusCode: 401, + }, + } + + got := authRecoveryCommand(cfg, httpErr) + if got != tt.want { + t.Errorf("authRecoveryCommand() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/auth/shared/writeable.go b/pkg/cmd/auth/shared/writeable.go index 381c7e02a66..bcc7da14e42 100644 --- a/pkg/cmd/auth/shared/writeable.go +++ b/pkg/cmd/auth/shared/writeable.go @@ -6,6 +6,12 @@ import ( "github.com/cli/cli/v2/internal/gh" ) +// AuthTokenRefreshable reports whether the token is stored by gh and can be +// renewed with `gh auth refresh`. +func AuthTokenRefreshable(token, src string) bool { + return token != "" && !strings.HasSuffix(src, "_TOKEN") && strings.HasPrefix(token, "gho_") +} + func AuthTokenWriteable(authCfg gh.AuthConfig, hostname string) (string, bool) { token, src := authCfg.ActiveToken(hostname) return src, (token == "" || !strings.HasSuffix(src, "_TOKEN")) diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index 348b9531d73..658a8d8bc79 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -96,6 +96,9 @@ func (e authEntry) String(cs *iostreams.ColorScheme) string { sb.WriteString(fmt.Sprintf(" - The token in %s is invalid.\n", e.TokenSource)) if authTokenWriteable(e.TokenSource) { loginInstructions := fmt.Sprintf("gh auth login -h %s", e.Host) + if shared.AuthTokenRefreshable(e.Token, e.TokenSource) { + loginInstructions = fmt.Sprintf("gh auth refresh -h %s", e.Host) + } logoutInstructions := fmt.Sprintf("gh auth logout -h %s -u %s", e.Host, e.Login) sb.WriteString(fmt.Sprintf(" - To re-authenticate, run: %s\n", cs.Bold(loginInstructions))) sb.WriteString(fmt.Sprintf(" - To forget about this account, run: %s\n", cs.Bold(logoutInstructions))) diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 4246b1e863f..cb2abb90ecf 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -184,7 +184,7 @@ func Test_statusRun(t *testing.T) { X Failed to log in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - Active account: true - The token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h ghe.io + - To re-authenticate, run: gh auth refresh -h ghe.io - To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe `), }, @@ -229,7 +229,7 @@ func Test_statusRun(t *testing.T) { X Failed to log in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - Active account: true - The token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h ghe.io + - To re-authenticate, run: gh auth refresh -h ghe.io - To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe `), }, @@ -447,7 +447,7 @@ func Test_statusRun(t *testing.T) { X Failed to log in to ghe.io account monalisa-ghe (GH_CONFIG_DIR/hosts.yml) - Active account: false - The token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h ghe.io + - To re-authenticate, run: gh auth refresh -h ghe.io - To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe `), }, @@ -535,7 +535,7 @@ func Test_statusRun(t *testing.T) { X Failed to log in to ghe.io account monalisa-ghe-2 (GH_CONFIG_DIR/hosts.yml) - Active account: true - The token in GH_CONFIG_DIR/hosts.yml is invalid. - - To re-authenticate, run: gh auth login -h ghe.io + - To re-authenticate, run: gh auth refresh -h ghe.io - To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe-2 `), }, From c139b17e9fe007126d9e44d1bd8bab7bac2943f5 Mon Sep 17 00:00:00 2001 From: Fredric Silberberg Date: Mon, 20 Apr 2026 11:52:03 -0700 Subject: [PATCH 061/182] Apply patch from code review feedback. --- internal/ghcmd/cmd.go | 2 +- internal/ghcmd/cmd_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index b76bc146fb5..5b840eb4eb4 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -295,7 +295,7 @@ func authRecoveryCommand(cfg gh.Config, httpErr api.HTTPError) string { return fmt.Sprintf("gh auth refresh -h %s", hostname) } - return "gh auth login" + return fmt.Sprintf("gh auth login -h %s", hostname) } func checkForUpdate(ctx context.Context, f *cmdutil.Factory, currentVersion string) (*update.ReleaseInfo, error) { diff --git a/internal/ghcmd/cmd_test.go b/internal/ghcmd/cmd_test.go index f7443511b84..d389bd7448f 100644 --- a/internal/ghcmd/cmd_test.go +++ b/internal/ghcmd/cmd_test.go @@ -536,14 +536,14 @@ func Test_authRecoveryCommand(t *testing.T) { token: "github_pat_abc123", source: "oauth_token", requestURL: "https://api.github.com/graphql", - want: "gh auth login", + want: "gh auth login -h github.com", }, { name: "env token", token: "gho_abc123", source: "GH_TOKEN", requestURL: "https://api.github.com/graphql", - want: "gh auth login", + want: "gh auth login -h github.com", }, { name: "missing request url", From 9a368f45e9186482a8c511cadcef76d81f7c6bdd Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 17 Apr 2026 22:06:28 +0200 Subject: [PATCH 062/182] feat(skills): support nested skills/ directories in discovery Relax skill discovery to recognize skills/ directories at any depth in the repository tree, not just at the root. This enables repos like hashicorp/agent-skills that organize skills under prefixes such as terraform/code-generation/skills/*/SKILL.md. Changes: - matchSkillConventions: add checks for /skills//SKILL.md and /skills///SKILL.md at any depth - isSkillPath: also match paths containing /skills/ for direct path-based install - DiscoverSkillByPath: fix namespace detection to find the skills segment anywhere in the path - Update error messages and help text to mention nested conventions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/skills/discovery/discovery.go | 76 +++++++- internal/skills/discovery/discovery_test.go | 191 +++++++++++++++++++- pkg/cmd/skills/install/install.go | 7 +- pkg/cmd/skills/install/install_test.go | 56 +++++- 4 files changed, 313 insertions(+), 17 deletions(-) diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index b2c8baaed93..1b0c7f0075a 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -405,6 +405,24 @@ func matchSkillConventions(entry treeEntry) *skillMatch { return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "plugins"} } + // Deeply nested skills/ directory: /skills//SKILL.md + // Matches skills/ at any depth, not just at the repository root. + // Exclude paths with dot-prefixed segments (handled by + // matchHiddenDirConventions) and paths under a plugins/ directory + // (handled by the plugins convention above). + if path.Base(parentDir) == "skills" && !hasHiddenSegment(entry.Path) && !hasPluginsAncestor(entry.Path) { + return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "skills"} + } + + // Deeply nested namespaced: /skills///SKILL.md + if path.Base(grandparentDir) == "skills" && !hasHiddenSegment(entry.Path) && !hasPluginsAncestor(entry.Path) { + namespace := path.Base(parentDir) + if !validateName(namespace) { + return nil + } + return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "skills-namespaced"} + } + if parentDir == "." && skillName != "skills" && skillName != "plugins" && !strings.HasPrefix(skillName, ".") { return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "root"} } @@ -534,6 +552,7 @@ func DiscoverSkillsWithOptions(client *api.Client, host, owner, repo, commitSHA return nil, fmt.Errorf( "no skills found in %s/%s\n"+ " Expected skills in skills/*/SKILL.md, skills/{scope}/*/SKILL.md,\n"+ + " {prefix}/skills/*/SKILL.md, {prefix}/skills/{scope}/*/SKILL.md,\n"+ " */SKILL.md, or plugins/*/skills/*/SKILL.md\n"+ " This repository may be a curated list rather than a skills publisher", owner, repo, @@ -667,18 +686,35 @@ func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skill return nil, fmt.Errorf("no SKILL.md found in %s", skillPath) } - var namespace string + var namespace, convention string parts := strings.Split(skillPath, "/") - if len(parts) >= 3 && parts[0] == "skills" { - namespace = parts[1] + for i, p := range parts { + if p != "skills" { + continue + } + + // Plugin convention: .../plugins//skills/ + if i >= 2 && parts[i-2] == "plugins" { + namespace = parts[i-1] + convention = "plugins" + break + } + + // Namespaced skill convention: .../skills// + afterSkills := parts[i+1:] + if len(afterSkills) >= 2 { + namespace = afterSkills[0] + } + break } skill := &Skill{ - Name: skillName, - Namespace: namespace, - Path: skillPath, - BlobSHA: blobSHA, - TreeSHA: treeSHA, + Name: skillName, + Namespace: namespace, + Convention: convention, + Path: skillPath, + BlobSHA: blobSHA, + TreeSHA: treeSHA, } skill.Description = fetchDescription(client, host, owner, repo, skill) @@ -907,7 +943,9 @@ func DiscoverLocalSkillsWithOptions(dir string, opts DiscoverOptions) ([]Skill, return nil, fmt.Errorf( "no skills found in %s\n"+ " Expected SKILL.md in the directory, or skills in skills/*/SKILL.md,\n"+ - " skills/{scope}/*/SKILL.md, */SKILL.md, or plugins/*/skills/*/SKILL.md", + " skills/{scope}/*/SKILL.md, {prefix}/skills/*/SKILL.md,\n"+ + " {prefix}/skills/{scope}/*/SKILL.md, */SKILL.md, or\n"+ + " plugins/*/skills/*/SKILL.md", dir, ) } @@ -955,6 +993,26 @@ func validateName(name string) bool { return safeNamePattern.MatchString(name) } +// hasHiddenSegment reports whether any path component starts with a dot. +func hasHiddenSegment(p string) bool { + for _, seg := range strings.Split(p, "/") { + if strings.HasPrefix(seg, ".") { + return true + } + } + return false +} + +// hasPluginsAncestor reports whether any path component is "plugins". +func hasPluginsAncestor(p string) bool { + for _, seg := range strings.Split(p, "/") { + if seg == "plugins" { + return true + } + } + return false +} + // IsSpecCompliant checks if a skill name matches the strict agentskills.io spec. func IsSpecCompliant(name string) bool { if len(name) == 0 || len(name) > 64 { diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 41600f21c72..bc3ce979b54 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -100,6 +100,52 @@ func TestMatchSkillConventions(t *testing.T) { path: ".hidden/SKILL.md", wantNil: true, }, + { + name: "nested skills directory", + path: "terraform/code-generation/skills/terraform-style-guide/SKILL.md", + wantName: "terraform-style-guide", + wantConvention: "skills", + }, + { + name: "deeply nested skills directory", + path: "a/b/c/skills/my-skill/SKILL.md", + wantName: "my-skill", + wantConvention: "skills", + }, + { + name: "nested namespaced skills directory", + path: "terraform/code-generation/skills/hashicorp/terraform-style-guide/SKILL.md", + wantName: "terraform-style-guide", + wantNamespace: "hashicorp", + wantConvention: "skills-namespaced", + }, + { + name: "single prefix before skills directory", + path: "packer/skills/packer-builder/SKILL.md", + wantName: "packer-builder", + wantConvention: "skills", + }, + { + name: "root-level skills still has priority", + path: "skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "skills", + }, + { + name: "nested skills dir itself is not a skill", + path: "terraform/skills/SKILL.md", + wantNil: true, + }, + { + name: "nested skills under hidden dir excluded", + path: ".claude/skills/code-review/SKILL.md", + wantNil: true, + }, + { + name: "nested plugins skills not matched as plain skills", + path: "vendor/plugins/hubot/skills/pr-summary/SKILL.md", + wantNil: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -865,6 +911,41 @@ func TestDiscoverSkills(t *testing.T) { }, wantSkills: []string{"code-review"}, }, + { + name: "discovers skills in nested skills directory", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "terraform/code-generation/skills/terraform-style-guide", "type": "tree", "sha": "tree-sha-1"}, + {"path": "terraform/code-generation/skills/terraform-style-guide/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "terraform/code-generation/skills/terraform-test", "type": "tree", "sha": "tree-sha-2"}, + {"path": "terraform/code-generation/skills/terraform-test/SKILL.md", "type": "blob", "sha": "blob-2"}, + {"path": "README.md", "type": "blob", "sha": "readme"}, + }, + })) + }, + wantSkills: []string{"terraform-style-guide", "terraform-test"}, + }, + { + name: "discovers mixed root-level and nested skills", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "skills/code-review", "type": "tree", "sha": "tree-sha-1"}, + {"path": "skills/code-review/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "terraform/skills/tf-lint", "type": "tree", "sha": "tree-sha-2"}, + {"path": "terraform/skills/tf-lint/SKILL.md", "type": "blob", "sha": "blob-2"}, + }, + })) + }, + wantSkills: []string{"code-review", "tf-lint"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -967,12 +1048,13 @@ func TestDiscoverSkillsWithOptions(t *testing.T) { func TestDiscoverSkillByPath(t *testing.T) { tests := []struct { - name string - skillPath string - stubs func(*httpmock.Registry) - wantName string - wantNS string - wantErr string + name string + skillPath string + stubs func(*httpmock.Registry) + wantName string + wantNS string + wantConvention string + wantErr string }{ { name: "discovers skill by path", @@ -1112,6 +1194,84 @@ func TestDiscoverSkillByPath(t *testing.T) { }, wantErr: "no SKILL.md found", }, + { + name: "deeply nested path discovers skill", + skillPath: "terraform/code-generation/skills/terraform-style-guide", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/terraform%2Fcode-generation%2Fskills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "terraform-style-guide", "path": "terraform/code-generation/skills/terraform-style-guide", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "terraform-style-guide", + }, + { + name: "deeply nested namespaced path sets namespace", + skillPath: "terraform/code-generation/skills/hashicorp/terraform-style-guide", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/terraform%2Fcode-generation%2Fskills%2Fhashicorp"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "terraform-style-guide", "path": "terraform/code-generation/skills/hashicorp/terraform-style-guide", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "terraform-style-guide", + wantNS: "hashicorp", + }, + { + name: "plugins path sets namespace and convention", + skillPath: "plugins/hubot/skills/pr-summary", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/plugins%2Fhubot%2Fskills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "pr-summary", "path": "plugins/hubot/skills/pr-summary", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/blobs/blob-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "blob-sha", "encoding": "base64", "content": "IyBTa2lsbA==", + })) + }, + wantName: "pr-summary", + wantNS: "hubot", + wantConvention: "plugins", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1131,6 +1291,9 @@ func TestDiscoverSkillByPath(t *testing.T) { require.NoError(t, err) assert.Equal(t, tt.wantName, skill.Name) assert.Equal(t, tt.wantNS, skill.Namespace) + if tt.wantConvention != "" { + assert.Equal(t, tt.wantConvention, skill.Convention) + } }) } } @@ -1184,6 +1347,19 @@ func TestDiscoverLocalSkills(t *testing.T) { setup: func(t *testing.T, dir string) {}, wantErr: "could not access", }, + { + name: "discovers skills in nested skills/ directory", + createDir: true, + setup: func(t *testing.T, dir string) { + t.Helper() + for _, name := range []string{"terraform-style-guide", "terraform-test"} { + skillDir := filepath.Join(dir, "terraform", "code-generation", "skills", name) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name), 0o644)) + } + }, + wantSkills: []string{"terraform-style-guide", "terraform-test"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1278,6 +1454,7 @@ func TestMatchesSkillPath(t *testing.T) { {name: "plugins convention", path: "plugins/hubot/skills/pr-summary/SKILL.md", wantName: "pr-summary"}, {name: "non-skill file", path: "README.md", wantName: ""}, {name: "non-SKILL.md in skill dir", path: "skills/code-review/prompt.txt", wantName: ""}, + {name: "nested skills convention", path: "terraform/code-generation/skills/terraform-style-guide/SKILL.md", wantName: "terraform-style-guide"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1300,6 +1477,8 @@ func TestMatchSkillPath(t *testing.T) { {name: "same name different namespace 1", path: "skills/kynan/commit/SKILL.md", wantName: "commit", wantNamespace: "kynan"}, {name: "same name different namespace 2", path: "skills/will/commit/SKILL.md", wantName: "commit", wantNamespace: "will"}, {name: "root convention", path: "my-skill/SKILL.md", wantName: "my-skill", wantNamespace: ""}, + {name: "nested skills convention", path: "terraform/code-generation/skills/terraform-style-guide/SKILL.md", wantName: "terraform-style-guide", wantNamespace: ""}, + {name: "nested namespaced convention", path: "terraform/code-generation/skills/hashicorp/terraform-style-guide/SKILL.md", wantName: "terraform-style-guide", wantNamespace: "hashicorp"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 88cd2673163..d792501fd27 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -107,7 +107,9 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru tracking metadata injected into frontmatter. Skills are discovered automatically using the %[1]sskills/*/SKILL.md%[1]s convention - defined by the Agent Skills specification. For more information on the specification, + defined by the Agent Skills specification, including when the %[1]sskills/%[1]s + directory is nested under a prefix (e.g. %[1]sterraform/code-generation/skills/...%[1]s). + For more information on the specification, see: https://agentskills.io/specification The skill argument can be a name, a namespaced name (%[1]sauthor/skill%[1]s), @@ -504,6 +506,9 @@ func isSkillPath(name string) bool { if strings.HasPrefix(name, "skills/") || strings.HasPrefix(name, "plugins/") { return true } + if strings.Contains(name, "/skills/") || strings.Contains(name, "/plugins/") { + return true + } return false } diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index c557c93e7fa..e4bd074bd5f 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "fmt" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -231,7 +232,7 @@ func stubSkillByPath(reg *httpmock.Registry, owner, repo, sha, skillPath, skillN parentPath = skillPath[:idx] } reg.Register( - httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, parentPath)), + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, url.PathEscape(parentPath))), httpmock.StringResponse(fmt.Sprintf(`[{"name": %q, "path": %q, "sha": %q, "type": "dir"}]`, skillName, skillPath, treeSHA)), ) } @@ -759,6 +760,34 @@ func TestInstallRun(t *testing.T) { }, wantStdout: "Installed git-commit", }, + { + name: "remote install by nested skill path skips full discovery", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubSkillByPath(reg, "monalisa", "skills-repo", "abc123", + "terraform/code-generation/skills/terraform-style-guide", "terraform-style-guide", "treeSHA") + // DiscoverSkillByPath: tree + blob (for fetchDescription) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + // installer.Install: tree + blob (again, for writing files) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "terraform/code-generation/skills/terraform-style-guide", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed terraform-style-guide", + }, { name: "remote install with URL repo argument", isTTY: true, @@ -2075,6 +2104,31 @@ func TestRunLocalInstall(t *testing.T) { } } +func Test_isSkillPath(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + {name: "empty string", path: "", want: false}, + {name: "plain skill name", path: "git-commit", want: false}, + {name: "SKILL.md at root", path: "SKILL.md", want: true}, + {name: "SKILL.md suffix", path: "skills/code-review/SKILL.md", want: true}, + {name: "starts with skills/", path: "skills/code-review", want: true}, + {name: "starts with plugins/", path: "plugins/hubot/skills/pr-summary", want: true}, + {name: "nested skills/ path", path: "terraform/code-generation/skills/terraform-style-guide", want: true}, + {name: "deeply nested skills/ path", path: "a/b/c/skills/my-skill", want: true}, + {name: "nested plugins/ path", path: "vendor/plugins/hubot/skills/pr-summary", want: true}, + {name: "name containing skills substring", path: "myskills", want: false}, + {name: "namespaced path", path: "skills/monalisa/issue-triage", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isSkillPath(tt.path)) + }) + } +} + func Test_printReviewHint(t *testing.T) { tests := []struct { name string From 33789149b9def6b66ddf247438a79fac94e22074 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:38:59 -0600 Subject: [PATCH 063/182] docs(skills): add gh and gh-skill agent skills Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- skills/gh-skill/SKILL.md | 124 +++++++++++++++++++++++++++++++++++++++ skills/gh/SKILL.md | 75 +++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 skills/gh-skill/SKILL.md create mode 100644 skills/gh/SKILL.md diff --git a/skills/gh-skill/SKILL.md b/skills/gh-skill/SKILL.md new file mode 100644 index 00000000000..59f58d325d9 --- /dev/null +++ b/skills/gh-skill/SKILL.md @@ -0,0 +1,124 @@ +--- +name: gh-skill +description: Manage agent skills with gh skill. Use this skill to discover, preview, install, update, and publish Agent Skills so an agent can self-manage the skills available in its environment. +--- + +# Managing skills with `gh skill` + +`gh skill` installs, previews, searches, updates, and publishes +[Agent Skills](https://agentskills.io). An agent can use it to keep its +own skill set in sync with one or more GitHub repositories. + +The command is also aliased as `gh skills`. Prefer the canonical singular +`gh skill` in scripts and docs. + +## Search + +```bash +gh skill search # free-text search +gh skill search --owner # restrict to one owner +gh skill search --limit 20 --page 2 +gh skill search --json skillName,repo,description +``` + +## Preview before installing + +```bash +gh skill preview / +gh skill preview / @v1.2.0 # pin a version +``` + +## Install + +```bash +gh skill install / +gh skill install / @v1.2.0 +gh skill install / skills// # exact path, fastest +gh skill install ./local-skills-repo --from-local +``` + +`/` and `` are both required. + +Useful flags: + +- `--agent ` - target host (e.g. `github-copilot`, `claude-code`, + `cursor`, `codex`, `gemini-cli`). Repeat for multiple. Default is + `github-copilot` when non-interactive. You should know what agent you are, + so set this appropriately to install for yourself. +- `--scope project|user` - `project` (default) writes inside the current + git repo; `user` writes to the home directory and applies everywhere. +- `--pin ` - pin to a tag, branch, or commit SHA. Mutually exclusive + with `--from-local` and with inline `@version` syntax. +- `--allow-hidden-dirs` - also discover skills under dot-directories such + as `.claude/skills/`. Don't use this unless you need to, it comes with risks. +- `--force` - overwrite an existing install. + +## Update + +```bash +gh skill update --all # update every installed skill +gh skill update # update one +gh skill update --force +gh skill update --unpin # drop the pin and move to latest +``` + +## Publish + +Publishing turns a repo into a discoverable skill source. Skills are +discovered with these conventions: + +- `skills//SKILL.md` +- `skills///SKILL.md` +- `/SKILL.md` (root-level) +- `plugins//skills//SKILL.md` + +Each `SKILL.md` needs YAML frontmatter: + +```yaml +--- +name: my-skill # must equal the directory name +description: One sentence... # required, recommended <= 1024 chars +license: MIT # optional but recommended +--- +``` + +Naming rules (enforced by `publish`): + +- Lowercase alphanumeric and hyphens only, 1–64 chars. +- Must start and end with `[a-z0-9]`. +- No double hyphens (`--`). +- `allowed-tools`, if present, must be a string (space-delimited), not a + YAML array. + +### Validate, then publish + +```bash +gh skill publish --dry-run # validate only, no release +gh skill publish --dry-run ./path/to/repo # validate a specific dir +gh skill publish --fix # auto-strip install metadata +gh skill publish --tag v1.0.0 # non-interactive publish +gh skill publish # interactive publish flow +``` + +`--fix` and `--dry-run` are mutually exclusive. `--fix` only rewrites +install-injected `metadata.github-*` keys and does not publish; commit +the result and re-run `publish`. + +The publish flow will: + +1. Add the `agent-skills` topic to the repo (so search can find it). +2. Use `--tag` (or prompt for one in a TTY). +3. Auto-push any unpushed commits. +4. Create a GitHub release with auto-generated notes. + +Always pass `--tag` so it doesn't fall through to the interactive flow. + +## Self-management pattern for agents + +A reasonable loop: + +1. `gh skill search --json name,repository` +2. `gh skill preview ` to inspect the SKILL.md. +3. `gh skill install --agent --pin ` for a + reproducible install. +4. Periodically `gh skill update --all` to refresh. \ No newline at end of file diff --git a/skills/gh/SKILL.md b/skills/gh/SKILL.md new file mode 100644 index 00000000000..e37dafdbab3 --- /dev/null +++ b/skills/gh/SKILL.md @@ -0,0 +1,75 @@ +--- +name: gh +description: Patterns for invoking the GitHub CLI (gh) from agents. Covers structured output, pagination, repo targeting, search vs list, gh api fallback. +--- + +# Reference + +## Interactivity policy + +`gh` already does the right thing in non-TTY contexts: it skips the pager, +strips ANSI color, and errors out fast with a helpful message instead of +prompting (e.g. `must provide --title and --body when not running interactively`). +You don't need to defensively set `GH_PAGER` or pass `--no-pager` (no such +flag exists). + +## Parsing JSON + +Human output from `gh` is column-formatted. If you want structured data: + +- Add `--json field1,field2,...` for structured output. +- Run a command with `--json` and **no field list** to print the full set of + available fields, then pick what you need. +- Use `--jq ''` for filtering without piping through a separate `jq`. +- Use `--template ''` when you want shaped text output. + +## Pagination and silent truncation + +List commands cap results. + +- `gh issue list`, `gh pr list`, `gh search ...`: pass `-L N` (`--limit N`). + The default is usually 30. +- Use `--json totalCount` to get the total number of items. This helps you know + if you need to paginate. +- For raw API calls use `gh api --paginate `. Combine with + `--jq` and (optionally) `--slurp` to assemble one array. + +## Repo targeting + +`gh` infers the repo from the cwd's git remotes. + +Pass `--repo OWNER/REPO` (`-R`) to override the resolved CWD repo. + +## Search vs list + +- `gh search issues|prs|code|repos|commits|users` uses GitHub's search + index and accepts the full search syntax (`is:open`, `author:`, + `label:`, `repo:owner/name`, `in:title`, ...). Prefer it for anything + cross-repo or filtered by author/label. +- `gh issue list --search "..."` and `gh pr list --search "..."` accept + the same syntax but are scoped to one repo. + +## Fall back to `gh api` for anything `--json` doesn't expose + +Sometimes useful data isn't on the typed commands. Examples: + +- Review-thread comments on a PR: `gh api repos/{owner}/{repo}/pulls/{n}/comments` + (the `--comments` flag on `gh pr view` shows issue-level comments only). +- Arbitrary GraphQL: `gh api graphql -f query='...' -F var=value`. +- REST shortcuts: `gh api repos/{owner}/{repo}/...` - note the + `{owner}/{repo}` placeholder is filled in for you when run from a repo + with detected remotes; pass them literally if you want determinism. + +## Authentication + +- `gh auth status` prints the active host(s), user, and which env var (if + any) is being honored. +- `gh auth status --json` is supported. + +## Other notes + +- `gh pr checkout ` switches branches. Use `gh pr diff ` or + `gh pr view ` if you only need to read. +- `NO_COLOR`, `CLICOLOR_FORCE`, and `GH_FORCE_TTY` are honored. Set + `GH_FORCE_TTY=1` if you want TTY-style output (colors, tables, the + pager, interactivity) inside an agent harness; leave it unset unless needed. From 991e37dc2550b421c8daed7cd405d4f0859931e9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:07:56 -0600 Subject: [PATCH 064/182] use hyphen instead --- skills/gh-skill/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/gh-skill/SKILL.md b/skills/gh-skill/SKILL.md index 59f58d325d9..319b3ce4c3b 100644 --- a/skills/gh-skill/SKILL.md +++ b/skills/gh-skill/SKILL.md @@ -84,7 +84,7 @@ license: MIT # optional but recommended Naming rules (enforced by `publish`): -- Lowercase alphanumeric and hyphens only, 1–64 chars. +- Lowercase alphanumeric and hyphens only, 1-64 chars. - Must start and end with `[a-z0-9]`. - No double hyphens (`--`). - `allowed-tools`, if present, must be a string (space-delimited), not a From 01ca82955bc3ebd7f2a282cee1d6630973d0a2e7 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:12:27 -0600 Subject: [PATCH 065/182] docs(skills): drop hand-copied naming rules from gh-skill Avoids drift between this skill and the actual validation logic in publish. Agents should run `gh skill publish --dry-run` to learn the current rules from the source of truth. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- skills/gh-skill/SKILL.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/skills/gh-skill/SKILL.md b/skills/gh-skill/SKILL.md index 319b3ce4c3b..c32c5ced9fa 100644 --- a/skills/gh-skill/SKILL.md +++ b/skills/gh-skill/SKILL.md @@ -82,14 +82,6 @@ license: MIT # optional but recommended --- ``` -Naming rules (enforced by `publish`): - -- Lowercase alphanumeric and hyphens only, 1-64 chars. -- Must start and end with `[a-z0-9]`. -- No double hyphens (`--`). -- `allowed-tools`, if present, must be a string (space-delimited), not a - YAML array. - ### Validate, then publish ```bash From 72884d9e415d51170ebb6f403c062a57bb5f2dfe Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:14:07 -0600 Subject: [PATCH 066/182] backtick skill filename Co-authored-by: Babak K. Shandiz --- skills/gh-skill/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/gh-skill/SKILL.md b/skills/gh-skill/SKILL.md index c32c5ced9fa..cd505bf2934 100644 --- a/skills/gh-skill/SKILL.md +++ b/skills/gh-skill/SKILL.md @@ -110,7 +110,7 @@ Always pass `--tag` so it doesn't fall through to the interactive flow. A reasonable loop: 1. `gh skill search --json name,repository` -2. `gh skill preview ` to inspect the SKILL.md. +2. `gh skill preview ` to inspect the `SKILL.md`. 3. `gh skill install --agent --pin ` for a reproducible install. 4. Periodically `gh skill update --all` to refresh. \ No newline at end of file From 65974f568f07e05baee448cc7adfad936ae0a5aa Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:18:40 -0600 Subject: [PATCH 067/182] docs(skills): note --template collisions and single-string search Addresses review feedback on PR #13244: - Flag that --template/-T collides with body-template flags on pr create / issue create, so agents should check --help. - Recommend the single quoted-string form for gh search (matching --search), since multi-arg invocations join oddly and can produce invalid queries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- skills/gh/SKILL.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/skills/gh/SKILL.md b/skills/gh/SKILL.md index e37dafdbab3..366978d1597 100644 --- a/skills/gh/SKILL.md +++ b/skills/gh/SKILL.md @@ -21,7 +21,10 @@ Human output from `gh` is column-formatted. If you want structured data: - Run a command with `--json` and **no field list** to print the full set of available fields, then pick what you need. - Use `--jq ''` for filtering without piping through a separate `jq`. -- Use `--template ''` when you want shaped text output. +- Use `--template ''` (alongside `--json`) when you want shaped + text output. Note that `--template`/`-T` collides with a body-template flag + on a few commands (e.g. `gh pr create -T`, `gh issue create -T`); always + check `--help` before assuming which one you're hitting. ## Pagination and silent truncation @@ -44,8 +47,10 @@ Pass `--repo OWNER/REPO` (`-R`) to override the resolved CWD repo. - `gh search issues|prs|code|repos|commits|users` uses GitHub's search index and accepts the full search syntax (`is:open`, `author:`, - `label:`, `repo:owner/name`, `in:title`, ...). Prefer it for anything - cross-repo or filtered by author/label. + `label:`, `repo:owner/name`, `in:title`, ...). Pass the entire query as + one quoted string, the same way you would for `--search`: + `gh search issues "is:open author:foo repo:cli/cli"`. Prefer it for + anything cross-repo or filtered by author/label. - `gh issue list --search "..."` and `gh pr list --search "..."` accept the same syntax but are scoped to one repo. From 01e345082cd816e2485cf88aea0f3b731dc5d09e Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:21:26 -0600 Subject: [PATCH 068/182] fix totalCount guidance Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- skills/gh/SKILL.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skills/gh/SKILL.md b/skills/gh/SKILL.md index 366978d1597..34cce8bc2d8 100644 --- a/skills/gh/SKILL.md +++ b/skills/gh/SKILL.md @@ -32,8 +32,9 @@ List commands cap results. - `gh issue list`, `gh pr list`, `gh search ...`: pass `-L N` (`--limit N`). The default is usually 30. -- Use `--json totalCount` to get the total number of items. This helps you know - if you need to paginate. +- `gh issue list` / `gh pr list` do not expose aggregate totals like + `totalCount` via `--json`. If you need a true total, use `gh api graphql` + to query `totalCount`; otherwise, treat `-L` as the cap for the current call. - For raw API calls use `gh api --paginate `. Combine with `--jq` and (optionally) `--slurp` to assemble one array. From 74d377313a570ee84232389bee5d6be952ae1410 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:22:08 -0600 Subject: [PATCH 069/182] use the right json field names for skills Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- skills/gh-skill/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/gh-skill/SKILL.md b/skills/gh-skill/SKILL.md index cd505bf2934..e0db2feaed4 100644 --- a/skills/gh-skill/SKILL.md +++ b/skills/gh-skill/SKILL.md @@ -109,7 +109,7 @@ Always pass `--tag` so it doesn't fall through to the interactive flow. A reasonable loop: -1. `gh skill search --json name,repository` +1. `gh skill search --json skillName,repo,namespace` 2. `gh skill preview ` to inspect the `SKILL.md`. 3. `gh skill install --agent --pin ` for a reproducible install. From 1160943af343d9904356db0d9f4bb2f5b5a9b651 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 21 Apr 2026 09:56:27 +0200 Subject: [PATCH 070/182] fix(skills): match skills by install name in preview command The preview command's selectSkill function only matched skills by DisplayName() and Name. For plugins-convention skills, the install hint outputs InstallName() (namespace/name), which matched neither - DisplayName() includes a [plugins] prefix and Name is just the base name. This caused 'skill not found' errors when users ran the suggested preview command after install. Add InstallName() as an additional match criterion so that namespaced skill identifiers produced by the install hint are accepted. Closes #13248 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/preview/preview.go | 2 +- pkg/cmd/skills/preview/preview_test.go | 45 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index e9f1e0442ce..98762fb119b 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -391,7 +391,7 @@ func isMarkdownFile(filePath string) bool { func selectSkill(opts *PreviewOptions, skills []discovery.Skill) (discovery.Skill, error) { if opts.SkillName != "" { for _, s := range skills { - if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName { + if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName || s.InstallName() == opts.SkillName { return s, nil } } diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index a5d5554ffe9..be3c861170e 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -206,6 +206,51 @@ func TestPreviewRun(t *testing.T) { }, wantStdout: "My Skill", }, + { + name: "preview plugins skill matched by install name", + tty: true, + opts: &PreviewOptions{ + repo: ghrepo.New("owner", "repo"), + SkillName: "aws-common/aws-mcp-setup", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(`{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "plugins", "type": "tree", "sha": "tree-plugins"}, + {"path": "plugins/aws-common", "type": "tree", "sha": "tree-awscommon"}, + {"path": "plugins/aws-common/skills", "type": "tree", "sha": "tree-awsskills"}, + {"path": "plugins/aws-common/skills/aws-mcp-setup", "type": "tree", "sha": "treeSHA3"}, + {"path": "plugins/aws-common/skills/aws-mcp-setup/SKILL.md", "type": "blob", "sha": "blob789"} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA3"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob789", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob789"), + httpmock.StringResponse(`{"sha": "blob789", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + }, + wantStdout: "My Skill", + }, { name: "skill not found", tty: true, From 6fcc9c24df4e0307a89be32d6ebaa0002d4545e3 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 21 Apr 2026 10:09:20 +0200 Subject: [PATCH 071/182] fix(skills): prioritize DisplayName/Name over InstallName match Use a two-pass search so exact DisplayName and Name matches are preferred over InstallName. This avoids incorrectly selecting a plugins-convention skill via InstallName when a standard namespaced skill with a matching DisplayName exists later in the sorted slice. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/preview/preview.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 98762fb119b..1c9d4d91329 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -391,7 +391,14 @@ func isMarkdownFile(filePath string) bool { func selectSkill(opts *PreviewOptions, skills []discovery.Skill) (discovery.Skill, error) { if opts.SkillName != "" { for _, s := range skills { - if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName || s.InstallName() == opts.SkillName { + if s.DisplayName() == opts.SkillName || s.Name == opts.SkillName { + return s, nil + } + } + // Fall back to InstallName so that namespaced identifiers produced + // by the post-install hint (e.g. "namespace/skill") are accepted. + for _, s := range skills { + if s.InstallName() == opts.SkillName { return s, nil } } From c50fb793eadeedfe7a2449ccd37afa55911aef6d Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 17 Apr 2026 14:43:20 +0200 Subject: [PATCH 072/182] Record official extension telemetry --- .../no-telemetry-for-extension.txtar | 4 +- ...elemetry-for-official-extension-stub.txtar | 20 ++++++ pkg/cmd/root/extension.go | 9 ++- pkg/cmd/root/extension_registration_test.go | 3 + pkg/cmd/root/extension_test.go | 63 +++++++++++++++++++ pkg/extensions/official.go | 27 ++++++++ pkg/extensions/official_test.go | 58 +++++++++++++++++ 7 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar diff --git a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar index b87b3e252ed..fcb820e0c2a 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar @@ -1,4 +1,6 @@ -# Extensions should not generate telemetry events +# Third-party extensions must not generate telemetry events, since the +# extension command name can be a user-authored identifier (e.g. an +# organization or project name). [!exec:bash] skip env GH_PRIVATE_ENABLE_TELEMETRY=1 diff --git a/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar b/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar new file mode 100644 index 00000000000..c64739af45d --- /dev/null +++ b/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar @@ -0,0 +1,20 @@ +# Official extension stubs (the hidden commands suggesting installation of +# GitHub-owned extensions) are safe to report via telemetry: their command +# names come from a fixed, hard-coded registry and do not contain any +# user-authored identifiers. + +env GH_PRIVATE_ENABLE_TELEMETRY=1 +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +# `stack` is registered in extensions.OfficialExtensions. Since no real +# extension is installed, the hidden stub runs and, in a non-TTY session, +# prints install instructions without prompting. +exec gh stack +stderr 'gh extension install github/gh-stack' + +# The stub invocation records a command_invocation event for the stub's +# command path. +stderr 'Telemetry payload:' +stderr '"type": "command_invocation"' +stderr '"command": "gh stack"' diff --git a/pkg/cmd/root/extension.go b/pkg/cmd/root/extension.go index 45a60332b8d..c432290aa49 100644 --- a/pkg/cmd/root/extension.go +++ b/pkg/cmd/root/extension.go @@ -79,7 +79,14 @@ func NewCmdExtension(io *iostreams.IOStreams, em extensions.ExtensionManager, ex } cmdutil.DisableAuthCheck(cmd) - cmdutil.DisableTelemetry(cmd) + // Extensions are user-installed and their names can be arbitrary + // (potentially including sensitive identifiers such as project or + // organization names), so we must not record telemetry for them by + // default. Official GitHub-owned extensions are a known, fixed set and + // can safely contribute their command name to telemetry. + if !extensions.IsOfficial(ext.Name(), ext.Owner()) { + cmdutil.DisableTelemetry(cmd) + } return cmd } diff --git a/pkg/cmd/root/extension_registration_test.go b/pkg/cmd/root/extension_registration_test.go index 828f51ca066..61d73b1b9b9 100644 --- a/pkg/cmd/root/extension_registration_test.go +++ b/pkg/cmd/root/extension_registration_test.go @@ -57,6 +57,9 @@ func TestNewCmdRoot_ExtensionRegistration(t *testing.T) { NameFunc: func() string { return extName }, + OwnerFunc: func() string { + return "" + }, }) } diff --git a/pkg/cmd/root/extension_test.go b/pkg/cmd/root/extension_test.go index 5e9e9b9bcf5..9bb5037a593 100644 --- a/pkg/cmd/root/extension_test.go +++ b/pkg/cmd/root/extension_test.go @@ -144,6 +144,9 @@ func TestNewCmdExtension_Updates(t *testing.T) { NameFunc: func() string { return tt.extName }, + OwnerFunc: func() string { + return "" + }, UpdateAvailableFunc: func() bool { return tt.extUpdateAvailable }, @@ -199,6 +202,9 @@ func TestNewCmdExtension_UpdateCheckIsNonblocking(t *testing.T) { NameFunc: func() string { return "major-update" }, + OwnerFunc: func() string { + return "" + }, UpdateAvailableFunc: func() bool { return true }, @@ -234,3 +240,60 @@ func TestNewCmdExtension_UpdateCheckIsNonblocking(t *testing.T) { t.Fatal("extension update check should have exited") } } + +func TestNewCmdExtension_TelemetryEnabledForOfficialExtensions(t *testing.T) { + tests := []struct { + name string + extName string + extOwner string + wantTelemetryOff bool + }{ + { + name: "official extension records telemetry", + extName: "stack", + extOwner: "github", + wantTelemetryOff: false, + }, + { + name: "official name with third-party owner disables telemetry", + extName: "stack", + extOwner: "williammartin", + wantTelemetryOff: true, + }, + { + name: "official name with empty owner disables telemetry", + extName: "stack", + extOwner: "", + wantTelemetryOff: true, + }, + { + name: "official extension name with mixed case disables telemetry", + extName: "STACK", + extOwner: "github", + wantTelemetryOff: true, + }, + { + name: "third-party extension disables telemetry", + extName: "my-custom-ext", + extOwner: "someone", + wantTelemetryOff: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + em := &extensions.ExtensionManagerMock{} + ext := &extensions.ExtensionMock{ + NameFunc: func() string { return tt.extName }, + OwnerFunc: func() string { return tt.extOwner }, + } + + cmd := root.NewCmdExtension(ios, em, ext, func(extensions.ExtensionManager, extensions.Extension) (*update.ReleaseInfo, error) { + return nil, nil + }) + + assert.Equal(t, tt.wantTelemetryOff, cmd.Annotations["telemetry"] == "disabled") + }) + } +} diff --git a/pkg/extensions/official.go b/pkg/extensions/official.go index a07c426df91..dc6bdc919b0 100644 --- a/pkg/extensions/official.go +++ b/pkg/extensions/official.go @@ -1,6 +1,8 @@ package extensions import ( + "strings" + "github.com/cli/cli/v2/internal/ghrepo" ) @@ -24,3 +26,28 @@ var OfficialExtensions = []OfficialExtension{ {Name: "aw", Owner: "github", Repo: "gh-aw"}, {Name: "stack", Owner: "github", Repo: "gh-stack"}, } + +// IsOfficial reports whether the given extension command name and owner +// match an entry in the OfficialExtensions registry. Owner must be +// checked alongside name because a user may have installed a third-party +// extension that happens to share a name with one of ours (e.g. +// `someuser/gh-stack` predates `github/gh-stack` becoming official). +// Owner will be empty for local extensions, in which case the extension +// is treated as non-official. +// +// Comparison is case-sensitive: on case-sensitive filesystems a user can +// install a private extension whose name differs only in casing (e.g. +// `gh-STACK`), and we must not treat that as official. Owner comparison +// is case-insensitive because GitHub usernames and organization names +// are themselves case-insensitive. +func IsOfficial(name, owner string) bool { + if owner == "" { + return false + } + for _, ext := range OfficialExtensions { + if ext.Name == name && strings.EqualFold(ext.Owner, owner) { + return true + } + } + return false +} diff --git a/pkg/extensions/official_test.go b/pkg/extensions/official_test.go index 047af580af1..6d16ece2cf9 100644 --- a/pkg/extensions/official_test.go +++ b/pkg/extensions/official_test.go @@ -13,3 +13,61 @@ func TestOfficialExtension_Repository(t *testing.T) { assert.Equal(t, "gh-stack", repo.RepoName()) assert.Equal(t, "github.com", repo.RepoHost()) } + +func TestIsOfficial(t *testing.T) { + tests := []struct { + name string + extName string + extOwner string + want bool + }{ + { + name: "known official extension matches", + extName: "stack", + extOwner: "github", + want: true, + }, + { + name: "official name with different owner is not official", + extName: "stack", + extOwner: "williammartin", + want: false, + }, + { + name: "official name with empty owner is not official", + extName: "stack", + extOwner: "", + want: false, + }, + { + name: "owner comparison is case-insensitive", + extName: "stack", + extOwner: "GitHub", + want: true, + }, + { + name: "mixed-case name does not match", + extName: "STACK", + extOwner: "github", + want: false, + }, + { + name: "unknown name is not official", + extName: "not-a-real-extension", + extOwner: "github", + want: false, + }, + { + name: "empty name is not official", + extName: "", + extOwner: "github", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsOfficial(tt.extName, tt.extOwner)) + }) + } +} From 6b811db4671c9e4d87bc48ab3a249218aa52c879 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 21 Apr 2026 17:12:46 +0200 Subject: [PATCH 073/182] Add telemetry command --- pkg/cmd/root/help_topic.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index 3002a351294..0becbf5c81b 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -127,6 +127,16 @@ var HelpTopics = []helpTopic{ a textual progress indicator. `, "`"), }, + { + name: "telemetry", + short: "Information about telemetry in gh", + long: heredoc.Doc(` + gh collects telemetry to help us understand how the CLI is being used and to improve it. + + To learn more about what data is collected, how it is used, and how to opt out, see: + + `), + }, { name: "reference", short: "A comprehensive reference of all gh commands", From 571bb1c923f9487df7828d75e5b0f424f701c0fc Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 21 Apr 2026 18:15:02 +0200 Subject: [PATCH 074/182] Log when there is no telemetry --- .../no-telemetry-for-completion.txtar | 2 +- .../no-telemetry-for-extension.txtar | 2 +- .../no-telemetry-for-ghes-user.txtar | 2 +- .../no-telemetry-for-send-telemetry.txtar | 2 +- internal/ghcmd/cmd.go | 16 ++++- internal/telemetry/telemetry.go | 38 +++++++----- internal/telemetry/telemetry_test.go | 58 +++++++++++++++---- 7 files changed, 90 insertions(+), 30 deletions(-) diff --git a/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar index ffde6e6059f..20139ce5f41 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar @@ -4,4 +4,4 @@ env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 exec gh completion -s bash -! stderr 'Telemetry payload:' +stderr 'Telemetry payload: none' diff --git a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar index fcb820e0c2a..19f3d69ccaf 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar @@ -22,7 +22,7 @@ cd $WORK # Run the extension and verify no telemetry is logged exec gh hello stdout 'hello from extension' -! stderr 'Telemetry payload:' +stderr 'Telemetry payload: none' -- gh-hello.sh -- #!/usr/bin/env bash diff --git a/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar index 0fe6f4bb210..f04fabf364e 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar @@ -5,4 +5,4 @@ env GH_TELEMETRY_SAMPLE_RATE=100 env GH_ENTERPRISE_TOKEN=fake-enterprise-token exec gh version -! stderr 'Telemetry payload:' +stderr 'Telemetry payload: none' diff --git a/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar index 7f9d0457aa9..28436aaae58 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar @@ -8,7 +8,7 @@ env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1 # It will fail to connect but that's fine — we only care about telemetry logging. stdin payload.json ! exec gh send-telemetry -! stderr 'Telemetry payload:' +stderr 'Telemetry payload: none' -- payload.json -- {"events":[{"type":"test","dimensions":{},"measures":{}}]} diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 34806f87449..9512e4b55bb 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -82,19 +82,31 @@ func Main() exitCode { case cfgErr != nil: // Without a valid on-disk config we can't honour user telemetry preferences, so disable it to be safe. telemetryService = &telemetry.NoOpService{} - case os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" || mightBeGHESUser(cfg): - telemetryService = &telemetry.NoOpService{} default: telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value) + telemetryDisabled := os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" || mightBeGHESUser(cfg) + switch telemetryState { case telemetry.Disabled: telemetryService = &telemetry.NoOpService{} case telemetry.Logged: + // Always construct the real service in log mode so that the log + // flusher runs and surfaces an explicit "Telemetry payload: none" + // marker when no events will be sent. This gives the user an + // observable signal that telemetry is wired up even when their + // context (e.g. GHES) causes events to be dropped. telemetryService = telemetry.NewService( telemetry.LogFlusher(ioStreams.ErrOut, ioStreams.ColorEnabled()), telemetry.WithAdditionalCommonDimensions(additionalCommonDimensions), ) + if telemetryDisabled { + telemetryService.Disable() + } case telemetry.Enabled: + if telemetryDisabled { + telemetryService = &telemetry.NoOpService{} + break + } sampleRate := 1 if v, err := strconv.Atoi(os.Getenv("GH_TELEMETRY_SAMPLE_RATE")); err == nil && v >= 0 && v <= 100 { sampleRate = v diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index 67fb8b7626e..edcd555c45f 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -166,17 +166,24 @@ func WithSampleRate(rate int) telemetryServiceOption { } // LogFlusher returns a flush function that writes telemetry payloads to the provided log writer. This is used for the "log" telemetry mode, which is intended for debugging and development. +// When there are no events to report (for example the command opted out of telemetry, the user is on GHES, or no events were recorded), a "Telemetry payload: none" marker is written so that the absence of events is observable. var LogFlusher = func(log io.Writer, colorEnabled bool) func(payload SendTelemetryPayload) { return func(payload SendTelemetryPayload) { + header := "Telemetry payload:" + if colorEnabled { + header = ansi.Color(header, "cyan+b") + } + + if len(payload.Events) == 0 { + fmt.Fprintf(log, "%s none\n", header) + return + } + payloadBytes, err := json.Marshal(payload) if err != nil { return } - header := "Telemetry payload:" - if colorEnabled { - header = ansi.Color(header, "cyan+b") - } fmt.Fprintf(log, "%s\n", header) if colorEnabled { @@ -190,8 +197,12 @@ var LogFlusher = func(log io.Writer, colorEnabled bool) func(payload SendTelemet } // GitHubFlusher returns a flush function that sends telemetry payloads to a child `gh send-telemetry` process. This is used for the "enabled" telemetry mode. +// Empty payloads are dropped without spawning a subprocess. var GitHubFlusher = func(executable string) func(payload SendTelemetryPayload) { return func(payload SendTelemetryPayload) { + if len(payload.Events) == 0 { + return + } SpawnSendTelemetry(executable, payload) } } @@ -278,28 +289,29 @@ func (s *service) Flush() { s.mu.Lock() defer s.mu.Unlock() - if s.disabled { - return - } - if s.previouslyCalled { return } s.previouslyCalled = true - if len(s.events) == 0 { + if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate { return } - if s.sampleRate > 0 && s.sampleRate < 100 && int(s.sampleBucket) >= s.sampleRate { - return + // When the service has been disabled mid-invocation (e.g. an enterprise host + // was contacted), discard any recorded events. We still call the flusher + // with an empty payload so that the log-mode flusher can surface the + // absence of telemetry rather than leaving the user staring at silence. + events := s.events + if s.disabled { + events = nil } payload := SendTelemetryPayload{ - Events: make([]PayloadEvent, len(s.events)), + Events: make([]PayloadEvent, len(events)), } - for i, recorded := range s.events { + for i, recorded := range events { dimensions := map[string]string{ "timestamp": recorded.recordedAt.UTC().Format("2006-01-02T15:04:05.000Z"), } diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 207d611eeca..17880cc874e 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -351,6 +351,22 @@ func TestNewServiceLogModeWithColorLogsToWriter(t *testing.T) { assert.Contains(t, output, "\033[", "expected ANSI escape sequences when color is enabled") } +func TestLogFlusherWritesNoneMarkerForEmptyPayload(t *testing.T) { + t.Run("no color", func(t *testing.T) { + var buf bytes.Buffer + LogFlusher(&buf, false)(SendTelemetryPayload{}) + assert.Equal(t, "Telemetry payload: none\n", buf.String()) + }) + + t.Run("with color", func(t *testing.T) { + var buf bytes.Buffer + LogFlusher(&buf, true)(SendTelemetryPayload{}) + output := buf.String() + assert.Contains(t, output, "Telemetry payload:") + assert.Contains(t, output, "none") + }) +} + func TestServiceDeviceIDFallback(t *testing.T) { t.Cleanup(stubDeviceIDError(errors.New("no device id"))) @@ -365,14 +381,19 @@ func TestServiceDeviceIDFallback(t *testing.T) { } func TestServiceFlush(t *testing.T) { - t.Run("does nothing when no events recorded", func(t *testing.T) { + t.Run("calls flusher with empty payload when no events recorded", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) + var captured SendTelemetryPayload called := false - svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc := newService(func(p SendTelemetryPayload) { + called = true + captured = p + }, nil) svc.Flush() - assert.False(t, called, "flusher should not be called with no events") + assert.True(t, called, "flusher should be called even with no events so log mode can surface the absence") + assert.Empty(t, captured.Events, "payload should have no events") }) t.Run("flushes events with merged dimensions", func(t *testing.T) { @@ -599,24 +620,33 @@ func TestWithAdditionalCommonDimensions(t *testing.T) { } func TestServiceDisable(t *testing.T) { - t.Run("prevents flush from sending events", func(t *testing.T) { + t.Run("drops recorded events from flushed payload", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) + var captured SendTelemetryPayload called := false - svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc := newService(func(p SendTelemetryPayload) { + called = true + captured = p + }, nil) svc.Record(ghtelemetry.Event{Type: "test"}) svc.Disable() svc.Flush() - assert.False(t, called, "flusher should not be called after Disable()") + assert.True(t, called, "flusher should still be called so log mode can surface the absence of events") + assert.Empty(t, captured.Events, "recorded events should be dropped after Disable()") }) - t.Run("prevents flush even with multiple recorded events", func(t *testing.T) { + t.Run("drops events even with multiple recorded events", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) + var captured SendTelemetryPayload called := false - svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc := newService(func(p SendTelemetryPayload) { + called = true + captured = p + }, nil) svc.Record(ghtelemetry.Event{Type: "event1"}) svc.Record(ghtelemetry.Event{Type: "event2"}) @@ -624,20 +654,26 @@ func TestServiceDisable(t *testing.T) { svc.Disable() svc.Flush() - assert.False(t, called, "flusher should not be called after Disable()") + assert.True(t, called, "flusher should still be called") + assert.Empty(t, captured.Events, "recorded events should be dropped after Disable()") }) t.Run("can be called before any events are recorded", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) + var captured SendTelemetryPayload called := false - svc := newService(func(SendTelemetryPayload) { called = true }, nil) + svc := newService(func(p SendTelemetryPayload) { + called = true + captured = p + }, nil) svc.Disable() svc.Record(ghtelemetry.Event{Type: "test"}) svc.Flush() - assert.False(t, called, "flusher should not be called when disabled before recording") + assert.True(t, called, "flusher should still be called") + assert.Empty(t, captured.Events, "events recorded after Disable() should be dropped") }) } From 50f0f8fc687e6382df3af6be09f75d966cfa7169 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 20 Apr 2026 21:08:18 +0200 Subject: [PATCH 075/182] feat(skills): detect re-published skills and offer upstream install When installing a skill whose SKILL.md contains github-repo metadata pointing to a different repository, the CLI detects it as a re-published skill and offers to redirect the install to the upstream source. In interactive mode, the user is prompted to choose between the re-publisher (default) and the upstream. In non-interactive mode, the install proceeds from the re-publisher with a notice. The --upstream flag skips the prompt and redirects to upstream directly, enabling non-interactive upstream installs in CI/scripts. If the user chooses upstream, the install restarts from that repo, resolving the latest version and discovering skills fresh. A skill_upstream_redirect telemetry event is emitted to track redirects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/install/install.go | 133 ++++++++++++++- pkg/cmd/skills/install/install_test.go | 220 +++++++++++++++++++++++++ 2 files changed, 352 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index d792501fd27..a22249225da 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -1,6 +1,7 @@ package install import ( + "encoding/base64" "errors" "fmt" "io" @@ -57,6 +58,7 @@ type InstallOptions struct { Force bool FromLocal bool // treat SkillSource as a local directory path AllowHiddenDirs bool // include skills in dot-prefixed directories + Upstream bool // install from upstream when re-published skill detected repo ghrepo.Interface // set when SkillSource is a GitHub repository localPath string // set when FromLocal is true @@ -193,6 +195,10 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru return err } + if err := cmdutil.MutuallyExclusive("--from-local and --upstream cannot be used together", opts.FromLocal, opts.Upstream); err != nil { + return err + } + if opts.Pin != "" && opts.SkillName != "" && strings.Contains(opts.SkillName, "@") { return cmdutil.FlagErrorf("cannot use --pin with an inline @version in the skill name") } @@ -212,6 +218,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting") cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository") cmd.Flags().BoolVar(&opts.AllowHiddenDirs, "allow-hidden-dirs", false, "Include skills in hidden directories (e.g. .claude/skills/, .agents/skills/)") + cmd.Flags().BoolVar(&opts.Upstream, "upstream", false, "Install from the upstream source when a re-published skill is detected") cmdutil.DisableAuthCheckFlag(cmd.Flags().Lookup("from-local")) return cmd @@ -248,13 +255,17 @@ func installRun(opts *InstallOptions) error { // Kick off the visibility fetch in parallel with the install work so // the extra API roundtrip doesn't add latency on the critical path. // The result is consumed when the telemetry event is emitted below. + // Capture repo fields now to avoid a data race if opts.repo is + // swapped during an upstream redirect. type visResult struct { vis discovery.RepoVisibility err error } visCh := make(chan visResult, 1) + visOwner := opts.repo.RepoOwner() + visRepo := opts.repo.RepoName() go func() { - vis, err := discovery.FetchRepoVisibility(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName()) + vis, err := discovery.FetchRepoVisibility(apiClient, hostname, visOwner, visRepo) visCh <- visResult{vis: vis, err: err} }() @@ -293,6 +304,43 @@ func installRun(opts *InstallOptions) error { } } + // Track upstream provenance detection result for telemetry. + upstreamSource := "none" + + // Check if the selected skill was re-published from an upstream source. + // The re-publisher's SKILL.md will have github-repo metadata pointing + // to the original source repo. If detected, offer to install directly + // from upstream instead. + if len(selectedSkills) == 1 && selectedSkills[0].BlobSHA != "" { + upstreamRepo, detected, err := checkUpstreamProvenance(opts, apiClient, hostname, selectedSkills[0], resolved.SHA) + if err != nil { + return err + } + if upstreamRepo != nil { + redirectDims := map[string]string{} + select { + case r := <-visCh: + if r.err == nil && r.vis == discovery.RepoVisibilityPublic { + redirectDims["from_owner"] = visOwner + redirectDims["from_repo"] = visRepo + } + case <-time.After(visibilityWaitTimeout): + } + opts.Telemetry.Record(ghtelemetry.Event{ + Type: "skill_upstream_redirect", + Dimensions: redirectDims, + }) + opts.repo = upstreamRepo + opts.SkillSource = ghrepo.FullName(upstreamRepo) + opts.version = "" + opts.Pin = "" + return installRun(opts) + } + if detected { + upstreamSource = "republisher" + } + } + printPreInstallDisclaimer(opts.IO.ErrOut, cs) selectedHosts, err := resolveHosts(opts, canPrompt) @@ -355,6 +403,7 @@ func installRun(opts *InstallOptions) error { dims := map[string]string{ "agent_hosts": mapAgentHostsToIDs(selectedHosts), "skill_host_type": ghinstance.CategorizeHost(opts.repo.RepoHost()), + "upstream_source": upstreamSource, } select { case r := <-visCh: @@ -1169,3 +1218,85 @@ func filterHiddenDirSkills(opts *InstallOptions, allSkills []discovery.Skill) ([ return standard, nil } + +// checkUpstreamProvenance fetches the skill's SKILL.md via the contents API +// to check if it contains github-repo metadata pointing to a different +// repository, indicating the skill was re-published from an upstream source. +// In interactive mode, the user is asked whether to install from the +// re-publisher or redirect to the upstream. Non-interactive mode always +// installs from the re-publisher. +// Returns (repo to redirect to, whether upstream was detected, error). +func checkUpstreamProvenance(opts *InstallOptions, client *api.Client, hostname string, skill discovery.Skill, commitSHA string) (ghrepo.Interface, bool, error) { + apiPath := fmt.Sprintf("repos/%s/%s/contents/%s?ref=%s", + opts.repo.RepoOwner(), opts.repo.RepoName(), + skill.Path+"/SKILL.md", commitSHA) + var fileResp struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + } + if err := client.REST(hostname, "GET", apiPath, nil, &fileResp); err != nil { + return nil, false, nil //nolint:nilerr // best-effort check; failing to fetch is not fatal + } + if fileResp.Encoding != "base64" { + return nil, false, nil + } + decoded, decodeErr := io.ReadAll(base64.NewDecoder(base64.StdEncoding, strings.NewReader(fileResp.Content))) + if decodeErr != nil { + return nil, false, nil //nolint:nilerr // best-effort; decode failure is not fatal + } + content := string(decoded) + + result, parseErr := frontmatter.Parse(content) + if parseErr != nil || result.Metadata.Meta == nil { + //nolint:nilerr // unparseable frontmatter means no upstream to detect + return nil, false, nil + } + + existingRepo, _ := result.Metadata.Meta["github-repo"].(string) + if existingRepo == "" { + return nil, false, nil + } + + currentRepoURL := source.BuildRepoURL(hostname, opts.repo.RepoOwner(), opts.repo.RepoName()) + if existingRepo == currentRepoURL { + return nil, false, nil + } + + upstreamRepo, parseErr := source.ParseRepoURL(existingRepo) + if parseErr != nil { + //nolint:nilerr // invalid repo URL means we can't redirect; install normally + return nil, false, nil + } + + cs := opts.IO.ColorScheme() + upstreamLabel := ghrepo.FullName(upstreamRepo) + repoSource := ghrepo.FullName(opts.repo) + + fmt.Fprintf(opts.IO.ErrOut, "%s This skill was originally published in %s\n", cs.WarningIcon(), upstreamLabel) + + if opts.Upstream { + fmt.Fprintf(opts.IO.ErrOut, "Redirecting install to %s...\n", upstreamLabel) + return upstreamRepo, true, nil + } + + if !opts.IO.CanPrompt() { + fmt.Fprintf(opts.IO.ErrOut, " Installing from %s (use --upstream or interactive mode to choose upstream)\n", repoSource) + return nil, true, nil + } + + choices := []string{ + fmt.Sprintf("%s (re-publisher, recommended)", repoSource), + fmt.Sprintf("%s (upstream)", upstreamLabel), + } + idx, err := opts.Prompter.Select("Install from:", "", choices) + if err != nil { + return nil, true, err + } + + if idx == 1 { + fmt.Fprintf(opts.IO.ErrOut, "Redirecting install to %s...\n", upstreamLabel) + return upstreamRepo, true, nil + } + + return nil, true, nil +} diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index e4bd074bd5f..b7aa956c5a9 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -125,6 +125,16 @@ func TestNewCmdInstall(t *testing.T) { cli: "monalisa/skills-repo --allow-hidden-dirs", wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", Scope: "project", AllowHiddenDirs: true}, }, + { + name: "upstream flag", + cli: "monalisa/skills-repo git-commit --upstream", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project", Upstream: true}, + }, + { + name: "from-local with --upstream is mutually exclusive", + cli: "--from-local ./local-dir --upstream", + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -2487,3 +2497,213 @@ func TestInstallRun_TelemetryMultipleSkills(t *testing.T) { assert.Contains(t, names, "code-review") assert.Contains(t, names, "git-commit") } + +var republishedContent = heredoc.Doc(` + --- + name: git-commit + description: Writes commits + metadata: + github-repo: https://github.com/monalisa/original-skills + github-tree-sha: upstreamTreeSHA + github-path: skills/git-commit + --- + # Git Commit +`) + +func stubContentsAPI(reg *httpmock.Registry, owner, repo, path, content string) { + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, path)), + httpmock.StringResponse(fmt.Sprintf(`{"content": %q, "encoding": "base64"}`, encoded)), + ) +} + +func TestInstallRun_UpstreamDetection(t *testing.T) { + tests := []struct { + name string + isTTY bool + stubs func(*httpmock.Registry) + opts func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "detects re-published skill and user picks re-publisher", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubContentsAPI(reg, "monalisa", "skills-repo", + "skills/git-commit/SKILL.md", republishedContent) + stubInstallFiles(reg, "monalisa", "skills-repo", + "treeSHA", "blobSHA", republishedContent) + }, + opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: &prompter.PrompterMock{ + SelectFunc: func(_ string, _ string, choices []string) (int, error) { + require.Len(t, choices, 2) + assert.Contains(t, choices[0], "monalisa/skills-repo") + assert.Contains(t, choices[1], "monalisa/original-skills") + return 0, nil + }, + }, + Telemetry: &telemetry.NoOpService{}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStderr: "originally published in monalisa/original-skills", + wantStdout: "Installed git-commit", + }, + { + name: "detects re-published skill and user picks upstream", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubContentsAPI(reg, "monalisa", "skills-repo", + "skills/git-commit/SKILL.md", republishedContent) + stubResolveVersion(reg, "monalisa", "original-skills", "v2.0.0", "upstream456") + stubDiscoverTree(reg, "monalisa", "original-skills", "upstream456", + singleSkillTreeJSON("git-commit", "upTreeSHA", "upBlobSHA")) + stubContentsAPI(reg, "monalisa", "original-skills", + "skills/git-commit/SKILL.md", gitCommitContent) + stubInstallFiles(reg, "monalisa", "original-skills", + "upTreeSHA", "upBlobSHA", gitCommitContent) + }, + opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: &prompter.PrompterMock{ + SelectFunc: func(_ string, _ string, choices []string) (int, error) { + require.Len(t, choices, 2) + assert.Contains(t, choices[0], "monalisa/skills-repo") + assert.Contains(t, choices[1], "monalisa/original-skills") + return 1, nil + }, + }, + Telemetry: &telemetry.NoOpService{}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStderr: "Redirecting install to monalisa/original-skills", + wantStdout: "Installed git-commit", + }, + { + name: "non-interactive defaults to re-publisher with notice", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubContentsAPI(reg, "monalisa", "skills-repo", + "skills/git-commit/SKILL.md", republishedContent) + stubInstallFiles(reg, "monalisa", "skills-repo", + "treeSHA", "blobSHA", republishedContent) + }, + opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Telemetry: &telemetry.NoOpService{}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStderr: "use --upstream", + wantStdout: "Installed git-commit", + }, + { + name: "non-interactive with --upstream redirects to upstream", + isTTY: false, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubContentsAPI(reg, "monalisa", "skills-repo", + "skills/git-commit/SKILL.md", republishedContent) + stubResolveVersion(reg, "monalisa", "original-skills", "v2.0.0", "upstream456") + stubDiscoverTree(reg, "monalisa", "original-skills", "upstream456", + singleSkillTreeJSON("git-commit", "upTreeSHA", "upBlobSHA")) + stubContentsAPI(reg, "monalisa", "original-skills", + "skills/git-commit/SKILL.md", gitCommitContent) + stubInstallFiles(reg, "monalisa", "original-skills", + "upTreeSHA", "upBlobSHA", gitCommitContent) + }, + opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Telemetry: &telemetry.NoOpService{}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + Upstream: true, + } + }, + wantStderr: "Redirecting install to monalisa/original-skills", + wantStdout: "Installed git-commit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.stubs(reg) + + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(tt.isTTY) + ios.SetStdinTTY(tt.isTTY) + ios.SetStderrTTY(tt.isTTY) + opts := tt.opts(t, ios, reg) + + err := installRun(opts) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + if tt.wantStdout != "" { + assert.Contains(t, stdout.String(), tt.wantStdout) + } + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + }) + } +} From ec4a3ed6bd5739dea9e9ccbc24a4ad9f9d45ddf4 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 21 Apr 2026 17:49:28 +0100 Subject: [PATCH 076/182] fix(telemetry): lower bias in sample bucket calc Signed-off-by: Babak K. Shandiz --- internal/telemetry/telemetry.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index edcd555c45f..e5dcbad9a49 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -3,6 +3,7 @@ package telemetry import ( "bytes" + "encoding/binary" "encoding/json" "errors" "fmt" @@ -232,7 +233,7 @@ func NewService(flusher func(SendTelemetryPayload), opts ...telemetryServiceOpti maps.Copy(commonDimensions, telemetryServiceOpts.additionalDimensions) hash := uuid.NewSHA1(uuid.Nil, []byte(invocationID)) - sampleBucket := hash[0] % 100 + sampleBucket := byte(binary.BigEndian.Uint32(hash[:4]) % 100) s := &service{ flush: flusher, From 0467ed499a63a1bb5af1c1361e4f5d54925a8f2a Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 21 Apr 2026 18:05:28 +0100 Subject: [PATCH 077/182] test(telemetry): assert ANSI escape chars for color codes Signed-off-by: Babak K. Shandiz --- internal/telemetry/telemetry_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index 17880cc874e..a796afd677d 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -364,6 +364,7 @@ func TestLogFlusherWritesNoneMarkerForEmptyPayload(t *testing.T) { output := buf.String() assert.Contains(t, output, "Telemetry payload:") assert.Contains(t, output, "none") + assert.Contains(t, output, "\x1b") // ANSI escape char for color codes }) } From 90ef03ea3894fa7902cc952174caaa37772e457d Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 21 Apr 2026 17:24:17 +0200 Subject: [PATCH 078/182] Enable telemetry without env var --- acceptance/testdata/skills/skills-install.txtar | 1 - acceptance/testdata/skills/skills-preview.txtar | 1 - acceptance/testdata/telemetry/command-invocation.txtar | 1 - acceptance/testdata/telemetry/no-telemetry-for-alias.txtar | 1 - acceptance/testdata/telemetry/no-telemetry-for-completion.txtar | 1 - acceptance/testdata/telemetry/no-telemetry-for-extension.txtar | 1 - acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar | 1 - .../testdata/telemetry/no-telemetry-for-send-telemetry.txtar | 1 - .../telemetry/telemetry-failure-does-not-break-command.txtar | 1 - .../telemetry/telemetry-for-official-extension-stub.txtar | 1 - internal/ghcmd/cmd.go | 2 +- 11 files changed, 1 insertion(+), 11 deletions(-) diff --git a/acceptance/testdata/skills/skills-install.txtar b/acceptance/testdata/skills/skills-install.txtar index 0311a0db280..442edb797f6 100644 --- a/acceptance/testdata/skills/skills-install.txtar +++ b/acceptance/testdata/skills/skills-install.txtar @@ -21,7 +21,6 @@ grep 'github-repo' $WORK/custom-skills/git-commit/SKILL.md # Telemetry: skill_install event records agent hosts, repo identifiers, # and (for a public repo) the installed skill name. -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 exec gh skill install github/awesome-copilot git-commit --scope user --force --agent github-copilot diff --git a/acceptance/testdata/skills/skills-preview.txtar b/acceptance/testdata/skills/skills-preview.txtar index af1d0bbbe2c..76aa9a6ecb1 100644 --- a/acceptance/testdata/skills/skills-preview.txtar +++ b/acceptance/testdata/skills/skills-preview.txtar @@ -10,7 +10,6 @@ stderr 'not found' # Telemetry: skill_preview event records repo identifiers and, for a # public repo, the skill name. -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 exec gh skill preview github/awesome-copilot git-commit diff --git a/acceptance/testdata/telemetry/command-invocation.txtar b/acceptance/testdata/telemetry/command-invocation.txtar index 86d668da5bf..d174c5c08f1 100644 --- a/acceptance/testdata/telemetry/command-invocation.txtar +++ b/acceptance/testdata/telemetry/command-invocation.txtar @@ -1,5 +1,4 @@ # Telemetry log mode outputs command invocation event to stderr -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 diff --git a/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar b/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar index 733bea11f5c..2bfe0657dc2 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-alias.txtar @@ -2,7 +2,6 @@ # resolved inner command should still record normally — its path is a core # gh command and conveys no user-authored identifier. -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 diff --git a/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar index 20139ce5f41..1204a7913bb 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-completion.txtar @@ -1,5 +1,4 @@ # The completion command should not generate a telemetry event -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 diff --git a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar index 19f3d69ccaf..5e9d2ea5d2a 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-extension.txtar @@ -3,7 +3,6 @@ # organization or project name). [!exec:bash] skip -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 diff --git a/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar index f04fabf364e..e8e1d8ffe97 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-ghes-user.txtar @@ -1,5 +1,4 @@ # GHES users should not get telemetry even when telemetry is enabled -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 env GH_ENTERPRISE_TOKEN=fake-enterprise-token diff --git a/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar index 28436aaae58..15e59fcf5e1 100644 --- a/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar +++ b/acceptance/testdata/telemetry/no-telemetry-for-send-telemetry.txtar @@ -1,5 +1,4 @@ # The send-telemetry command should not itself generate a telemetry event -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1 diff --git a/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar b/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar index ca1fc4b4ad2..14c4b67a6a8 100644 --- a/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar +++ b/acceptance/testdata/telemetry/telemetry-failure-does-not-break-command.txtar @@ -1,5 +1,4 @@ # Command completes successfully even when telemetry endpoint is unreachable -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=enabled env GH_TELEMETRY_SAMPLE_RATE=100 env GH_TELEMETRY_ENDPOINT_URL=http://localhost:1 diff --git a/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar b/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar index c64739af45d..b200590bf27 100644 --- a/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar +++ b/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar @@ -3,7 +3,6 @@ # names come from a fixed, hard-coded registry and do not contain any # user-authored identifiers. -env GH_PRIVATE_ENABLE_TELEMETRY=1 env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 9512e4b55bb..67b1564e206 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -84,7 +84,7 @@ func Main() exitCode { telemetryService = &telemetry.NoOpService{} default: telemetryState := telemetry.ParseTelemetryState(cfg.Telemetry().Value) - telemetryDisabled := os.Getenv("GH_PRIVATE_ENABLE_TELEMETRY") == "" || mightBeGHESUser(cfg) + telemetryDisabled := mightBeGHESUser(cfg) switch telemetryState { case telemetry.Disabled: From f52acd51e92f24e467f3cac4380e632ca00ff2bc Mon Sep 17 00:00:00 2001 From: orbisai0security Date: Wed, 22 Apr 2026 16:05:54 +0530 Subject: [PATCH 079/182] fix: yaml.github-actions.security.run-shell-injection.run-shell-injection security vulnerability Automated security fix generated by Orbis Security AI --- .github/workflows/deployment.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index ebda8eda5f6..19ffc51d46c 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -32,8 +32,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Validate tag name format + env: + TAG_NAME: ${{ inputs.tag_name }} run: | - if [[ ! "${{ inputs.tag_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Invalid tag name format. Must be in the form v1.2.3" exit 1 fi From 7095e2a4fcad136f196973ebd729c332e374ed41 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 22 Apr 2026 13:48:51 +0200 Subject: [PATCH 080/182] Fix SetSampleRate not updating sample_rate dimension The sample_rate common dimension was set once at service creation time and never updated when SetSampleRate was called later. This caused commands like 'gh skill publish' that override the sample rate via PersistentPreRunE to report the wrong sample_rate in telemetry events (e.g. 1 instead of 100). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/telemetry/telemetry.go | 2 ++ internal/telemetry/telemetry_test.go | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go index e5dcbad9a49..3943060b124 100644 --- a/internal/telemetry/telemetry.go +++ b/internal/telemetry/telemetry.go @@ -14,6 +14,7 @@ import ( "path/filepath" "runtime" "slices" + "strconv" "strings" "sync" "time" @@ -283,6 +284,7 @@ func (s *service) SetSampleRate(rate int) { defer s.mu.Unlock() s.sampleRate = rate + s.commonDimensions["sample_rate"] = strconv.Itoa(rate) } func (s *service) Flush() { diff --git a/internal/telemetry/telemetry_test.go b/internal/telemetry/telemetry_test.go index a796afd677d..98180a1263c 100644 --- a/internal/telemetry/telemetry_test.go +++ b/internal/telemetry/telemetry_test.go @@ -579,6 +579,24 @@ func TestServiceSampling(t *testing.T) { assert.False(t, called, "flusher should not be called after SetSampleRate reduced the rate") }) + t.Run("SetSampleRate updates sample_rate dimension", func(t *testing.T) { + t.Cleanup(stubDeviceID("test-device")) + + var captured SendTelemetryPayload + svc := newService(func(p SendTelemetryPayload) { captured = p }, ghtelemetry.Dimensions{ + "sample_rate": "1", + }) + svc.sampleRate = 1 + svc.sampleBucket = 0 + + svc.SetSampleRate(100) + svc.Record(ghtelemetry.Event{Type: "test"}) + svc.Flush() + + require.Len(t, captured.Events, 1) + assert.Equal(t, "100", captured.Events[0].Dimensions["sample_rate"]) + }) + t.Run("WithSampleRate option sets rate on construction", func(t *testing.T) { t.Cleanup(stubDeviceID("test-device")) From 63262dce8b38243d6651d4620d0010c9075415da Mon Sep 17 00:00:00 2001 From: sammorrowdrums Date: Wed, 22 Apr 2026 22:07:30 +0200 Subject: [PATCH 081/182] feat(skills): support GHEC with data residency hosts Widen ValidateSupportedHost to accept tenancy hosts (*.ghe.com) alongside github.com. GHEC with data residency uses these domains, and all skill subcommands (search, install, preview, publish, update) now allow them. GitHub Enterprise Server remains unsupported and is explicitly rejected with a clear error message. Also fix the lockfile writer to use the actual host when constructing SourceURL instead of hardcoding github.com. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/skills/installer/installer.go | 4 +-- internal/skills/lockfile/lockfile.go | 5 ++-- internal/skills/lockfile/lockfile_test.go | 31 ++++++++++++++++++++--- internal/skills/source/source.go | 15 ++++++++--- internal/skills/source/source_test.go | 4 ++- pkg/cmd/skills/install/install_test.go | 2 +- pkg/cmd/skills/preview/preview_test.go | 2 +- pkg/cmd/skills/publish/publish.go | 2 +- pkg/cmd/skills/publish/publish_test.go | 2 +- pkg/cmd/skills/search/search_test.go | 2 +- pkg/cmd/skills/update/update_test.go | 2 +- 11 files changed, 52 insertions(+), 19 deletions(-) diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index e27d35f5b46..6072b98cafb 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -76,7 +76,7 @@ func Install(opts *Options) (*Result, error) { return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err) } var warnings []string - if err := lockfile.RecordInstall(skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil { + if err := lockfile.RecordInstall(opts.Host, skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil { warnings = append(warnings, fmt.Sprintf("could not record install for %s: %v", skill.InstallName(), err)) } return &Result{Installed: []string{skill.InstallName()}, Dir: targetDir, Warnings: warnings}, nil @@ -129,7 +129,7 @@ func Install(opts *Options) (*Result, error) { } installed = append(installed, r.name) skill := opts.Skills[i] - if err := lockfile.RecordInstall(skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil { + if err := lockfile.RecordInstall(opts.Host, skill.InstallName(), opts.Owner, opts.Repo, skill.Path+"/SKILL.md", skill.TreeSHA, opts.PinnedRef); err != nil { warnings = append(warnings, fmt.Sprintf("could not record install for %s: %v", skill.InstallName(), err)) } } diff --git a/internal/skills/lockfile/lockfile.go b/internal/skills/lockfile/lockfile.go index 42d2abb34c1..2e6697234b4 100644 --- a/internal/skills/lockfile/lockfile.go +++ b/internal/skills/lockfile/lockfile.go @@ -10,6 +10,7 @@ import ( "time" "github.com/cli/cli/v2/internal/flock" + "github.com/cli/cli/v2/internal/ghinstance" ) const ( @@ -93,7 +94,7 @@ func writeTo(f *os.File, lf *file) error { // RecordInstall adds or updates a skill entry in the lock file. // It uses a file-based lock to prevent concurrent read-modify-write races // when multiple install processes run simultaneously. -func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) error { +func RecordInstall(host, skillName, owner, repo, skillPath, treeSHA, pinnedRef string) error { lockPath, err := lockfilePath() if err != nil { return err @@ -124,7 +125,7 @@ func RecordInstall(skillName, owner, repo, skillPath, treeSHA, pinnedRef string) f.Skills[skillName] = entry{ Source: owner + "/" + repo, SourceType: "github", - SourceURL: "https://github.com/" + owner + "/" + repo + ".git", + SourceURL: ghinstance.HostPrefix(host) + owner + "/" + repo + ".git", SkillPath: skillPath, SkillFolderHash: treeSHA, InstalledAt: installedAt, diff --git a/internal/skills/lockfile/lockfile_test.go b/internal/skills/lockfile/lockfile_test.go index d68e9a8f169..7a040a550fc 100644 --- a/internal/skills/lockfile/lockfile_test.go +++ b/internal/skills/lockfile/lockfile_test.go @@ -24,6 +24,7 @@ func TestRecordInstall(t *testing.T) { tests := []struct { name string setup func(t *testing.T) + host string skill string owner string repo string @@ -35,6 +36,7 @@ func TestRecordInstall(t *testing.T) { }{ { name: "fresh install creates lockfile", + host: "github.com", skill: "code-review", owner: "monalisa", repo: "octocat-skills", @@ -55,8 +57,25 @@ func TestRecordInstall(t *testing.T) { assert.Empty(t, e.PinnedRef) }, }, + { + name: "tenancy host uses correct URL", + host: "mycompany.ghe.com", + skill: "code-review", + owner: "monalisa", + repo: "octocat-skills", + skillPath: "skills/code-review/SKILL.md", + treeSHA: "abc123", + verify: func(t *testing.T, lockPath string) { + t.Helper() + f := readTestLockfile(t, lockPath) + require.Contains(t, f.Skills, "code-review") + e := f.Skills["code-review"] + assert.Equal(t, "https://mycompany.ghe.com/monalisa/octocat-skills.git", e.SourceURL) + }, + }, { name: "install with pinned ref", + host: "github.com", skill: "pr-summary", owner: "hubot", repo: "skills-repo", @@ -73,8 +92,9 @@ func TestRecordInstall(t *testing.T) { name: "multiple skills coexist", setup: func(t *testing.T) { t.Helper() - require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "sha1", "")) + require.NoError(t, RecordInstall("github.com", "code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "sha1", "")) }, + host: "github.com", skill: "issue-triage", owner: "monalisa", repo: "octocat-skills", @@ -107,6 +127,7 @@ func TestRecordInstall(t *testing.T) { require.NoError(t, err) t.Cleanup(unlock) }, + host: "github.com", skill: "code-review", owner: "monalisa", repo: "octocat-skills", @@ -123,6 +144,7 @@ func TestRecordInstall(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Dir(lockPath), 0o755)) require.NoError(t, os.WriteFile(lockPath, []byte("{invalid json"), 0o644)) }, + host: "github.com", skill: "code-review", owner: "monalisa", repo: "octocat-skills", @@ -145,6 +167,7 @@ func TestRecordInstall(t *testing.T) { data, _ := json.Marshal(file{Version: 999, Skills: map[string]entry{"old-skill": {}}}) require.NoError(t, os.WriteFile(lockPath, data, 0o644)) }, + host: "github.com", skill: "code-review", owner: "monalisa", repo: "octocat-skills", @@ -166,7 +189,7 @@ func TestRecordInstall(t *testing.T) { tt.setup(t) } - err := RecordInstall(tt.skill, tt.owner, tt.repo, tt.skillPath, tt.treeSHA, tt.pinnedRef) + err := RecordInstall(tt.host, tt.skill, tt.owner, tt.repo, tt.skillPath, tt.treeSHA, tt.pinnedRef) if tt.wantErr { require.Error(t, err) return @@ -181,10 +204,10 @@ func TestRecordInstall(t *testing.T) { t.Run("update preserves InstalledAt and updates treeSHA", func(t *testing.T) { lockPath := setupTestHome(t) - require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "old-sha", "")) + require.NoError(t, RecordInstall("github.com", "code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "old-sha", "")) firstInstalledAt := readTestLockfile(t, lockPath).Skills["code-review"].InstalledAt - require.NoError(t, RecordInstall("code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "new-sha", "")) + require.NoError(t, RecordInstall("github.com", "code-review", "monalisa", "octocat-skills", "skills/code-review/SKILL.md", "new-sha", "")) entry := readTestLockfile(t, lockPath).Skills["code-review"] assert.Equal(t, "new-sha", entry.SkillFolderHash, "treeSHA should be updated") diff --git a/internal/skills/source/source.go b/internal/skills/source/source.go index 5e8f5288805..ff0e5e9d76e 100644 --- a/internal/skills/source/source.go +++ b/internal/skills/source/source.go @@ -4,6 +4,8 @@ import ( "fmt" "strings" + ghauth "github.com/cli/go-gh/v2/pkg/auth" + "github.com/cli/cli/v2/internal/ghrepo" ) @@ -48,16 +50,21 @@ func ParseMetadataRepo(meta map[string]interface{}) (ghrepo.Interface, bool, err return repo, true, nil } -// ValidateSupportedHost rejects hosts that are not supported in public preview. +// ValidateSupportedHost rejects hosts that are not supported. +// Supported hosts are github.com and GHEC with data residency (*.ghe.com). +// GitHub Enterprise Server is not currently supported. func ValidateSupportedHost(host string) error { host = normalizeHost(host) if host == "" { return fmt.Errorf("could not determine repository host") } - if host != SupportedHost { - return fmt.Errorf("GitHub Skills currently supports only %s as a host; got %s", SupportedHost, host) + if host == SupportedHost || ghauth.IsTenancy(host) { + return nil + } + if ghauth.IsEnterprise(host) { + return fmt.Errorf("GitHub Skills does not currently support GitHub Enterprise Server; got %s", host) } - return nil + return fmt.Errorf("unsupported host for GitHub Skills: %s", host) } func normalizeHost(host string) string { diff --git a/internal/skills/source/source_test.go b/internal/skills/source/source_test.go index f797591b4c6..9c2457d3f7a 100644 --- a/internal/skills/source/source_test.go +++ b/internal/skills/source/source_test.go @@ -72,5 +72,7 @@ func TestParseMetadataRepo(t *testing.T) { func TestValidateSupportedHost(t *testing.T) { require.NoError(t, ValidateSupportedHost("github.com")) - require.ErrorContains(t, ValidateSupportedHost("acme.ghes.com"), "supports only github.com") + require.NoError(t, ValidateSupportedHost("mycompany.ghe.com"), "GHEC data residency tenancy hosts should be accepted") + require.ErrorContains(t, ValidateSupportedHost("acme.ghes.com"), "does not currently support GitHub Enterprise Server") + require.ErrorContains(t, ValidateSupportedHost("github.localhost"), "unsupported host") } diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index b7aa956c5a9..f6202b97724 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -1177,7 +1177,7 @@ func TestInstallRun(t *testing.T) { SkillName: "git-commit", } }, - wantErr: "supports only github.com", + wantErr: "does not currently support GitHub Enterprise Server", }, { name: "select all skills in interactive prompt", diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index be3c861170e..7d02ff2ee85 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -403,7 +403,7 @@ func TestPreviewRun_UnsupportedHost(t *testing.T) { repo: ghrepo.NewWithHost("github", "awesome-copilot", "acme.ghes.com"), Telemetry: &telemetry.NoOpService{}, }) - require.ErrorContains(t, err, "supports only github.com") + require.ErrorContains(t, err, "does not currently support GitHub Enterprise Server") } func TestPreviewRun_Interactive(t *testing.T) { diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 3a27fc5a2cd..c53ea0b6b72 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -968,7 +968,7 @@ func detectGitHubRemote(gitClient *git.Client, dir string) (*gitHubRemote, error } // parseGitHubURL extracts owner/repo from a GitHub remote URL. -// Only GitHub.com URLs are recognized. +// Only github.com and GHEC data residency (*.ghe.com) URLs are recognized. func parseGitHubURL(rawURL string) (ghrepo.Interface, error) { u, err := git.ParseURL(rawURL) if err != nil { diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index a4f48dfe6e0..757cc5126c2 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -171,7 +171,7 @@ func TestPublishRun_UnsupportedHost(t *testing.T) { HttpClient: func() (*http.Client, error) { return nil, nil }, host: "acme.ghes.com", }) - require.ErrorContains(t, err, "supports only github.com") + require.ErrorContains(t, err, "does not currently support GitHub Enterprise Server") } func TestPublishRun(t *testing.T) { diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index 763ca2124e1..cf66ba4acb4 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -33,7 +33,7 @@ func TestSearchRun_UnsupportedHost(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, Config: func() (gh.Config, error) { return cfg, nil }, }) - require.ErrorContains(t, err, "supports only github.com") + require.ErrorContains(t, err, "does not currently support GitHub Enterprise Server") } func TestNewCmdSearch(t *testing.T) { diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index 86fdcaa80eb..5d0208043de 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -171,7 +171,7 @@ func TestScanInstalledSkills(t *testing.T) { require.NoError(t, err) require.Len(t, skills, 1) require.Error(t, skills[0].metadataErr) - assert.Contains(t, skills[0].metadataErr.Error(), "supports only github.com") + assert.Contains(t, skills[0].metadataErr.Error(), "does not currently support GitHub Enterprise Server") }, }, { From 8498bdf43585fe4d575ac152fdfcedb764548280 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 23:32:10 +0200 Subject: [PATCH 082/182] feat(skills): add --allow-hidden-dirs flag to preview command Add support for the --allow-hidden-dirs flag in `gh skill preview`, matching the existing pattern in `gh skill install`. This allows users to preview skills located in hidden directories (e.g. .claude/skills/, .agents/skills/). Changes: - Add AllowHiddenDirs field to PreviewOptions - Register --allow-hidden-dirs flag on the preview command - Switch from DiscoverSkills to DiscoverSkillsWithOptions to get all skills including hidden-dir ones - Add filterHiddenDirSkills to exclude hidden-dir skills by default, showing a hint when they are found but excluded - Print a warning when --allow-hidden-dirs is used and hidden skills are present - Return an error when only hidden-dir skills exist without the flag Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/skills/discovery/discovery.go | 20 ++ pkg/cmd/skills/install/install.go | 24 +-- pkg/cmd/skills/preview/preview.go | 49 ++++- pkg/cmd/skills/preview/preview_test.go | 263 ++++++++++++++++++++++++- 4 files changed, 329 insertions(+), 27 deletions(-) diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 1b0c7f0075a..df17c5b9f21 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -101,6 +101,26 @@ func HasHiddenDirSkills(skills []Skill) bool { return false } +// HiddenDirFilterResult holds the outcome of partitioning skills into standard +// and hidden-dir buckets. +type HiddenDirFilterResult struct { + Standard []Skill + HiddenCount int +} + +// PartitionHiddenDirSkills splits skills into standard and hidden-dir groups. +func PartitionHiddenDirSkills(skills []Skill) HiddenDirFilterResult { + var r HiddenDirFilterResult + for _, s := range skills { + if s.IsHiddenDirConvention() { + r.HiddenCount++ + } else { + r.Standard = append(r.Standard, s) + } + } + return r +} + // ResolvedRef contains the resolved git reference and its SHA. type ResolvedRef struct { Ref string // fully qualified ref (refs/heads/*, refs/tags/*) or commit SHA diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index a22249225da..cca960a880a 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -1180,10 +1180,9 @@ func kiroResourcePath(installDir, gitRoot string) string { return filepath.ToSlash(installDir) } -// filterHiddenDirSkills separates hidden-dir skills from the full list and -// applies the --allow-hidden-dirs flag logic. When the flag is set, all skills -// are returned and a warning is printed. When the flag is not set, hidden-dir -// skills are excluded and an error is returned if no standard skills remain. +// filterHiddenDirSkills applies the --allow-hidden-dirs flag logic. When the +// flag is set, all skills are returned with a warning. Otherwise, hidden-dir +// skills are excluded with an error if no standard skills remain. func filterHiddenDirSkills(opts *InstallOptions, allSkills []discovery.Skill) ([]discovery.Skill, error) { cs := opts.IO.ColorScheme() @@ -1198,25 +1197,16 @@ func filterHiddenDirSkills(opts *InstallOptions, allSkills []discovery.Skill) ([ return allSkills, nil } - var standard []discovery.Skill - var hiddenCount int - for _, s := range allSkills { - if s.IsHiddenDirConvention() { - hiddenCount++ - } else { - standard = append(standard, s) - } - } - - if len(standard) == 0 && hiddenCount > 0 { + r := discovery.PartitionHiddenDirSkills(allSkills) + if len(r.Standard) == 0 && r.HiddenCount > 0 { return nil, fmt.Errorf( "no standard skills found, but %d skill(s) exist in hidden directories\n"+ " Use --allow-hidden-dirs to include them", - hiddenCount, + r.HiddenCount, ) } - return standard, nil + return r.Standard, nil } // checkUpstreamProvenance fetches the skill's SKILL.md via the contents API diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 1c9d4d91329..e6c202b0992 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -32,9 +32,10 @@ type PreviewOptions struct { ExecutablePath string RenderFile func(string, string) string - RepoArg string - SkillName string - Version string // resolved from @suffix on SkillName + RepoArg string + SkillName string + Version string // resolved from @suffix on SkillName + AllowHiddenDirs bool // include skills in dot-prefixed directories repo ghrepo.Interface } @@ -110,6 +111,8 @@ func NewCmdPreview(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru }, } + cmd.Flags().BoolVar(&opts.AllowHiddenDirs, "allow-hidden-dirs", false, "Include skills in hidden directories (e.g. .claude/skills/, .agents/skills/)") + return cmd } @@ -151,12 +154,17 @@ func previewRun(opts *PreviewOptions) error { } opts.IO.StartProgressIndicatorWithLabel("Discovering skills") - skills, err := discovery.DiscoverSkills(apiClient, hostname, owner, repoName, resolved.SHA) + allSkills, err := discovery.DiscoverSkillsWithOptions(apiClient, hostname, owner, repoName, resolved.SHA, discovery.DiscoverOptions{}) opts.IO.StopProgressIndicator() if err != nil { return err } + skills, err := filterHiddenDirSkills(opts, allSkills) + if err != nil { + return err + } + sort.Slice(skills, func(i, j int) bool { return skills[i].DisplayName() < skills[j].DisplayName() }) @@ -388,6 +396,39 @@ func isMarkdownFile(filePath string) bool { } } +// filterHiddenDirSkills applies the --allow-hidden-dirs flag logic. When the +// flag is set, all skills are returned with a warning. Otherwise, hidden-dir +// skills are excluded with a hint or error. +func filterHiddenDirSkills(opts *PreviewOptions, allSkills []discovery.Skill) ([]discovery.Skill, error) { + cs := opts.IO.ColorScheme() + + if opts.AllowHiddenDirs { + if discovery.HasHiddenDirSkills(allSkills) { + fmt.Fprint(opts.IO.ErrOut, heredoc.Docf(` + %[1]s Skills in hidden directories (e.g. .claude/, .agents/) may be installed + copies from another publisher. Verify the skill's origin and check for a + canonical source. + `, cs.WarningIcon())) + } + return allSkills, nil + } + + r := discovery.PartitionHiddenDirSkills(allSkills) + if r.HiddenCount > 0 { + if len(r.Standard) == 0 { + return nil, fmt.Errorf( + "no standard skills found, but %d skill(s) exist in hidden directories\n"+ + " Use --allow-hidden-dirs to include them", + r.HiddenCount, + ) + } + fmt.Fprintf(opts.IO.ErrOut, "%s %d skill(s) in hidden directories were excluded, use --%s to include them\n", + cs.Yellow("!"), r.HiddenCount, "allow-hidden-dirs") + } + + return r.Standard, nil +} + func selectSkill(opts *PreviewOptions, skills []discovery.Skill) (discovery.Skill, error) { if opts.SkillName != "" { for _, s := range skills { diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index be3c861170e..229b2a63dfb 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -11,6 +11,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" + "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -22,12 +23,13 @@ import ( func TestNewCmdPreview(t *testing.T) { tests := []struct { - name string - input string - wantRepo string - wantSkillName string - wantVersion string - wantErr bool + name string + input string + wantRepo string + wantSkillName string + wantVersion string + wantAllowHiddenDirs bool + wantErr bool }{ { name: "repo and skill", @@ -64,6 +66,13 @@ func TestNewCmdPreview(t *testing.T) { input: "a b c", wantErr: true, }, + { + name: "allow-hidden-dirs flag", + input: "github/awesome-copilot my-skill --allow-hidden-dirs", + wantRepo: "github/awesome-copilot", + wantSkillName: "my-skill", + wantAllowHiddenDirs: true, + }, } for _, tt := range tests { @@ -95,6 +104,7 @@ func TestNewCmdPreview(t *testing.T) { assert.Equal(t, tt.wantRepo, gotOpts.RepoArg) assert.Equal(t, tt.wantSkillName, gotOpts.SkillName) assert.Equal(t, tt.wantVersion, gotOpts.Version) + assert.Equal(t, tt.wantAllowHiddenDirs, gotOpts.AllowHiddenDirs) }) } } @@ -1068,3 +1078,244 @@ func TestPreviewRun_TelemetryVisibility(t *testing.T) { }) } } + +func TestFilterHiddenDirSkills(t *testing.T) { + standardSkill := discovery.Skill{Name: "my-skill", Convention: "standard"} + hiddenSkill := discovery.Skill{Name: "hidden-skill", Convention: "hidden-dir"} + hiddenNS := discovery.Skill{Name: "ns-skill", Convention: "hidden-dir-namespaced"} + + tests := []struct { + name string + allowHiddenDirs bool + skills []discovery.Skill + wantCount int + wantErr string + wantStderr string + }{ + { + name: "no hidden skills returns all", + skills: []discovery.Skill{standardSkill}, + wantCount: 1, + }, + { + name: "hidden skills excluded by default", + skills: []discovery.Skill{standardSkill, hiddenSkill}, + wantCount: 1, + wantStderr: "1 skill(s) in hidden directories were excluded", + }, + { + name: "multiple hidden skills excluded with hint", + skills: []discovery.Skill{standardSkill, hiddenSkill, hiddenNS}, + wantCount: 1, + wantStderr: "2 skill(s) in hidden directories were excluded", + }, + { + name: "only hidden skills returns error", + skills: []discovery.Skill{hiddenSkill, hiddenNS}, + wantErr: "no standard skills found, but 2 skill(s) exist in hidden directories", + }, + { + name: "allow-hidden-dirs includes all skills", + allowHiddenDirs: true, + skills: []discovery.Skill{standardSkill, hiddenSkill}, + wantCount: 2, + wantStderr: "Skills in hidden directories", + }, + { + name: "allow-hidden-dirs with no hidden skills", + allowHiddenDirs: true, + skills: []discovery.Skill{standardSkill}, + wantCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + opts := &PreviewOptions{ + IO: ios, + AllowHiddenDirs: tt.allowHiddenDirs, + } + + result, err := filterHiddenDirSkills(opts, tt.skills) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + assert.Len(t, result, tt.wantCount) + if tt.wantStderr != "" { + assert.Contains(t, stderr.String(), tt.wantStderr) + } + }) + } +} + +func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { + skillContent := heredoc.Doc(` + --- + name: my-skill + description: A test skill + --- + # My Skill + + This is the skill content. + `) + encodedContent := base64.StdEncoding.EncodeToString([]byte(skillContent)) + + // Tree contains both a standard skill and a hidden-dir skill + treeJSON := `{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": "skills/my-skill", "type": "tree", "sha": "treeSHA"}, + {"path": "skills/my-skill/SKILL.md", "type": "blob", "sha": "blob123"}, + {"path": ".claude/skills/hidden-skill", "type": "tree", "sha": "treeHidden"}, + {"path": ".claude/skills/hidden-skill/SKILL.md", "type": "blob", "sha": "blobHidden"} + ] + }` + + t.Run("hidden skills excluded by default with hint", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(treeJSON), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob123", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob123"), + httpmock.StringResponse(`{"sha": "blob123", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("owner", "repo"), + SkillName: "my-skill", + Telemetry: &telemetry.NoOpService{}, + } + + err := previewRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "My Skill") + assert.Contains(t, stderr.String(), "skill(s) in hidden directories were excluded") + assert.Contains(t, stderr.String(), "allow-hidden-dirs") + }) + + t.Run("allow-hidden-dirs includes hidden skills", func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(treeJSON), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeHidden"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blobHidden", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blobHidden"), + httpmock.StringResponse(`{"sha": "blobHidden", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("owner", "repo"), + SkillName: "hidden-skill", + AllowHiddenDirs: true, + Telemetry: &telemetry.NoOpService{}, + } + + err := previewRun(opts) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "My Skill") + assert.Contains(t, stderr.String(), "Skills in hidden directories") + assert.NotContains(t, stderr.String(), "were excluded") + }) + + t.Run("only hidden skills without flag returns error", func(t *testing.T) { + onlyHiddenTree := `{ + "sha": "abc123", + "truncated": false, + "tree": [ + {"path": ".claude/skills/hidden-skill", "type": "tree", "sha": "treeHidden"}, + {"path": ".claude/skills/hidden-skill/SKILL.md", "type": "blob", "sha": "blobHidden"} + ] + }` + + reg := &httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/abc123"), + httpmock.StringResponse(onlyHiddenTree), + ) + + ios, _, _, _ := iostreams.Test() + ios.SetStdoutTTY(false) + ios.SetStdinTTY(false) + + opts := &PreviewOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + Prompter: &prompter.PrompterMock{}, + repo: ghrepo.New("owner", "repo"), + SkillName: "hidden-skill", + Telemetry: &telemetry.NoOpService{}, + } + + err := previewRun(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "no standard skills found") + assert.Contains(t, err.Error(), "--allow-hidden-dirs") + }) +} From 2e93afc272cf2c74f06ff4e4f40bbb59b41f7dde Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 01:26:31 +0200 Subject: [PATCH 083/182] Install skills flat by Name, not namespaced InstallName Most agent clients (Claude Code, Copilot, etc.) only discover immediate subdirectories of their skills folder. When a skill repository used namespaced paths like skills/author/my-skill/, the installer created nested directories (e.g. .claude/skills/author/my-skill/) that clients could not find. This separates the skill's identity (InstallName, used for lockfile keys, search, filtering, display) from the filesystem path (Name, used for the install directory). Skills are now always installed flat: .claude/skills/my-skill/SKILL.md (not .claude/skills/author/my-skill/) Changes: - installer: use skill.Name for directory paths instead of InstallName - install.go: use skill.Name for overwrite checks and prompts - collisions: detect conflicts by Name since flat install means two skills with the same Name but different Namespace values will collide - update: clean up old namespaced directories when migrating to flat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/skills/discovery/collisions.go | 16 +++++---- internal/skills/installer/installer.go | 8 +++-- pkg/cmd/skills/install/install.go | 4 +-- pkg/cmd/skills/install/install_test.go | 45 ++++++++++--------------- pkg/cmd/skills/update/update.go | 18 ++++++++++ pkg/cmd/skills/update/update_test.go | 6 +++- 6 files changed, 57 insertions(+), 40 deletions(-) diff --git a/internal/skills/discovery/collisions.go b/internal/skills/discovery/collisions.go index 38bf9b26b25..6aae3c7b7de 100644 --- a/internal/skills/discovery/collisions.go +++ b/internal/skills/discovery/collisions.go @@ -6,20 +6,22 @@ import ( "strings" ) -// NameCollision represents a group of skills that share the same InstallName -// and would overwrite each other when installed to the same directory. +// NameCollision represents a group of skills that share the same install +// directory name and would overwrite each other when installed. type NameCollision struct { - Name string // the conflicting install name (may include namespace prefix) + Name string // the conflicting skill name (directory name) DisplayNames []string // display names of each conflicting skill } -// FindNameCollisions detects skills that share the same InstallName and returns a -// sorted slice of collisions. Callers decide how to present the conflict to -// the user (different flows need different error messages). +// FindNameCollisions detects skills whose Name fields collide (meaning they +// would be installed to the same directory) and returns a sorted slice of +// collisions. Skills are installed flat by Name, so two skills with the same +// Name but different Namespace values still conflict. Callers decide how to +// present the conflict to the user. func FindNameCollisions(skills []Skill) []NameCollision { byName := make(map[string][]Skill) for _, s := range skills { - byName[s.InstallName()] = append(byName[s.InstallName()], s) + byName[s.Name] = append(byName[s.Name], s) } var collisions []NameCollision diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index e27d35f5b46..7a355093de6 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -178,7 +178,10 @@ func InstallLocal(opts *LocalOptions) (*Result, error) { } func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) error { - skillDir := filepath.Join(baseDir, filepath.FromSlash(skill.InstallName())) + // Use skill.Name (not InstallName) so skills are always installed flat. + // Most agent clients only discover immediate subdirectories of their + // skills folder and do not find skills nested under namespace directories. + skillDir := filepath.Join(baseDir, skill.Name) if err := os.MkdirAll(skillDir, 0o755); err != nil { return fmt.Errorf("could not create directory %s: %w", skillDir, err) } @@ -246,7 +249,8 @@ func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) } func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { - skillDir := filepath.Join(baseDir, filepath.FromSlash(skill.InstallName())) + // Use skill.Name (not InstallName) for a flat directory layout. + skillDir := filepath.Join(baseDir, skill.Name) if err := os.MkdirAll(skillDir, 0o755); err != nil { return fmt.Errorf("could not create directory %s: %w", skillDir, err) } diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index a22249225da..6c098853157 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -971,7 +971,7 @@ func truncateDescription(s string, maxWidth int) string { func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir string, canPrompt bool) ([]discovery.Skill, error) { var existing, fresh []discovery.Skill for _, s := range skills { - dir := filepath.Join(targetDir, filepath.FromSlash(s.InstallName())) + dir := filepath.Join(targetDir, s.Name) if _, err := os.Stat(dir); err == nil { existing = append(existing, s) } else { @@ -1013,7 +1013,7 @@ func checkOverwrite(opts *InstallOptions, skills []discovery.Skill, targetDir st } func existingSkillPrompt(targetDir string, incoming discovery.Skill) string { - skillFile := filepath.Join(targetDir, filepath.FromSlash(incoming.InstallName()), "SKILL.md") + skillFile := filepath.Join(targetDir, incoming.Name, "SKILL.md") data, err := os.ReadFile(skillFile) if err != nil { return fmt.Sprintf("Skill %q already exists. Overwrite?", incoming.DisplayName()) diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index b7aa956c5a9..2003325920e 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -857,7 +857,7 @@ func TestInstallRun(t *testing.T) { wantErr: "conflicting names", }, { - name: "remote install all with namespaced skills avoids collisions", + name: "remote install all with namespaced skills detects collisions", isTTY: true, stubs: func(reg *httpmock.Registry) { stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") @@ -868,7 +868,7 @@ func TestInstallRun(t *testing.T) { `{"path": "skills/bob/xlsx-pro", "type": "tree", "sha": "treeB"}, ` + `{"path": "skills/bob/xlsx-pro/SKILL.md", "type": "blob", "sha": "blobB"}` stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", treeJSON) - // Extra blob stubs consumed by FetchDescriptionsConcurrent during interactive selection. + // Blob stubs consumed by FetchDescriptionsConcurrent during interactive selection. contentA := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n")) contentB := base64.StdEncoding.EncodeToString([]byte("---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n")) reg.Register( @@ -877,10 +877,6 @@ func TestInstallRun(t *testing.T) { reg.Register( httpmock.REST("GET", "repos/monalisa/skills-repo/git/blobs/blobB"), httpmock.StringResponse(fmt.Sprintf(`{"sha": "blobB", "content": %q, "encoding": "base64"}`, contentB))) - stubInstallFiles(reg, "monalisa", "skills-repo", "treeA", "blobA", - "---\nname: xlsx-pro\ndescription: Alice\n---\n# A\n") - stubInstallFiles(reg, "monalisa", "skills-repo", "treeB", "blobB", - "---\nname: xlsx-pro\ndescription: Bob\n---\n# B\n") }, opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { t.Helper() @@ -901,7 +897,7 @@ func TestInstallRun(t *testing.T) { Dir: t.TempDir(), } }, - wantStdout: "Installed", + wantErr: "conflicting names", }, { name: "remote install friendlyDir shows tilde for home paths", @@ -1670,7 +1666,7 @@ func TestRunLocalInstall(t *testing.T) { wantStdout: "Installed direct-skill", }, { - name: "namespaced skills install to separate directories", + name: "namespaced skills with same name collide in flat install", isTTY: true, setup: func(t *testing.T, sourceDir, _ string) { t.Helper() @@ -1699,38 +1695,25 @@ func TestRunLocalInstall(t *testing.T) { GitClient: &git.Client{RepoDir: t.TempDir()}, } }, - verify: func(t *testing.T, targetDir string) { - t.Helper() - _, err := os.Stat(filepath.Join(targetDir, "alice", "xlsx-pro", "SKILL.md")) - assert.NoError(t, err, "alice/xlsx-pro should be installed") - _, err = os.Stat(filepath.Join(targetDir, "bob", "xlsx-pro", "SKILL.md")) - assert.NoError(t, err, "bob/xlsx-pro should be installed") - }, - wantStdout: "Installed alice/xlsx-pro", + wantErr: "conflicting names", }, { - name: "local install with --force overwrites namespaced skill", + name: "local install with --force overwrites namespaced skill flat", isTTY: true, setup: func(t *testing.T, sourceDir, targetDir string) { t.Helper() - for _, ns := range []string{"alice", "bob"} { - writeLocalTestSkill(t, sourceDir, filepath.Join("skills", ns, "xlsx-pro"), - fmt.Sprintf("---\nname: xlsx-pro\ndescription: %s xlsx-pro\n---\n# Test\n", ns)) - } - require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "alice", "xlsx-pro"), 0o755)) + writeLocalTestSkill(t, sourceDir, filepath.Join("skills", "alice", "xlsx-pro"), + "---\nname: xlsx-pro\ndescription: alice xlsx-pro\n---\n# Test\n") + require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "xlsx-pro"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(targetDir, "xlsx-pro", "SKILL.md"), []byte("old"), 0o644)) }, opts: func(ios *iostreams.IOStreams, sourceDir, targetDir string) *InstallOptions { t.Helper() - pm := &prompter.PrompterMock{ - MultiSelectWithSearchFunc: func(_, _ string, _, _ []string, _ func(string) prompter.MultiSelectSearchResult) ([]string, error) { - return []string{allSkillsKey}, nil - }, - } return &InstallOptions{ IO: ios, SkillSource: sourceDir, localPath: sourceDir, - Prompter: pm, + SkillName: "xlsx-pro", Force: true, Agent: "github-copilot", Scope: "project", @@ -1739,6 +1722,12 @@ func TestRunLocalInstall(t *testing.T) { GitClient: &git.Client{RepoDir: t.TempDir()}, } }, + verify: func(t *testing.T, targetDir string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(targetDir, "xlsx-pro", "SKILL.md")) + require.NoError(t, err) + assert.Contains(t, string(content), "alice xlsx-pro") + }, wantStdout: "Installed", }, { diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 7923a6bdec4..8b0d831e0ed 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -414,6 +414,24 @@ func updateRun(opts *UpdateOptions) error { failed = true continue } + + // When the install location has changed (e.g. migrating from a + // namespaced layout to flat), remove the old directory so that the + // stale copy does not shadow the freshly installed one. + newDir := filepath.Join(installOpts.Dir, u.skill.Name) + if installOpts.Dir == "" && u.local.host != nil { + if d, err := u.local.host.InstallDir(u.local.scope, gitRoot, homeDir); err == nil { + newDir = filepath.Join(d, u.skill.Name) + } + } + if newDir != "" && u.local.dir != "" && filepath.Clean(newDir) != filepath.Clean(u.local.dir) { + _ = os.RemoveAll(u.local.dir) + // Remove the parent if it is now empty (leftover namespace directory). + parent := filepath.Dir(u.local.dir) + if entries, readErr := os.ReadDir(parent); readErr == nil && len(entries) == 0 { + _ = os.Remove(parent) + } + } if opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.Out, "%s Updated %s\n", cs.SuccessIcon(), u.local.name) } else { diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index 86fdcaa80eb..752e652b9a2 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -726,10 +726,14 @@ func TestUpdateRun(t *testing.T) { }, verify: func(t *testing.T, dir string) { t.Helper() - content, err := os.ReadFile(filepath.Join(dir, "monalisa", "code-review", "SKILL.md")) + // After update, skill should be installed flat (not namespaced). + content, err := os.ReadFile(filepath.Join(dir, "code-review", "SKILL.md")) require.NoError(t, err) assert.Contains(t, string(content), "github-repo: https://github.com/monalisa/octocat-skills") assert.NotContains(t, string(content), "Old namespaced content") + // Old namespaced directory should be cleaned up. + _, err = os.Stat(filepath.Join(dir, "monalisa", "code-review")) + assert.True(t, os.IsNotExist(err), "old namespaced directory should be removed") }, wantStdout: "Updated monalisa/code-review", }, From d961de44d72793e0915e0ba9a48ff5d2a610be56 Mon Sep 17 00:00:00 2001 From: sammorrowdrums Date: Thu, 23 Apr 2026 10:23:26 +0200 Subject: [PATCH 084/182] fix(skills): include --allow-hidden-dirs in preview hint from install The review hint printed after `gh skill install --allow-hidden-dirs` suggests `gh skill preview` commands. Those commands would fail for hidden-dir skills because preview would filter them out. Pass the flag through so the suggested commands work as-is. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/install/install.go | 18 ++++++++++------ pkg/cmd/skills/install/install_test.go | 29 ++++++++++++++++++++------ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index cca960a880a..32c1e403558 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -391,7 +391,7 @@ func installRun(opts *InstallOptions) error { } printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed) - printReviewHint(opts.IO.ErrOut, cs, repoSource, resolved.SHA, result.Installed) + printReviewHint(opts.IO.ErrOut, cs, repoSource, resolved.SHA, result.Installed, opts.AllowHiddenDirs) printHostHints(opts.IO.ErrOut, cs, plan.hosts, result.Installed, result.Dir, gitRoot) } @@ -536,7 +536,7 @@ func runLocalInstall(opts *InstallOptions) error { } printFileTree(opts.IO.ErrOut, cs, result.Dir, result.Installed) - printReviewHint(opts.IO.ErrOut, cs, "", "", result.Installed) + printReviewHint(opts.IO.ErrOut, cs, "", "", result.Installed, false) printHostHints(opts.IO.ErrOut, cs, plan.hosts, result.Installed, result.Dir, gitRoot) } @@ -1118,8 +1118,10 @@ func printPreInstallDisclaimer(w io.Writer, cs *iostreams.ColorScheme) { // printReviewHint warns the user to review installed skills and suggests preview commands. // When sha is non-empty the suggested commands include @SHA so the user previews -// exactly the version that was installed. -func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo, sha string, skillNames []string) { +// exactly the version that was installed. When allowHiddenDirs is true, the +// suggested commands include --allow-hidden-dirs so previewing hidden-dir +// skills works without an extra manual step. +func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo, sha string, skillNames []string, allowHiddenDirs bool) { if len(skillNames) == 0 { return } @@ -1130,11 +1132,15 @@ func printReviewHint(w io.Writer, cs *iostreams.ColorScheme, repo, sha string, s } fmt.Fprintln(w, " Review installed content before use:") fmt.Fprintln(w) + hiddenFlag := "" + if allowHiddenDirs { + hiddenFlag = " --allow-hidden-dirs" + } for _, name := range skillNames { if sha != "" { - fmt.Fprintf(w, " gh skill preview %s %s@%s\n", repo, name, sha) + fmt.Fprintf(w, " gh skill preview %s %s@%s%s\n", repo, name, sha, hiddenFlag) } else { - fmt.Fprintf(w, " gh skill preview %s %s\n", repo, name) + fmt.Fprintf(w, " gh skill preview %s %s%s\n", repo, name, hiddenFlag) } } fmt.Fprintln(w) diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index b7aa956c5a9..db319229251 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -2141,11 +2141,12 @@ func Test_isSkillPath(t *testing.T) { func Test_printReviewHint(t *testing.T) { tests := []struct { - name string - repo string - sha string - skillNames []string - wantOutput string + name string + repo string + sha string + skillNames []string + allowHiddenDirs bool + wantOutput string }{ { name: "remote install with SHA includes SHA in preview command", @@ -2182,6 +2183,22 @@ func Test_printReviewHint(t *testing.T) { skillNames: []string{}, wantOutput: "", }, + { + name: "allow-hidden-dirs appends flag to preview command", + repo: "owner/repo", + sha: "abc123", + skillNames: []string{"hidden-skill"}, + allowHiddenDirs: true, + wantOutput: "gh skill preview owner/repo hidden-skill@abc123 --allow-hidden-dirs", + }, + { + name: "allow-hidden-dirs without SHA", + repo: "owner/repo", + sha: "", + skillNames: []string{"hidden-skill"}, + allowHiddenDirs: true, + wantOutput: "gh skill preview owner/repo hidden-skill --allow-hidden-dirs", + }, } for _, tt := range tests { @@ -2189,7 +2206,7 @@ func Test_printReviewHint(t *testing.T) { ios, _, _, _ := iostreams.Test() cs := ios.ColorScheme() var buf strings.Builder - printReviewHint(&buf, cs, tt.repo, tt.sha, tt.skillNames) + printReviewHint(&buf, cs, tt.repo, tt.sha, tt.skillNames, tt.allowHiddenDirs) if tt.wantOutput == "" { assert.Empty(t, buf.String()) } else { From 9a3dc9fce7bb5997f96fbaa04847dd15b2494930 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 23 Apr 2026 13:41:26 +0200 Subject: [PATCH 085/182] Fix log terminal injection --- .../run-view-log-escape-sequences.txtar | 70 +++++++++++++++++++ pkg/cmd/run/view/view.go | 5 +- pkg/cmd/run/view/view_test.go | 40 +++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 acceptance/testdata/workflow/run-view-log-escape-sequences.txtar diff --git a/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar b/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar new file mode 100644 index 00000000000..14c75cd8646 --- /dev/null +++ b/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar @@ -0,0 +1,70 @@ +# This test ensure that a malicious workflow which emit terminal control sequences (ESC, OSC, CSI) in +# its log output does not result in terminal injection when logs are displayed using `gh run view --log` + +# Use gh as a credential helper +exec gh auth setup-git + +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Commit the workflow file +cd $SCRIPT_NAME-$RANDOM_STRING +mkdir .github/workflows +mv ../workflow.yml .github/workflows/workflow.yml +exec git add .github/workflows/workflow.yml +exec git commit -m 'Create workflow with escape sequences' +exec git push -u origin main + +# Sleep because it takes a second for the workflow to register +sleep 1 + +# Run the workflow +exec gh workflow run 'Escape Sequence PoC' + +# It takes some time for a workflow run to register +sleep 10 + +# Get the run ID we want to view +exec gh run list --json databaseId --jq '.[0].databaseId' +stdout2env RUN_ID + +# Wait for workflow to complete +exec gh run watch $RUN_ID --exit-status + +# View the logs and check that raw ESC bytes (0x1b) are NOT present in output. +# If this assertion fails, it means terminal escape sequences from the workflow +# log are being passed through to the user's terminal unsanitised. +exec gh run view $RUN_ID --log + +# The output should contain the safe/visible text but not raw ESC bytes. +# \x1b is the ESC byte - it must not appear in the output. +! stdout '\x1b' + +# The log output should still contain the non-escape parts of the log lines. +stdout 'ESCAPE_MARKER_START' +stdout 'ESCAPE_MARKER_END' + +-- workflow.yml -- +name: Escape Sequence PoC + +on: + workflow_dispatch: + +jobs: + emit-escape-sequences: + runs-on: ubuntu-latest + steps: + - name: Emit terminal escape sequences + run: | + # OSC title set: \x1b]0;TITLE\x07 + printf 'ESCAPE_MARKER_START \033]0;HIJACKED_TITLE\007 ESCAPE_MARKER_END\n' + # CSI color: \x1b[31m ... \x1b[0m + printf 'ESCAPE_MARKER_START \033[31mRED_TEXT\033[0m ESCAPE_MARKER_END\n' + # Screen title set (from original PoC): \x1bk ... \x1b\\ + printf 'ESCAPE_MARKER_START \033k;malicious command;\033\\ ESCAPE_MARKER_END\n' diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index bed9e3bfa09..3e5199452e2 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -22,7 +22,9 @@ import ( "github.com/cli/cli/v2/pkg/cmd/run/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/go-gh/v2/pkg/asciisanitizer" "github.com/spf13/cobra" + "golang.org/x/text/transform" ) type RunLogCache struct { @@ -579,7 +581,8 @@ func displayLogSegments(w io.Writer, segments []logSegment) error { } func copyLogWithLinePrefix(w io.Writer, r io.Reader, prefix string) error { - scanner := bufio.NewScanner(r) + sanitized := transform.NewReader(r, &asciisanitizer.Sanitizer{}) + scanner := bufio.NewScanner(sanitized) for scanner.Scan() { fmt.Fprintf(w, "%s%s\n", prefix, scanner.Text()) } diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index 14749fcf66d..c3ee9a54ad8 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -2759,6 +2759,46 @@ var expectedRunLogOutput = fmt.Sprintf("%s%s", coolJobRunLogOutput, sadJobRunLog var expectedRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", coolJobRunWithNoStepLogsLogOutput, sadJobRunWithNoStepLogsLogOutput) var expectedLegacyRunLogOutputWithNoSteps = fmt.Sprintf("%s%s", legacyCoolJobRunWithNoStepLogsLogOutput, legacySadJobRunWithNoStepLogsLogOutput) +func TestCopyLogWithLinePrefix_TerminalEscapeSequences(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "OSC title set sequence", + input: "normal prefix\x1b]0;HIJACKED TITLE\x07trailing text\n", + }, + { + name: "CSI color sequence", + input: "\x1b[31mRED TEXT\x1b[0m normal text\n", + }, + { + name: "screen title set sequence used in original report", + input: "\x1bk;echo this is an arbitrary command;\x1b\\\n", + }, + { + name: "CSI window title query", + input: "before\x1b[21tafter\n", + }, + { + name: "multiple escape sequences", + input: "\x1b]0;title\x07\x1b[31mred\x1b[0m\x1b[21t\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := copyLogWithLinePrefix(&buf, strings.NewReader(tt.input), "jobname\tstep\t") + require.NoError(t, err) + + output := buf.String() + assert.NotContains(t, output, "\x1b", + "output should not contain raw ESC (0x1b) bytes, got: %q", output) + }) + } +} + func TestRunLog(t *testing.T) { t.Run("when the cache dir doesn't exist, exists return false", func(t *testing.T) { cacheDir := t.TempDir() + "/non-existent-dir" From c8e013991948fa17c3ec1b5141eebebdde9f872c Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 23 Apr 2026 15:31:38 +0200 Subject: [PATCH 086/182] Update acceptance/testdata/workflow/run-view-log-escape-sequences.txtar Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../testdata/workflow/run-view-log-escape-sequences.txtar | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar b/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar index 14c75cd8646..47978cf4dce 100644 --- a/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar +++ b/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar @@ -1,4 +1,4 @@ -# This test ensure that a malicious workflow which emit terminal control sequences (ESC, OSC, CSI) in +# This test ensures that a malicious workflow which emit terminal control sequences (ESC, OSC, CSI) in # its log output does not result in terminal injection when logs are displayed using `gh run view --log` # Use gh as a credential helper From 47f379cf0d2131784d9506d82d1c8d275700445b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:03:29 +0000 Subject: [PATCH 087/182] chore(deps): bump github.com/gdamore/tcell/v2 from 2.13.8 to 2.13.9 Bumps [github.com/gdamore/tcell/v2](https://github.com/gdamore/tcell) from 2.13.8 to 2.13.9. - [Release notes](https://github.com/gdamore/tcell/releases) - [Changelog](https://github.com/gdamore/tcell/blob/main/CHANGESv3.md) - [Commits](https://github.com/gdamore/tcell/compare/v2.13.8...v2.13.9) --- updated-dependencies: - dependency-name: github.com/gdamore/tcell/v2 dependency-version: 2.13.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 70197d2ae8f..0e5bd924490 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea github.com/distribution/reference v0.6.0 github.com/gabriel-vasile/mimetype v1.4.13 - github.com/gdamore/tcell/v2 v2.13.8 + github.com/gdamore/tcell/v2 v2.13.9 github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.21.5 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 diff --git a/go.sum b/go.sum index d1266872a86..5a5d25af535 100644 --- a/go.sum +++ b/go.sum @@ -205,8 +205,8 @@ github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9 github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= -github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= +github.com/gdamore/tcell/v2 v2.13.9 h1:uI5l3DYPcFvHINKlGft+en23evOKL+dwtD21QR8ejVA= +github.com/gdamore/tcell/v2 v2.13.9/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= From aba7c591f3ad6a1aaa10f721b2b623371bfdb72d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 14:04:05 +0000 Subject: [PATCH 088/182] chore(deps): bump charm.land/bubbletea/v2 from 2.0.2 to 2.0.6 Bumps [charm.land/bubbletea/v2](https://github.com/charmbracelet/bubbletea) from 2.0.2 to 2.0.6. - [Release notes](https://github.com/charmbracelet/bubbletea/releases) - [Commits](https://github.com/charmbracelet/bubbletea/compare/v2.0.2...v2.0.6) --- updated-dependencies: - dependency-name: charm.land/bubbletea/v2 dependency-version: 2.0.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 70197d2ae8f..dc10a4fad43 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.26.2 require ( charm.land/bubbles/v2 v2.1.0 - charm.land/bubbletea/v2 v2.0.2 + charm.land/bubbletea/v2 v2.0.6 charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.3 github.com/AlecAivazis/survey/v2 v2.3.7 @@ -81,7 +81,7 @@ require ( github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect diff --git a/go.sum b/go.sum index d1266872a86..2c79ace711d 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= -charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= -charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= +charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= @@ -116,8 +116,8 @@ github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= -github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= +github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= From 993d4bb630bd77ff4226cffdc9a59aed893a8463 Mon Sep 17 00:00:00 2001 From: Cassidy James Blaede Date: Thu, 23 Apr 2026 10:35:16 -0600 Subject: [PATCH 089/182] install_linux: correct typo in Homebrew copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Just a little typo I noticed when looking at the instructions. updated → updates --- docs/install_linux.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install_linux.md b/docs/install_linux.md index 99f5d82828a..9b90a43393f 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -165,7 +165,7 @@ sudo zypper update gh [Homebrew](https://brew.sh/) is a free and open-source software package management system that simplifies the installation of software on Apple's operating system, macOS, as well as Linux. -The [GitHub CLI formulae](https://formulae.brew.sh/formula/gh) is supported by the GitHub CLI maintainers with help from our friends at Homebrew with updated powered by [homebrew/hoomebrew-core](https://github.com/Homebrew/homebrew-core/blob/main/Formula/g/gh.rb). +The [GitHub CLI formulae](https://formulae.brew.sh/formula/gh) is supported by the GitHub CLI maintainers with help from our friends at Homebrew with updates powered by [homebrew/hoomebrew-core](https://github.com/Homebrew/homebrew-core/blob/main/Formula/g/gh.rb). To install: From de6a9eb3e440a7ee5d1b40ec49914e469c413442 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Fri, 24 Apr 2026 09:59:09 +0100 Subject: [PATCH 090/182] chore: fix zsh completion on debian Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + .goreleaser.yml | 5 +++++ Makefile | 6 +++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 461b6a5a0e2..25549846a52 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /share/fish/vendor_completions.d /share/man/man1 /share/zsh/site-functions +/share/zsh/vendor-completions /gh-cli .envrc /dist diff --git a/.goreleaser.yml b/.goreleaser.yml index b264b58e86c..9dd3c3e00bc 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -106,3 +106,8 @@ nfpms: #build:linux dst: "/usr/share/fish/vendor_completions.d/gh.fish" - src: "./share/zsh/site-functions/_gh" dst: "/usr/share/zsh/site-functions/_gh" + # Debian/Ubuntu zsh does not look in /usr/share/zsh/site-functions by default, + # so we also install to vendor-completions. See https://github.com/cli/cli/issues/13166 + - src: "./share/zsh/vendor-completions/_gh" + dst: "/usr/share/zsh/vendor-completions/_gh" + packager: deb diff --git a/Makefile b/Makefile index fb8bf40911b..c3b18f31332 100644 --- a/Makefile +++ b/Makefile @@ -33,10 +33,14 @@ manpages: script/build$(EXE) .PHONY: completions completions: bin/gh$(EXE) - mkdir -p ./share/bash-completion/completions ./share/fish/vendor_completions.d ./share/zsh/site-functions + mkdir -p ./share/bash-completion/completions ./share/fish/vendor_completions.d ./share/zsh/site-functions ./share/zsh/vendor-completions bin/gh$(EXE) completion -s bash > ./share/bash-completion/completions/gh bin/gh$(EXE) completion -s fish > ./share/fish/vendor_completions.d/gh.fish bin/gh$(EXE) completion -s zsh > ./share/zsh/site-functions/_gh + # On Debian/Ubuntu the default zsh fpath does not include /usr/share/zsh/site-functions + # but does include /usr/share/zsh/vendor-completions, so we ship both paths in our + # .deb and .rpm packages. See https://github.com/cli/cli/issues/13166 + cp ./share/zsh/site-functions/_gh ./share/zsh/vendor-completions/_gh .PHONY: lint lint: From dde46dc42406db437458c5151b428a9318b743b3 Mon Sep 17 00:00:00 2001 From: Max Beizer Date: Fri, 24 Apr 2026 17:16:00 -0500 Subject: [PATCH 091/182] Add "Resource not accessible" to ProjectsV2IgnorableError When a token (GitHub App, fine-grained PAT, or GITHUB_TOKEN) lacks the project permission, querying projectItems on a PR or issue fails with "Resource not accessible by integration" or "Resource not accessible by personal access token". ProjectsV2IgnorableError did not match these errors, causing commands like pr view, pr edit, and issue view to fail entirely instead of gracefully omitting project data. Add "Resource not accessible by" as an ignorable error prefix. This is safe because ProjectsV2IgnorableError is only called in project-specific code paths. Closes #13280 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_projects_v2.go | 25 ++++++++++++++----------- api/queries_projects_v2_test.go | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/api/queries_projects_v2.go b/api/queries_projects_v2.go index 0126c1caa3b..b5f46655d7b 100644 --- a/api/queries_projects_v2.go +++ b/api/queries_projects_v2.go @@ -9,12 +9,13 @@ import ( ) const ( - errorProjectsV2ReadScope = "field requires one of the following scopes: ['read:project']" - errorProjectsV2UserField = "Field 'projectsV2' doesn't exist on type 'User'" - errorProjectsV2RepositoryField = "Field 'projectsV2' doesn't exist on type 'Repository'" - errorProjectsV2OrganizationField = "Field 'projectsV2' doesn't exist on type 'Organization'" - errorProjectsV2IssueField = "Field 'projectItems' doesn't exist on type 'Issue'" - errorProjectsV2PullRequestField = "Field 'projectItems' doesn't exist on type 'PullRequest'" + errorProjectsV2ReadScope = "field requires one of the following scopes: ['read:project']" + errorProjectsV2UserField = "Field 'projectsV2' doesn't exist on type 'User'" + errorProjectsV2RepositoryField = "Field 'projectsV2' doesn't exist on type 'Repository'" + errorProjectsV2OrganizationField = "Field 'projectsV2' doesn't exist on type 'Organization'" + errorProjectsV2IssueField = "Field 'projectItems' doesn't exist on type 'Issue'" + errorProjectsV2PullRequestField = "Field 'projectItems' doesn't exist on type 'PullRequest'" + errorProjectsV2ResourceNotAccessible = "Resource not accessible by" ) type ProjectV2 struct { @@ -321,10 +322,11 @@ func CurrentUserProjectsV2(client *Client, hostname string) ([]ProjectV2, error) } // When querying ProjectsV2 fields we generally don't want to show the user -// scope errors and field does not exist errors. ProjectsV2IgnorableError -// checks against known error strings to see if an error can be safely ignored. -// Due to the fact that the GraphQLClient can return multiple types of errors -// this uses brittle string comparison to check against the known error strings. +// scope errors, field does not exist errors, or authorization errors. +// ProjectsV2IgnorableError checks against known error strings to see if an +// error can be safely ignored. Due to the fact that the GraphQLClient can +// return multiple types of errors this uses brittle string comparison to check +// against the known error strings. func ProjectsV2IgnorableError(err error) bool { msg := err.Error() if strings.Contains(msg, errorProjectsV2ReadScope) || @@ -332,7 +334,8 @@ func ProjectsV2IgnorableError(err error) bool { strings.Contains(msg, errorProjectsV2RepositoryField) || strings.Contains(msg, errorProjectsV2OrganizationField) || strings.Contains(msg, errorProjectsV2IssueField) || - strings.Contains(msg, errorProjectsV2PullRequestField) { + strings.Contains(msg, errorProjectsV2PullRequestField) || + strings.Contains(msg, errorProjectsV2ResourceNotAccessible) { return true } return false diff --git a/api/queries_projects_v2_test.go b/api/queries_projects_v2_test.go index 3d29a19c144..1f1d91b8295 100644 --- a/api/queries_projects_v2_test.go +++ b/api/queries_projects_v2_test.go @@ -317,6 +317,21 @@ func TestProjectsV2IgnorableError(t *testing.T) { errMsg: "Field 'projectItems' doesn't exist on type 'PullRequest'", expectOut: true, }, + { + name: "resource not accessible by integration", + errMsg: "Resource not accessible by integration", + expectOut: true, + }, + { + name: "resource not accessible by personal access token", + errMsg: "Resource not accessible by personal access token", + expectOut: true, + }, + { + name: "resource not accessible by integration with path context", + errMsg: "GraphQL: Resource not accessible by integration (repository.pullRequest.projectItems.nodes.0)", + expectOut: true, + }, { name: "other error", errMsg: "some other graphql error message", From e40c592e42b708559925cf8c0ab4b869e8e4ace6 Mon Sep 17 00:00:00 2001 From: travellertales Date: Mon, 27 Apr 2026 10:19:56 -0400 Subject: [PATCH 092/182] Update command.go --- pkg/cmd/extension/command.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index de6e29ab769..3852a070593 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -50,6 +50,10 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { When an extension is executed, gh will check for new versions once every 24 hours and display an upgrade notice. See %[1]sgh help environment%[1]s for information on disabling extension notices. + Extensions are not verified, signed, or endorsed by GitHub. When you install or upgrade + an extension, you are trusting its publisher. It is your responsibility to review the + source and provenance of any extension before use. + For the list of available extensions, see . `, "`"), Aliases: []string{"extensions", "ext"}, From ed31e2f6e8b8079115409aec11c7960bf2360cc4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:26:50 +0000 Subject: [PATCH 093/182] chore(deps): bump goreleaser/goreleaser-action from 7.0.0 to 7.2.1 Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 7.0.0 to 7.2.1. - [Commits](https://github.com/goreleaser/goreleaser-action/compare/ec59f474b9834571250b370d4735c50f8e2d1e29...1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8) --- updated-dependencies: - dependency-name: goreleaser/goreleaser-action dependency-version: 7.2.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/deployment.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 19ffc51d46c..311690c33ba 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -52,7 +52,7 @@ jobs: with: go-version-file: 'go.mod' - name: Install GoReleaser - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. @@ -113,7 +113,7 @@ jobs: security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$keychain_password" "$keychain" rm "$RUNNER_TEMP/cert.p12" - name: Install GoReleaser - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. @@ -175,7 +175,7 @@ jobs: with: go-version-file: 'go.mod' - name: Install GoReleaser - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 + uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. From 0e3c4991c7bc694b59ad7fa0ac5a781a6ad4501d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:27:14 +0000 Subject: [PATCH 094/182] chore(deps): bump github.com/mattn/go-isatty from 0.0.21 to 0.0.22 Bumps [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) from 0.0.21 to 0.0.22. - [Commits](https://github.com/mattn/go-isatty/compare/v0.0.21...v0.0.22) --- updated-dependencies: - dependency-name: github.com/mattn/go-isatty dependency-version: 0.0.22 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ef2f42b231a..e444213d32b 100644 --- a/go.mod +++ b/go.mod @@ -40,7 +40,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/klauspost/compress v1.18.5 github.com/mattn/go-colorable v0.1.14 - github.com/mattn/go-isatty v0.0.21 + github.com/mattn/go-isatty v0.0.22 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/microsoft/dev-tunnels v0.1.19 github.com/muhammadmuzzammil1998/jsonc v1.0.0 diff --git a/go.sum b/go.sum index 2b912f0b1ac..9980facc36e 100644 --- a/go.sum +++ b/go.sum @@ -383,8 +383,8 @@ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= -github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= From 06ac7b6e0a2e71217bcd8be379e4ab584088dc6c Mon Sep 17 00:00:00 2001 From: cli automation Date: Tue, 28 Apr 2026 04:20:26 +0000 Subject: [PATCH 095/182] Bump Go to 1.26.2 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index ef2f42b231a..9bddf19b2dd 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/cli/cli/v2 -go 1.26.1 +go 1.26.0 toolchain go1.26.2 From 20e4d25147ea90d629d689a01675eac551fc6362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Dost=C3=A1l?= Date: Tue, 28 Apr 2026 18:20:23 +0200 Subject: [PATCH 096/182] Add missing //go:build integration tag to verify_integration_test.go The four tests in this file (TestVerifyIntegration, TestVerifyIntegrationCustomIssuer, TestVerifyIntegrationReusableWorkflow, TestVerifyIntegrationReusableWorkflowSignerWorkflow) call NewLiveSigstoreVerifier which requires network access to Sigstore and GitHub TUF servers. Unlike the other integration test files in this package (attestation_integration_test.go, sigstore_integration_test.go, inspect_integration_test.go), this file was missing the //go:build integration tag, causing these tests to run during a regular 'go test ./...' and fail in network-isolated build environments. --- pkg/cmd/attestation/verify/verify_integration_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index b9994141313..ed20a9007fd 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package verify import ( From 6d6ea5f3719ca29802005d877b2beaa06beca7df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Dost=C3=A1l?= Date: Tue, 28 Apr 2026 18:25:51 +0200 Subject: [PATCH 097/182] Fix flaky Password test by increasing echo mode setup timeout The beforePasswordSendTimeout was set to 100 microseconds, which is insufficient for huh to disable echo mode on the PTY in slow or constrained environments (e.g. network-isolated build containers). Increase to 100 milliseconds to avoid the race condition. --- internal/prompter/accessible_prompter_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 372b0e6705f..b67156bdfc3 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -34,7 +34,7 @@ import ( // but doesn't mandate that prompts always look exactly the same. func TestAccessiblePrompter(t *testing.T) { - beforePasswordSendTimeout := 100 * time.Microsecond + beforePasswordSendTimeout := 100 * time.Millisecond t.Run("Select", func(t *testing.T) { console := newTestVirtualTerminal(t) From 8b89c8b2b2eb9920f944bf3091fda5724ddee717 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:40:51 -0400 Subject: [PATCH 098/182] Enable extended PR screening for external PRs Opts in to the new PR screening features in the shared triage workflow: - Instantly closes PRs with zero file changes - Detects same-author resubmissions of recently closed PRs - Fast-tracks small, well-described fixes to ready-for-review - Accelerates closure of large unsolicited PRs (3 days vs 7) Depends on desktop/gh-cli-and-desktop-shared-workflows#17 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/triage-pull-requests.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/triage-pull-requests.yml b/.github/workflows/triage-pull-requests.yml index 92ba43d4a66..887f5e366fa 100644 --- a/.github/workflows/triage-pull-requests.yml +++ b/.github/workflows/triage-pull-requests.yml @@ -31,6 +31,10 @@ jobs: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'edited') uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-pr-requirements.yml@main + with: + enable_pr_screening: true + days_until_close: 4 + large_pr_days_until_close: 2 permissions: issues: read pull-requests: write @@ -38,6 +42,10 @@ jobs: close-unmet-requirements: if: github.event_name == 'schedule' uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-pr-requirements.yml@main + with: + enable_pr_screening: true + days_until_close: 4 + large_pr_days_until_close: 2 permissions: issues: read pull-requests: write From 4ed70026815d71b97b54d2ceeb038e992a34fd55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 1 May 2026 10:16:10 +0300 Subject: [PATCH 099/182] Switch from actions/attest-build-provenance to actions/attest https://github.com/actions/attest-build-provenance#usage > As of version 4, actions/attest-build-provenance is simply a wrapper > on top of actions/attest. > > Existing applications may continue to use the attest-build-provenance > action, but new implementations should use actions/attest instead. --- .github/workflows/deployment.yml | 2 +- docs/release-process-deep-dive.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 311690c33ba..4e56fb0bf24 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -340,7 +340,7 @@ jobs: rpmsign --addsign dist/*.rpm - name: Attest release artifacts if: inputs.environment == 'production' - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-path: "dist/gh_*" create-storage-record: false # (default: true) diff --git a/docs/release-process-deep-dive.md b/docs/release-process-deep-dive.md index 31f44f6efd2..4d060841a5a 100644 --- a/docs/release-process-deep-dive.md +++ b/docs/release-process-deep-dive.md @@ -495,7 +495,7 @@ release: rpmsign --addsign dist/*.rpm - name: Attest release artifacts if: inputs.environment == 'production' - uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0 + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 with: subject-path: "dist/gh_*" - name: Run createrepo From d8b8655f2199bbda719d56a38943d69031095e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 1 May 2026 10:22:13 +0300 Subject: [PATCH 100/182] Grammar fixes --- api/http_client.go | 2 +- git/client.go | 2 +- internal/config/config.go | 4 ++-- pkg/cmd/agent-task/capi/job.go | 2 +- pkg/cmd/issue/pin/pin.go | 2 +- pkg/cmd/issue/unpin/unpin.go | 2 +- pkg/cmd/label/clone.go | 2 +- pkg/cmd/pr/create/create_test.go | 4 ++-- pkg/cmd/workflow/shared/shared_test.go | 2 +- pkg/iostreams/iostreams_progress_indicator_test.go | 2 +- 10 files changed, 12 insertions(+), 12 deletions(-) diff --git a/api/http_client.go b/api/http_client.go index be7a6b8a71c..206b8ed56a8 100644 --- a/api/http_client.go +++ b/api/http_client.go @@ -92,7 +92,7 @@ func NewCachedHTTPClient(httpClient *http.Client, ttl time.Duration) *http.Clien return &newClient } -// AddCacheTTLHeader adds an header to the request telling the cache that the request +// AddCacheTTLHeader adds a header to the request telling the cache that the request // should be cached for a specified amount of time. func AddCacheTTLHeader(rt http.RoundTripper, ttl time.Duration) http.RoundTripper { return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { diff --git a/git/client.go b/git/client.go index 7f2487fce53..9f6670d6200 100644 --- a/git/client.go +++ b/git/client.go @@ -106,7 +106,7 @@ func (c *Client) Command(ctx context.Context, args ...string) (*Command, error) // It is only usable when constructed by another function in the package because the empty pattern, // without allMatching set to true, will result in an error in AuthenticatedCommand. // -// Callers can currently opt-in to an slightly less secure mode for backwards compatibility by using +// Callers can currently opt-in to a slightly less secure mode for backwards compatibility by using // AllMatchingCredentialsPattern. type CredentialPattern struct { allMatching bool // should only be constructable via AllMatchingCredentialsPattern diff --git a/internal/config/config.go b/internal/config/config.go index a694ca65461..dadfa284b30 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -580,7 +580,7 @@ color_labels: disabled accessible_colors: disabled # Whether an accessible prompter should be used. Supported values: enabled, disabled accessible_prompter: disabled -# Whether to use a animated spinner as a progress indicator. If disabled, a textual progress indicator is used instead. Supported values: enabled, disabled +# Whether to use an animated spinner as a progress indicator. If disabled, a textual progress indicator is used instead. Supported values: enabled, disabled spinner: enabled ` @@ -681,7 +681,7 @@ var Options = []ConfigOption{ }, { Key: spinnerKey, - Description: "whether to use a animated spinner as a progress indicator", + Description: "whether to use an animated spinner as a progress indicator", DefaultValue: "enabled", AllowedValues: []string{"enabled", "disabled"}, CurrentValue: func(c gh.Config, hostname string) string { diff --git a/pkg/cmd/agent-task/capi/job.go b/pkg/cmd/agent-task/capi/job.go index 2e37d4f5ee2..a09338695b0 100644 --- a/pkg/cmd/agent-task/capi/job.go +++ b/pkg/cmd/agent-task/capi/job.go @@ -127,7 +127,7 @@ func (c *CAPIClient) CreateJob(ctx context.Context, owner, repo, problemStatemen return &j, nil } -// GetJob retrieves a agent job +// GetJob retrieves an agent job func (c *CAPIClient) GetJob(ctx context.Context, owner, repo, jobID string) (*Job, error) { if owner == "" || repo == "" || jobID == "" { return nil, errors.New("owner, repo, and jobID are required") diff --git a/pkg/cmd/issue/pin/pin.go b/pkg/cmd/issue/pin/pin.go index 290bec50797..ab5d87fe996 100644 --- a/pkg/cmd/issue/pin/pin.go +++ b/pkg/cmd/issue/pin/pin.go @@ -33,7 +33,7 @@ func NewCmdPin(f *cmdutil.Factory, runF func(*PinOptions) error) *cobra.Command cmd := &cobra.Command{ Use: "pin { | }", - Short: "Pin a issue", + Short: "Pin an issue", Long: heredoc.Doc(` Pin an issue to a repository. diff --git a/pkg/cmd/issue/unpin/unpin.go b/pkg/cmd/issue/unpin/unpin.go index ca22aa82eee..96e801a689e 100644 --- a/pkg/cmd/issue/unpin/unpin.go +++ b/pkg/cmd/issue/unpin/unpin.go @@ -34,7 +34,7 @@ func NewCmdUnpin(f *cmdutil.Factory, runF func(*UnpinOptions) error) *cobra.Comm cmd := &cobra.Command{ Use: "unpin { | }", - Short: "Unpin a issue", + Short: "Unpin an issue", Long: heredoc.Doc(` Unpin an issue from a repository. diff --git a/pkg/cmd/label/clone.go b/pkg/cmd/label/clone.go index a02c4764ad8..b8c4631c61e 100644 --- a/pkg/cmd/label/clone.go +++ b/pkg/cmd/label/clone.go @@ -50,7 +50,7 @@ func newCmdClone(f *cmdutil.Factory, runF func(*cloneOptions) error) *cobra.Comm # Clone and overwrite labels from cli/cli repository into the current repository $ gh label clone cli/cli --force - # Clone labels from cli/cli repository into a octocat/cli repository + # Clone labels from cli/cli repository into octocat/cli repository $ gh label clone cli/cli --repo octocat/cli `), Args: cmdutil.ExactArgs(1, "cannot clone labels: source-repository argument required"), diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index 5bad889b675..a622b60c891 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -861,7 +861,7 @@ func Test_createRun(t *testing.T) { { "filename": "template1", "body": "this is a bug" }, { "filename": "template2", - "body": "this is a enhancement" } + "body": "this is an enhancement" } ] } } }`)) reg.Register( httpmock.GraphQL(`mutation PullRequestCreate\b`), @@ -1092,7 +1092,7 @@ func Test_createRun(t *testing.T) { { "filename": "template1", "body": "this is a bug" }, { "filename": "template2", - "body": "this is a enhancement" } + "body": "this is an enhancement" } ] } } }`), ) reg.Register( diff --git a/pkg/cmd/workflow/shared/shared_test.go b/pkg/cmd/workflow/shared/shared_test.go index cd53b667c3e..cc9017d3643 100644 --- a/pkg/cmd/workflow/shared/shared_test.go +++ b/pkg/cmd/workflow/shared/shared_test.go @@ -406,7 +406,7 @@ func TestGetWorkflows(t *testing.T) { } } -// generateWorkflows returns an slice of workflows with the given count, labeled +// generateWorkflows returns a slice of workflows with the given count, labeled // with the page number of testing pagination. // The page number is used to generate unique Names and IDs for each workflow. func generateWorkflows(t *testing.T, workflowCount int, pageNum int) []Workflow { diff --git a/pkg/iostreams/iostreams_progress_indicator_test.go b/pkg/iostreams/iostreams_progress_indicator_test.go index 60d0ece91e3..8e27e60a533 100644 --- a/pkg/iostreams/iostreams_progress_indicator_test.go +++ b/pkg/iostreams/iostreams_progress_indicator_test.go @@ -27,7 +27,7 @@ func TestStartProgressIndicatorWithLabel(t *testing.T) { // waiting for input because the console is not ready to be read. // But in this case, we are not blocking waiting for input and stdout // can be constantly read. This means the timeout will never be reached - // in the event of a expectation failure. + // in the event of an expectation failure. // To fix this, we need to implement our own timeout that is based // specifically on the total time spent reading the console and waiting // for the target string instead of the max time for a single read From 8ff70e6e7ad6b2df3fd2a6988ed287189dd60ce2 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 1 May 2026 17:23:01 +0200 Subject: [PATCH 101/182] Remove numberFieldOnly API shortcut Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/pr/shared/finder.go | 6 ------ pkg/cmd/pr/shared/finder_test.go | 19 ------------------- 2 files changed, 25 deletions(-) diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index 80f1e707de6..ade90653418 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -207,7 +207,6 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err fields := set.NewStringSet() fields.AddValues(opts.Fields) - numberFieldOnly := fields.Len() == 1 && fields.Contains("number") fields.AddValues([]string{"id", "number"}) // for additional preload queries below if fields.Contains("isInMergeQueue") || fields.Contains("isMergeQueueEnabled") { @@ -248,11 +247,6 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err var pr *api.PullRequest if f.prNumber > 0 { - // If we have a PR number, let's look it up - if numberFieldOnly { - // avoid hitting the API if we already have all the information - return &api.PullRequest{Number: f.prNumber}, f.baseRefRepo, nil - } pr, err = findByNumber(httpClient, f.baseRefRepo, f.prNumber, fields.ToSlice()) if err != nil { return pr, f.baseRefRepo, err diff --git a/pkg/cmd/pr/shared/finder_test.go b/pkg/cmd/pr/shared/finder_test.go index af34370609f..8177fe144ce 100644 --- a/pkg/cmd/pr/shared/finder_test.go +++ b/pkg/cmd/pr/shared/finder_test.go @@ -326,25 +326,6 @@ func TestFind(t *testing.T) { }, wantErr: true, }, - { - name: "number only", - args: args{ - selector: "13", - fields: []string{"number"}, - baseRepoFn: stubBaseRepoFn(ghrepo.New("ORIGINOWNER", "REPO"), nil), - branchFn: func() (string, error) { - return "blueberries", nil - }, - gitConfigClient: stubGitConfigClient{ - readBranchConfigFn: stubBranchConfig(git.BranchConfig{}, nil), - pushDefaultFn: stubPushDefault(git.PushDefaultSimple, nil), - remotePushDefaultFn: stubRemotePushDefault("", nil), - }, - }, - httpStub: nil, - wantPR: 13, - wantRepo: "https://github.com/ORIGINOWNER/REPO", - }, { name: "pr number zero", args: args{ From 6dc432ec479a00b7fb272327d0e6fbb9350a5519 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 16:42:15 +0000 Subject: [PATCH 102/182] chore(deps): bump github.com/klauspost/compress from 1.18.5 to 1.18.6 Bumps [github.com/klauspost/compress](https://github.com/klauspost/compress) from 1.18.5 to 1.18.6. - [Release notes](https://github.com/klauspost/compress/releases) - [Commits](https://github.com/klauspost/compress/compare/v1.18.5...v1.18.6) --- updated-dependencies: - dependency-name: github.com/klauspost/compress dependency-version: 1.18.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 92681afac7f..709894753f2 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( github.com/in-toto/attestation v1.2.0 github.com/joho/godotenv v1.5.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/klauspost/compress v1.18.5 + github.com/klauspost/compress v1.18.6 github.com/mattn/go-colorable v0.1.14 github.com/mattn/go-isatty v0.0.22 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d diff --git a/go.sum b/go.sum index 9980facc36e..be25979b0d9 100644 --- a/go.sum +++ b/go.sum @@ -365,8 +365,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= From 1caa3b7475611186a617071106dd415e996b25f1 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 7 May 2026 17:20:03 +0200 Subject: [PATCH 103/182] Bump copilot telemetry sampling to 100% Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/copilot/copilot.go | 5 ++++- pkg/cmd/copilot/copilot_test.go | 20 +++++++++++++++++++- pkg/cmd/root/root.go | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/copilot/copilot.go b/pkg/cmd/copilot/copilot.go index 1f2b7779858..50b00e9fe45 100644 --- a/pkg/cmd/copilot/copilot.go +++ b/pkg/cmd/copilot/copilot.go @@ -20,6 +20,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safepaths" ghzip "github.com/cli/cli/v2/internal/zip" @@ -37,7 +38,7 @@ type CopilotOptions struct { Remove bool } -func NewCmdCopilot(f *cmdutil.Factory, runF func(*CopilotOptions) error) *cobra.Command { +func NewCmdCopilot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*CopilotOptions) error) *cobra.Command { opts := &CopilotOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, @@ -80,6 +81,8 @@ func NewCmdCopilot(f *cmdutil.Factory, runF func(*CopilotOptions) error) *cobra. `), DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { + telemetry.SetSampleRate(ghtelemetry.SAMPLE_ALL) + stopParsePos := -1 for i, arg := range args { if arg == "--" { diff --git a/pkg/cmd/copilot/copilot_test.go b/pkg/cmd/copilot/copilot_test.go index e7c8fb02755..07e0191e6ba 100644 --- a/pkg/cmd/copilot/copilot_test.go +++ b/pkg/cmd/copilot/copilot_test.go @@ -14,6 +14,8 @@ import ( "runtime" "testing" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" @@ -110,7 +112,7 @@ func TestNewCmdCopilot(t *testing.T) { assert.NoError(t, err) var gotOpts *CopilotOptions - cmd := NewCmdCopilot(f, func(opts *CopilotOptions) error { + cmd := NewCmdCopilot(f, &telemetry.CommandRecorderSpy{}, func(opts *CopilotOptions) error { gotOpts = opts return nil }) @@ -586,3 +588,19 @@ func TestDownloadCopilot(t *testing.T) { require.Equal(t, localPath, path, "downloadCopilot() path mismatch") }) } + +func TestCopilotCommandIsSampledAt100(t *testing.T) { + spy := &telemetry.CommandRecorderSpy{} + factory := &cmdutil.Factory{} + cmd := NewCmdCopilot(factory, spy, func(opts *CopilotOptions) error { + return nil + }) + cmd.SetArgs([]string{}) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err := cmd.ExecuteC() + require.NoError(t, err) + require.Equal(t, ghtelemetry.SAMPLE_ALL, spy.LastSampleRate) +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 9f4fa6f5bdc..4a23fc59ea4 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -152,7 +152,7 @@ func NewCmdRoot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, versi cmd.AddCommand(skillsCmd.NewCmdSkills(f, telemetry)) // Root commands with standalone functionality and no subcommands - cmd.AddCommand(copilotCmd.NewCmdCopilot(f, nil)) + cmd.AddCommand(copilotCmd.NewCmdCopilot(f, telemetry, nil)) cmd.AddCommand(statusCmd.NewCmdStatus(f, nil)) cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil)) cmd.AddCommand(licensesCmd.NewCmdLicenses(f)) From acf2f730c19dd0bc904969e58a133776698caaf0 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 7 May 2026 17:34:09 +0200 Subject: [PATCH 104/182] Record accessibility state in telemetry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../accessibility-dimensions-disabled.txtar | 9 +++++++++ .../telemetry/accessibility-dimensions.txtar | 13 +++++++++++++ internal/ghcmd/cmd.go | 14 +++++++++----- 3 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 acceptance/testdata/telemetry/accessibility-dimensions-disabled.txtar create mode 100644 acceptance/testdata/telemetry/accessibility-dimensions.txtar diff --git a/acceptance/testdata/telemetry/accessibility-dimensions-disabled.txtar b/acceptance/testdata/telemetry/accessibility-dimensions-disabled.txtar new file mode 100644 index 00000000000..2a10d23da71 --- /dev/null +++ b/acceptance/testdata/telemetry/accessibility-dimensions-disabled.txtar @@ -0,0 +1,9 @@ +# Telemetry log mode records accessibility features as disabled by default +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 + +exec gh version +stderr '"accessible_colors": "false"' +stderr '"accessible_prompter": "false"' +stderr '"color_labels": "false"' +stderr '"spinner_disabled": "false"' diff --git a/acceptance/testdata/telemetry/accessibility-dimensions.txtar b/acceptance/testdata/telemetry/accessibility-dimensions.txtar new file mode 100644 index 00000000000..9df0b524019 --- /dev/null +++ b/acceptance/testdata/telemetry/accessibility-dimensions.txtar @@ -0,0 +1,13 @@ +# Telemetry log mode records accessibility feature state as dimensions +env GH_TELEMETRY=log +env GH_TELEMETRY_SAMPLE_RATE=100 +env GH_ACCESSIBLE_COLORS=true +env GH_ACCESSIBLE_PROMPTER=true +env GH_COLOR_LABELS=true +env GH_SPINNER_DISABLED=true + +exec gh version +stderr '"accessible_colors": "true"' +stderr '"accessible_prompter": "true"' +stderr '"color_labels": "true"' +stderr '"spinner_disabled": "true"' diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 9d4908bd868..b7e15bd5f4e 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -71,11 +71,15 @@ func Main() exitCode { ghExecutablePath := executablePath("gh") additionalCommonDimensions := ghtelemetry.Dimensions{ - "version": strings.TrimPrefix(buildVersion, "v"), - "is_tty": strconv.FormatBool(ioStreams.IsStdoutTTY()), - "agent": string(agents.Detect()), - "ci": strconv.FormatBool(ci.IsCI()), - "github_actions": strconv.FormatBool(ci.IsGitHubActions()), + "version": strings.TrimPrefix(buildVersion, "v"), + "is_tty": strconv.FormatBool(ioStreams.IsStdoutTTY()), + "agent": string(agents.Detect()), + "ci": strconv.FormatBool(ci.IsCI()), + "github_actions": strconv.FormatBool(ci.IsGitHubActions()), + "accessible_colors": strconv.FormatBool(ioStreams.AccessibleColorsEnabled()), + "accessible_prompter": strconv.FormatBool(ioStreams.AccessiblePrompterEnabled()), + "color_labels": strconv.FormatBool(ioStreams.ColorLabels()), + "spinner_disabled": strconv.FormatBool(ioStreams.GetSpinnerDisabled()), } var telemetryService ghtelemetry.Service From c48bc1a7d1f4adc9dca524ef99435291b35c24b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Dost=C3=A1l?= Date: Tue, 28 Apr 2026 19:50:35 +0200 Subject: [PATCH 105/182] Poll TTY echo mode instead of sleeping in password tests Replace the fixed-duration sleep with a polling loop that checks the actual TTY echo flag before sending password input. This eliminates the race condition where huh has not yet disabled echo mode, which caused flaky test failures in slow environments. Follow-up to #13304. --- internal/prompter/accessible_prompter_test.go | 33 ++++++++++++++----- internal/prompter/echo_test_darwin.go | 5 +++ internal/prompter/echo_test_linux.go | 5 +++ 3 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 internal/prompter/echo_test_darwin.go create mode 100644 internal/prompter/echo_test_linux.go diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index b67156bdfc3..8c4d8ce9247 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -5,6 +5,7 @@ package prompter_test import ( "fmt" "io" + "os" "slices" "strings" "testing" @@ -17,6 +18,7 @@ import ( "github.com/hinshun/vt10x" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" ) // The following tests are broadly testing the accessible prompter, and NOT asserting @@ -34,8 +36,6 @@ import ( // but doesn't mandate that prompts always look exactly the same. func TestAccessiblePrompter(t *testing.T) { - beforePasswordSendTimeout := 100 * time.Millisecond - t.Run("Select", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) @@ -505,8 +505,8 @@ func TestAccessiblePrompter(t *testing.T) { _, err := console.ExpectString("Enter password") require.NoError(t, err) - // Wait to ensure huh has time to set the echo mode - time.Sleep(beforePasswordSendTimeout) + // Wait until huh has disabled echo mode on the TTY + waitForEchoDisabled(t, console.Tty(), 5*time.Second) // Enter a number _, err = console.SendLine(dummyPassword) @@ -596,8 +596,8 @@ func TestAccessiblePrompter(t *testing.T) { _, err := console.ExpectString("Paste your authentication token:") require.NoError(t, err) - // Wait to ensure huh has time to set the echo mode - time.Sleep(beforePasswordSendTimeout) + // Wait until huh has disabled echo mode on the TTY + waitForEchoDisabled(t, console.Tty(), 5*time.Second) // Enter some dummy auth token _, err = console.SendLine(dummyAuthToken) @@ -641,8 +641,8 @@ func TestAccessiblePrompter(t *testing.T) { _, err = console.ExpectString("Paste your authentication token:") require.NoError(t, err) - // Wait to ensure huh has time to set the echo mode - time.Sleep(beforePasswordSendTimeout) + // Wait until huh has disabled echo mode on the TTY + waitForEchoDisabled(t, console.Tty(), 5*time.Second) // Now enter some dummy auth token to return control back to the test _, err = console.SendLine(dummyAuthTokenForAfterFailure) @@ -956,3 +956,20 @@ func testCloser(t *testing.T, closer io.Closer) { t.Errorf("Close failed: %s", err) } } + +// waitForEchoDisabled polls the TTY until echo mode is disabled or the +// timeout is reached. This is used in password and auth token tests to +// ensure that huh has configured the terminal before we send input. +func waitForEchoDisabled(t *testing.T, tty *os.File, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + termios, err := unix.IoctlGetTermios(int(tty.Fd()), ioctlGetTermios) + require.NoError(t, err) + if termios.Lflag&unix.ECHO == 0 { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("timed out waiting for echo mode to be disabled") +} diff --git a/internal/prompter/echo_test_darwin.go b/internal/prompter/echo_test_darwin.go new file mode 100644 index 00000000000..7019fa4dfd2 --- /dev/null +++ b/internal/prompter/echo_test_darwin.go @@ -0,0 +1,5 @@ +package prompter_test + +import "golang.org/x/sys/unix" + +const ioctlGetTermios = unix.TIOCGETA diff --git a/internal/prompter/echo_test_linux.go b/internal/prompter/echo_test_linux.go new file mode 100644 index 00000000000..a7507730181 --- /dev/null +++ b/internal/prompter/echo_test_linux.go @@ -0,0 +1,5 @@ +package prompter_test + +import "golang.org/x/sys/unix" + +const ioctlGetTermios = unix.TCGETS From 9c4184de6f8c208a11e4329b90fa9844efd728e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Dost=C3=A1l?= Date: Tue, 28 Apr 2026 19:57:35 +0200 Subject: [PATCH 106/182] Address review feedback on echo mode polling - Rename echo_test_{linux,darwin}.go to echo_{linux,darwin}_test.go so they are only compiled during tests - Narrow build tag from !windows to linux || darwin to avoid compile failures on other Unix platforms - Return error from waitForEchoDisabled instead of calling t.Fatal, since the function is called from goroutines where FailNow would only terminate the calling goroutine --- internal/prompter/accessible_prompter_test.go | 19 ++++++++++--------- ...cho_test_darwin.go => echo_darwin_test.go} | 0 ...{echo_test_linux.go => echo_linux_test.go} | 0 3 files changed, 10 insertions(+), 9 deletions(-) rename internal/prompter/{echo_test_darwin.go => echo_darwin_test.go} (100%) rename internal/prompter/{echo_test_linux.go => echo_linux_test.go} (100%) diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index 8c4d8ce9247..ee6eba3a93e 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build linux || darwin package prompter_test @@ -506,7 +506,7 @@ func TestAccessiblePrompter(t *testing.T) { require.NoError(t, err) // Wait until huh has disabled echo mode on the TTY - waitForEchoDisabled(t, console.Tty(), 5*time.Second) + require.NoError(t, waitForEchoDisabled(console.Tty(), 5*time.Second)) // Enter a number _, err = console.SendLine(dummyPassword) @@ -597,7 +597,7 @@ func TestAccessiblePrompter(t *testing.T) { require.NoError(t, err) // Wait until huh has disabled echo mode on the TTY - waitForEchoDisabled(t, console.Tty(), 5*time.Second) + require.NoError(t, waitForEchoDisabled(console.Tty(), 5*time.Second)) // Enter some dummy auth token _, err = console.SendLine(dummyAuthToken) @@ -642,7 +642,7 @@ func TestAccessiblePrompter(t *testing.T) { require.NoError(t, err) // Wait until huh has disabled echo mode on the TTY - waitForEchoDisabled(t, console.Tty(), 5*time.Second) + require.NoError(t, waitForEchoDisabled(console.Tty(), 5*time.Second)) // Now enter some dummy auth token to return control back to the test _, err = console.SendLine(dummyAuthTokenForAfterFailure) @@ -960,16 +960,17 @@ func testCloser(t *testing.T, closer io.Closer) { // waitForEchoDisabled polls the TTY until echo mode is disabled or the // timeout is reached. This is used in password and auth token tests to // ensure that huh has configured the terminal before we send input. -func waitForEchoDisabled(t *testing.T, tty *os.File, timeout time.Duration) { - t.Helper() +func waitForEchoDisabled(tty *os.File, timeout time.Duration) error { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { termios, err := unix.IoctlGetTermios(int(tty.Fd()), ioctlGetTermios) - require.NoError(t, err) + if err != nil { + return fmt.Errorf("getting terminal attributes: %w", err) + } if termios.Lflag&unix.ECHO == 0 { - return + return nil } time.Sleep(time.Millisecond) } - t.Fatal("timed out waiting for echo mode to be disabled") + return fmt.Errorf("timed out waiting for echo mode to be disabled") } diff --git a/internal/prompter/echo_test_darwin.go b/internal/prompter/echo_darwin_test.go similarity index 100% rename from internal/prompter/echo_test_darwin.go rename to internal/prompter/echo_darwin_test.go diff --git a/internal/prompter/echo_test_linux.go b/internal/prompter/echo_linux_test.go similarity index 100% rename from internal/prompter/echo_test_linux.go rename to internal/prompter/echo_linux_test.go From a44721d233be9a2f6f0b5ee5c4f71274acb8d296 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 7 May 2026 20:20:09 +0200 Subject: [PATCH 107/182] Add explicit build tags to platform-specific echo test files The Go toolchain infers constraints from _darwin/_linux filename suffixes, but explicit //go:build tags make the constraint visible without relying on filename conventions, consistent with modern Go style. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/prompter/echo_darwin_test.go | 2 ++ internal/prompter/echo_linux_test.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/internal/prompter/echo_darwin_test.go b/internal/prompter/echo_darwin_test.go index 7019fa4dfd2..2cb3130d9db 100644 --- a/internal/prompter/echo_darwin_test.go +++ b/internal/prompter/echo_darwin_test.go @@ -1,3 +1,5 @@ +//go:build darwin + package prompter_test import "golang.org/x/sys/unix" diff --git a/internal/prompter/echo_linux_test.go b/internal/prompter/echo_linux_test.go index a7507730181..ad63bd1d526 100644 --- a/internal/prompter/echo_linux_test.go +++ b/internal/prompter/echo_linux_test.go @@ -1,3 +1,5 @@ +//go:build linux + package prompter_test import "golang.org/x/sys/unix" From f47e459cf556aa492172491fbfb105af0b18e1b3 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 7 May 2026 20:33:42 +0200 Subject: [PATCH 108/182] Fix skills acceptance tests --- .../skills/skills-install-namespaced.txtar | 39 ++++++++++--------- .../skills/skills-publish-dry-run.txtar | 7 +--- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/acceptance/testdata/skills/skills-install-namespaced.txtar b/acceptance/testdata/skills/skills-install-namespaced.txtar index db39bead0f3..9aa83ef5650 100644 --- a/acceptance/testdata/skills/skills-install-namespaced.txtar +++ b/acceptance/testdata/skills/skills-install-namespaced.txtar @@ -1,20 +1,21 @@ -# Two namespaced skills with the same base name in the same repo should +# Two namespaced skills with different base names in the same repo should # be independently installable using path-based disambiguation. +# Skills are installed flat (by base name) so each must have a unique name. # Use gh as a credential helper exec gh auth setup-git -# Create a repo with two namespaced skills that share the name "deploy" +# Create a repo with two namespaced skills that have unique base names exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --public --add-readme defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING cd $SCRIPT_NAME-$RANDOM_STRING -mkdir -p skills/alice/deploy -mkdir -p skills/bob/deploy -cp $WORK/alice-skill.md skills/alice/deploy/SKILL.md -cp $WORK/bob-skill.md skills/bob/deploy/SKILL.md +mkdir -p skills/alice/alice-deploy +mkdir -p skills/bob/bob-deploy +cp $WORK/alice-skill.md skills/alice/alice-deploy/SKILL.md +cp $WORK/bob-skill.md skills/bob/bob-deploy/SKILL.md exec git add -A exec git commit -m 'Add namespaced skills' @@ -23,25 +24,25 @@ exec git push origin main # Publish so the skills are discoverable exec gh skill publish --tag v1.0.0 -# Install alice's deploy skill using the full path to disambiguate -exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING skills/alice/deploy --scope user --force -stdout 'Installed alice/deploy' +# Install alice's skill using the full path to disambiguate +exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING skills/alice/alice-deploy --scope user --force +stdout 'Installed alice/alice-deploy' -# Install bob's deploy skill using the full path -exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING skills/bob/deploy --scope user --force -stdout 'Installed bob/deploy' +# Install bob's skill using the full path +exec gh skill install $ORG/$SCRIPT_NAME-$RANDOM_STRING skills/bob/bob-deploy --scope user --force +stdout 'Installed bob/bob-deploy' -# Verify both were installed to separate directories -exists $HOME/.copilot/skills/alice/deploy/SKILL.md -exists $HOME/.copilot/skills/bob/deploy/SKILL.md +# Verify both were installed to flat directories (by base name) +exists $HOME/.copilot/skills/alice-deploy/SKILL.md +exists $HOME/.copilot/skills/bob-deploy/SKILL.md # Verify each has the correct content -grep 'Alice' $HOME/.copilot/skills/alice/deploy/SKILL.md -grep 'Bob' $HOME/.copilot/skills/bob/deploy/SKILL.md +grep 'Alice' $HOME/.copilot/skills/alice-deploy/SKILL.md +grep 'Bob' $HOME/.copilot/skills/bob-deploy/SKILL.md -- alice-skill.md -- --- -name: deploy +name: alice-deploy description: Alice's deployment skill --- @@ -51,7 +52,7 @@ Deploys infrastructure using Alice's conventions. -- bob-skill.md -- --- -name: deploy +name: bob-deploy description: Bob's deployment skill --- diff --git a/acceptance/testdata/skills/skills-publish-dry-run.txtar b/acceptance/testdata/skills/skills-publish-dry-run.txtar index 786204951e1..fe4d160c314 100644 --- a/acceptance/testdata/skills/skills-publish-dry-run.txtar +++ b/acceptance/testdata/skills/skills-publish-dry-run.txtar @@ -1,5 +1,6 @@ # Publish dry-run from a directory with no skills/ should fail gracefully -! exec gh skill publish --dry-run $WORK +mkdir $WORK/empty-dir +! exec gh skill publish --dry-run $WORK/empty-dir stderr 'no skills found in' # Publish dry-run against a valid skill directory should succeed @@ -10,10 +11,6 @@ stdout 'hello-world' exec gh skill publish --dry-run --tag v1.0.0 $WORK/test-repo stdout 'hello-world' -# Publish dry-run with --fix -exec gh skill publish --dry-run --fix $WORK/test-repo -stdout 'hello-world' - -- test-repo/skills/hello-world/SKILL.md -- --- name: hello-world From cdda57e3601d2ce94ebae101d0dfa4983479ee6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 21:56:04 +0000 Subject: [PATCH 109/182] Bump Go toolchain to 1.26.3 Agent-Logs-Url: https://github.com/cli/cli/sessions/74d21df5-9f54-4a76-97ee-7b2e2d7bd6be Co-authored-by: BagToad <47394200+BagToad@users.noreply.github.com> --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 709894753f2..158b1fe2b1d 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/cli/cli/v2 go 1.26.0 -toolchain go1.26.2 +toolchain go1.26.3 require ( charm.land/bubbles/v2 v2.1.0 From 701492b06b093e7887b1610ddcdf591bb135c7f7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 14:03:46 +0000 Subject: [PATCH 110/182] chore(deps): bump golang.org/x/sys from 0.43.0 to 0.44.0 Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.43.0 to 0.44.0. - [Commits](https://github.com/golang/sys/compare/v0.43.0...v0.44.0) --- updated-dependencies: - dependency-name: golang.org/x/sys dependency-version: 0.44.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 158b1fe2b1d..c734339698f 100644 --- a/go.mod +++ b/go.mod @@ -59,7 +59,7 @@ require ( github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.50.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.43.0 + golang.org/x/sys v0.44.0 golang.org/x/term v0.42.0 golang.org/x/text v0.36.0 google.golang.org/grpc v1.80.0 diff --git a/go.sum b/go.sum index be25979b0d9..0170ac4e6cf 100644 --- a/go.sum +++ b/go.sum @@ -602,8 +602,8 @@ golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= From 8fb4f3354cb61f30d204eb0673c20d0824e1cef8 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 8 May 2026 11:44:07 -0600 Subject: [PATCH 111/182] Fix triage-pull-requests skipping PRs that open as draft When a PR is opened as draft and later marked ready for review, the check-requirements job's if filter excluded the ready_for_review action, so the screening workflow never ran and unmet-requirements was never applied. Add ready_for_review to the action filter so screening runs when a draft PR transitions to requesting review. Companion fix in desktop/gh-cli-and-desktop-shared-workflows updates the called workflow's own filters to handle ready_for_review consistently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/triage-pull-requests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/triage-pull-requests.yml b/.github/workflows/triage-pull-requests.yml index 887f5e366fa..e55fce76b4b 100644 --- a/.github/workflows/triage-pull-requests.yml +++ b/.github/workflows/triage-pull-requests.yml @@ -29,7 +29,7 @@ jobs: check-requirements: if: >- github.event_name == 'pull_request_target' && - (github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'edited') + (github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'edited' || github.event.action == 'ready_for_review') uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-pr-requirements.yml@main with: enable_pr_screening: true From 3d9f22c115ac7680be0a97a5c3c715a902680ad8 Mon Sep 17 00:00:00 2001 From: adehad <26027314+adehad@users.noreply.github.com> Date: Sat, 9 May 2026 08:35:38 +0100 Subject: [PATCH 112/182] fix(telemetry): use CREATE_NO_WINDOW to prevent tzutil console flash on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DETACHED_PROCESS leaves the gh send-telemetry child with no console at all. When the transitive dependency thlib/go-timezone-local invokes `tzutil /g` to resolve the local IANA timezone, the console-subsystem tzutil binary allocates a fresh conhost — producing a visible window flash on every gh invocation, which accumulates as orphan terminals under terminal configurations that keep windows open on exit. CREATE_NO_WINDOW gives the child a non-visible console that descendants inherit, suppressing the flash. CREATE_NEW_PROCESS_GROUP is preserved so Ctrl+C still does not propagate to the detached telemetry child. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/telemetry/detach_windows.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/internal/telemetry/detach_windows.go b/internal/telemetry/detach_windows.go index eb610163b5d..c4d62b30770 100644 --- a/internal/telemetry/detach_windows.go +++ b/internal/telemetry/detach_windows.go @@ -10,7 +10,15 @@ import ( // detachAttrs returns SysProcAttr configured to place the child in its own // process group so that console signals (Ctrl+C) delivered to the parent's -// group are not forwarded to the child. +// group are not forwarded to the child, and to suppress any console window +// for the child and its descendants. +// +// CREATE_NO_WINDOW is preferred over DETACHED_PROCESS here: DETACHED_PROCESS +// removes the console entirely, which causes any console-subsystem descendant +// (e.g. tzutil.exe invoked transitively to resolve the local IANA timezone) +// to allocate a fresh conhost window, producing a visible flash on every gh +// invocation. CREATE_NO_WINDOW gives the child a non-visible console that +// descendants can inherit, avoiding the flash. func detachAttrs() *syscall.SysProcAttr { - return &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS} + return &syscall.SysProcAttr{CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.CREATE_NO_WINDOW} } From d9fab039abdb38a62bd1aa1082ff17018219ef87 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Mon, 11 May 2026 10:54:57 +0100 Subject: [PATCH 113/182] add skill list command --- pkg/cmd/skills/list/list.go | 480 +++++++++++++++++++++++++++++++ pkg/cmd/skills/list/list_test.go | 348 ++++++++++++++++++++++ pkg/cmd/skills/skills.go | 5 + 3 files changed, 833 insertions(+) create mode 100644 pkg/cmd/skills/list/list.go create mode 100644 pkg/cmd/skills/list/list_test.go diff --git a/pkg/cmd/skills/list/list.go b/pkg/cmd/skills/list/list.go new file mode 100644 index 00000000000..8e687b2e89a --- /dev/null +++ b/pkg/cmd/skills/list/list.go @@ -0,0 +1,480 @@ +package list + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/skills/discovery" + "github.com/cli/cli/v2/internal/skills/frontmatter" + "github.com/cli/cli/v2/internal/skills/installer" + "github.com/cli/cli/v2/internal/skills/registry" + "github.com/cli/cli/v2/internal/skills/source" + "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +var skillListFields = []string{ + "skillName", + "hosts", + "scope", + "sourceURL", + "version", + "pinned", + "path", +} + +// ListOptions holds dependencies and user-provided flags for the list command. +type ListOptions struct { + IO *iostreams.IOStreams + Telemetry ghtelemetry.EventRecorder + GitClient *git.Client + Exporter cmdutil.Exporter + + Agent string + Scope string + ScopeChanged bool + Dir string +} + +type agentInfo struct { + id string +} + +type scanTarget struct { + dir string + hosts []agentInfo + scope string +} + +type listedSkill struct { + skillName string + hostIDs []string + scope string + source string + sourceURL string + version string + pinned bool + path string +} + +// ExportData implements cmdutil.exportable for --json output. +func (s listedSkill) ExportData(fields []string) map[string]interface{} { + data := map[string]interface{}{} + for _, f := range fields { + switch f { + case "skillName": + data[f] = s.skillName + case "hosts": + data[f] = s.hostIDs + case "scope": + data[f] = s.scope + case "sourceURL": + data[f] = s.sourceURL + case "version": + data[f] = s.version + case "pinned": + data[f] = s.pinned + case "path": + data[f] = s.path + } + } + return data +} + +// NewCmdList creates the "skills list" command. +func NewCmdList(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + Telemetry: telemetry, + GitClient: f.GitClient, + } + + cmd := &cobra.Command{ + Use: "list [flags]", + Short: "List installed skills (preview)", + Aliases: []string{"ls"}, + Long: heredoc.Docf(` + List installed agent skills across known agent host directories. + + By default, scans all supported agent hosts in both project and user scope. + Use %[1]s--agent%[1]s to scan one host, %[1]s--scope%[1]s to scan only project or user + scope, or %[1]s--dir%[1]s to scan a custom skills directory. + + Project-scope skills are discovered relative to the current git repository + root. User-scope skills are discovered relative to your home directory. + `, "`"), + Example: heredoc.Doc(` + # List all installed skills + $ gh skill list + + # List skills installed for Claude Code + $ gh skill list --agent claude-code + + # List user-scope skills + $ gh skill list --scope user + + # List skills as JSON + $ gh skill list --json skillName,sourceURL,scope,version,pinned,path + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + opts.ScopeChanged = cmd.Flags().Changed("scope") + + if opts.Dir != "" && opts.Agent != "" { + return cmdutil.FlagErrorf("--dir and --agent cannot be used together") + } + if opts.Dir != "" && opts.ScopeChanged { + return cmdutil.FlagErrorf("--dir and --scope cannot be used together") + } + + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + cmdutil.StringEnumFlag(cmd, &opts.Agent, "agent", "", "", registry.AgentIDs(), "Filter by target agent") + cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "", []string{string(registry.ScopeProject), string(registry.ScopeUser)}, "Filter by installation scope") + cmd.Flags().StringVar(&opts.Dir, "dir", "", "Scan a custom directory for installed skills") + cmdutil.AddJSONFlags(cmd, &opts.Exporter, skillListFields) + + return cmd +} + +func listRun(opts *ListOptions) error { + skills, err := listInstalledSkills(opts) + if err != nil { + return err + } + sortListedSkills(skills) + recordListTelemetry(opts, len(skills)) + + if opts.Exporter != nil { + return opts.Exporter.Write(opts.IO, skills) + } + + if len(skills) == 0 { + return cmdutil.NewNoResultsError("no installed skills found") + } + + return renderTable(opts.IO, skills) +} + +func listInstalledSkills(opts *ListOptions) ([]listedSkill, error) { + targets, err := buildScanTargets(opts) + if err != nil { + return nil, err + } + + var all []listedSkill + for _, target := range targets { + skills, scanErr := scanInstalledSkills(target.dir, target.hosts, target.scope) + if scanErr != nil { + if opts.Dir != "" { + return nil, fmt.Errorf("could not scan directory: %w", scanErr) + } + continue + } + all = append(all, skills...) + } + + return all, nil +} + +func buildScanTargets(opts *ListOptions) ([]scanTarget, error) { + if opts.Dir != "" { + dir, err := filepath.Abs(opts.Dir) + if err != nil { + return nil, fmt.Errorf("could not resolve path: %w", err) + } + return []scanTarget{{dir: dir, scope: "custom"}}, nil + } + + gitRoot := installer.ResolveGitRoot(opts.GitClient) + homeDir := installer.ResolveHomeDir() + + hosts, err := selectedHosts(opts.Agent) + if err != nil { + return nil, err + } + scopes := selectedScopes(opts.Scope) + + byDir := map[string]int{} + var targets []scanTarget + for _, host := range hosts { + for _, scope := range scopes { + dir, installErr := host.InstallDir(scope, gitRoot, homeDir) + if installErr != nil { + continue + } + + if idx, ok := byDir[dir]; ok { + targets[idx].hosts = appendHost(targets[idx].hosts, host) + continue + } + + byDir[dir] = len(targets) + targets = append(targets, scanTarget{ + dir: dir, + hosts: []agentInfo{{id: host.ID}}, + scope: string(scope), + }) + } + } + + return targets, nil +} + +func selectedHosts(agentID string) ([]*registry.AgentHost, error) { + if agentID != "" { + host, err := registry.FindByID(agentID) + if err != nil { + return nil, err + } + return []*registry.AgentHost{host}, nil + } + + hosts := make([]*registry.AgentHost, len(registry.Agents)) + for i := range registry.Agents { + hosts[i] = ®istry.Agents[i] + } + return hosts, nil +} + +func selectedScopes(scope string) []registry.Scope { + if scope != "" { + return []registry.Scope{registry.Scope(scope)} + } + return []registry.Scope{registry.ScopeProject, registry.ScopeUser} +} + +func appendHost(hosts []agentInfo, host *registry.AgentHost) []agentInfo { + for _, existing := range hosts { + if existing.id == host.ID { + return hosts + } + } + return append(hosts, agentInfo{id: host.ID}) +} + +func scanInstalledSkills(skillsDir string, hosts []agentInfo, scope string) ([]listedSkill, error) { + entries, err := os.ReadDir(skillsDir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("could not read skills directory: %w", err) + } + + var skills []listedSkill + for _, e := range entries { + if !e.IsDir() { + continue + } + + // Flat layout: {dir}/{name}/SKILL.md. + skillDir := filepath.Join(skillsDir, e.Name()) + skillFile := filepath.Join(skillDir, "SKILL.md") + if data, readErr := os.ReadFile(skillFile); readErr == nil { + skills = append(skills, parseInstalledSkill(data, e.Name(), skillDir, hosts, scope)) + continue + } + + // Namespaced layout: {dir}/{namespace}/{name}/SKILL.md. + subEntries, subErr := os.ReadDir(skillDir) + if subErr != nil { + continue + } + for _, sub := range subEntries { + if !sub.IsDir() { + continue + } + subSkillDir := filepath.Join(skillDir, sub.Name()) + subSkillFile := filepath.Join(subSkillDir, "SKILL.md") + if data, readErr := os.ReadFile(subSkillFile); readErr == nil { + installName := e.Name() + "/" + sub.Name() + skills = append(skills, parseInstalledSkill(data, installName, subSkillDir, hosts, scope)) + } + } + } + + return skills, nil +} + +func parseInstalledSkill(data []byte, name, dir string, hosts []agentInfo, scope string) listedSkill { + s := listedSkill{ + skillName: name, + hostIDs: hostIDs(hosts), + scope: scope, + path: dir, + } + + result, err := frontmatter.Parse(string(data)) + if err != nil { + return s + } + + meta := result.Metadata.Meta + if meta == nil { + return s + } + + if sourcePath, _ := meta["github-path"].(string); sourcePath != "" { + if skillName := skillNameFromSourcePath(sourcePath); skillName != "" { + s.skillName = skillName + } + } + + if repoURL, _ := meta["github-repo"].(string); repoURL != "" { + s.sourceURL = repoURL + s.source = repoURL + if repo, parseErr := source.ParseRepoURL(repoURL); parseErr == nil { + s.source = ghrepo.FullName(repo) + s.sourceURL = source.BuildRepoURL(repo.RepoHost(), repo.RepoOwner(), repo.RepoName()) + } + } else if localPath, _ := meta["local-path"].(string); localPath != "" { + s.sourceURL = localPath + s.source = localPath + } + + if ref, _ := meta["github-ref"].(string); ref != "" { + s.version = discovery.ShortRef(ref) + } + if pinnedRef, _ := meta["github-pinned"].(string); pinnedRef != "" { + s.pinned = true + if s.version == "" { + s.version = pinnedRef + } + } + + return s +} + +func skillNameFromSourcePath(sourcePath string) string { + sourcePath = strings.TrimSuffix(sourcePath, "/SKILL.md") + sourcePath = strings.Trim(sourcePath, "/") + if sourcePath == "" { + return "" + } + + parts := strings.Split(sourcePath, "/") + for i := len(parts) - 1; i >= 0; i-- { + if parts[i] != "skills" { + continue + } + + if i >= 2 && parts[i-2] == "plugins" && i+1 < len(parts) { + return parts[i-1] + "/" + parts[len(parts)-1] + } + + afterSkills := len(parts) - i - 1 + switch afterSkills { + case 0: + return "" + case 1: + return parts[i+1] + default: + return parts[i+1] + "/" + parts[len(parts)-1] + } + } + + return parts[len(parts)-1] +} + +func hostIDs(hosts []agentInfo) []string { + ids := make([]string, len(hosts)) + for i, host := range hosts { + ids[i] = host.id + } + return ids +} + +func sortListedSkills(skills []listedSkill) { + sort.Slice(skills, func(i, j int) bool { + if skills[i].skillName != skills[j].skillName { + return skills[i].skillName < skills[j].skillName + } + if skills[i].scope != skills[j].scope { + return skills[i].scope < skills[j].scope + } + if formatHosts(skills[i].hostIDs) != formatHosts(skills[j].hostIDs) { + return formatHosts(skills[i].hostIDs) < formatHosts(skills[j].hostIDs) + } + return skills[i].path < skills[j].path + }) +} + +func renderTable(io *iostreams.IOStreams, skills []listedSkill) error { + table := tableprinter.New(io, tableprinter.WithHeader("Name", "Agent", "Scope", "Source")) + + for _, skill := range skills { + table.AddField(skill.skillName) + table.AddField(formatHosts(skill.hostIDs)) + table.AddField(displayOrDash(skill.scope)) + table.AddField(displayOrDash(skill.source)) + table.EndRow() + } + + return table.Render() +} + +func displayOrDash(value string) string { + if value == "" { + return "-" + } + return value +} + +func formatHosts(hosts []string) string { + if len(hosts) == 0 { + return "-" + } + return strings.Join(hosts, ",") +} + +func recordListTelemetry(opts *ListOptions, skillCount int) { + if opts.Telemetry == nil { + return + } + + agentHosts := opts.Agent + if agentHosts == "" { + agentHosts = "all" + } + scope := opts.Scope + if scope == "" { + scope = "all" + } + customDir := "false" + if opts.Dir != "" { + customDir = "true" + scope = "custom" + } + format := "table" + if opts.Exporter != nil { + format = "json" + } + + opts.Telemetry.Record(ghtelemetry.Event{ + Type: "skill_list", + Dimensions: ghtelemetry.Dimensions{ + "agent_hosts": agentHosts, + "custom_dir": customDir, + "format": format, + "scope": scope, + }, + Measures: ghtelemetry.Measures{ + "skill_count": int64(skillCount), + }, + }) +} diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go new file mode 100644 index 00000000000..4b8b397e8e6 --- /dev/null +++ b/pkg/cmd/skills/list/list_test.go @@ -0,0 +1,348 @@ +package list + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/telemetry" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wantOpts ListOptions + wantJSON bool + wantErr string + }{ + { + name: "no flags", + cli: "", + wantOpts: ListOptions{}, + }, + { + name: "agent and scope filters", + cli: "--agent claude-code --scope user", + wantOpts: ListOptions{ + Agent: "claude-code", + Scope: "user", + ScopeChanged: true, + }, + }, + { + name: "custom dir", + cli: "--dir ./skills", + wantOpts: ListOptions{ + Dir: "./skills", + }, + }, + { + name: "json fields", + cli: "--json skillName,sourceURL,scope,version,pinned,path", + wantJSON: true, + }, + { + name: "too many args", + cli: "extra", + wantErr: "unknown command", + }, + { + name: "invalid agent", + cli: "--agent unknown", + wantErr: "invalid argument", + }, + { + name: "invalid scope", + cli: "--scope org", + wantErr: "invalid argument", + }, + { + name: "dir and agent are mutually exclusive", + cli: "--dir ./skills --agent claude-code", + wantErr: "--dir and --agent cannot be used together", + }, + { + name: "dir and scope are mutually exclusive", + cli: "--dir ./skills --scope user", + wantErr: "--dir and --scope cannot be used together", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, GitClient: &git.Client{}} + + var gotOpts *ListOptions + cmd := NewCmdList(f, &telemetry.NoOpService{}, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + + args, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(args) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + err = cmd.Execute() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + require.NotNil(t, gotOpts) + assert.Equal(t, tt.wantOpts.Agent, gotOpts.Agent) + assert.Equal(t, tt.wantOpts.Scope, gotOpts.Scope) + assert.Equal(t, tt.wantOpts.ScopeChanged, gotOpts.ScopeChanged) + assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) + if tt.wantJSON { + assert.NotNil(t, gotOpts.Exporter) + } + }) + } +} + +func TestNewCmdList_Metadata(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{IOStreams: ios, GitClient: &git.Client{}} + cmd := NewCmdList(f, &telemetry.NoOpService{}, nil) + + assert.Equal(t, "list [flags]", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + assert.NotEmpty(t, cmd.Example) + assert.Contains(t, cmd.Aliases, "ls") + + for _, flag := range []string{"agent", "scope", "dir", "json"} { + assert.NotNil(t, cmd.Flags().Lookup(flag), "missing flag: --%s", flag) + } +} + +func TestListRun(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, repoDir, homeDir string) + opts func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions + wantStdout string + wantJSON string + wantErr string + verify func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy) + }{ + { + name: "lists project skill for selected shared agent", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, repoDir, ".agents/skills/git-commit", remoteSkillFrontmatter("git-commit", "skills/git-commit", "refs/tags/v1.0.0", "")) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Agent: "cursor", + Scope: "project", + } + }, + wantStdout: "git-commit\tcursor\tproject\tmonalisa/skills-repo\n", + verify: func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy) { + require.Len(t, spy.Events, 1) + event := spy.Events[0] + assert.Equal(t, "skill_list", event.Type) + assert.Equal(t, "cursor", event.Dimensions["agent_hosts"]) + assert.Equal(t, "project", event.Dimensions["scope"]) + assert.Equal(t, int64(1), event.Measures["skill_count"]) + }, + }, + { + name: "lists user skill as json", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, homeDir, ".claude/skills/code-review", remoteSkillFrontmatter("code-review", "skills/code-review", "refs/tags/v2.0.0", "v2.0.0")) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"skillName", "hosts", "scope", "sourceURL", "version", "pinned", "path"}) + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Exporter: exporter, + Agent: "claude-code", + Scope: "user", + } + }, + wantJSON: fmt.Sprintf(`[ + { + "skillName": "code-review", + "hosts": ["claude-code"], + "scope": "user", + "sourceURL": "https://github.com/monalisa/skills-repo", + "version": "v2.0.0", + "pinned": true, + "path": %q + } + ]`, filepath.Join("HOME", ".claude", "skills", "code-review")), + verify: func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy) { + assert.Equal(t, "json", spy.Events[0].Dimensions["format"]) + }, + }, + { + name: "custom directory with local metadata", + setup: func(t *testing.T, repoDir, homeDir string) { + customDir := filepath.Join(repoDir, "custom-skills") + writeSkill(t, customDir, "local-helper", heredoc.Doc(` + --- + name: local-helper + metadata: + local-path: /src/local-helper + --- + Body + `)) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Dir: filepath.Join(repoDir, "custom-skills"), + } + }, + wantStdout: "local-helper\t-\tcustom\t/src/local-helper\n", + }, + { + name: "recovers namespaced skill name from source path", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, repoDir, ".agents/skills/xlsx-pro", remoteSkillFrontmatter("xlsx-pro", "skills/bob/xlsx-pro", "refs/heads/main", "")) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Agent: "github-copilot", + Scope: "project", + } + }, + wantStdout: "bob/xlsx-pro\tgithub-copilot\tproject\tmonalisa/skills-repo\n", + }, + { + name: "no installed skills returns no results", + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Agent: "github-copilot", + Scope: "project", + } + }, + wantErr: "no installed skills found", + }, + { + name: "no installed skills with json returns empty array", + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"skillName"}) + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Exporter: exporter, + Agent: "github-copilot", + Scope: "project", + } + }, + wantJSON: "[]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repoDir := t.TempDir() + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + if tt.setup != nil { + tt.setup(t, repoDir, homeDir) + } + + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(false) + spy := &telemetry.CommandRecorderSpy{} + opts := tt.opts(ios, repoDir, homeDir, spy) + + err := listRun(opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) + if tt.wantJSON != "" { + expected := tt.wantJSON + expected = string(bytes.ReplaceAll([]byte(expected), []byte(filepath.Join("HOME")), []byte(homeDir))) + assert.JSONEq(t, expected, stdout.String()) + } else { + assert.Equal(t, tt.wantStdout, stdout.String()) + } + if tt.verify != nil { + tt.verify(t, stdout.String(), spy) + } + }) + } +} + +func TestRenderTableUsesAgentHeader(t *testing.T) { + ios, _, stdout, _ := iostreams.Test() + ios.SetStdoutTTY(true) + + err := renderTable(ios, []listedSkill{{ + skillName: "git-commit", + hostIDs: []string{"github-copilot"}, + scope: "project", + source: "monalisa/skills-repo", + version: "v1.0.0", + }}) + + require.NoError(t, err) + assert.Contains(t, stdout.String(), "AGENT") + assert.NotContains(t, stdout.String(), "HOST") +} + +func writeSkill(t *testing.T, baseDir, relDir, content string) { + t.Helper() + skillDir := filepath.Join(baseDir, filepath.FromSlash(relDir)) + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) +} + +func remoteSkillFrontmatter(name, sourcePath, ref, pinned string) string { + pinnedLine := "" + if pinned != "" { + pinnedLine = fmt.Sprintf(" github-pinned: %s\n", pinned) + } + return fmt.Sprintf(heredoc.Doc(` + --- + name: %s + metadata: + github-repo: https://github.com/monalisa/skills-repo + github-ref: %s + github-tree-sha: abc123 + github-path: %s + %s--- + Body + `), name, ref, sourcePath, pinnedLine) +} diff --git a/pkg/cmd/skills/skills.go b/pkg/cmd/skills/skills.go index 05a87c38651..1399d049b73 100644 --- a/pkg/cmd/skills/skills.go +++ b/pkg/cmd/skills/skills.go @@ -4,6 +4,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/pkg/cmd/skills/install" + skilllist "github.com/cli/cli/v2/pkg/cmd/skills/list" "github.com/cli/cli/v2/pkg/cmd/skills/preview" "github.com/cli/cli/v2/pkg/cmd/skills/publish" "github.com/cli/cli/v2/pkg/cmd/skills/search" @@ -32,6 +33,9 @@ func NewCmdSkills(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder) *co # Install a skill $ gh skill install github/awesome-copilot documentation-writer + # List installed skills + $ gh skill list + # Preview a skill before installing $ gh skill preview github/awesome-copilot documentation-writer @@ -48,6 +52,7 @@ func NewCmdSkills(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder) *co } cmd.AddCommand(install.NewCmdInstall(f, telemetry, nil)) + cmd.AddCommand(skilllist.NewCmdList(f, telemetry, nil)) cmd.AddCommand(preview.NewCmdPreview(f, telemetry, nil)) cmd.AddCommand(publish.NewCmdPublish(f, nil)) cmd.AddCommand(search.NewCmdSearch(f, telemetry, nil)) From 601dd346b00b357a0541239fb80b34c3795e7c33 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Mon, 11 May 2026 14:32:15 +0100 Subject: [PATCH 114/182] fix(copilot): hint to run copilot directly when exec fails When the copilot binary is found in PATH but exec fails (e.g., due to unusual characters like parentheses in the path on Windows), append a hint suggesting the user run `copilot` directly without `gh`. The hint is only shown when the binary was already present on the host, not when it was freshly downloaded and installed by `gh`. Closes cli/cli#13106 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/copilot/copilot.go | 21 ++++++++++++++++++--- pkg/cmd/copilot/copilot_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/copilot/copilot.go b/pkg/cmd/copilot/copilot.go index 50b00e9fe45..d0cd2e8fc95 100644 --- a/pkg/cmd/copilot/copilot.go +++ b/pkg/cmd/copilot/copilot.go @@ -142,8 +142,9 @@ func runCopilot(opts *CopilotOptions) error { return nil } - copilotPath := findCopilotBinary() - if copilotPath == "" { + copilotPath := findCopilotBinaryFunc() + foundInPath := copilotPath != "" + if !foundInPath { if opts.IO.CanPrompt() { confirmed, err := opts.Prompter.Confirm("GitHub Copilot CLI is not installed. Would you like to install it?", true) if err != nil { @@ -175,12 +176,18 @@ func runCopilot(opts *CopilotOptions) error { externalCmd.Stderr = opts.IO.ErrOut externalCmd.Env = append(os.Environ(), "COPILOT_GH=true") - if err := externalCmd.Run(); err != nil { + if err := runExternalCmdFunc(externalCmd); err != nil { if exitErr, ok := err.(*exec.ExitError); ok { // We terminate with os.Exit here, preserving the exit code from Copilot CLI, // and also preventing stdio writes by callers up the stack. os.Exit(exitErr.ExitCode()) } + if foundInPath { + // The binary exists in PATH but exec failed, possibly due to + // unusual characters in the path (see https://github.com/cli/cli/issues/13106). + // Suggest running copilot directly as a workaround. + return fmt.Errorf("%w\nTry running `copilot` directly without `gh`.", err) + } return err } return nil @@ -200,6 +207,14 @@ func copilotBinaryPath() string { return filepath.Join(copilotInstallDir(), binaryName) } +var runExternalCmdFunc = runExternalCmd + +func runExternalCmd(cmd *exec.Cmd) error { + return cmd.Run() +} + +var findCopilotBinaryFunc = findCopilotBinary + // findCopilotBinary returns the path to the Copilot CLI binary, if installed, // with the following order of precedence: // 1. `copilot` in the PATH diff --git a/pkg/cmd/copilot/copilot_test.go b/pkg/cmd/copilot/copilot_test.go index 07e0191e6ba..16c0b115576 100644 --- a/pkg/cmd/copilot/copilot_test.go +++ b/pkg/cmd/copilot/copilot_test.go @@ -10,6 +10,7 @@ import ( "fmt" "net/http" "os" + "os/exec" "path/filepath" "runtime" "testing" @@ -589,6 +590,32 @@ func TestDownloadCopilot(t *testing.T) { }) } +func TestRunCopilot_execFailureHint(t *testing.T) { + ios, _, _, _ := iostreams.Test() + opts := &CopilotOptions{ + IO: ios, + CopilotArgs: []string{}, + } + + origFind := findCopilotBinaryFunc + findCopilotBinaryFunc = func() string { + return "/usr/bin/copilot" + } + t.Cleanup(func() { findCopilotBinaryFunc = origFind }) + + execErr := fmt.Errorf("exec failed: something went wrong") + origRun := runExternalCmdFunc + runExternalCmdFunc = func(_ *exec.Cmd) error { + return execErr + } + t.Cleanup(func() { runExternalCmdFunc = origRun }) + + err := runCopilot(opts) + require.Error(t, err) + require.ErrorIs(t, err, execErr) + require.Contains(t, err.Error(), "Try running `copilot` directly without `gh`.") +} + func TestCopilotCommandIsSampledAt100(t *testing.T) { spy := &telemetry.CommandRecorderSpy{} factory := &cmdutil.Factory{} From ae7bd54d431cd914b20efab191c6ab357fe24d6a Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 12 May 2026 12:24:29 +0100 Subject: [PATCH 115/182] fix(copilot): provide full path to copilot binary on exec error Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> --- pkg/cmd/copilot/copilot.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/copilot/copilot.go b/pkg/cmd/copilot/copilot.go index d0cd2e8fc95..cc83ef48efa 100644 --- a/pkg/cmd/copilot/copilot.go +++ b/pkg/cmd/copilot/copilot.go @@ -183,10 +183,10 @@ func runCopilot(opts *CopilotOptions) error { os.Exit(exitErr.ExitCode()) } if foundInPath { - // The binary exists in PATH but exec failed, possibly due to + // We found a `copilot` binary but exec failed, possibly due to // unusual characters in the path (see https://github.com/cli/cli/issues/13106). // Suggest running copilot directly as a workaround. - return fmt.Errorf("%w\nTry running `copilot` directly without `gh`.", err) + return fmt.Errorf("%w\nFailed to run '%s', try running `copilot` directly without `gh`.", err, copilotPath) } return err } From 24c7b25afdb44a12ea823029c3fd7a3092a8be73 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 13:04:19 +0000 Subject: [PATCH 116/182] fix(copilot): update test assertion to match updated error message --- pkg/cmd/copilot/copilot_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/copilot/copilot_test.go b/pkg/cmd/copilot/copilot_test.go index 16c0b115576..fa173f5286c 100644 --- a/pkg/cmd/copilot/copilot_test.go +++ b/pkg/cmd/copilot/copilot_test.go @@ -613,7 +613,7 @@ func TestRunCopilot_execFailureHint(t *testing.T) { err := runCopilot(opts) require.Error(t, err) require.ErrorIs(t, err, execErr) - require.Contains(t, err.Error(), "Try running `copilot` directly without `gh`.") + require.Contains(t, err.Error(), "try running `copilot` directly without `gh`.") } func TestCopilotCommandIsSampledAt100(t *testing.T) { From 965f63b31aee1fbf71fc2029f2d778321299919b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 21:46:30 +0000 Subject: [PATCH 117/182] chore(deps): bump golang.org/x/term from 0.42.0 to 0.43.0 Bumps [golang.org/x/term](https://github.com/golang/term) from 0.42.0 to 0.43.0. - [Commits](https://github.com/golang/term/compare/v0.42.0...v0.43.0) --- updated-dependencies: - dependency-name: golang.org/x/term dependency-version: 0.43.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c734339698f..f73dba32dff 100644 --- a/go.mod +++ b/go.mod @@ -60,7 +60,7 @@ require ( golang.org/x/crypto v0.50.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.44.0 - golang.org/x/term v0.42.0 + golang.org/x/term v0.43.0 golang.org/x/text v0.36.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 diff --git a/go.sum b/go.sum index 0170ac4e6cf..188c49b44e3 100644 --- a/go.sum +++ b/go.sum @@ -607,8 +607,8 @@ golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= From 6b3421b1450438d1c08b9c9ac82d4e8e23619689 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 22:40:29 +0000 Subject: [PATCH 118/182] chore(deps): bump google.golang.org/grpc from 1.80.0 to 1.81.0 Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.80.0 to 1.81.0. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.80.0...v1.81.0) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.81.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 8 ++++---- go.sum | 24 ++++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index f73dba32dff..8c63e7a62ac 100644 --- a/go.mod +++ b/go.mod @@ -62,7 +62,7 @@ require ( golang.org/x/sys v0.44.0 golang.org/x/term v0.43.0 golang.org/x/text v0.36.0 - google.golang.org/grpc v1.80.0 + google.golang.org/grpc v1.81.0 google.golang.org/protobuf v1.36.11 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 @@ -178,9 +178,9 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect diff --git a/go.sum b/go.sum index 188c49b44e3..ea1516df5c5 100644 --- a/go.sum +++ b/go.sum @@ -549,16 +549,16 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= -go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.step.sm/crypto v0.77.2 h1:qFjjei+RHc5kP5R7NW9OUWT7SqWIuAOvOkXqg4fNWj8= go.step.sm/crypto v0.77.2/go.mod h1:W0YJb9onM5l78qgkXIJ2Up6grnwW8EtpCKIza/NCg0o= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -636,8 +636,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From dc5d3927b24154b26edeb42107293ed16852aad7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 22:50:45 +0000 Subject: [PATCH 119/182] chore(deps): bump golang.org/x/text from 0.36.0 to 0.37.0 Bumps [golang.org/x/text](https://github.com/golang/text) from 0.36.0 to 0.37.0. - [Release notes](https://github.com/golang/text/releases) - [Commits](https://github.com/golang/text/compare/v0.36.0...v0.37.0) --- updated-dependencies: - dependency-name: golang.org/x/text dependency-version: 0.37.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8c63e7a62ac..d2edf3ec711 100644 --- a/go.mod +++ b/go.mod @@ -61,7 +61,7 @@ require ( golang.org/x/sync v0.20.0 golang.org/x/sys v0.44.0 golang.org/x/term v0.43.0 - golang.org/x/text v0.36.0 + golang.org/x/text v0.37.0 google.golang.org/grpc v1.81.0 google.golang.org/protobuf v1.36.11 gopkg.in/h2non/gock.v1 v1.1.2 diff --git a/go.sum b/go.sum index ea1516df5c5..00c383d1876 100644 --- a/go.sum +++ b/go.sum @@ -615,8 +615,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From 83bc3de748abca16d950f65d624052be8d30edae Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 13 May 2026 12:43:28 +0200 Subject: [PATCH 120/182] Update CODEOWNERS for skills directory ownership --- .github/CODEOWNERS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4a23f3901f5..6342034d09a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,5 +14,5 @@ test/integration/attestation-cmd @cli/package-security @cli/code-reviewers pkg/cmd/attestation/verification/embed/tuf-repo.github.com/ @cli/tuf-root-reviewers @cli/code-reviewers -pkg/cmd/skills/ @cli/skill-reviewers @cli/code-reviewers -internal/skills/ @cli/skill-reviewers @cli/code-reviewers +pkg/cmd/skills/ @cli/skills @cli/code-reviewers +internal/skills/ @cli/skills @cli/code-reviewers From 8c69492de8a75aa5da15e0c221a871c0ee2cebe6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:05:33 +0000 Subject: [PATCH 121/182] Initial plan From f5610036b9206cef35b79e5fd923c3d038ca17aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:11:09 +0000 Subject: [PATCH 122/182] Update bump-go.sh to handle missing toolchain directive - Add early trap setup to avoid exit code issues - Handle missing toolchain directive gracefully with `|| true` - Add logic to detect when toolchain is expected to be missing - Add informative messages about missing toolchain - Implement smart toolchain handling: - Skip toolchain when go version matches latest (redundant) - Add toolchain when go version is older than latest - Update toolchain when it exists but is outdated Co-authored-by: williammartin <1611510+williammartin@users.noreply.github.com> --- .github/workflows/scripts/bump-go.sh | 49 ++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/.github/workflows/scripts/bump-go.sh b/.github/workflows/scripts/bump-go.sh index f0762f3d3c2..853a26de8de 100755 --- a/.github/workflows/scripts/bump-go.sh +++ b/.github/workflows/scripts/bump-go.sh @@ -49,23 +49,39 @@ echo " → toolchain : $TOOLCHAIN_VERSION" # ---- Prepare Git branch --------------------------------------------------- CURRENT_GO_DIRECTIVE=$(grep -E '^go ' "$GO_MOD" | cut -d ' ' -f2) -CURRENT_TOOLCHAIN_DIRECTIVE=$(grep -E '^toolchain ' "$GO_MOD" | cut -d ' ' -f2) - -if [[ "$CURRENT_GO_DIRECTIVE" = "$GO_DIRECTIVE_VERSION" && \ - "$CURRENT_TOOLCHAIN_DIRECTIVE" = "go$TOOLCHAIN_VERSION" ]]; then - echo "Already on latest Go version: $CURRENT_GO_DIRECTIVE (toolchain: $CURRENT_TOOLCHAIN_DIRECTIVE)" - exit 0 -fi +CURRENT_TOOLCHAIN_DIRECTIVE=$(grep -E '^toolchain ' "$GO_MOD" | cut -d ' ' -f2 || true) BRANCH="bump-go-$TOOLCHAIN_VERSION" +BRANCH_CREATED=0 + +# Set up cleanup trap early (before any potential exits) cleanup() { - git checkout - >/dev/null 2>&1 || true - git branch -D "$BRANCH" >/dev/null 2>&1 || true + if [[ $BRANCH_CREATED -eq 1 ]]; then + git checkout - >/dev/null 2>&1 || true + git branch -D "$BRANCH" >/dev/null 2>&1 || true + fi } trap cleanup EXIT +# Check if we're already up to date +# Note: toolchain directive may be missing when go directive == latest toolchain. +# This is expected behavior - `go mod tidy` removes the toolchain line when +# the minimum Go version matches the latest toolchain, as it's redundant. +if [[ "$CURRENT_GO_DIRECTIVE" = "$GO_DIRECTIVE_VERSION" ]]; then + if [[ -z "$CURRENT_TOOLCHAIN_DIRECTIVE" ]]; then + # No toolchain directive present - this is expected when go version == latest + echo "Already on latest Go version: $CURRENT_GO_DIRECTIVE" + echo " → Note: No toolchain directive (expected when go version matches latest toolchain)" + exit 0 + elif [[ "$CURRENT_TOOLCHAIN_DIRECTIVE" = "go$TOOLCHAIN_VERSION" ]]; then + echo "Already on latest Go version: $CURRENT_GO_DIRECTIVE (toolchain: $CURRENT_TOOLCHAIN_DIRECTIVE)" + exit 0 + fi +fi + echo "Creating branch $BRANCH" git switch -c "$BRANCH" >/dev/null 2>&1 +BRANCH_CREATED=1 # ---- Patch go.mod ----------------------------------------------------------- if [[ "$CURRENT_GO_DIRECTIVE" != "$GO_DIRECTIVE_VERSION" ]]; then @@ -73,7 +89,20 @@ if [[ "$CURRENT_GO_DIRECTIVE" != "$GO_DIRECTIVE_VERSION" ]]; then echo " • go directive $CURRENT_GO_DIRECTIVE → $GO_DIRECTIVE_VERSION" fi -if [[ "$CURRENT_TOOLCHAIN_DIRECTIVE" != "go$TOOLCHAIN_VERSION" ]]; then +# Handle toolchain directive - may need to add, update, or skip +if [[ -z "$CURRENT_TOOLCHAIN_DIRECTIVE" ]]; then + # No toolchain directive exists + if [[ "$GO_DIRECTIVE_VERSION" = "$(cut -d. -f1-2 <<< "$TOOLCHAIN_VERSION").0" ]]; then + # go directive matches latest toolchain - toolchain line is redundant + echo " • toolchain directive not needed (go version matches latest toolchain)" + else + # go directive is older than latest toolchain - add toolchain directive after go line + sed -Ei.bak "/^go [0-9]+\.[0-9]+/a\\ +toolchain go$TOOLCHAIN_VERSION" "$GO_MOD" + echo " • toolchain directive added: go$TOOLCHAIN_VERSION" + fi +elif [[ "$CURRENT_TOOLCHAIN_DIRECTIVE" != "go$TOOLCHAIN_VERSION" ]]; then + # Toolchain directive exists but needs updating sed -Ei.bak "s/^toolchain go[0-9]+\.[0-9]+\.[0-9]+.*$/toolchain go$TOOLCHAIN_VERSION/" "$GO_MOD" echo " • toolchain $CURRENT_TOOLCHAIN_DIRECTIVE → go$TOOLCHAIN_VERSION" fi From 653ffc23ca27a82d3e3a4be9b7800e2aefeca4ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:13:09 +0000 Subject: [PATCH 123/182] Improve version comparison to handle both X.Y.0 and X.Y.Z formats - Compare major.minor versions instead of exact string match - Preserve go directive if already at latest major.minor - Handle cases where go.mod has full patch version (e.g., 1.25.6) - Update PR body to use actual go directive from modified go.mod - Add detailed comments explaining version handling logic Co-authored-by: williammartin <1611510+williammartin@users.noreply.github.com> --- .github/workflows/scripts/bump-go.sh | 45 ++++++++++++++++++---------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/.github/workflows/scripts/bump-go.sh b/.github/workflows/scripts/bump-go.sh index 853a26de8de..a494979390c 100755 --- a/.github/workflows/scripts/bump-go.sh +++ b/.github/workflows/scripts/bump-go.sh @@ -40,17 +40,19 @@ echo "Fetching latest stable Go version…" LATEST_JSON=$(curl -fsSL https://go.dev/dl/?mode=json | jq -c '[.[] | select(.stable==true)][0]') FULL_VERSION=$(jq -r '.version' <<< "$LATEST_JSON") # e.g. go1.23.4 TOOLCHAIN_VERSION="${FULL_VERSION#go}" # e.g. 1.23.4 -# `go mod tidy` will always add `.0` if there is no minor version -# so let's just ensure .0 is suffixed to the go directive. -GO_DIRECTIVE_VERSION="$(cut -d. -f1-2 <<< "$TOOLCHAIN_VERSION").0" -echo " → go : $GO_DIRECTIVE_VERSION" -echo " → toolchain : $TOOLCHAIN_VERSION" +# The go directive can be either X.Y.0 (minor version) or X.Y.Z (patch version) +# We accept both forms as "latest" if they match the toolchain's major.minor +LATEST_MAJOR_MINOR="$(cut -d. -f1-2 <<< "$TOOLCHAIN_VERSION")" + +echo " → latest toolchain : $TOOLCHAIN_VERSION" # ---- Prepare Git branch --------------------------------------------------- CURRENT_GO_DIRECTIVE=$(grep -E '^go ' "$GO_MOD" | cut -d ' ' -f2) CURRENT_TOOLCHAIN_DIRECTIVE=$(grep -E '^toolchain ' "$GO_MOD" | cut -d ' ' -f2 || true) +CURRENT_MAJOR_MINOR="$(cut -d. -f1-2 <<< "$CURRENT_GO_DIRECTIVE")" + BRANCH="bump-go-$TOOLCHAIN_VERSION" BRANCH_CREATED=0 @@ -64,19 +66,21 @@ cleanup() { trap cleanup EXIT # Check if we're already up to date -# Note: toolchain directive may be missing when go directive == latest toolchain. +# Note: toolchain directive may be missing when go directive >= latest toolchain. # This is expected behavior - `go mod tidy` removes the toolchain line when -# the minimum Go version matches the latest toolchain, as it's redundant. -if [[ "$CURRENT_GO_DIRECTIVE" = "$GO_DIRECTIVE_VERSION" ]]; then +# the minimum Go version matches or exceeds the latest toolchain, as it's redundant. +if [[ "$CURRENT_MAJOR_MINOR" = "$LATEST_MAJOR_MINOR" ]]; then + # Current go directive is at the same major.minor as latest if [[ -z "$CURRENT_TOOLCHAIN_DIRECTIVE" ]]; then - # No toolchain directive present - this is expected when go version == latest - echo "Already on latest Go version: $CURRENT_GO_DIRECTIVE" + # No toolchain directive present - this is expected when go version >= latest + echo "Already on latest Go version: $CURRENT_GO_DIRECTIVE (latest toolchain: $TOOLCHAIN_VERSION)" echo " → Note: No toolchain directive (expected when go version matches latest toolchain)" exit 0 elif [[ "$CURRENT_TOOLCHAIN_DIRECTIVE" = "go$TOOLCHAIN_VERSION" ]]; then echo "Already on latest Go version: $CURRENT_GO_DIRECTIVE (toolchain: $CURRENT_TOOLCHAIN_DIRECTIVE)" exit 0 fi + # Current go directive is latest but toolchain is outdated - continue to update toolchain fi echo "Creating branch $BRANCH" @@ -84,16 +88,22 @@ git switch -c "$BRANCH" >/dev/null 2>&1 BRANCH_CREATED=1 # ---- Patch go.mod ----------------------------------------------------------- -if [[ "$CURRENT_GO_DIRECTIVE" != "$GO_DIRECTIVE_VERSION" ]]; then - sed -Ei.bak "s/^go [0-9]+\.[0-9]+.*$/go $GO_DIRECTIVE_VERSION/" "$GO_MOD" - echo " • go directive $CURRENT_GO_DIRECTIVE → $GO_DIRECTIVE_VERSION" +# Only update go directive if we're not already at the latest major.minor version +if [[ "$CURRENT_MAJOR_MINOR" != "$LATEST_MAJOR_MINOR" ]]; then + # Bump to the latest major.minor.0 (preserves the convention of X.Y.0 for go directive) + NEW_GO_DIRECTIVE="$LATEST_MAJOR_MINOR.0" + sed -Ei.bak "s/^go [0-9]+\.[0-9]+.*$/go $NEW_GO_DIRECTIVE/" "$GO_MOD" + echo " • go directive $CURRENT_GO_DIRECTIVE → $NEW_GO_DIRECTIVE" + # After updating, the current go directive is now the new one for toolchain logic + CURRENT_GO_DIRECTIVE="$NEW_GO_DIRECTIVE" fi # Handle toolchain directive - may need to add, update, or skip if [[ -z "$CURRENT_TOOLCHAIN_DIRECTIVE" ]]; then # No toolchain directive exists - if [[ "$GO_DIRECTIVE_VERSION" = "$(cut -d. -f1-2 <<< "$TOOLCHAIN_VERSION").0" ]]; then - # go directive matches latest toolchain - toolchain line is redundant + CURRENT_MAJOR_MINOR="$(cut -d. -f1-2 <<< "$CURRENT_GO_DIRECTIVE")" + if [[ "$CURRENT_MAJOR_MINOR" = "$LATEST_MAJOR_MINOR" ]]; then + # go directive is at latest major.minor - toolchain line is redundant echo " • toolchain directive not needed (go version matches latest toolchain)" else # go directive is older than latest toolchain - add toolchain directive after go line @@ -139,10 +149,13 @@ if [[ $APPLY -eq 0 ]]; then fi # ---- Push & PR -------------------------------------------------------------- +# Get the actual go directive from the updated go.mod +FINAL_GO_DIRECTIVE=$(grep -E '^go ' "$GO_MOD" | cut -d ' ' -f2) + PR_BODY=$(cat < Date: Thu, 29 Jan 2026 19:16:20 +0000 Subject: [PATCH 124/182] Address code review comments - Clarify comment to say "matches" instead of "matches or exceeds" - Update PR body to accurately reflect final go.mod state - Show actual toolchain if present, or note it's not specified - Provide clear explanation when toolchain is omitted Co-authored-by: williammartin <1611510+williammartin@users.noreply.github.com> --- .github/workflows/scripts/bump-go.sh | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/scripts/bump-go.sh b/.github/workflows/scripts/bump-go.sh index a494979390c..24b995b4edf 100755 --- a/.github/workflows/scripts/bump-go.sh +++ b/.github/workflows/scripts/bump-go.sh @@ -66,13 +66,13 @@ cleanup() { trap cleanup EXIT # Check if we're already up to date -# Note: toolchain directive may be missing when go directive >= latest toolchain. +# Note: toolchain directive may be missing when go directive matches latest toolchain. # This is expected behavior - `go mod tidy` removes the toolchain line when -# the minimum Go version matches or exceeds the latest toolchain, as it's redundant. +# the minimum Go version matches the latest toolchain, as it's redundant. if [[ "$CURRENT_MAJOR_MINOR" = "$LATEST_MAJOR_MINOR" ]]; then # Current go directive is at the same major.minor as latest if [[ -z "$CURRENT_TOOLCHAIN_DIRECTIVE" ]]; then - # No toolchain directive present - this is expected when go version >= latest + # No toolchain directive present - this is expected when go version matches latest echo "Already on latest Go version: $CURRENT_GO_DIRECTIVE (latest toolchain: $TOOLCHAIN_VERSION)" echo " → Note: No toolchain directive (expected when go version matches latest toolchain)" exit 0 @@ -151,14 +151,25 @@ fi # ---- Push & PR -------------------------------------------------------------- # Get the actual go directive from the updated go.mod FINAL_GO_DIRECTIVE=$(grep -E '^go ' "$GO_MOD" | cut -d ' ' -f2) +FINAL_TOOLCHAIN_DIRECTIVE=$(grep -E '^toolchain ' "$GO_MOD" | cut -d ' ' -f2 || true) -PR_BODY=$(cat < Date: Thu, 29 Jan 2026 19:44:36 +0000 Subject: [PATCH 125/182] Rewrite script to use go mod edit instead of grep/sed Replace manual parsing and editing with go mod edit: - Use 'go mod edit -json' to read current go and toolchain directives - Use 'go mod edit -go' to update go directive - Use 'go mod edit -toolchain' to update toolchain directive - Remove manual sed/grep parsing and .bak file handling - More reliable and maintainable than custom text manipulation Co-authored-by: williammartin <1611510+williammartin@users.noreply.github.com> --- .github/workflows/scripts/bump-go.sh | 34 ++++++++++++++++------------ 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/.github/workflows/scripts/bump-go.sh b/.github/workflows/scripts/bump-go.sh index 24b995b4edf..eadeb88436e 100755 --- a/.github/workflows/scripts/bump-go.sh +++ b/.github/workflows/scripts/bump-go.sh @@ -47,9 +47,14 @@ LATEST_MAJOR_MINOR="$(cut -d. -f1-2 <<< "$TOOLCHAIN_VERSION")" echo " → latest toolchain : $TOOLCHAIN_VERSION" -# ---- Prepare Git branch --------------------------------------------------- -CURRENT_GO_DIRECTIVE=$(grep -E '^go ' "$GO_MOD" | cut -d ' ' -f2) -CURRENT_TOOLCHAIN_DIRECTIVE=$(grep -E '^toolchain ' "$GO_MOD" | cut -d ' ' -f2 || true) +# ---- Read current go.mod state using go mod edit ---------------------------- +GO_MOD_JSON=$(go mod edit -json "$GO_MOD") +CURRENT_GO_DIRECTIVE=$(jq -r '.Go // ""' <<< "$GO_MOD_JSON") +CURRENT_TOOLCHAIN_DIRECTIVE=$(jq -r '.Toolchain // ""' <<< "$GO_MOD_JSON") + +# Handle empty values from jq +[[ "$CURRENT_GO_DIRECTIVE" == "null" || -z "$CURRENT_GO_DIRECTIVE" ]] && CURRENT_GO_DIRECTIVE="" +[[ "$CURRENT_TOOLCHAIN_DIRECTIVE" == "null" || -z "$CURRENT_TOOLCHAIN_DIRECTIVE" ]] && CURRENT_TOOLCHAIN_DIRECTIVE="" CURRENT_MAJOR_MINOR="$(cut -d. -f1-2 <<< "$CURRENT_GO_DIRECTIVE")" @@ -87,12 +92,12 @@ echo "Creating branch $BRANCH" git switch -c "$BRANCH" >/dev/null 2>&1 BRANCH_CREATED=1 -# ---- Patch go.mod ----------------------------------------------------------- +# ---- Patch go.mod using go mod edit ----------------------------------------- # Only update go directive if we're not already at the latest major.minor version if [[ "$CURRENT_MAJOR_MINOR" != "$LATEST_MAJOR_MINOR" ]]; then # Bump to the latest major.minor.0 (preserves the convention of X.Y.0 for go directive) NEW_GO_DIRECTIVE="$LATEST_MAJOR_MINOR.0" - sed -Ei.bak "s/^go [0-9]+\.[0-9]+.*$/go $NEW_GO_DIRECTIVE/" "$GO_MOD" + go mod edit -go="$NEW_GO_DIRECTIVE" "$GO_MOD" echo " • go directive $CURRENT_GO_DIRECTIVE → $NEW_GO_DIRECTIVE" # After updating, the current go directive is now the new one for toolchain logic CURRENT_GO_DIRECTIVE="$NEW_GO_DIRECTIVE" @@ -106,19 +111,16 @@ if [[ -z "$CURRENT_TOOLCHAIN_DIRECTIVE" ]]; then # go directive is at latest major.minor - toolchain line is redundant echo " • toolchain directive not needed (go version matches latest toolchain)" else - # go directive is older than latest toolchain - add toolchain directive after go line - sed -Ei.bak "/^go [0-9]+\.[0-9]+/a\\ -toolchain go$TOOLCHAIN_VERSION" "$GO_MOD" + # go directive is older than latest toolchain - add toolchain directive + go mod edit -toolchain="go$TOOLCHAIN_VERSION" "$GO_MOD" echo " • toolchain directive added: go$TOOLCHAIN_VERSION" fi elif [[ "$CURRENT_TOOLCHAIN_DIRECTIVE" != "go$TOOLCHAIN_VERSION" ]]; then # Toolchain directive exists but needs updating - sed -Ei.bak "s/^toolchain go[0-9]+\.[0-9]+\.[0-9]+.*$/toolchain go$TOOLCHAIN_VERSION/" "$GO_MOD" + go mod edit -toolchain="go$TOOLCHAIN_VERSION" "$GO_MOD" echo " • toolchain $CURRENT_TOOLCHAIN_DIRECTIVE → go$TOOLCHAIN_VERSION" fi -rm -f "$GO_MOD.bak" - git add "$GO_MOD" # ---- Commit ----------------------------------------------------------------- @@ -149,9 +151,13 @@ if [[ $APPLY -eq 0 ]]; then fi # ---- Push & PR -------------------------------------------------------------- -# Get the actual go directive from the updated go.mod -FINAL_GO_DIRECTIVE=$(grep -E '^go ' "$GO_MOD" | cut -d ' ' -f2) -FINAL_TOOLCHAIN_DIRECTIVE=$(grep -E '^toolchain ' "$GO_MOD" | cut -d ' ' -f2 || true) +# Get the actual go directive from the updated go.mod using go mod edit +FINAL_GO_MOD_JSON=$(go mod edit -json "$GO_MOD") +FINAL_GO_DIRECTIVE=$(jq -r '.Go // ""' <<< "$FINAL_GO_MOD_JSON") +FINAL_TOOLCHAIN_DIRECTIVE=$(jq -r '.Toolchain // ""' <<< "$FINAL_GO_MOD_JSON") + +# Handle empty/null values +[[ "$FINAL_TOOLCHAIN_DIRECTIVE" == "null" || -z "$FINAL_TOOLCHAIN_DIRECTIVE" ]] && FINAL_TOOLCHAIN_DIRECTIVE="" if [[ -n "$FINAL_TOOLCHAIN_DIRECTIVE" ]]; then PR_BODY=$(cat < Date: Thu, 29 Jan 2026 19:54:42 +0000 Subject: [PATCH 126/182] Remove unnecessary null checks in jq output handling The jq -r flag with // "" operator already returns empty string for null/missing values, never the literal string "null". The checks for == "null" are unnecessary. Co-authored-by: babakks <36728931+babakks@users.noreply.github.com> --- .github/workflows/scripts/bump-go.sh | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/scripts/bump-go.sh b/.github/workflows/scripts/bump-go.sh index eadeb88436e..d67412499fe 100755 --- a/.github/workflows/scripts/bump-go.sh +++ b/.github/workflows/scripts/bump-go.sh @@ -52,10 +52,6 @@ GO_MOD_JSON=$(go mod edit -json "$GO_MOD") CURRENT_GO_DIRECTIVE=$(jq -r '.Go // ""' <<< "$GO_MOD_JSON") CURRENT_TOOLCHAIN_DIRECTIVE=$(jq -r '.Toolchain // ""' <<< "$GO_MOD_JSON") -# Handle empty values from jq -[[ "$CURRENT_GO_DIRECTIVE" == "null" || -z "$CURRENT_GO_DIRECTIVE" ]] && CURRENT_GO_DIRECTIVE="" -[[ "$CURRENT_TOOLCHAIN_DIRECTIVE" == "null" || -z "$CURRENT_TOOLCHAIN_DIRECTIVE" ]] && CURRENT_TOOLCHAIN_DIRECTIVE="" - CURRENT_MAJOR_MINOR="$(cut -d. -f1-2 <<< "$CURRENT_GO_DIRECTIVE")" BRANCH="bump-go-$TOOLCHAIN_VERSION" @@ -156,9 +152,6 @@ FINAL_GO_MOD_JSON=$(go mod edit -json "$GO_MOD") FINAL_GO_DIRECTIVE=$(jq -r '.Go // ""' <<< "$FINAL_GO_MOD_JSON") FINAL_TOOLCHAIN_DIRECTIVE=$(jq -r '.Toolchain // ""' <<< "$FINAL_GO_MOD_JSON") -# Handle empty/null values -[[ "$FINAL_TOOLCHAIN_DIRECTIVE" == "null" || -z "$FINAL_TOOLCHAIN_DIRECTIVE" ]] && FINAL_TOOLCHAIN_DIRECTIVE="" - if [[ -n "$FINAL_TOOLCHAIN_DIRECTIVE" ]]; then PR_BODY=$(cat < Date: Wed, 13 May 2026 13:25:39 +0200 Subject: [PATCH 127/182] Simplify bump-go.sh toolchain logic Address review feedback: always set both go and toolchain directives via go mod edit, then let go mod tidy normalize. This eliminates complex conditional toolchain handling. Additional fixes: - Add go mod tidy after edits to reconcile dependencies - Commit go.sum alongside go.mod - Filter PR search to open PRs only (--state open) - Use GITHUB_REPOSITORY for repo instead of hardcoding - Use git diff to detect no-op bumps post-tidy - Read go.mod state via go mod edit -json instead of grep Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/scripts/bump-go.sh | 126 +++++++++++---------------- 1 file changed, 50 insertions(+), 76 deletions(-) diff --git a/.github/workflows/scripts/bump-go.sh b/.github/workflows/scripts/bump-go.sh index d67412499fe..16dd346e815 100755 --- a/.github/workflows/scripts/bump-go.sh +++ b/.github/workflows/scripts/bump-go.sh @@ -1,15 +1,15 @@ #!/usr/bin/env bash # -# bump-go.sh — Update go.mod `go` directive and toolchain to latest stable Go release. +# bump-go.sh -- Update go.mod `go` directive and toolchain to latest stable Go release. # # Usage: # ./bump-go.sh [--apply|-a] # -# By default the script runs in *dry‑run* mode: it creates a local branch, +# By default the script runs in *dry-run* mode: it creates a local branch, # commits the version bump, shows the exact patch, **checks for an existing PR** # with the same title, and exits. Nothing is pushed. The temporary branch is # deleted automatically on exit, so your working tree stays clean. Pass -# --apply (or -a) to push the branch and open a new PR *only if one doesn’t +# --apply (or -a) to push the branch and open a new PR *only if one doesn't # already exist*. # ----------------------------------------------------------------------------- set -euo pipefail @@ -35,29 +35,32 @@ done [[ -z "$GO_MOD" ]] && usage [[ -f "$GO_MOD" ]] || { echo "Error: '$GO_MOD' not found" >&2; exit 1; } +REPO="cli/cli" +MODULE_DIR=$(dirname "$GO_MOD") +GO_SUM="$MODULE_DIR/go.sum" + # ---- Discover latest stable Go release -------------------------------------- -echo "Fetching latest stable Go version…" +echo "Fetching latest stable Go version..." LATEST_JSON=$(curl -fsSL https://go.dev/dl/?mode=json | jq -c '[.[] | select(.stable==true)][0]') FULL_VERSION=$(jq -r '.version' <<< "$LATEST_JSON") # e.g. go1.23.4 TOOLCHAIN_VERSION="${FULL_VERSION#go}" # e.g. 1.23.4 +GO_DIRECTIVE_VERSION="$(cut -d. -f1-2 <<< "$TOOLCHAIN_VERSION").0" -# The go directive can be either X.Y.0 (minor version) or X.Y.Z (patch version) -# We accept both forms as "latest" if they match the toolchain's major.minor -LATEST_MAJOR_MINOR="$(cut -d. -f1-2 <<< "$TOOLCHAIN_VERSION")" - -echo " → latest toolchain : $TOOLCHAIN_VERSION" +echo " → go directive : $GO_DIRECTIVE_VERSION" +echo " → toolchain : go$TOOLCHAIN_VERSION" # ---- Read current go.mod state using go mod edit ---------------------------- GO_MOD_JSON=$(go mod edit -json "$GO_MOD") CURRENT_GO_DIRECTIVE=$(jq -r '.Go // ""' <<< "$GO_MOD_JSON") -CURRENT_TOOLCHAIN_DIRECTIVE=$(jq -r '.Toolchain // ""' <<< "$GO_MOD_JSON") +CURRENT_TOOLCHAIN=$(jq -r '.Toolchain // ""' <<< "$GO_MOD_JSON") -CURRENT_MAJOR_MINOR="$(cut -d. -f1-2 <<< "$CURRENT_GO_DIRECTIVE")" +echo " → current go : $CURRENT_GO_DIRECTIVE" +echo " → current tc : ${CURRENT_TOOLCHAIN:-(none)}" +# ---- Prepare Git branch ----------------------------------------------------- BRANCH="bump-go-$TOOLCHAIN_VERSION" BRANCH_CREATED=0 -# Set up cleanup trap early (before any potential exits) cleanup() { if [[ $BRANCH_CREATED -eq 1 ]]; then git checkout - >/dev/null 2>&1 || true @@ -66,58 +69,32 @@ cleanup() { } trap cleanup EXIT -# Check if we're already up to date -# Note: toolchain directive may be missing when go directive matches latest toolchain. -# This is expected behavior - `go mod tidy` removes the toolchain line when -# the minimum Go version matches the latest toolchain, as it's redundant. -if [[ "$CURRENT_MAJOR_MINOR" = "$LATEST_MAJOR_MINOR" ]]; then - # Current go directive is at the same major.minor as latest - if [[ -z "$CURRENT_TOOLCHAIN_DIRECTIVE" ]]; then - # No toolchain directive present - this is expected when go version matches latest - echo "Already on latest Go version: $CURRENT_GO_DIRECTIVE (latest toolchain: $TOOLCHAIN_VERSION)" - echo " → Note: No toolchain directive (expected when go version matches latest toolchain)" - exit 0 - elif [[ "$CURRENT_TOOLCHAIN_DIRECTIVE" = "go$TOOLCHAIN_VERSION" ]]; then - echo "Already on latest Go version: $CURRENT_GO_DIRECTIVE (toolchain: $CURRENT_TOOLCHAIN_DIRECTIVE)" - exit 0 - fi - # Current go directive is latest but toolchain is outdated - continue to update toolchain -fi - echo "Creating branch $BRANCH" git switch -c "$BRANCH" >/dev/null 2>&1 BRANCH_CREATED=1 -# ---- Patch go.mod using go mod edit ----------------------------------------- -# Only update go directive if we're not already at the latest major.minor version -if [[ "$CURRENT_MAJOR_MINOR" != "$LATEST_MAJOR_MINOR" ]]; then - # Bump to the latest major.minor.0 (preserves the convention of X.Y.0 for go directive) - NEW_GO_DIRECTIVE="$LATEST_MAJOR_MINOR.0" - go mod edit -go="$NEW_GO_DIRECTIVE" "$GO_MOD" - echo " • go directive $CURRENT_GO_DIRECTIVE → $NEW_GO_DIRECTIVE" - # After updating, the current go directive is now the new one for toolchain logic - CURRENT_GO_DIRECTIVE="$NEW_GO_DIRECTIVE" -fi - -# Handle toolchain directive - may need to add, update, or skip -if [[ -z "$CURRENT_TOOLCHAIN_DIRECTIVE" ]]; then - # No toolchain directive exists - CURRENT_MAJOR_MINOR="$(cut -d. -f1-2 <<< "$CURRENT_GO_DIRECTIVE")" - if [[ "$CURRENT_MAJOR_MINOR" = "$LATEST_MAJOR_MINOR" ]]; then - # go directive is at latest major.minor - toolchain line is redundant - echo " • toolchain directive not needed (go version matches latest toolchain)" - else - # go directive is older than latest toolchain - add toolchain directive - go mod edit -toolchain="go$TOOLCHAIN_VERSION" "$GO_MOD" - echo " • toolchain directive added: go$TOOLCHAIN_VERSION" - fi -elif [[ "$CURRENT_TOOLCHAIN_DIRECTIVE" != "go$TOOLCHAIN_VERSION" ]]; then - # Toolchain directive exists but needs updating - go mod edit -toolchain="go$TOOLCHAIN_VERSION" "$GO_MOD" - echo " • toolchain $CURRENT_TOOLCHAIN_DIRECTIVE → go$TOOLCHAIN_VERSION" +# ---- Patch go.mod ----------------------------------------------------------- +# Always set both directives and let `go mod tidy` normalize. +# When the go directive version matches the toolchain version, tidy will remove +# the toolchain line because it is redundant -- this is expected Go behavior. +go mod edit -go="$GO_DIRECTIVE_VERSION" -toolchain="go$TOOLCHAIN_VERSION" "$GO_MOD" +echo " • set go directive → $GO_DIRECTIVE_VERSION" +echo " • set toolchain → go$TOOLCHAIN_VERSION" + +# Let go mod tidy reconcile dependencies and normalize directives. +echo " • running go mod tidy..." +pushd "$MODULE_DIR" > /dev/null +go mod tidy +popd > /dev/null + +# ---- Check if anything actually changed ------------------------------------- +if git diff --quiet -- "$GO_MOD" "$GO_SUM" 2>/dev/null; then + echo "Already on latest Go version -- no changes needed." + exit 0 fi git add "$GO_MOD" +[[ -f "$GO_SUM" ]] && git add "$GO_SUM" # ---- Commit ----------------------------------------------------------------- COMMIT_MSG="Bump Go to $TOOLCHAIN_VERSION" @@ -127,48 +104,45 @@ COMMIT_HASH=$(git rev-parse --short HEAD) PR_TITLE="$COMMIT_MSG" # ---- Check for existing PR -------------------------------------------------- -existing_pr=$(gh search prs --repo cli/cli --match title "$PR_TITLE" --json title --jq "map(select(.title == \"$PR_TITLE\") | .title) | length > 0") +existing_pr=$(gh search prs --repo "$REPO" --state open --match title "$PR_TITLE" \ + --json title --jq "map(select(.title == \"$PR_TITLE\") | .title) | length > 0") if [[ "$existing_pr" == "true" ]]; then echo "Found an existing open PR titled '$PR_TITLE'. Skipping push/PR creation." if [[ $APPLY -eq 0 ]]; then - echo -e "\n=== DRY‑RUN DIFF (commit $COMMIT_HASH):\n" + echo -e "\n=== DRY-RUN DIFF (commit $COMMIT_HASH):\n" git --no-pager show --color "$COMMIT_HASH" fi exit 0 fi -# ---- Dry‑run handling ------------------------------------------------------- +# ---- Dry-run handling ------------------------------------------------------- if [[ $APPLY -eq 0 ]]; then - echo -e "\n=== DRY‑RUN DIFF (commit $COMMIT_HASH):\n" + echo -e "\n=== DRY-RUN DIFF (commit $COMMIT_HASH):\n" git --no-pager show --color "$COMMIT_HASH" echo -e "\nIf --apply were provided, script would continue with:\n git push -u origin $BRANCH\n gh pr create --title \"$PR_TITLE\" --body \n" exit 0 fi # ---- Push & PR -------------------------------------------------------------- -# Get the actual go directive from the updated go.mod using go mod edit FINAL_GO_MOD_JSON=$(go mod edit -json "$GO_MOD") -FINAL_GO_DIRECTIVE=$(jq -r '.Go // ""' <<< "$FINAL_GO_MOD_JSON") -FINAL_TOOLCHAIN_DIRECTIVE=$(jq -r '.Toolchain // ""' <<< "$FINAL_GO_MOD_JSON") - -if [[ -n "$FINAL_TOOLCHAIN_DIRECTIVE" ]]; then - PR_BODY=$(cat < Date: Wed, 13 May 2026 14:19:14 +0000 Subject: [PATCH 128/182] chore(deps): bump golang.org/x/crypto from 0.50.0 to 0.51.0 Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.50.0 to 0.51.0. - [Commits](https://github.com/golang/crypto/compare/v0.50.0...v0.51.0) --- updated-dependencies: - dependency-name: golang.org/x/crypto dependency-version: 0.51.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d2edf3ec711..715bc101295 100644 --- a/go.mod +++ b/go.mod @@ -57,7 +57,7 @@ require ( github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/yuin/goldmark v1.8.2 github.com/zalando/go-keyring v0.2.8 - golang.org/x/crypto v0.50.0 + golang.org/x/crypto v0.51.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.44.0 golang.org/x/term v0.43.0 diff --git a/go.sum b/go.sum index 00c383d1876..69272e7792e 100644 --- a/go.sum +++ b/go.sum @@ -573,8 +573,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= From 216b7cf6689aa2a65a2e4d372ff711ff9934ea1b Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 13 May 2026 15:57:56 +0100 Subject: [PATCH 129/182] fix linting --- pkg/cmd/skills/list/list_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go index 4b8b397e8e6..d132eb1111d 100644 --- a/pkg/cmd/skills/list/list_test.go +++ b/pkg/cmd/skills/list/list_test.go @@ -1,11 +1,11 @@ package list import ( - "bytes" "fmt" "io" "os" "path/filepath" + "strings" "testing" "github.com/MakeNowJust/heredoc" @@ -293,7 +293,7 @@ func TestListRun(t *testing.T) { require.NoError(t, err) if tt.wantJSON != "" { expected := tt.wantJSON - expected = string(bytes.ReplaceAll([]byte(expected), []byte(filepath.Join("HOME")), []byte(homeDir))) + expected = strings.ReplaceAll(expected, "HOME", homeDir) assert.JSONEq(t, expected, stdout.String()) } else { assert.Equal(t, tt.wantStdout, stdout.String()) From 1fac1218f39562101cd4ee2f5d99165ccaf254e1 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 13 May 2026 22:34:26 +0100 Subject: [PATCH 130/182] fix tests --- pkg/cmd/skills/list/list_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go index d132eb1111d..01503680fb5 100644 --- a/pkg/cmd/skills/list/list_test.go +++ b/pkg/cmd/skills/list/list_test.go @@ -293,7 +293,7 @@ func TestListRun(t *testing.T) { require.NoError(t, err) if tt.wantJSON != "" { expected := tt.wantJSON - expected = strings.ReplaceAll(expected, "HOME", homeDir) + expected = strings.ReplaceAll(expected, "HOME", strings.ReplaceAll(homeDir, `\`, `\\`)) assert.JSONEq(t, expected, stdout.String()) } else { assert.Equal(t, tt.wantStdout, stdout.String()) From 8bef879aab5f047809341fbf75522d37c95a7959 Mon Sep 17 00:00:00 2001 From: Aiden Park <275402320+vip892766gma@users.noreply.github.com> Date: Thu, 14 May 2026 04:13:18 +0000 Subject: [PATCH 131/182] docs: fix duplicated "of" in release-process-deep-dive --- docs/release-process-deep-dive.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-process-deep-dive.md b/docs/release-process-deep-dive.md index 4d060841a5a..8cf645ffd98 100644 --- a/docs/release-process-deep-dive.md +++ b/docs/release-process-deep-dive.md @@ -659,7 +659,7 @@ Using [`mislav/bump-homebrew-formula-action`](https://github.com/mislav/bump-hom [`./script/release`](https://github.com/cli/cli/blob/817eeb26e567de11007c8a82c25e61c7e20e4337/script/release) is used by `gh` maintainers to [create a new release](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/docs/releasing.md). When invoked it executes `gh workflow run` in order to kick off the workflow described in detail above. However, that workflow also calls back into `./script/release` with the `--local` flag resulting in release artifacts being created on the machine invoking it. Each OS specific job in the workflow additionally provides the `--platform` flag. -The surprising behaviour in `./script/release` is that it uses `sed` to modify the base [`.goreleaser.yml` ](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml) file, so that only platform specific sections are retained. For example, in the case of of `linux` only the [`linux` build](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L27) and [`npmfs`](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L78) section would be configured for `GoReleaser`. The `archive` sections are addressed by [requirements](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L52) on previous platform builds. +The surprising behaviour in `./script/release` is that it uses `sed` to modify the base [`.goreleaser.yml` ](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml) file, so that only platform specific sections are retained. For example, in the case of `linux` only the [`linux` build](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L27) and [`npmfs`](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L78) section would be configured for `GoReleaser`. The `archive` sections are addressed by [requirements](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml#L52) on previous platform builds. Each build entry in [`.goreleaser.yml` ](https://github.com/cli/cli/blob/756f4ec04abdc9fdbab3fef35b182c546ef1dd17/.goreleaser.yml) specifies the platforms that are supported, for example: From 5c437a887595846af0ac053d0b03bd0a5783295d Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Thu, 14 May 2026 10:31:10 -0700 Subject: [PATCH 132/182] Derive digest algorithm from ref length in release verify commands The 'gh release verify' and 'gh release verify-asset' commands hard-coded a 'sha1:' prefix when constructing the digest identifier for a release tag's commit SHA. Once GitHub repositories using SHA-256 commit digests are supported, that ref will be a 64-character SHA-256 hash and labeling it as 'sha1:' is both misleading in user output and incorrect for the attestation lookup. Add a shared 'DigestAlgForRef' helper that returns 'sha256' for 64-char digests and 'sha1' otherwise (preserving existing behavior for SHA-1 repositories), and use it at both call sites. Add test coverage for the helper and for the SHA-256 error path in both commands. Fixes #13429 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/release/shared/fetch.go | 13 +++++++ pkg/cmd/release/shared/fetch_test.go | 35 +++++++++++++++++++ pkg/cmd/release/verify-asset/verify_asset.go | 2 +- .../release/verify-asset/verify_asset_test.go | 32 +++++++++++++++++ pkg/cmd/release/verify/verify.go | 2 +- pkg/cmd/release/verify/verify_test.go | 29 +++++++++++++++ 6 files changed, 111 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index e1f155d046b..f2e370ecf96 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -170,6 +170,19 @@ func FetchRefSHA(ctx context.Context, httpClient *http.Client, repo ghrepo.Inter return ref.Object.SHA, nil } +// DigestAlgForRef returns the digest algorithm name corresponding to the given +// git ref SHA. SHA-1 git object IDs are 40 hex characters and SHA-256 git +// object IDs are 64 hex characters. Unknown lengths default to "sha1" to +// preserve backwards-compatible behavior. +func DigestAlgForRef(digest string) string { + switch len(digest) { + case 64: + return "sha256" + default: + return "sha1" + } +} + // FetchRelease finds a published repository release by its tagName, or a draft release by its pending tag name. func FetchRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (*Release, error) { cc, cancel := context.WithCancel(ctx) diff --git a/pkg/cmd/release/shared/fetch_test.go b/pkg/cmd/release/shared/fetch_test.go index 9b4e5df8083..0720b876f6f 100644 --- a/pkg/cmd/release/shared/fetch_test.go +++ b/pkg/cmd/release/shared/fetch_test.go @@ -92,3 +92,38 @@ func TestFetchRefSHA(t *testing.T) { }) } } + +func TestDigestAlgForRef(t *testing.T) { + tests := []struct { + name string + digest string + expected string + }{ + { + name: "sha1 (40 hex chars)", + digest: "1234567890abcdef1234567890abcdef12345678", + expected: "sha1", + }, + { + name: "sha256 (64 hex chars)", + digest: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + expected: "sha256", + }, + { + name: "empty string defaults to sha1", + digest: "", + expected: "sha1", + }, + { + name: "unexpected length defaults to sha1", + digest: "abc", + expected: "sha1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, DigestAlgForRef(tt.digest)) + }) + } +} diff --git a/pkg/cmd/release/verify-asset/verify_asset.go b/pkg/cmd/release/verify-asset/verify_asset.go index acd8a134e8e..2cebce53bc8 100644 --- a/pkg/cmd/release/verify-asset/verify_asset.go +++ b/pkg/cmd/release/verify-asset/verify_asset.go @@ -142,7 +142,7 @@ func verifyAssetRun(config *VerifyAssetConfig) error { return err } - releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") + releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, shared.DigestAlgForRef(ref)) // Find attestations for the release tag SHA attestations, err := config.AttClient.GetByDigest(api.FetchParams{ diff --git a/pkg/cmd/release/verify-asset/verify_asset_test.go b/pkg/cmd/release/verify-asset/verify_asset_test.go index 530f478ed16..8e3654119ce 100644 --- a/pkg/cmd/release/verify-asset/verify_asset_test.go +++ b/pkg/cmd/release/verify-asset/verify_asset_test.go @@ -197,6 +197,38 @@ func Test_verifyAssetRun_FailedNoAttestations(t *testing.T) { require.ErrorContains(t, err, "no attestations found for tag v1") } +func Test_verifyAssetRun_FailedNoAttestations_SHA256(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + releaseAssetPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip") + + cfg := &VerifyAssetConfig{ + Opts: &VerifyAssetOptions{ + AssetFilePath: releaseAssetPath, + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewFailTestClient(), + AttVerifier: nil, + } + + err = verifyAssetRun(cfg) + require.ErrorContains(t, err, "no attestations found for tag v1") + require.ErrorContains(t, err, "sha256:"+fakeSHA) +} + func Test_verifyAssetRun_FailedTagNotInAttestation(t *testing.T) { ios, _, _, _ := iostreams.Test() diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 65516764ebe..39e27bbc50b 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -130,7 +130,7 @@ func verifyRun(config *VerifyConfig) error { return err } - releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, "sha1") + releaseRefDigest := artifact.NewDigestedArtifactForRelease(ref, shared.DigestAlgForRef(ref)) // Find all the attestations for the release tag SHA attestations, err := config.AttClient.GetByDigest(api.FetchParams{ diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go index 40009fc7d5a..df6070cc689 100644 --- a/pkg/cmd/release/verify/verify_test.go +++ b/pkg/cmd/release/verify/verify_test.go @@ -131,6 +131,35 @@ func Test_verifyRun_FailedNoAttestations(t *testing.T) { require.ErrorContains(t, err, "no attestations for tag v1") } +func Test_verifyRun_FailedNoAttestations_SHA256(t *testing.T) { + ios, _, _, _ := iostreams.Test() + tagName := "v1" + + fakeHTTP := &httpmock.Registry{} + defer fakeHTTP.Verify(t) + fakeSHA := "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + shared.StubFetchRefSHA(t, fakeHTTP, "owner", "repo", tagName, fakeSHA) + + baseRepo, err := ghrepo.FromFullName("owner/repo") + require.NoError(t, err) + + cfg := &VerifyConfig{ + Opts: &VerifyOptions{ + TagName: tagName, + BaseRepo: baseRepo, + Exporter: nil, + }, + IO: ios, + HttpClient: &http.Client{Transport: fakeHTTP}, + AttClient: api.NewFailTestClient(), + AttVerifier: nil, + } + + err = verifyRun(cfg) + require.ErrorContains(t, err, "no attestations for tag v1") + require.ErrorContains(t, err, "sha256:"+fakeSHA) +} + func Test_verifyRun_FailedTagNotInAttestation(t *testing.T) { ios, _, _, _ := iostreams.Test() From 93e0a2811b7bf8d110c2558c123da773d2046fbd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 14:03:42 +0000 Subject: [PATCH 133/182] chore(deps): bump google.golang.org/grpc from 1.81.0 to 1.81.1 Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.81.0 to 1.81.1. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.81.0...v1.81.1) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.81.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 715bc101295..395aba966c3 100644 --- a/go.mod +++ b/go.mod @@ -62,7 +62,7 @@ require ( golang.org/x/sys v0.44.0 golang.org/x/term v0.43.0 golang.org/x/text v0.37.0 - google.golang.org/grpc v1.81.0 + google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.11 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 69272e7792e..90f6a825319 100644 --- a/go.sum +++ b/go.sum @@ -636,8 +636,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= -google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From d9eb0627dceeb49b2943fa992414eb185787d02e Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Fri, 15 May 2026 07:57:58 -0700 Subject: [PATCH 134/182] Assert digest prefix in release verify no-attestation tests Address PR review feedback: - Rename SHA1 tests to make the algorithm explicit - Assert the sha1:/sha256: prefix appears in the error - Use a capturing MockClient so we verify the actual digest sent to GetByDigest, not just the wrapped error message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../release/verify-asset/verify_asset_test.go | 25 ++++++++++++++++--- pkg/cmd/release/verify/verify_test.go | 25 ++++++++++++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/release/verify-asset/verify_asset_test.go b/pkg/cmd/release/verify-asset/verify_asset_test.go index 8e3654119ce..dc881ec00a9 100644 --- a/pkg/cmd/release/verify-asset/verify_asset_test.go +++ b/pkg/cmd/release/verify-asset/verify_asset_test.go @@ -166,7 +166,7 @@ func Test_verifyAssetRun_SuccessNoTagArg(t *testing.T) { require.NoError(t, err) } -func Test_verifyAssetRun_FailedNoAttestations(t *testing.T) { +func Test_verifyAssetRun_FailedNoAttestations_SHA1(t *testing.T) { ios, _, _, _ := iostreams.Test() tagName := "v1" @@ -180,6 +180,14 @@ func Test_verifyAssetRun_FailedNoAttestations(t *testing.T) { releaseAssetPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip") + var capturedParams api.FetchParams + attClient := &api.MockClient{ + OnGetByDigest: func(params api.FetchParams) ([]*api.Attestation, error) { + capturedParams = params + return api.OnGetByDigestFailure(params) + }, + } + cfg := &VerifyAssetConfig{ Opts: &VerifyAssetOptions{ AssetFilePath: releaseAssetPath, @@ -189,12 +197,14 @@ func Test_verifyAssetRun_FailedNoAttestations(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, - AttClient: api.NewFailTestClient(), + AttClient: attClient, AttVerifier: nil, } err = verifyAssetRun(cfg) require.ErrorContains(t, err, "no attestations found for tag v1") + require.ErrorContains(t, err, "sha1:"+fakeSHA) + require.Equal(t, "sha1:"+fakeSHA, capturedParams.Digest) } func Test_verifyAssetRun_FailedNoAttestations_SHA256(t *testing.T) { @@ -211,6 +221,14 @@ func Test_verifyAssetRun_FailedNoAttestations_SHA256(t *testing.T) { releaseAssetPath := test.NormalizeRelativePath("../../attestation/test/data/github_release_artifact.zip") + var capturedParams api.FetchParams + attClient := &api.MockClient{ + OnGetByDigest: func(params api.FetchParams) ([]*api.Attestation, error) { + capturedParams = params + return api.OnGetByDigestFailure(params) + }, + } + cfg := &VerifyAssetConfig{ Opts: &VerifyAssetOptions{ AssetFilePath: releaseAssetPath, @@ -220,13 +238,14 @@ func Test_verifyAssetRun_FailedNoAttestations_SHA256(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, - AttClient: api.NewFailTestClient(), + AttClient: attClient, AttVerifier: nil, } err = verifyAssetRun(cfg) require.ErrorContains(t, err, "no attestations found for tag v1") require.ErrorContains(t, err, "sha256:"+fakeSHA) + require.Equal(t, "sha256:"+fakeSHA, capturedParams.Digest) } func Test_verifyAssetRun_FailedTagNotInAttestation(t *testing.T) { diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go index df6070cc689..ccb3b35a6ba 100644 --- a/pkg/cmd/release/verify/verify_test.go +++ b/pkg/cmd/release/verify/verify_test.go @@ -103,7 +103,7 @@ func Test_verifyRun_Success(t *testing.T) { require.NoError(t, err) } -func Test_verifyRun_FailedNoAttestations(t *testing.T) { +func Test_verifyRun_FailedNoAttestations_SHA1(t *testing.T) { ios, _, _, _ := iostreams.Test() tagName := "v1" @@ -115,6 +115,14 @@ func Test_verifyRun_FailedNoAttestations(t *testing.T) { baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) + var capturedParams api.FetchParams + attClient := &api.MockClient{ + OnGetByDigest: func(params api.FetchParams) ([]*api.Attestation, error) { + capturedParams = params + return api.OnGetByDigestFailure(params) + }, + } + cfg := &VerifyConfig{ Opts: &VerifyOptions{ TagName: tagName, @@ -123,12 +131,14 @@ func Test_verifyRun_FailedNoAttestations(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, - AttClient: api.NewFailTestClient(), + AttClient: attClient, AttVerifier: nil, } err = verifyRun(cfg) require.ErrorContains(t, err, "no attestations for tag v1") + require.ErrorContains(t, err, "sha1:"+fakeSHA) + require.Equal(t, "sha1:"+fakeSHA, capturedParams.Digest) } func Test_verifyRun_FailedNoAttestations_SHA256(t *testing.T) { @@ -143,6 +153,14 @@ func Test_verifyRun_FailedNoAttestations_SHA256(t *testing.T) { baseRepo, err := ghrepo.FromFullName("owner/repo") require.NoError(t, err) + var capturedParams api.FetchParams + attClient := &api.MockClient{ + OnGetByDigest: func(params api.FetchParams) ([]*api.Attestation, error) { + capturedParams = params + return api.OnGetByDigestFailure(params) + }, + } + cfg := &VerifyConfig{ Opts: &VerifyOptions{ TagName: tagName, @@ -151,13 +169,14 @@ func Test_verifyRun_FailedNoAttestations_SHA256(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, - AttClient: api.NewFailTestClient(), + AttClient: attClient, AttVerifier: nil, } err = verifyRun(cfg) require.ErrorContains(t, err, "no attestations for tag v1") require.ErrorContains(t, err, "sha256:"+fakeSHA) + require.Equal(t, "sha256:"+fakeSHA, capturedParams.Digest) } func Test_verifyRun_FailedTagNotInAttestation(t *testing.T) { From 084afb30d24be55c8d4a0a4ef296137cc9461452 Mon Sep 17 00:00:00 2001 From: Charlie Tonneslan Date: Sat, 16 May 2026 13:56:46 -0400 Subject: [PATCH 135/182] docs: drop --repo gh-cli from dnf install lines The documented dnf install lines pin resolution to the gh-cli repo with --repo gh-cli. That repo only ships the gh package, so on a fresh Fedora install without git already present the install fails with "nothing provides git needed by gh-2.87.3-1.x86_64 from gh-cli" (#12808). Dropping --repo lets dnf pull git from the system repos while still installing gh from gh-cli. The maintainer suggested this in the issue thread after spotting that other projects (docker, vagrant) use addrepo without the restrictive --repo flag. Closes #12808 Signed-off-by: Charlie Tonneslan --- docs/install_linux.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/install_linux.md b/docs/install_linux.md index 9b90a43393f..3d2f2980fec 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -100,7 +100,7 @@ To install: ```bash sudo dnf install dnf5-plugins sudo dnf config-manager addrepo --from-repofile=https://cli.github.com/packages/rpm/gh-cli.repo -sudo dnf install gh --repo gh-cli +sudo dnf install gh ``` To upgrade: @@ -119,7 +119,7 @@ To install: ```bash sudo dnf install 'dnf-command(config-manager)' sudo dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo -sudo dnf install gh --repo gh-cli +sudo dnf install gh ``` To upgrade: From 2a98757f70160596e410acd5e2aa1c74208840e0 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Mon, 18 May 2026 10:26:24 +0100 Subject: [PATCH 136/182] fix test --- pkg/cmd/skills/list/list_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go index 01503680fb5..021b29442fa 100644 --- a/pkg/cmd/skills/list/list_test.go +++ b/pkg/cmd/skills/list/list_test.go @@ -273,6 +273,7 @@ func TestListRun(t *testing.T) { repoDir := t.TempDir() homeDir := t.TempDir() t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) if tt.setup != nil { tt.setup(t, repoDir, homeDir) From 00fc8c923ab3f321a412e1c89e425c107122f0bb Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Tue, 19 May 2026 14:56:56 +0100 Subject: [PATCH 137/182] fix discovery support in nested dirs --- internal/skills/discovery/discovery.go | 39 +++++++++---------- internal/skills/discovery/discovery_test.go | 43 +++++++++++++++++++-- pkg/cmd/skills/install/install.go | 13 ++++++- pkg/cmd/skills/install/install_test.go | 31 +++++++++++++++ 4 files changed, 100 insertions(+), 26 deletions(-) diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index df17c5b9f21..b2d966c988c 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -451,18 +451,21 @@ func matchSkillConventions(entry treeEntry) *skillMatch { } // matchHiddenDirConventions checks if a blob path matches a skill convention -// under a hidden (dot-prefixed) root directory. These patterns mirror the -// standard skills/ conventions but rooted under .{host}/skills/: +// under a hidden (dot-prefixed) directory. These patterns mirror the standard +// skills/ conventions, but only when the path contains a hidden segment: // -// - .{host}/skills/*/SKILL.md -> "hidden-dir" -// - .{host}/skills/{scope}/*/SKILL.md -> "hidden-dir-namespaced" +// - {prefix}/.{host}/skills/*/SKILL.md -> "hidden-dir" +// - {prefix}/.{host}/skills/{scope}/*/SKILL.md -> "hidden-dir-namespaced" func matchHiddenDirConventions(entry treeEntry) *skillMatch { if path.Base(entry.Path) != "SKILL.md" { return nil } + if !hasHiddenSegment(entry.Path) { + return nil + } - // .{host}/skills/* - // .{host}/skills/{scope}/* + // {prefix}/.{host}/skills/* + // {prefix}/.{host}/skills/{scope}/* dir := path.Dir(entry.Path) skillName := path.Base(dir) @@ -470,29 +473,23 @@ func matchHiddenDirConventions(entry treeEntry) *skillMatch { return nil } - // .{host}/skills - // .{host}/skills/{scope} + // {prefix}/.{host}/skills + // {prefix}/.{host}/skills/{scope} parentDir := path.Dir(dir) - // .{host}/skills/*/SKILL.md + // {prefix}/.{host}/skills/*/SKILL.md if path.Base(parentDir) == "skills" { - hiddenRoot := path.Dir(parentDir) - if path.Dir(hiddenRoot) == "." && strings.HasPrefix(hiddenRoot, ".") { - return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "hidden-dir"} - } + return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "hidden-dir"} } - // .{host}/skills/{scope}/*/SKILL.md + // {prefix}/.{host}/skills/{scope}/*/SKILL.md grandparentDir := path.Dir(parentDir) if path.Base(grandparentDir) == "skills" { - hiddenRoot := path.Dir(grandparentDir) - if path.Dir(hiddenRoot) == "." && strings.HasPrefix(hiddenRoot, ".") { - namespace := path.Base(parentDir) - if !validateName(namespace) { - return nil - } - return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "hidden-dir-namespaced"} + namespace := path.Base(parentDir) + if !validateName(namespace) { + return nil } + return &skillMatch{entry: entry, name: skillName, namespace: namespace, skillDir: dir, convention: "hidden-dir-namespaced"} } return nil diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index bc3ce979b54..24ef0081ab7 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -202,6 +202,19 @@ func TestMatchHiddenDirConventions(t *testing.T) { wantNamespace: "monalisa", wantConvention: "hidden-dir-namespaced", }, + { + name: "nested hidden dir skills directory", + path: "foo/bar/.claude/skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "hidden-dir", + }, + { + name: "nested hidden dir namespaced skill", + path: "foo/bar/.claude/skills/monalisa/code-review/SKILL.md", + wantName: "code-review", + wantNamespace: "monalisa", + wantConvention: "hidden-dir-namespaced", + }, { name: "not a SKILL.md file", path: ".claude/skills/code-review/README.md", @@ -228,9 +241,10 @@ func TestMatchHiddenDirConventions(t *testing.T) { wantNil: true, }, { - name: "too deeply nested hidden dir", - path: ".claude/nested/skills/code-review/SKILL.md", - wantNil: true, + name: "hidden dir with nested skills directory", + path: ".claude/nested/skills/code-review/SKILL.md", + wantName: "code-review", + wantConvention: "hidden-dir", }, { name: "invalid skill name", @@ -992,6 +1006,14 @@ func TestDiscoverSkillsWithOptions(t *testing.T) { }, } + nestedHiddenTree := map[string]interface{}{ + "sha": "abc123", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "foo/bar/.claude/skills/hidden-skill", "type": "tree", "sha": "tree-sha-1"}, + {"path": "foo/bar/.claude/skills/hidden-skill/SKILL.md", "type": "blob", "sha": "blob-1"}, + }, + } + emptyTree := map[string]interface{}{ "sha": "abc123", "truncated": false, "tree": []map[string]interface{}{ @@ -1015,6 +1037,11 @@ func TestDiscoverSkillsWithOptions(t *testing.T) { tree: mixedTree, wantSkills: []string{"hidden-skill", "standard-skill"}, }, + { + name: "nested hidden-dir tree returns hidden skill", + tree: nestedHiddenTree, + wantSkills: []string{"hidden-skill"}, + }, { name: "no skills at all", tree: emptyTree, @@ -1415,6 +1442,16 @@ func TestDiscoverLocalSkillsWithOptions(t *testing.T) { }, wantSkills: []string{"standard", "hidden"}, }, + { + name: "nested hidden dir returns skill", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "foo", "bar", ".claude", "skills", "hidden") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# hidden"), 0o644)) + }, + wantSkills: []string{"hidden"}, + }, { name: "no skills at all", setup: func(t *testing.T, _ string) { t.Helper() }, diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index caaa7221490..61f9458a346 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -115,8 +115,10 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru see: https://agentskills.io/specification The skill argument can be a name, a namespaced name (%[1]sauthor/skill%[1]s), - or an exact path within the repository (%[1]sskills/author/skill%[1]s or - %[1]sskills/author/skill/SKILL.md%[1]s). + or an exact path within the repository (%[1]sskills/author/skill%[1]s, + %[1]spackages/agent-skills/code-review%[1]s, or any %[1]s.../SKILL.md%[1]s path). + Namespaced names with one slash are matched by name. Use a %[1]sSKILL.md%[1]s + suffix to force a one-directory path outside the standard conventions. Performance tip: when installing from a large repository with many skills, providing an exact path instead of a skill name avoids a @@ -155,6 +157,9 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru # Install from a large namespaced repo by path (efficient, skips full discovery) $ gh skill install github/awesome-copilot skills/monalisa/code-review + # Install from a non-standard nested path (efficient, skips full discovery) + $ gh skill install oracle/netsuite-suitecloud-sdk packages/agent-skills/netsuite-ai-connector-instructions + # Install from a local directory $ gh skill install ./my-skills-repo --from-local @@ -546,6 +551,7 @@ func runLocalInstall(opts *InstallOptions) error { // isSkillPath returns true if the argument looks like a repo-relative path // rather than a simple skill name. func isSkillPath(name string) bool { + name = strings.TrimSuffix(name, "/") if name == "" { return false } @@ -558,6 +564,9 @@ func isSkillPath(name string) bool { if strings.Contains(name, "/skills/") || strings.Contains(name, "/plugins/") { return true } + if strings.Count(name, "/") >= 2 { + return true + } return false } diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index b126f1ac788..c9c384675fb 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -798,6 +798,34 @@ func TestInstallRun(t *testing.T) { }, wantStdout: "Installed terraform-style-guide", }, + { + name: "remote install by arbitrary nested skill path skips full discovery", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubSkillByPath(reg, "monalisa", "skills-repo", "abc123", + "packages/agent-skills/netsuite-ai-connector-instructions", "netsuite-ai-connector-instructions", "treeSHA") + // DiscoverSkillByPath: tree + blob (for fetchDescription) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + // installer.Install: tree + blob (again, for writing files) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + SkillName: "packages/agent-skills/netsuite-ai-connector-instructions", + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: t.TempDir(), + } + }, + wantStdout: "Installed netsuite-ai-connector-instructions", + }, { name: "remote install with URL repo argument", isTTY: true, @@ -2118,7 +2146,10 @@ func Test_isSkillPath(t *testing.T) { {name: "nested skills/ path", path: "terraform/code-generation/skills/terraform-style-guide", want: true}, {name: "deeply nested skills/ path", path: "a/b/c/skills/my-skill", want: true}, {name: "nested plugins/ path", path: "vendor/plugins/hubot/skills/pr-summary", want: true}, + {name: "arbitrary nested skill path", path: "packages/agent-skills/netsuite-ai-connector-instructions", want: true}, + {name: "arbitrary nested skill path with trailing slash", path: "skills-catalog/matlab-core/matlab-debugging/", want: true}, {name: "name containing skills substring", path: "myskills", want: false}, + {name: "namespaced skill name", path: "monalisa/code-review", want: false}, {name: "namespaced path", path: "skills/monalisa/issue-triage", want: true}, } for _, tt := range tests { From fb748cb2bf3a434ff12f5268c9983a65310c6520 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Tue, 19 May 2026 15:31:43 +0100 Subject: [PATCH 138/182] add logic to preview too --- internal/skills/discovery/discovery.go | 22 +++++++++ internal/skills/discovery/discovery_test.go | 28 +++++++++++ pkg/cmd/skills/install/install.go | 18 +------ pkg/cmd/skills/preview/preview.go | 52 ++++++++++++++------- pkg/cmd/skills/preview/preview_test.go | 49 +++++++++++++++++++ 5 files changed, 136 insertions(+), 33 deletions(-) diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index b2d966c988c..f7b4ae8b2f7 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -390,6 +390,28 @@ func MatchSkillPath(filePath string) (name, namespace string) { return m.name, m.namespace } +// IsSkillPath reports whether a skill selector looks like a repo-relative path +// rather than a simple skill name. +func IsSkillPath(name string) bool { + name = strings.TrimSuffix(name, "/") + if name == "" { + return false + } + if name == "SKILL.md" || strings.HasSuffix(name, "/SKILL.md") { + return true + } + if strings.HasPrefix(name, "skills/") || strings.HasPrefix(name, "plugins/") { + return true + } + if strings.Contains(name, "/skills/") || strings.Contains(name, "/plugins/") { + return true + } + if strings.Count(name, "/") >= 2 { + return true + } + return false +} + // matchSkillConventions checks if a blob path matches any known skill convention. func matchSkillConventions(entry treeEntry) *skillMatch { if path.Base(entry.Path) != "SKILL.md" { diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 24ef0081ab7..297b65910cd 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -1526,6 +1526,34 @@ func TestMatchSkillPath(t *testing.T) { } } +func TestIsSkillPath(t *testing.T) { + tests := []struct { + name string + path string + want bool + }{ + {name: "empty string", path: "", want: false}, + {name: "plain skill name", path: "git-commit", want: false}, + {name: "SKILL.md at root", path: "SKILL.md", want: true}, + {name: "SKILL.md suffix", path: "skills/code-review/SKILL.md", want: true}, + {name: "starts with skills/", path: "skills/code-review", want: true}, + {name: "starts with plugins/", path: "plugins/hubot/skills/pr-summary", want: true}, + {name: "nested skills/ path", path: "terraform/code-generation/skills/terraform-style-guide", want: true}, + {name: "deeply nested skills/ path", path: "a/b/c/skills/my-skill", want: true}, + {name: "nested plugins/ path", path: "vendor/plugins/hubot/skills/pr-summary", want: true}, + {name: "arbitrary nested skill path", path: "packages/agent-skills/netsuite-ai-connector-instructions", want: true}, + {name: "arbitrary nested skill path with trailing slash", path: "skills-catalog/matlab-core/matlab-debugging/", want: true}, + {name: "name containing skills substring", path: "myskills", want: false}, + {name: "namespaced skill name", path: "monalisa/code-review", want: false}, + {name: "namespaced path", path: "skills/monalisa/issue-triage", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, IsSkillPath(tt.path)) + }) + } +} + func TestDiscoverSkillFiles(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 61f9458a346..b426257f1ba 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -551,23 +551,7 @@ func runLocalInstall(opts *InstallOptions) error { // isSkillPath returns true if the argument looks like a repo-relative path // rather than a simple skill name. func isSkillPath(name string) bool { - name = strings.TrimSuffix(name, "/") - if name == "" { - return false - } - if name == "SKILL.md" || strings.HasSuffix(name, "/SKILL.md") { - return true - } - if strings.HasPrefix(name, "skills/") || strings.HasPrefix(name, "plugins/") { - return true - } - if strings.Contains(name, "/skills/") || strings.Contains(name, "/plugins/") { - return true - } - if strings.Count(name, "/") >= 2 { - return true - } - return false + return discovery.IsSkillPath(name) } func resolveRepoArg(skillSource string, canPrompt bool, p prompter.Prompter) (ghrepo.Interface, string, error) { diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index e6c202b0992..6e8e398422d 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -69,6 +69,12 @@ func NewCmdPreview(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru When run with only a repository argument, lists available skills and prompts for selection. + The skill argument can be a name, a namespaced name (%[1]sauthor/skill%[1]s), + or an exact path within the repository (%[1]sskills/author/skill%[1]s, + %[1]spackages/agent-skills/code-review%[1]s, or any %[1]s.../SKILL.md%[1]s path). + Namespaced names with one slash are matched by name. Use a %[1]sSKILL.md%[1]s + suffix to force a one-directory path outside the standard conventions. + To preview a specific version of the skill, append %[1]s@VERSION%[1]s to the skill name. The version is resolved as a git tag, branch, or commit SHA. `, "`"), @@ -82,6 +88,9 @@ func NewCmdPreview(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru # Preview a skill at a specific commit SHA $ gh skill preview github/awesome-copilot documentation-writer@abc123def456 + # Preview from a non-standard nested path (efficient, skips full discovery) + $ gh skill preview oracle/netsuite-suitecloud-sdk packages/agent-skills/netsuite-ai-connector-instructions + # Browse and preview interactively $ gh skill preview github/awesome-copilot `), @@ -153,25 +162,36 @@ func previewRun(opts *PreviewOptions) error { return fmt.Errorf("could not resolve version: %w", err) } - opts.IO.StartProgressIndicatorWithLabel("Discovering skills") - allSkills, err := discovery.DiscoverSkillsWithOptions(apiClient, hostname, owner, repoName, resolved.SHA, discovery.DiscoverOptions{}) - opts.IO.StopProgressIndicator() - if err != nil { - return err - } + var skill discovery.Skill + if discovery.IsSkillPath(opts.SkillName) { + opts.IO.StartProgressIndicatorWithLabel("Looking up skill") + found, err := discovery.DiscoverSkillByPath(apiClient, hostname, owner, repoName, resolved.SHA, opts.SkillName) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + skill = *found + } else { + opts.IO.StartProgressIndicatorWithLabel("Discovering skills") + allSkills, err := discovery.DiscoverSkillsWithOptions(apiClient, hostname, owner, repoName, resolved.SHA, discovery.DiscoverOptions{}) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } - skills, err := filterHiddenDirSkills(opts, allSkills) - if err != nil { - return err - } + skills, err := filterHiddenDirSkills(opts, allSkills) + if err != nil { + return err + } - sort.Slice(skills, func(i, j int) bool { - return skills[i].DisplayName() < skills[j].DisplayName() - }) + sort.Slice(skills, func(i, j int) bool { + return skills[i].DisplayName() < skills[j].DisplayName() + }) - skill, err := selectSkill(opts, skills) - if err != nil { - return err + skill, err = selectSkill(opts, skills) + if err != nil { + return err + } } opts.IO.StartProgressIndicatorWithLabel("Fetching skill content") diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index 3bc98fece56..fca5dff5081 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -261,6 +261,55 @@ func TestPreviewRun(t *testing.T) { }, wantStdout: "My Skill", }, + { + name: "preview by arbitrary nested skill path skips full discovery", + tty: true, + opts: &PreviewOptions{ + repo: ghrepo.New("owner", "repo"), + SkillName: "packages/agent-skills/netsuite-ai-connector-instructions", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/owner/repo/releases/latest"), + httpmock.StringResponse(`{"tag_name": "v1.0.0"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/ref/tags/v1.0.0"), + httpmock.StringResponse(`{"object": {"sha": "abc123", "type": "commit"}}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/contents/packages%2Fagent-skills"), + httpmock.StringResponse(`[ + {"name": "netsuite-ai-connector-instructions", "path": "packages/agent-skills/netsuite-ai-connector-instructions", "sha": "treeSHA4", "type": "dir"} + ]`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA4"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob999", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob999"), + httpmock.StringResponse(`{"sha": "blob999", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA4"), + httpmock.StringResponse(`{ + "tree": [ + {"path": "SKILL.md", "type": "blob", "sha": "blob999", "size": 50} + ] + }`), + ) + reg.Register( + httpmock.REST("GET", "repos/owner/repo/git/blobs/blob999"), + httpmock.StringResponse(`{"sha": "blob999", "content": "`+encodedContent+`", "encoding": "base64"}`), + ) + }, + wantStdout: "My Skill", + }, { name: "skill not found", tty: true, From 55808753070c023c45b40592fbfc624d8f03a759 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Tue, 19 May 2026 15:58:07 +0100 Subject: [PATCH 139/182] skip prompting for skills without metadata when running gh skill update --all --- pkg/cmd/skills/update/update.go | 3 ++- pkg/cmd/skills/update/update_test.go | 36 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 8b0d831e0ed..8d6d1e42f89 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -91,6 +91,7 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Co Skills without GitHub metadata (e.g. installed manually or by another tool) are prompted for their source repository in interactive mode. + With %[1]s--all%[1]s or in non-interactive mode, they are skipped with a notice. The update re-downloads the skill with metadata injected, so future updates work automatically. @@ -221,7 +222,7 @@ func updateRun(opts *UpdateOptions) error { if s.owner != "" && s.repo != "" { continue } - if !canPrompt { + if !canPrompt || opts.All { noMeta = append(noMeta, s.name) continue } diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index c30eff0a7a1..b7c40ff4bbb 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -474,6 +474,42 @@ func TestUpdateRun(t *testing.T) { }, wantStderr: "no GitHub metadata", }, + { + name: "all skips no-metadata skill without prompting", + setup: func(t *testing.T, dir string) { + t.Helper() + skillDir := filepath.Join(dir, "manual-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(heredoc.Doc(` + --- + name: manual-skill + --- + No metadata + `)), 0o644)) + }, + stubs: func(reg *httpmock.Registry) {}, + opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + return &UpdateOptions{ + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Prompter: &prompter.PrompterMock{ + InputFunc: func(prompt string, defaultValue string) (string, error) { + return "", fmt.Errorf("unexpected prompt") + }, + }, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + } + }, + wantStderr: "no GitHub metadata", + }, { name: "all up to date", setup: func(t *testing.T, dir string) { From abac9a1d1d5ce63e7fa1cf4fc8425ea35b46a5c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 00:14:28 +0000 Subject: [PATCH 140/182] chore(deps): bump github.com/theupdateframework/go-tuf/v2 Bumps [github.com/theupdateframework/go-tuf/v2](https://github.com/theupdateframework/go-tuf) from 2.4.1 to 2.4.2. - [Release notes](https://github.com/theupdateframework/go-tuf/releases) - [Commits](https://github.com/theupdateframework/go-tuf/compare/v2.4.1...v2.4.2) --- updated-dependencies: - dependency-name: github.com/theupdateframework/go-tuf/v2 dependency-version: 2.4.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 395aba966c3..31ea18ee4e3 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - github.com/theupdateframework/go-tuf/v2 v2.4.1 + github.com/theupdateframework/go-tuf/v2 v2.4.2 github.com/twitchtv/twirp v8.1.3+incompatible github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/yuin/goldmark v1.8.2 @@ -159,13 +159,13 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/rodaine/table v1.3.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect github.com/sigstore/rekor v1.5.0 // indirect github.com/sigstore/rekor-tiles/v2 v2.0.1 // indirect - github.com/sigstore/sigstore v1.10.5 // indirect + github.com/sigstore/sigstore v1.10.6 // indirect github.com/sigstore/timestamp-authority/v2 v2.0.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/spf13/cast v1.10.0 // indirect diff --git a/go.sum b/go.sum index 90f6a825319..36182a4447f 100644 --- a/go.sum +++ b/go.sum @@ -452,8 +452,8 @@ github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGq github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= -github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14= -github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk= +github.com/secure-systems-lab/go-securesystemslib v0.11.0 h1:iuCR9kcMFD4QurdKrGvPLoKZLv9YvwPYVr0473BdtFs= +github.com/secure-systems-lab/go-securesystemslib v0.11.0/go.mod h1:+PMOTjUGwHj2vcZ+TFKlb1tXRbrdWE1LYDT5i9JC80Q= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= @@ -470,8 +470,8 @@ github.com/sigstore/rekor v1.5.0 h1:rL7SghHd5HLCtsCrxw0yQg+NczGvM75EjSPPWuGjaiQ= github.com/sigstore/rekor v1.5.0/go.mod h1:D7JoVCUkxwQOpPDNYeu+CE8zeBC18Y5uDo6tF8s2rcQ= github.com/sigstore/rekor-tiles/v2 v2.0.1 h1:1Wfz15oSRNGF5Dzb0lWn5W8+lfO50ork4PGIfEKjZeo= github.com/sigstore/rekor-tiles/v2 v2.0.1/go.mod h1:Pjsbhzj5hc3MKY8FfVTYHBUHQEnP0ozC4huatu4x7OU= -github.com/sigstore/sigstore v1.10.5 h1:KqrOjDhNOVY+uOzQFat2FrGLClPPCb3uz8pK3wuI+ow= -github.com/sigstore/sigstore v1.10.5/go.mod h1:k/mcVVXw3I87dYG/iCVTSW2xTrW7vPzxxGic4KqsqXs= +github.com/sigstore/sigstore v1.10.6 h1:YWhMQfTrJSK80QB1pbxjYeAwGKx+5UwWPPAY9hrPPZg= +github.com/sigstore/sigstore v1.10.6/go.mod h1:k/mcVVXw3I87dYG/iCVTSW2xTrW7vPzxxGic4KqsqXs= github.com/sigstore/sigstore-go v1.1.4 h1:wTTsgCHOfqiEzVyBYA6mDczGtBkN7cM8mPpjJj5QvMg= github.com/sigstore/sigstore-go v1.1.4/go.mod h1:2U/mQOT9cjjxrtIUeKDVhL+sHBKsnWddn8URlswdBsg= github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.5 h1:aqHRubTITULckG9JAcq2FEhtKkT/RRE8oErfuV3smSI= @@ -508,8 +508,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= -github.com/theupdateframework/go-tuf/v2 v2.4.1 h1:K6ewW064rKZCPkRo1W/CTbTtm/+IB4+coG1iNURAGCw= -github.com/theupdateframework/go-tuf/v2 v2.4.1/go.mod h1:Nex2enPVYDFCklrnbTzl3OVwD7fgIAj0J5++z/rvCj8= +github.com/theupdateframework/go-tuf/v2 v2.4.2 h1:w7976/W8uTwlsegP5nRymlpjPgrwSh+AXUf85is6nJk= +github.com/theupdateframework/go-tuf/v2 v2.4.2/go.mod h1:JqBrIUnNLAaNq/8GmBcEMFWfAFBbqp/MkJEJseXKbks= github.com/thlib/go-timezone-local v0.0.6 h1:Ii3QJ4FhosL/+eCZl6Hsdr4DDU4tfevNoV83yAEo2tU= github.com/thlib/go-timezone-local v0.0.6/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI= From 16a20347dd33b8f67abd8990ab0940d35f522233 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 20 May 2026 10:24:44 +0100 Subject: [PATCH 141/182] fix warning message to make it clear --- pkg/cmd/skills/update/update.go | 2 +- pkg/cmd/skills/update/update_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index 8d6d1e42f89..88e3518554f 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -338,7 +338,7 @@ func updateRun(opts *UpdateOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s %s is pinned to %s (skipped)\n", cs.Muted("⊘"), s.name, s.pinned) } for _, name := range noMeta { - fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata. Reinstall to enable updates\n", cs.WarningIcon(), name) + fmt.Fprintf(opts.IO.ErrOut, "%s %s has no GitHub metadata. Run `gh skill update %s` interactively to add metadata, or reinstall to enable updates\n", cs.WarningIcon(), name, name) } if len(updates) == 0 { diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index b7c40ff4bbb..9e9ef44973c 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -508,7 +508,7 @@ func TestUpdateRun(t *testing.T) { All: true, } }, - wantStderr: "no GitHub metadata", + wantStderr: "Run `gh skill update manual-skill` interactively", }, { name: "all up to date", From 3faf4e9d52356296d0fc8f526480fb52df546682 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 20 May 2026 16:36:47 +0200 Subject: [PATCH 142/182] Remove third-party license debris --- .github/workflows/codeql.yml | 9 --------- .golangci.yml | 5 ----- 2 files changed, 14 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 457e929c356..d82eb171f89 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -46,15 +46,6 @@ jobs: upload: false output: sarif-results - - name: Filter SARIF for third-party code - if: matrix.language == 'go' - uses: advanced-security/filter-sarif@2da736ff05ef065cb2894ac6892e47b5eac2c3c0 # v1.1.0.1.1 - with: - patterns: | - -third-party/** - input: sarif-results/${{ matrix.language }}.sarif - output: sarif-results/${{ matrix.language }}.sarif - - name: Upload filtered SARIF uses: github/codeql-action/upload-sarif@v4 with: diff --git a/.golangci.yml b/.golangci.yml index 932a4b4384b..f50707936b2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -29,8 +29,6 @@ linters: # - staticcheck # - errcheck exclusions: - paths: - - third-party rules: - path: _test\.go$ linters: @@ -62,9 +60,6 @@ linters: formatters: enable: - gofmt - exclusions: - paths: - - third-party issues: max-issues-per-linter: 0 From 97d1cbd9fc5499a2f804d991b353821e17b19eb7 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 20 May 2026 15:37:31 +0100 Subject: [PATCH 143/182] add --all flag to install all skills in a repo --- pkg/cmd/skills/install/install.go | 22 ++++++++-- pkg/cmd/skills/install/install_test.go | 60 +++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index caaa7221490..0dc62911d6f 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -55,6 +55,7 @@ type InstallOptions struct { ScopeChanged bool // true when --scope was explicitly set Pin string Dir string // overrides --agent and --scope + All bool Force bool FromLocal bool // treat SkillSource as a local directory path AllowHiddenDirs bool // include skills in dot-prefixed directories @@ -135,9 +136,9 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru frontmatter. This metadata identifies the source repository and enables %[1]sgh skill update%[1]s to detect changes. - When run interactively, the command prompts for any missing arguments. - When run non-interactively, %[1]srepository%[1]s and a skill name are - required. + Use %[1]s--all%[1]s to install every discovered skill from the repository + without prompting. When run non-interactively, %[1]srepository%[1]s and either + a skill name or %[1]s--all%[1]s are required. `, "`", registry.AgentHelpList()), Example: heredoc.Doc(` # Interactive: choose repo, skill, and agent @@ -149,6 +150,9 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru # Install a specific skill $ gh skill install github/awesome-copilot git-commit + # Install all skills from a repository + $ gh skill install github/awesome-copilot --all + # Install a specific version $ gh skill install github/awesome-copilot git-commit@v1.2.0 @@ -181,6 +185,10 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru } opts.ScopeChanged = cmd.Flags().Changed("scope") + if opts.All && opts.SkillName != "" { + return cmdutil.FlagErrorf("cannot use `--all` with skill name") + } + // Resolve the source type early so installRun can branch directly. if opts.FromLocal { if opts.SkillSource == "" { @@ -215,6 +223,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope") cmd.Flags().StringVar(&opts.Pin, "pin", "", "Pin to a specific git tag or commit SHA") cmd.Flags().StringVar(&opts.Dir, "dir", "", "Install to a custom directory (overrides --agent and --scope)") + cmd.Flags().BoolVar(&opts.All, "all", false, "Install all skills without prompting") cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting") cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository") cmd.Flags().BoolVar(&opts.AllowHiddenDirs, "allow-hidden-dirs", false, "Include skills in hidden directories (e.g. .claude/skills/, .agents/skills/)") @@ -682,6 +691,13 @@ func selectSkillsWithSelector(opts *InstallOptions, skills []discovery.Skill, ca return nil } + if opts.All { + if err := checkCollisions(skills); err != nil { + return nil, err + } + return skills, nil + } + if opts.SkillName != "" { return sel.matchByName(opts, skills) } diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index b126f1ac788..e9cb2d7dc58 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -45,6 +45,11 @@ func TestNewCmdInstall(t *testing.T) { cli: "monalisa/skills-repo git-commit", wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", SkillName: "git-commit", Scope: "project"}, }, + { + name: "repo and all flag", + cli: "monalisa/skills-repo --all", + wantOpts: InstallOptions{SkillSource: "monalisa/skills-repo", All: true, Scope: "project"}, + }, { name: "all flags", cli: "monalisa/skills-repo git-commit --agent github-copilot --scope user --pin v1.0.0 --force", @@ -77,6 +82,11 @@ func TestNewCmdInstall(t *testing.T) { cli: "monalisa/skills-repo git-commit@v1.0.0 --pin v2.0.0", wantErr: true, }, + { + name: "all conflicts with skill name", + cli: "monalisa/skills-repo git-commit --all", + wantErr: true, + }, { name: "alias add works", cli: "monalisa/skills-repo git-commit", @@ -171,6 +181,7 @@ func TestNewCmdInstall(t *testing.T) { assert.Equal(t, tt.wantOpts.Scope, gotOpts.Scope) assert.Equal(t, tt.wantOpts.Pin, gotOpts.Pin) assert.Equal(t, tt.wantOpts.Dir, gotOpts.Dir) + assert.Equal(t, tt.wantOpts.All, gotOpts.All) assert.Equal(t, tt.wantOpts.Force, gotOpts.Force) assert.Equal(t, tt.wantOpts.FromLocal, gotOpts.FromLocal) assert.Equal(t, tt.wantOpts.AllowHiddenDirs, gotOpts.AllowHiddenDirs) @@ -194,7 +205,7 @@ func TestNewCmdInstall(t *testing.T) { assert.NotEmpty(t, cmd.Example) assert.Contains(t, cmd.Aliases, "add") - for _, flag := range []string{"agent", "scope", "pin", "dir", "force"} { + for _, flag := range []string{"agent", "scope", "pin", "dir", "all", "force"} { assert.NotNil(t, cmd.Flags().Lookup(flag), "missing flag: --%s", flag) } }) @@ -265,6 +276,14 @@ var gitCommitContent = heredoc.Doc(` # Git Commit `) +var codeReviewContent = heredoc.Doc(` + --- + name: code-review + description: Reviews code + --- + # Code Review +`) + // singleSkillTreeJSON returns tree entries for a single skill with the given name. func singleSkillTreeJSON(name, treeSHA, blobSHA string) string { return fmt.Sprintf( @@ -1529,6 +1548,45 @@ func TestInstallRun(t *testing.T) { } } +func TestInstallRun_AllInstallsRemoteSkills(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("code-review", "tree-cr", "blob-cr")+", "+ + singleSkillTreeJSON("git-commit", "tree-gc", "blob-gc")) + stubInstallFiles(reg, "monalisa", "skills-repo", "tree-cr", "blob-cr", codeReviewContent) + stubInstallFiles(reg, "monalisa", "skills-repo", "tree-gc", "blob-gc", gitCommitContent) + + ios, _, stdout, stderr := iostreams.Test() + targetDir := t.TempDir() + + err := installRun(&InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + SkillSource: "monalisa/skills-repo", + All: true, + Force: true, + Agent: "github-copilot", + Scope: "project", + ScopeChanged: true, + Dir: targetDir, + Telemetry: &telemetry.NoOpService{}, + }) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Installed code-review") + assert.Contains(t, stdout.String(), "Installed git-commit") + assert.NotContains(t, stderr.String(), "must specify a skill name") + require.FileExists(t, filepath.Join(targetDir, "code-review", "SKILL.md")) + require.FileExists(t, filepath.Join(targetDir, "git-commit", "SKILL.md")) +} + func TestInstallProgress(t *testing.T) { ios, _, _, _ := iostreams.Test() From 57480dd7503df4add682dbedccc08ff20498a823 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 20 May 2026 20:39:07 +0200 Subject: [PATCH 144/182] Remove dependency on persistent token --- .github/workflows/detect-spam.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/detect-spam.yml b/.github/workflows/detect-spam.yml index fd259bd640c..d856d75a456 100644 --- a/.github/workflows/detect-spam.yml +++ b/.github/workflows/detect-spam.yml @@ -4,20 +4,19 @@ on: types: [opened] permissions: - contents: none - issues: write - models: read + contents: read # check out the repo to run the spam-detection scripts. + issues: write # read issue contents (gh issue view), comment, label, and close issues detected as spam. + models: read # run inference via `gh models run` for spam classification. jobs: issue-spam: runs-on: ubuntu-latest - environment: cli-automation steps: - name: Checkout repository uses: actions/checkout@v6 - name: Run spam detection env: - GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN }} + GH_TOKEN: ${{ github.token }} ISSUE_URL: ${{ github.event.issue.html_url }} run: | ./.github/workflows/scripts/spam-detection/process-issue.sh "$ISSUE_URL" From 6b5e3bc22cca246812e3f33b558b63c2e0be01f2 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 20 May 2026 20:57:58 +0200 Subject: [PATCH 145/182] Remove discussion workflow --- .github/workflows/triage-discussion-label.yml | 23 ------------------- 1 file changed, 23 deletions(-) delete mode 100644 .github/workflows/triage-discussion-label.yml diff --git a/.github/workflows/triage-discussion-label.yml b/.github/workflows/triage-discussion-label.yml deleted file mode 100644 index e2e4ea5e58a..00000000000 --- a/.github/workflows/triage-discussion-label.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: Process Discuss Label -run-name: ${{ github.event_name == 'issues' && github.event.issue.title || github.event.pull_request.title }} -permissions: {} -on: - issues: - types: - - labeled - # pull_request_target (not pull_request) to access secrets for fork PRs. - # Safe: no PR code is checked out or executed. - pull_request_target: - types: - - labeled - -jobs: - discuss: - if: github.event.action == 'labeled' && github.event.label.name == 'discuss' - uses: desktop/gh-cli-and-desktop-shared-workflows/.github/workflows/triage-discuss.yml@main - with: - target_repo: 'github/cli' - cc_team: '@github/cli' - environment: cli-discuss-automation - secrets: - discussion_token: ${{ secrets.CLI_DISCUSSION_TRIAGE_TOKEN }} From 6bbaae0f9a554bfdd643b6ba301617d13205cd65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 16:07:58 -0600 Subject: [PATCH 146/182] chore(deps): bump goreleaser/goreleaser-action from 7.2.1 to 7.2.2 (#13461) Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 7.2.1 to 7.2.2. - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8...5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89) --- updated-dependencies: - dependency-name: goreleaser/goreleaser-action dependency-version: 7.2.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deployment.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 4e56fb0bf24..f9e3d393e00 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -52,7 +52,7 @@ jobs: with: go-version-file: 'go.mod' - name: Install GoReleaser - uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. @@ -113,7 +113,7 @@ jobs: security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$keychain_password" "$keychain" rm "$RUNNER_TEMP/cert.p12" - name: Install GoReleaser - uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. @@ -175,7 +175,7 @@ jobs: with: go-version-file: 'go.mod' - name: Install GoReleaser - uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 with: # The version is pinned not only for security purposes, but also to avoid breaking # our scripts, which rely on the specific file names generated by GoReleaser. From b49fc3124a7c385dcb5c556937a0a706ec0af48e Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 21 May 2026 14:52:59 +0200 Subject: [PATCH 147/182] Stop bumping homebrew on release --- .github/workflows/deployment.yml | 12 +----------- .github/workflows/homebrew-bump.yml | 26 -------------------------- docs/release-process-deep-dive.md | 17 +++-------------- docs/releasing.md | 16 ++++++++-------- 4 files changed, 12 insertions(+), 59 deletions(-) delete mode 100644 .github/workflows/homebrew-bump.yml diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 4e56fb0bf24..c6295df841c 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -414,14 +414,4 @@ jobs: else git log --oneline @{upstream}.. git diff --name-status @{upstream}.. - fi - - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@ccf2332299a883f6af50a1d2d41e5df7904dd769 - if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') - with: - formula-name: gh - formula-path: Formula/g/gh.rb - tag-name: ${{ inputs.tag_name }} - push-to: williammartin/homebrew-core - env: - COMMITTER_TOKEN: ${{ secrets.HOMEBREW_PR_PAT }} + fi \ No newline at end of file diff --git a/.github/workflows/homebrew-bump.yml b/.github/workflows/homebrew-bump.yml deleted file mode 100644 index eccf933dd77..00000000000 --- a/.github/workflows/homebrew-bump.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: homebrew-bump-debug - -permissions: - contents: write - -on: - workflow_dispatch: - inputs: - tag_name: - required: true - type: string - environment: - default: production - type: environment -jobs: - bump: - runs-on: ubuntu-latest - steps: - - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@ccf2332299a883f6af50a1d2d41e5df7904dd769 - if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') - with: - formula-name: gh - tag-name: ${{ inputs.tag_name }} - env: - COMMITTER_TOKEN: ${{ secrets.HOMEBREW_PR_PAT }} diff --git a/docs/release-process-deep-dive.md b/docs/release-process-deep-dive.md index 4d060841a5a..f3da2775b18 100644 --- a/docs/release-process-deep-dive.md +++ b/docs/release-process-deep-dive.md @@ -11,7 +11,6 @@ From a high level, the [release workflow](https://github.com/cli/cli/blob/537a22 * Builds and updates the [manual](https://cli.github.com/manual) and repository packages * Creates GitHub Attestations for the artifacts * Creates a GitHub Release and attaches the artifacts - * Bumps the `gh` [homebrew-core formula](https://github.com/Homebrew/homebrew-core/blob/2df031cbd8f7bc9b9a380e941ccefcf3c8f3d02b/Formula/g/gh.rb) # Jobs Deep Dive @@ -569,16 +568,6 @@ release: git log --oneline @{upstream}.. git diff --name-status @{upstream}.. fi - - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@v3 - if: inputs.environment == 'production' && !contains(inputs.tag_name, '-') - with: - formula-name: gh - formula-path: Formula/g/gh.rb - tag-name: ${{ inputs.tag_name }} - push-to: williammartin/homebrew-core - env: - COMMITTER_TOKEN: ${{ secrets.HOMEBREW_PR_PAT }} ``` @@ -647,11 +636,11 @@ In previous steps, a git commit was made for the manual, and files had moved int Occasionally, the repository can become unwieldy due to hosting so many large binary artifacts. Instructions can be found in the README for that repository. -#### Homebrew Formula +#### Homebrew -Using [`mislav/bump-homebrew-formula-action`](https://github.com/mislav/bump-homebrew-formula-action), a PR for the `gh` [`homebrew-core` formula](https://github.com/Homebrew/homebrew-core/blob/master/Formula/g/gh.rb) is created. The fork repository is currently owned by `williammartin` as PRs are [not accepted from organizations.](https://github.com/cli/cli/pull/7953) +Historically, we used [`mislav/bump-homebrew-formula-action`](https://github.com/mislav/bump-homebrew-formula-action), a PR for the `gh` [`homebrew-core` formula](https://github.com/Homebrew/homebrew-core/blob/master/Formula/g/gh.rb) is created. The fork repository was owned by `williammartin` as PRs are [not accepted from organizations.](https://github.com/cli/cli/pull/7953) -`Homebrew/formulae.brew.sh` makes new formula versions available every 15 minutes through scheduled CI workflow. For more information, see https://docs.brew.sh/Formula-Cookbook#an-introduction +However, since this required a legacy PAT token to open a PR between this repos, it was deemed too much risk for our security. As such, we now rely on [homebrew's autobump](https://docs.brew.sh/Autobump). ## Deepest Dive diff --git a/docs/releasing.md b/docs/releasing.md index b424266d4ff..403f88ec1ed 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -21,13 +21,14 @@ What this does is: - Uploads all release artifacts to a new GitHub Release; - A new git tag `vX.Y.Z` is created in the remote repository; - The changelog is [generated from the list of merged pull requests](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes); -- Updates [GitHub CLI marketing site](https://cli.github.com) with the contents of the new release; -- Updates the [`gh` Homebrew formula](https://github.com/williammartin/homebrew-core/blob/master/Formula/g/gh.rb) in the [`homebrew/homebrew-core` repo](https://github.com/search?q=repo%3AHomebrew%2Fhomebrew-core+%22gh%22+in%3Atitle&type=pullrequests). +- Updates [GitHub CLI marketing site](https://cli.github.com) with the contents of the new release. -> [!NOTE] -> `Homebrew/formulae.brew.sh` makes new formula versions available every 15 minutes through scheduled [CI workflow](https://github.com/Homebrew/formulae.brew.sh/actions/workflows/tests.yml). -> -> For more information, see https://docs.brew.sh/Formula-Cookbook#an-introduction +## Bumping Homebrew + +Homebrew bumps are handled by [autobump](https://docs.brew.sh/Autobump), which runs periodically every 3 hours. In cases where a quicker rollout is required, a pull request can be opened manually with the following steps: + 1. Replace the version number in the url to point ot the updated version. + 2. Calculate and replace the sha256 value. + 3. Open the PR. To test out the build system while avoiding creating an actual release: @@ -60,6 +61,5 @@ Occasionally, it might be necessary to clean up a bad release and re-release. 1. Delete the release and associated tag 2. Re-release and monitor the workflow run logs -3. Open pull request updating [`gh` Homebrew formula](https://github.com/williammartin/homebrew-core/blob/master/Formula/g/gh.rb) - with new SHA versions, linking the previous PR +3. Open pull request updating [`gh` Homebrew formula](https://github.com/williammartin/homebrew-core/blob/master/Formula/g/gh.rb) with new SHA versions, linking the previous PR 4. Verify resulting Debian and RPM packages, Homebrew formula From 69148fb32921d3332b07cc6fafbf190d0d0a3862 Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 21 May 2026 17:15:32 +0200 Subject: [PATCH 148/182] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/release-process-deep-dive.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/release-process-deep-dive.md b/docs/release-process-deep-dive.md index f3da2775b18..4e4928be776 100644 --- a/docs/release-process-deep-dive.md +++ b/docs/release-process-deep-dive.md @@ -638,9 +638,9 @@ Occasionally, the repository can become unwieldy due to hosting so many large bi #### Homebrew -Historically, we used [`mislav/bump-homebrew-formula-action`](https://github.com/mislav/bump-homebrew-formula-action), a PR for the `gh` [`homebrew-core` formula](https://github.com/Homebrew/homebrew-core/blob/master/Formula/g/gh.rb) is created. The fork repository was owned by `williammartin` as PRs are [not accepted from organizations.](https://github.com/cli/cli/pull/7953) +Historically, we used [`mislav/bump-homebrew-formula-action`](https://github.com/mislav/bump-homebrew-formula-action). It created a PR for the `gh` [`homebrew-core` formula](https://github.com/Homebrew/homebrew-core/blob/master/Formula/g/gh.rb). The fork repository was owned by `williammartin` because PRs are [not accepted from organizations.](https://github.com/cli/cli/pull/7953) -However, since this required a legacy PAT token to open a PR between this repos, it was deemed too much risk for our security. As such, we now rely on [homebrew's autobump](https://docs.brew.sh/Autobump). +However, since this required a legacy PAT token to open a PR between these repositories, it was deemed too much risk for our security. As such, we now rely on [Homebrew's autobump](https://docs.brew.sh/Autobump). ## Deepest Dive From 230498e917293e3b6d4e123f4608904cecb9eb8e Mon Sep 17 00:00:00 2001 From: William Martin Date: Thu, 21 May 2026 17:15:55 +0200 Subject: [PATCH 149/182] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/releasing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releasing.md b/docs/releasing.md index 403f88ec1ed..9f304699127 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -61,5 +61,5 @@ Occasionally, it might be necessary to clean up a bad release and re-release. 1. Delete the release and associated tag 2. Re-release and monitor the workflow run logs -3. Open pull request updating [`gh` Homebrew formula](https://github.com/williammartin/homebrew-core/blob/master/Formula/g/gh.rb) with new SHA versions, linking the previous PR +3. Open pull request updating [`gh` Homebrew formula](https://github.com/Homebrew/homebrew-core/blob/master/Formula/g/gh.rb) with new SHA versions, linking the previous PR 4. Verify resulting Debian and RPM packages, Homebrew formula From 88b0c5118386513aec81998873125e1c6331fdcf Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Thu, 21 May 2026 17:58:49 +0100 Subject: [PATCH 150/182] address bagtoad's feedback --- pkg/cmd/skills/list/list.go | 104 ++++++++++++------------- pkg/cmd/skills/list/list_test.go | 126 +++++++++++++++++++++++++------ 2 files changed, 150 insertions(+), 80 deletions(-) diff --git a/pkg/cmd/skills/list/list.go b/pkg/cmd/skills/list/list.go index 8e687b2e89a..05d71285025 100644 --- a/pkg/cmd/skills/list/list.go +++ b/pkg/cmd/skills/list/list.go @@ -32,7 +32,8 @@ var skillListFields = []string{ "path", } -// ListOptions holds dependencies and user-provided flags for the list command. +const scopeCustom = "custom" + type ListOptions struct { IO *iostreams.IOStreams Telemetry ghtelemetry.EventRecorder @@ -45,25 +46,21 @@ type ListOptions struct { Dir string } -type agentInfo struct { - id string -} - type scanTarget struct { - dir string - hosts []agentInfo - scope string + dir string + agentHostIDs []string + scope string } type listedSkill struct { - skillName string - hostIDs []string - scope string - source string - sourceURL string - version string - pinned bool - path string + skillName string + agentHostIDs []string + scope string + source string + sourceURL string + version string + pinned bool + path string } // ExportData implements cmdutil.exportable for --json output. @@ -74,7 +71,7 @@ func (s listedSkill) ExportData(fields []string) map[string]interface{} { case "skillName": data[f] = s.skillName case "hosts": - data[f] = s.hostIDs + data[f] = s.agentHostIDs case "scope": data[f] = s.scope case "sourceURL": @@ -129,11 +126,11 @@ func NewCmdList(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF RunE: func(cmd *cobra.Command, args []string) error { opts.ScopeChanged = cmd.Flags().Changed("scope") - if opts.Dir != "" && opts.Agent != "" { - return cmdutil.FlagErrorf("--dir and --agent cannot be used together") + if err := cmdutil.MutuallyExclusive("--dir and --agent cannot be used together", opts.Dir != "", opts.Agent != ""); err != nil { + return err } - if opts.Dir != "" && opts.ScopeChanged { - return cmdutil.FlagErrorf("--dir and --scope cannot be used together") + if err := cmdutil.MutuallyExclusive("--dir and --scope cannot be used together", opts.Dir != "", opts.ScopeChanged); err != nil { + return err } if runF != nil { @@ -178,7 +175,7 @@ func listInstalledSkills(opts *ListOptions) ([]listedSkill, error) { var all []listedSkill for _, target := range targets { - skills, scanErr := scanInstalledSkills(target.dir, target.hosts, target.scope) + skills, scanErr := scanInstalledSkills(target.dir, target.agentHostIDs, target.scope) if scanErr != nil { if opts.Dir != "" { return nil, fmt.Errorf("could not scan directory: %w", scanErr) @@ -197,13 +194,16 @@ func buildScanTargets(opts *ListOptions) ([]scanTarget, error) { if err != nil { return nil, fmt.Errorf("could not resolve path: %w", err) } - return []scanTarget{{dir: dir, scope: "custom"}}, nil + if _, err := os.Stat(dir); err != nil { + return nil, fmt.Errorf("could not access directory: %w", err) + } + return []scanTarget{{dir: dir, scope: scopeCustom}}, nil } gitRoot := installer.ResolveGitRoot(opts.GitClient) homeDir := installer.ResolveHomeDir() - hosts, err := selectedHosts(opts.Agent) + agentHosts, err := selectedHosts(opts.Agent) if err != nil { return nil, err } @@ -211,23 +211,23 @@ func buildScanTargets(opts *ListOptions) ([]scanTarget, error) { byDir := map[string]int{} var targets []scanTarget - for _, host := range hosts { + for _, agentHost := range agentHosts { for _, scope := range scopes { - dir, installErr := host.InstallDir(scope, gitRoot, homeDir) + dir, installErr := agentHost.InstallDir(scope, gitRoot, homeDir) if installErr != nil { continue } if idx, ok := byDir[dir]; ok { - targets[idx].hosts = appendHost(targets[idx].hosts, host) + targets[idx].agentHostIDs = appendAgentHostID(targets[idx].agentHostIDs, agentHost.ID) continue } byDir[dir] = len(targets) targets = append(targets, scanTarget{ - dir: dir, - hosts: []agentInfo{{id: host.ID}}, - scope: string(scope), + dir: dir, + agentHostIDs: []string{agentHost.ID}, + scope: string(scope), }) } } @@ -258,16 +258,16 @@ func selectedScopes(scope string) []registry.Scope { return []registry.Scope{registry.ScopeProject, registry.ScopeUser} } -func appendHost(hosts []agentInfo, host *registry.AgentHost) []agentInfo { - for _, existing := range hosts { - if existing.id == host.ID { - return hosts +func appendAgentHostID(agentHostIDs []string, agentHostID string) []string { + for _, existing := range agentHostIDs { + if existing == agentHostID { + return agentHostIDs } } - return append(hosts, agentInfo{id: host.ID}) + return append(agentHostIDs, agentHostID) } -func scanInstalledSkills(skillsDir string, hosts []agentInfo, scope string) ([]listedSkill, error) { +func scanInstalledSkills(skillsDir string, agentHostIDs []string, scope string) ([]listedSkill, error) { entries, err := os.ReadDir(skillsDir) if os.IsNotExist(err) { return nil, nil @@ -286,7 +286,7 @@ func scanInstalledSkills(skillsDir string, hosts []agentInfo, scope string) ([]l skillDir := filepath.Join(skillsDir, e.Name()) skillFile := filepath.Join(skillDir, "SKILL.md") if data, readErr := os.ReadFile(skillFile); readErr == nil { - skills = append(skills, parseInstalledSkill(data, e.Name(), skillDir, hosts, scope)) + skills = append(skills, parseInstalledSkill(data, e.Name(), skillDir, agentHostIDs, scope)) continue } @@ -303,7 +303,7 @@ func scanInstalledSkills(skillsDir string, hosts []agentInfo, scope string) ([]l subSkillFile := filepath.Join(subSkillDir, "SKILL.md") if data, readErr := os.ReadFile(subSkillFile); readErr == nil { installName := e.Name() + "/" + sub.Name() - skills = append(skills, parseInstalledSkill(data, installName, subSkillDir, hosts, scope)) + skills = append(skills, parseInstalledSkill(data, installName, subSkillDir, agentHostIDs, scope)) } } } @@ -311,12 +311,12 @@ func scanInstalledSkills(skillsDir string, hosts []agentInfo, scope string) ([]l return skills, nil } -func parseInstalledSkill(data []byte, name, dir string, hosts []agentInfo, scope string) listedSkill { +func parseInstalledSkill(data []byte, name, dir string, agentHostIDs []string, scope string) listedSkill { s := listedSkill{ - skillName: name, - hostIDs: hostIDs(hosts), - scope: scope, - path: dir, + skillName: name, + agentHostIDs: agentHostIDs, + scope: scope, + path: dir, } result, err := frontmatter.Parse(string(data)) @@ -391,14 +391,6 @@ func skillNameFromSourcePath(sourcePath string) string { return parts[len(parts)-1] } -func hostIDs(hosts []agentInfo) []string { - ids := make([]string, len(hosts)) - for i, host := range hosts { - ids[i] = host.id - } - return ids -} - func sortListedSkills(skills []listedSkill) { sort.Slice(skills, func(i, j int) bool { if skills[i].skillName != skills[j].skillName { @@ -407,8 +399,8 @@ func sortListedSkills(skills []listedSkill) { if skills[i].scope != skills[j].scope { return skills[i].scope < skills[j].scope } - if formatHosts(skills[i].hostIDs) != formatHosts(skills[j].hostIDs) { - return formatHosts(skills[i].hostIDs) < formatHosts(skills[j].hostIDs) + if formatHosts(skills[i].agentHostIDs) != formatHosts(skills[j].agentHostIDs) { + return formatHosts(skills[i].agentHostIDs) < formatHosts(skills[j].agentHostIDs) } return skills[i].path < skills[j].path }) @@ -419,7 +411,7 @@ func renderTable(io *iostreams.IOStreams, skills []listedSkill) error { for _, skill := range skills { table.AddField(skill.skillName) - table.AddField(formatHosts(skill.hostIDs)) + table.AddField(formatHosts(skill.agentHostIDs)) table.AddField(displayOrDash(skill.scope)) table.AddField(displayOrDash(skill.source)) table.EndRow() @@ -439,7 +431,7 @@ func formatHosts(hosts []string) string { if len(hosts) == 0 { return "-" } - return strings.Join(hosts, ",") + return strings.Join(hosts, ", ") } func recordListTelemetry(opts *ListOptions, skillCount int) { @@ -458,7 +450,7 @@ func recordListTelemetry(opts *ListOptions, skillCount int) { customDir := "false" if opts.Dir != "" { customDir = "true" - scope = "custom" + scope = scopeCustom } format := "table" if opts.Exporter != nil { diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go index 021b29442fa..33cd65ce141 100644 --- a/pkg/cmd/skills/list/list_test.go +++ b/pkg/cmd/skills/list/list_test.go @@ -82,7 +82,10 @@ func TestNewCmdList(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: ios, GitClient: &git.Client{}} + f := &cmdutil.Factory{ + IOStreams: ios, + GitClient: &git.Client{}, + } var gotOpts *ListOptions cmd := NewCmdList(f, &telemetry.NoOpService{}, func(opts *ListOptions) error { @@ -116,22 +119,6 @@ func TestNewCmdList(t *testing.T) { } } -func TestNewCmdList_Metadata(t *testing.T) { - ios, _, _, _ := iostreams.Test() - f := &cmdutil.Factory{IOStreams: ios, GitClient: &git.Client{}} - cmd := NewCmdList(f, &telemetry.NoOpService{}, nil) - - assert.Equal(t, "list [flags]", cmd.Use) - assert.NotEmpty(t, cmd.Short) - assert.NotEmpty(t, cmd.Long) - assert.NotEmpty(t, cmd.Example) - assert.Contains(t, cmd.Aliases, "ls") - - for _, flag := range []string{"agent", "scope", "dir", "json"} { - assert.NotNil(t, cmd.Flags().Lookup(flag), "missing flag: --%s", flag) - } -} - func TestListRun(t *testing.T) { tests := []struct { name string @@ -198,6 +185,31 @@ func TestListRun(t *testing.T) { assert.Equal(t, "json", spy.Events[0].Dimensions["format"]) }, }, + { + name: "preserves tenant host in json source url", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, homeDir, ".copilot/skills/tenant-skill", remoteSkillFrontmatterForRepo("tenant-skill", "https://octocorp.ghe.com/monalisa/skills-repo", "skills/tenant-skill", "refs/heads/main", "")) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"skillName", "sourceURL", "path"}) + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Exporter: exporter, + Agent: "github-copilot", + Scope: "user", + } + }, + wantJSON: fmt.Sprintf(`[ + { + "skillName": "tenant-skill", + "sourceURL": "https://octocorp.ghe.com/monalisa/skills-repo", + "path": %q + } + ]`, filepath.Join("HOME", ".copilot", "skills", "tenant-skill")), + }, { name: "custom directory with local metadata", setup: func(t *testing.T, repoDir, homeDir string) { @@ -221,6 +233,18 @@ func TestListRun(t *testing.T) { }, wantStdout: "local-helper\t-\tcustom\t/src/local-helper\n", }, + { + name: "custom directory must exist", + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Dir: filepath.Join(repoDir, "missing-skills"), + } + }, + wantErr: "could not access directory", + }, { name: "recovers namespaced skill name from source path", setup: func(t *testing.T, repoDir, homeDir string) { @@ -237,6 +261,55 @@ func TestListRun(t *testing.T) { }, wantStdout: "bob/xlsx-pro\tgithub-copilot\tproject\tmonalisa/skills-repo\n", }, + { + name: "recovers plugin skill name from source path", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, repoDir, ".agents/skills/foo", remoteSkillFrontmatter("foo", "plugins/myplugin/skills/foo", "refs/heads/main", "")) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Agent: "github-copilot", + Scope: "project", + } + }, + wantStdout: "myplugin/foo\tgithub-copilot\tproject\tmonalisa/skills-repo\n", + }, + { + name: "partial metadata has empty json source url", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, repoDir, ".agents/skills/partial", heredoc.Doc(` + --- + name: partial + metadata: + github-ref: refs/heads/main + --- + Body + `)) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + exporter := cmdutil.NewJSONExporter() + exporter.SetFields([]string{"skillName", "sourceURL", "version", "pinned"}) + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Exporter: exporter, + Agent: "github-copilot", + Scope: "project", + } + }, + wantJSON: `[ + { + "skillName": "partial", + "sourceURL": "", + "version": "main", + "pinned": false + } + ]`, + }, { name: "no installed skills returns no results", opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { @@ -311,15 +384,16 @@ func TestRenderTableUsesAgentHeader(t *testing.T) { ios.SetStdoutTTY(true) err := renderTable(ios, []listedSkill{{ - skillName: "git-commit", - hostIDs: []string{"github-copilot"}, - scope: "project", - source: "monalisa/skills-repo", - version: "v1.0.0", + skillName: "git-commit", + agentHostIDs: []string{"github-copilot", "cursor"}, + scope: "project", + source: "monalisa/skills-repo", + version: "v1.0.0", }}) require.NoError(t, err) assert.Contains(t, stdout.String(), "AGENT") + assert.Contains(t, stdout.String(), "github-copilot, cursor") assert.NotContains(t, stdout.String(), "HOST") } @@ -331,6 +405,10 @@ func writeSkill(t *testing.T, baseDir, relDir, content string) { } func remoteSkillFrontmatter(name, sourcePath, ref, pinned string) string { + return remoteSkillFrontmatterForRepo(name, "https://github.com/monalisa/skills-repo", sourcePath, ref, pinned) +} + +func remoteSkillFrontmatterForRepo(name, repoURL, sourcePath, ref, pinned string) string { pinnedLine := "" if pinned != "" { pinnedLine = fmt.Sprintf(" github-pinned: %s\n", pinned) @@ -339,11 +417,11 @@ func remoteSkillFrontmatter(name, sourcePath, ref, pinned string) string { --- name: %s metadata: - github-repo: https://github.com/monalisa/skills-repo + github-repo: %s github-ref: %s github-tree-sha: abc123 github-path: %s %s--- Body - `), name, ref, sourcePath, pinnedLine) + `), name, repoURL, ref, sourcePath, pinnedLine) } From 48ef6eb079a6f655db57241b958a8156146723ba Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Fri, 22 May 2026 10:20:44 +0100 Subject: [PATCH 151/182] handle skills in skills/ folder when running list command, by marking them as published --- pkg/cmd/skills/list/list.go | 101 ++++++++++++++++++++++++++++--- pkg/cmd/skills/list/list_test.go | 44 ++++++++++++++ 2 files changed, 136 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/skills/list/list.go b/pkg/cmd/skills/list/list.go index 05d71285025..ca227425742 100644 --- a/pkg/cmd/skills/list/list.go +++ b/pkg/cmd/skills/list/list.go @@ -32,7 +32,19 @@ var skillListFields = []string{ "path", } -const scopeCustom = "custom" +const ( + agentHostPublished = "published" + agentHostPublishedDisplay = "n/a (published)" + scopeCustom = "custom" +) + +type scanFilter int + +const ( + scanAllSkills scanFilter = iota + scanInstalledOnly + scanPublishedOnly +) type ListOptions struct { IO *iostreams.IOStreams @@ -50,6 +62,7 @@ type scanTarget struct { dir string agentHostIDs []string scope string + filter scanFilter } type listedSkill struct { @@ -175,7 +188,7 @@ func listInstalledSkills(opts *ListOptions) ([]listedSkill, error) { var all []listedSkill for _, target := range targets { - skills, scanErr := scanInstalledSkills(target.dir, target.agentHostIDs, target.scope) + skills, scanErr := scanInstalledSkills(target.dir, target.agentHostIDs, target.scope, target.filter) if scanErr != nil { if opts.Dir != "" { return nil, fmt.Errorf("could not scan directory: %w", scanErr) @@ -220,6 +233,7 @@ func buildScanTargets(opts *ListOptions) ([]scanTarget, error) { if idx, ok := byDir[dir]; ok { targets[idx].agentHostIDs = appendAgentHostID(targets[idx].agentHostIDs, agentHost.ID) + targets[idx].filter = mergeScanFilters(targets[idx].filter, scanFilterForAgentHost(agentHost, scope)) continue } @@ -228,9 +242,18 @@ func buildScanTargets(opts *ListOptions) ([]scanTarget, error) { dir: dir, agentHostIDs: []string{agentHost.ID}, scope: string(scope), + filter: scanFilterForAgentHost(agentHost, scope), }) } } + if shouldListPublishedProjectSkills(opts.Agent, scopes, gitRoot) { + targets = append(targets, scanTarget{ + dir: filepath.Join(gitRoot, "skills"), + agentHostIDs: []string{agentHostPublished}, + scope: string(registry.ScopeProject), + filter: scanPublishedOnly, + }) + } return targets, nil } @@ -267,7 +290,33 @@ func appendAgentHostID(agentHostIDs []string, agentHostID string) []string { return append(agentHostIDs, agentHostID) } -func scanInstalledSkills(skillsDir string, agentHostIDs []string, scope string) ([]listedSkill, error) { +func scanFilterForAgentHost(agentHost *registry.AgentHost, scope registry.Scope) scanFilter { + if scope == registry.ScopeProject && agentHost.ProjectDir == "skills" { + return scanInstalledOnly + } + return scanAllSkills +} + +func mergeScanFilters(a, b scanFilter) scanFilter { + if a == b { + return a + } + return scanAllSkills +} + +func shouldListPublishedProjectSkills(agentID string, scopes []registry.Scope, gitRoot string) bool { + if agentID != "" || gitRoot == "" { + return false + } + for _, scope := range scopes { + if scope == registry.ScopeProject { + return true + } + } + return false +} + +func scanInstalledSkills(skillsDir string, agentHostIDs []string, scope string, filter scanFilter) ([]listedSkill, error) { entries, err := os.ReadDir(skillsDir) if os.IsNotExist(err) { return nil, nil @@ -286,7 +335,10 @@ func scanInstalledSkills(skillsDir string, agentHostIDs []string, scope string) skillDir := filepath.Join(skillsDir, e.Name()) skillFile := filepath.Join(skillDir, "SKILL.md") if data, readErr := os.ReadFile(skillFile); readErr == nil { - skills = append(skills, parseInstalledSkill(data, e.Name(), skillDir, agentHostIDs, scope)) + skill, hasInstallMetadata := parseInstalledSkill(data, e.Name(), skillDir, agentHostIDs, scope) + if shouldIncludeSkill(filter, hasInstallMetadata) { + skills = append(skills, skill) + } continue } @@ -303,7 +355,10 @@ func scanInstalledSkills(skillsDir string, agentHostIDs []string, scope string) subSkillFile := filepath.Join(subSkillDir, "SKILL.md") if data, readErr := os.ReadFile(subSkillFile); readErr == nil { installName := e.Name() + "/" + sub.Name() - skills = append(skills, parseInstalledSkill(data, installName, subSkillDir, agentHostIDs, scope)) + skill, hasInstallMetadata := parseInstalledSkill(data, installName, subSkillDir, agentHostIDs, scope) + if shouldIncludeSkill(filter, hasInstallMetadata) { + skills = append(skills, skill) + } } } } @@ -311,7 +366,18 @@ func scanInstalledSkills(skillsDir string, agentHostIDs []string, scope string) return skills, nil } -func parseInstalledSkill(data []byte, name, dir string, agentHostIDs []string, scope string) listedSkill { +func shouldIncludeSkill(filter scanFilter, hasInstallMetadata bool) bool { + switch filter { + case scanInstalledOnly: + return hasInstallMetadata + case scanPublishedOnly: + return !hasInstallMetadata + default: + return true + } +} + +func parseInstalledSkill(data []byte, name, dir string, agentHostIDs []string, scope string) (listedSkill, bool) { s := listedSkill{ skillName: name, agentHostIDs: agentHostIDs, @@ -321,13 +387,14 @@ func parseInstalledSkill(data []byte, name, dir string, agentHostIDs []string, s result, err := frontmatter.Parse(string(data)) if err != nil { - return s + return s, false } meta := result.Metadata.Meta if meta == nil { - return s + return s, false } + installMetadata := hasInstallMetadata(meta) if sourcePath, _ := meta["github-path"].(string); sourcePath != "" { if skillName := skillNameFromSourcePath(sourcePath); skillName != "" { @@ -357,7 +424,20 @@ func parseInstalledSkill(data []byte, name, dir string, agentHostIDs []string, s } } - return s + return s, installMetadata +} + +func hasInstallMetadata(meta map[string]interface{}) bool { + for _, key := range []string{"github-repo", "github-ref", "github-tree-sha", "github-path", "github-pinned", "local-path"} { + value, ok := meta[key] + if !ok { + continue + } + if str, ok := value.(string); !ok || strings.TrimSpace(str) != "" { + return true + } + } + return false } func skillNameFromSourcePath(sourcePath string) string { @@ -431,6 +511,9 @@ func formatHosts(hosts []string) string { if len(hosts) == 0 { return "-" } + if len(hosts) == 1 && hosts[0] == agentHostPublished { + return agentHostPublishedDisplay + } return strings.Join(hosts, ", ") } diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go index 33cd65ce141..b9eef019f26 100644 --- a/pkg/cmd/skills/list/list_test.go +++ b/pkg/cmd/skills/list/list_test.go @@ -245,6 +245,50 @@ func TestListRun(t *testing.T) { }, wantErr: "could not access directory", }, + { + name: "lists source skills in bare project skills directory as published", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, repoDir, "skills/gh", heredoc.Doc(` + --- + name: gh + description: GitHub CLI patterns + --- + Body + `)) + writeSkill(t, repoDir, "skills/gh-skill", heredoc.Doc(` + --- + name: gh-skill + description: GitHub Skill patterns + --- + Body + `)) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Scope: "project", + } + }, + wantStdout: "gh\tn/a (published)\tproject\t-\ngh-skill\tn/a (published)\tproject\t-\n", + }, + { + name: "lists openclaw project skill with install metadata", + setup: func(t *testing.T, repoDir, homeDir string) { + writeSkill(t, repoDir, "skills/openclaw-helper", remoteSkillFrontmatter("openclaw-helper", "skills/openclaw-helper", "refs/heads/main", "")) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Agent: "openclaw", + Scope: "project", + } + }, + wantStdout: "openclaw-helper\topenclaw\tproject\tmonalisa/skills-repo\n", + }, { name: "recovers namespaced skill name from source path", setup: func(t *testing.T, repoDir, homeDir string) { From 1bfcf1c0099e977874540e660ce35c76b6d98922 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Fri, 22 May 2026 12:08:59 +0100 Subject: [PATCH 152/182] Merge pull request #13486 from cli/tommy/update-x-crypto Update golang.org/x/crypto --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 395aba966c3..abdca9e7fd3 100644 --- a/go.mod +++ b/go.mod @@ -57,9 +57,9 @@ require ( github.com/vmihailenco/msgpack/v5 v5.4.1 github.com/yuin/goldmark v1.8.2 github.com/zalando/go-keyring v0.2.8 - golang.org/x/crypto v0.51.0 + golang.org/x/crypto v0.52.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.44.0 + golang.org/x/sys v0.45.0 golang.org/x/term v0.43.0 golang.org/x/text v0.37.0 google.golang.org/grpc v1.81.1 @@ -183,7 +183,7 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.35.0 // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/net v0.54.0 // indirect golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect diff --git a/go.sum b/go.sum index 90f6a825319..9a85c07709f 100644 --- a/go.sum +++ b/go.sum @@ -573,8 +573,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -585,8 +585,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -602,8 +602,8 @@ golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= From a9026fdb66dd047cee564f2b7a1168d2027c7c92 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 22 May 2026 13:11:21 +0200 Subject: [PATCH 153/182] Run govulncheck daily instead of weekly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/govulncheck.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 530e7f4c97b..ae05aeca8d0 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -1,7 +1,7 @@ name: Go Vulnerability Check on: schedule: - - cron: "0 0 * * 1" # Every Monday at midnight UTC + - cron: "0 0 * * *" # Every day at midnight UTC workflow_dispatch: jobs: From 386d72ea980627a57cf1c0f39225cdef619d161c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 11:11:22 +0000 Subject: [PATCH 154/182] chore(deps): bump github.com/google/go-containerregistry Bumps [github.com/google/go-containerregistry](https://github.com/google/go-containerregistry) from 0.21.5 to 0.21.6. - [Release notes](https://github.com/google/go-containerregistry/releases) - [Commits](https://github.com/google/go-containerregistry/compare/v0.21.5...v0.21.6) --- updated-dependencies: - dependency-name: github.com/google/go-containerregistry dependency-version: 0.21.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 11 ++++------- go.sum | 20 ++++++++------------ 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index abdca9e7fd3..ac72aa30436 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/gabriel-vasile/mimetype v1.4.13 github.com/gdamore/tcell/v2 v2.13.9 github.com/google/go-cmp v0.7.0 - github.com/google/go-containerregistry v0.21.5 + github.com/google/go-containerregistry v0.21.6 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 @@ -94,13 +94,12 @@ require ( github.com/cli/shurcooL-graphql v0.0.4 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/docker/cli v29.4.0+incompatible // indirect + github.com/docker/cli v29.4.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.18.0 // indirect @@ -145,7 +144,6 @@ require ( github.com/mattn/go-runewidth v0.0.23 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect @@ -173,7 +171,6 @@ require ( github.com/thlib/go-timezone-local v0.0.6 // indirect github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c // indirect github.com/transparency-dev/merkle v0.0.2 // indirect - github.com/vbatts/tar-split v0.12.2 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect @@ -182,9 +179,9 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.35.0 // indirect + golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.54.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/tools v0.45.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect gotest.tools/v3 v3.5.2 // indirect diff --git a/go.sum b/go.sum index 9a85c07709f..06572f25718 100644 --- a/go.sum +++ b/go.sum @@ -162,8 +162,6 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= -github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= -github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -189,8 +187,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM= -github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= +github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -279,8 +277,8 @@ github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCY github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM= -github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU= +github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM= +github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= @@ -528,8 +526,6 @@ github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU= github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= -github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= -github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= @@ -579,8 +575,8 @@ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/y golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -623,8 +619,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= From d4a57fe4f9d8f168fc9ff06b4cb431699a287505 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 22 May 2026 13:13:43 +0200 Subject: [PATCH 155/182] Add 3 day dependabot cooldown period Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/dependabot.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4c08abeabef..3d974364d2e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,8 @@ updates: directory: "/" schedule: interval: "daily" + cooldown: + default-days: 3 ignore: - dependency-name: "*" update-types: @@ -12,3 +14,5 @@ updates: directory: "/" schedule: interval: "daily" + cooldown: + default-days: 3 From 01e0b8dfd3d227f0ea0b9b719c812a0215f73650 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 13:03:04 +0000 Subject: [PATCH 156/182] chore(deps): bump github/codeql-action from 4 to 4.35.5 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.35.5. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4...v4.35.5) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.35.5 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 6 +++--- .github/workflows/govulncheck.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d82eb171f89..db7951bc0bf 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -34,20 +34,20 @@ jobs: go-version-file: "go.mod" - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@v4.35.5 with: languages: ${{ matrix.language }} queries: security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@v4.35.5 with: category: "/language:${{ matrix.language }}" upload: false output: sarif-results - name: Upload filtered SARIF - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@v4.35.5 with: sarif_file: sarif-results/${{ matrix.language }}.sarif category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index ae05aeca8d0..90f2b3cf04d 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -26,6 +26,6 @@ jobs: go run golang.org/x/vuln/cmd/govulncheck@d1f380186385b4f64e00313f31743df8e4b89a77 -format sarif ./... > gh.sarif - name: Upload SARIF report - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@v4.35.5 with: sarif_file: gh.sarif From 994024f8ac6eafecdd8bd8d37896e6c319ba1bf5 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Fri, 22 May 2026 15:12:03 +0100 Subject: [PATCH 157/182] address copilot comments --- internal/skills/discovery/discovery.go | 26 ++++++++++++++----- internal/skills/discovery/discovery_test.go | 28 ++++++++++++++++++++- pkg/cmd/skills/install/install_test.go | 2 +- pkg/cmd/skills/preview/preview.go | 2 +- pkg/cmd/skills/preview/preview_test.go | 4 --- 5 files changed, 49 insertions(+), 13 deletions(-) diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index f7b4ae8b2f7..55d27447846 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -397,7 +397,7 @@ func IsSkillPath(name string) bool { if name == "" { return false } - if name == "SKILL.md" || strings.HasSuffix(name, "/SKILL.md") { + if strings.HasSuffix(name, "/SKILL.md") { return true } if strings.HasPrefix(name, "skills/") || strings.HasPrefix(name, "plugins/") { @@ -473,11 +473,12 @@ func matchSkillConventions(entry treeEntry) *skillMatch { } // matchHiddenDirConventions checks if a blob path matches a skill convention -// under a hidden (dot-prefixed) directory. These patterns mirror the standard -// skills/ conventions, but only when the path contains a hidden segment: +// under a path that contains a hidden (dot-prefixed) directory. These patterns +// mirror the standard skills/ conventions, but only when a hidden segment +// appears anywhere in the ancestor path: // -// - {prefix}/.{host}/skills/*/SKILL.md -> "hidden-dir" -// - {prefix}/.{host}/skills/{scope}/*/SKILL.md -> "hidden-dir-namespaced" +// - {prefix}/.{hidden}/{suffix}/skills/*/SKILL.md -> "hidden-dir" +// - {prefix}/.{hidden}/{suffix}/skills/{scope}/*/SKILL.md -> "hidden-dir-namespaced" func matchHiddenDirConventions(entry treeEntry) *skillMatch { if path.Base(entry.Path) != "SKILL.md" { return nil @@ -674,8 +675,19 @@ func FetchDescriptionsConcurrent(client *api.Client, host, owner, repo string, s wg.Wait() } +// DiscoverSkillByPathOptions controls optional behavior for DiscoverSkillByPathWithOptions. +type DiscoverSkillByPathOptions struct { + SkipDescription bool +} + // DiscoverSkillByPath looks up a single skill by its exact path in the repository. func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skillPath string) (*Skill, error) { + return DiscoverSkillByPathWithOptions(client, host, owner, repo, commitSHA, skillPath, DiscoverSkillByPathOptions{}) +} + +// DiscoverSkillByPathWithOptions looks up a single skill by its exact path in +// the repository, applying the given options. +func DiscoverSkillByPathWithOptions(client *api.Client, host, owner, repo, commitSHA, skillPath string, opts DiscoverSkillByPathOptions) (*Skill, error) { skillPath = strings.TrimSuffix(skillPath, "/SKILL.md") skillPath = strings.TrimSuffix(skillPath, "/") @@ -756,7 +768,9 @@ func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skill TreeSHA: treeSHA, } - skill.Description = fetchDescription(client, host, owner, repo, skill) + if !opts.SkipDescription { + skill.Description = fetchDescription(client, host, owner, repo, skill) + } return skill, nil } diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index 297b65910cd..d00f8d7acfb 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -1325,6 +1325,32 @@ func TestDiscoverSkillByPath(t *testing.T) { } } +func TestDiscoverSkillByPathWithOptionsSkipsDescription(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/contents/skills"), + httpmock.JSONResponse([]map[string]interface{}{ + {"name": "code-review", "path": "skills/code-review", "sha": "tree-sha", "type": "dir"}, + })) + reg.Register( + httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-sha"), + httpmock.JSONResponse(map[string]interface{}{ + "sha": "tree-sha", "truncated": false, + "tree": []map[string]interface{}{ + {"path": "SKILL.md", "type": "blob", "sha": "blob-sha"}, + }, + })) + + client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + skill, err := DiscoverSkillByPathWithOptions(client, "github.com", "monalisa", "octocat-skills", "abc123", "skills/code-review", DiscoverSkillByPathOptions{SkipDescription: true}) + + require.NoError(t, err) + assert.Equal(t, "code-review", skill.Name) + assert.Empty(t, skill.Description) +} + func TestDiscoverLocalSkills(t *testing.T) { tests := []struct { name string @@ -1534,7 +1560,7 @@ func TestIsSkillPath(t *testing.T) { }{ {name: "empty string", path: "", want: false}, {name: "plain skill name", path: "git-commit", want: false}, - {name: "SKILL.md at root", path: "SKILL.md", want: true}, + {name: "bare SKILL.md", path: "SKILL.md", want: false}, {name: "SKILL.md suffix", path: "skills/code-review/SKILL.md", want: true}, {name: "starts with skills/", path: "skills/code-review", want: true}, {name: "starts with plugins/", path: "plugins/hubot/skills/pr-summary", want: true}, diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index c9c384675fb..54ea1d4554c 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -2139,7 +2139,7 @@ func Test_isSkillPath(t *testing.T) { }{ {name: "empty string", path: "", want: false}, {name: "plain skill name", path: "git-commit", want: false}, - {name: "SKILL.md at root", path: "SKILL.md", want: true}, + {name: "bare SKILL.md", path: "SKILL.md", want: false}, {name: "SKILL.md suffix", path: "skills/code-review/SKILL.md", want: true}, {name: "starts with skills/", path: "skills/code-review", want: true}, {name: "starts with plugins/", path: "plugins/hubot/skills/pr-summary", want: true}, diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 6e8e398422d..7f870b635c7 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -165,7 +165,7 @@ func previewRun(opts *PreviewOptions) error { var skill discovery.Skill if discovery.IsSkillPath(opts.SkillName) { opts.IO.StartProgressIndicatorWithLabel("Looking up skill") - found, err := discovery.DiscoverSkillByPath(apiClient, hostname, owner, repoName, resolved.SHA, opts.SkillName) + found, err := discovery.DiscoverSkillByPathWithOptions(apiClient, hostname, owner, repoName, resolved.SHA, opts.SkillName, discovery.DiscoverSkillByPathOptions{SkipDescription: true}) opts.IO.StopProgressIndicator() if err != nil { return err diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index fca5dff5081..48d301f7c06 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -291,10 +291,6 @@ func TestPreviewRun(t *testing.T) { ] }`), ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/blobs/blob999"), - httpmock.StringResponse(`{"sha": "blob999", "content": "`+encodedContent+`", "encoding": "base64"}`), - ) reg.Register( httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA4"), httpmock.StringResponse(`{ From 94e3b1fca0f8549b7edb43e0b6971b235d69f0b8 Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 22 May 2026 16:09:54 +0200 Subject: [PATCH 158/182] SHA pin first-party GitHub Actions Pins every actions/* and github/* uses: reference in .github/workflows to its commit SHA, with the human-readable version preserved in a trailing comment, matching the convention already used for third-party actions. Removes the supply-chain exposure left by the floating @vN tags now that dependabot has a 3-day cooldown configured. Closes #13490 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/bump-go.yml | 4 ++-- .github/workflows/codeql.yml | 10 +++++----- .github/workflows/deployment.yml | 24 ++++++++++++------------ .github/workflows/detect-spam.yml | 2 +- .github/workflows/go.yml | 8 ++++---- .github/workflows/govulncheck.yml | 6 +++--- .github/workflows/lint.yml | 8 ++++---- 7 files changed, 31 insertions(+), 31 deletions(-) diff --git a/.github/workflows/bump-go.yml b/.github/workflows/bump-go.yml index f9647b21064..cd9170872d2 100644 --- a/.github/workflows/bump-go.yml +++ b/.github/workflows/bump-go.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index db7951bc0bf..9cb49337a19 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,29 +25,29 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Go if: matrix.language == 'go' - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" - name: Initialize CodeQL - uses: github/codeql-action/init@v4.35.5 + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: languages: ${{ matrix.language }} queries: security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.35.5 + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: category: "/language:${{ matrix.language }}" upload: false output: sarif-results - name: Upload filtered SARIF - uses: github/codeql-action/upload-sarif@v4.35.5 + uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: sarif_file: sarif-results/${{ matrix.language }}.sarif category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index da8b6193f7a..b19a523b60a 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -46,9 +46,9 @@ jobs: if: contains(inputs.platforms, 'linux') steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' - name: Install GoReleaser @@ -72,7 +72,7 @@ jobs: run: | go run ./cmd/gen-docs --website --doc-path dist/manual tar -czvf dist/manual.tar.gz -C dist -- manual - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: linux if-no-files-found: error @@ -89,9 +89,9 @@ jobs: if: contains(inputs.platforms, 'macos') steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' - name: Configure macOS signing @@ -152,7 +152,7 @@ jobs: run: | shopt -s failglob script/pkgmacos "$TAG_NAME" - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: macos if-no-files-found: error @@ -169,9 +169,9 @@ jobs: if: contains(inputs.platforms, 'windows') steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' - name: Install GoReleaser @@ -269,7 +269,7 @@ jobs: Get-ChildItem -Path .\dist -Filter *.msi | ForEach-Object { .\script\sign.ps1 $_.FullName } - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: windows if-no-files-found: error @@ -285,11 +285,11 @@ jobs: if: inputs.release steps: - name: Checkout cli/cli - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Merge built artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - name: Checkout documentation site - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: github/cli.github.com path: site diff --git a/.github/workflows/detect-spam.yml b/.github/workflows/detect-spam.yml index d856d75a456..967a5013c9a 100644 --- a/.github/workflows/detect-spam.yml +++ b/.github/workflows/detect-spam.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run spam detection env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index da2f7379e96..ac9b732bfa3 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -18,10 +18,10 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" @@ -45,10 +45,10 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: "go.mod" diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 90f2b3cf04d..65721bd6fd3 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -12,10 +12,10 @@ jobs: security-events: write steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' @@ -26,6 +26,6 @@ jobs: go run golang.org/x/vuln/cmd/govulncheck@d1f380186385b4f64e00313f31743df8e4b89a77 -format sarif ./... > gh.sarif - name: Upload SARIF report - uses: github/codeql-action/upload-sarif@v4.35.5 + uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: sarif_file: gh.sarif diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d55a944c854..3b6fc5b2283 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,10 +23,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' @@ -67,10 +67,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: 'go.mod' From fb671706ec124d5405946825b2f57a0b6c9fd576 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Fri, 22 May 2026 16:07:26 +0100 Subject: [PATCH 159/182] address copilot comments, clarify text --- pkg/cmd/skills/install/install.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 0dc62911d6f..a2153069fc7 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -137,7 +137,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru enables %[1]sgh skill update%[1]s to detect changes. Use %[1]s--all%[1]s to install every discovered skill from the repository - without prompting. When run non-interactively, %[1]srepository%[1]s and either + without prompting for skill selection. When run non-interactively, %[1]srepository%[1]s and either a skill name or %[1]s--all%[1]s are required. `, "`", registry.AgentHelpList()), Example: heredoc.Doc(` @@ -186,7 +186,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru opts.ScopeChanged = cmd.Flags().Changed("scope") if opts.All && opts.SkillName != "" { - return cmdutil.FlagErrorf("cannot use `--all` with skill name") + return cmdutil.FlagErrorf("cannot use --all with a skill argument") } // Resolve the source type early so installRun can branch directly. @@ -223,7 +223,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru cmdutil.StringEnumFlag(cmd, &opts.Scope, "scope", "", "project", []string{"project", "user"}, "Installation scope") cmd.Flags().StringVar(&opts.Pin, "pin", "", "Pin to a specific git tag or commit SHA") cmd.Flags().StringVar(&opts.Dir, "dir", "", "Install to a custom directory (overrides --agent and --scope)") - cmd.Flags().BoolVar(&opts.All, "all", false, "Install all skills without prompting") + cmd.Flags().BoolVar(&opts.All, "all", false, "Install all skills without prompting for skill selection") cmd.Flags().BoolVarP(&opts.Force, "force", "f", false, "Overwrite existing skills without prompting") cmd.Flags().BoolVar(&opts.FromLocal, "from-local", false, "Treat the argument as a local directory path instead of a repository") cmd.Flags().BoolVar(&opts.AllowHiddenDirs, "allow-hidden-dirs", false, "Include skills in hidden directories (e.g. .claude/skills/, .agents/skills/)") From e60f00e30880079adefd258c388c642b1e97ddf6 Mon Sep 17 00:00:00 2001 From: Torben <8199725+tenjaa@users.noreply.github.com> Date: Fri, 22 May 2026 20:40:10 +0200 Subject: [PATCH 160/182] Allow agents as application for secrets (#13421) Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> --- pkg/cmd/secret/delete/delete.go | 6 +-- pkg/cmd/secret/delete/delete_test.go | 36 +++++++++++++++ pkg/cmd/secret/list/list.go | 6 +-- pkg/cmd/secret/list/list_test.go | 69 ++++++++++++++++++++++++++++ pkg/cmd/secret/secret.go | 2 +- pkg/cmd/secret/set/set.go | 6 +-- pkg/cmd/secret/set/set_test.go | 61 ++++++++++++++++++++++++ pkg/cmd/secret/shared/shared.go | 5 ++ pkg/cmd/secret/shared/shared_test.go | 18 ++++++++ 9 files changed, 199 insertions(+), 10 deletions(-) diff --git a/pkg/cmd/secret/delete/delete.go b/pkg/cmd/secret/delete/delete.go index b73b598488e..b1a5b1d3930 100644 --- a/pkg/cmd/secret/delete/delete.go +++ b/pkg/cmd/secret/delete/delete.go @@ -40,9 +40,9 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co Short: "Delete secrets", Long: heredoc.Doc(` Delete a secret on one of the following levels: - - repository (default): available to GitHub Actions runs or Dependabot in a repository + - repository (default): available to GitHub Actions runs, Agents sessions, or Dependabot in a repository - environment: available to GitHub Actions runs for a deployment environment in a repository - - organization: available to GitHub Actions runs, Dependabot, or Codespaces within an organization + - organization: available to GitHub Actions runs, Agents sessions, Dependabot, or Codespaces within an organization - user: available to Codespaces for your user `), Args: cobra.ExactArgs(1), @@ -81,7 +81,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Delete a secret for an organization") cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Delete a secret for an environment") cmd.Flags().BoolVarP(&opts.UserSecrets, "user", "u", false, "Delete a secret for your user") - cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Codespaces, shared.Dependabot}, "Delete a secret for a specific application") + cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Agents, shared.Codespaces, shared.Dependabot}, "Delete a secret for a specific application") return cmd } diff --git a/pkg/cmd/secret/delete/delete_test.go b/pkg/cmd/secret/delete/delete_test.go index 48200b8813b..570df4615d5 100644 --- a/pkg/cmd/secret/delete/delete_test.go +++ b/pkg/cmd/secret/delete/delete_test.go @@ -89,6 +89,23 @@ func TestNewCmdDelete(t *testing.T) { Application: "Codespaces", }, }, + { + name: "Agents org", + cli: "cool --app agents --org UmbrellaCorporation", + wants: DeleteOptions{ + SecretName: "cool", + OrgName: "UmbrellaCorporation", + Application: "Agents", + }, + }, + { + name: "Agents repo", + cli: "cool --app Agents", + wants: DeleteOptions{ + SecretName: "cool", + Application: "Agents", + }, + }, } for _, tt := range tests { @@ -311,6 +328,17 @@ func Test_removeRun_repo(t *testing.T) { reg.Register(httpmock.WithHost(httpmock.REST("DELETE", "api/v3/repos/owner/repo/dependabot/secrets/cool_dependabot_secret"), "example.com"), httpmock.StatusStringResponse(204, "No Content")) }, }, + { + name: "Agents", + opts: &DeleteOptions{ + Application: "agents", + SecretName: "cool_agents_secret", + }, + host: "github.com", + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.WithHost(httpmock.REST("DELETE", "repos/owner/repo/agents/secrets/cool_agents_secret"), "api.github.com"), httpmock.StatusStringResponse(204, "No Content")) + }, + }, { name: "defaults to Actions", opts: &DeleteOptions{ @@ -433,6 +461,14 @@ func Test_removeRun_org(t *testing.T) { }, wantPath: "orgs/UmbrellaCorporation/codespaces/secrets/tVirus", }, + { + name: "Agents org", + opts: &DeleteOptions{ + Application: "agents", + OrgName: "UmbrellaCorporation", + }, + wantPath: "orgs/UmbrellaCorporation/agents/secrets/tVirus", + }, } for _, tt := range tests { diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go index 06476a86d49..66334ea9152 100644 --- a/pkg/cmd/secret/list/list.go +++ b/pkg/cmd/secret/list/list.go @@ -60,9 +60,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Short: "List secrets", Long: heredoc.Doc(` List secrets on one of the following levels: - - repository (default): available to GitHub Actions runs or Dependabot in a repository + - repository (default): available to GitHub Actions runs, Agents sessions, or Dependabot in a repository - environment: available to GitHub Actions runs for a deployment environment in a repository - - organization: available to GitHub Actions runs, Dependabot, or Codespaces within an organization + - organization: available to GitHub Actions runs, Agents sessions, Dependabot, or Codespaces within an organization - user: available to Codespaces for your user `), Aliases: []string{"ls"}, @@ -98,7 +98,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization") cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "List secrets for an environment") cmd.Flags().BoolVarP(&opts.UserSecrets, "user", "u", false, "List a secret for your user") - cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Codespaces, shared.Dependabot}, "List secrets for a specific application") + cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Agents, shared.Codespaces, shared.Dependabot}, "List secrets for a specific application") cmdutil.AddJSONFlags(cmd, &opts.Exporter, secretFields) return cmd } diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go index 5c4dd4874fa..da7cb892356 100644 --- a/pkg/cmd/secret/list/list_test.go +++ b/pkg/cmd/secret/list/list_test.go @@ -74,6 +74,21 @@ func Test_NewCmdList(t *testing.T) { OrgName: "UmbrellaCorporation", }, }, + { + name: "Agents repo", + cli: "--app Agents", + wants: ListOptions{ + Application: "Agents", + }, + }, + { + name: "Agents org", + cli: "--app Agents --org UmbrellaCorporation", + wants: ListOptions{ + Application: "Agents", + OrgName: "UmbrellaCorporation", + }, + }, } for _, tt := range tests { @@ -443,6 +458,58 @@ func Test_listRun(t *testing.T) { "SECRET_THREE\t1975-11-30T00:00:00Z\tSELECTED", }, }, + { + name: "Agents repo tty", + tty: true, + opts: &ListOptions{ + Application: "Agents", + }, + wantOut: []string{ + "NAME UPDATED", + "SECRET_ONE about 34 years ago", + "SECRET_TWO about 2 years ago", + "SECRET_THREE about 47 years ago", + }, + }, + { + name: "Agents repo not tty", + tty: false, + opts: &ListOptions{ + Application: "Agents", + }, + wantOut: []string{ + "SECRET_ONE\t1988-10-11T00:00:00Z", + "SECRET_TWO\t2020-12-04T00:00:00Z", + "SECRET_THREE\t1975-11-30T00:00:00Z", + }, + }, + { + name: "Agents org tty", + tty: true, + opts: &ListOptions{ + Application: "Agents", + OrgName: "UmbrellaCorporation", + }, + wantOut: []string{ + "NAME UPDATED VISIBILITY", + "SECRET_ONE about 34 years ago Visible to all repositories", + "SECRET_TWO about 2 years ago Visible to private repositories", + "SECRET_THREE about 47 years ago Visible to 2 selected repositories", + }, + }, + { + name: "Agents org not tty", + tty: false, + opts: &ListOptions{ + Application: "Agents", + OrgName: "UmbrellaCorporation", + }, + wantOut: []string{ + "SECRET_ONE\t1988-10-11T00:00:00Z\tALL", + "SECRET_TWO\t2020-12-04T00:00:00Z\tPRIVATE", + "SECRET_THREE\t1975-11-30T00:00:00Z\tSELECTED", + }, + }, } for _, tt := range tests { @@ -542,6 +609,8 @@ func Test_listRun(t *testing.T) { if tt.opts.Application == "Dependabot" { path = strings.Replace(path, "actions", "dependabot", 1) + } else if tt.opts.Application == "Agents" { + path = strings.Replace(path, "actions", "agents", 1) } reg.Register(httpmock.REST("GET", path), httpmock.JSONResponse(payload)) diff --git a/pkg/cmd/secret/secret.go b/pkg/cmd/secret/secret.go index 6c08e1d2405..32d974fafa6 100644 --- a/pkg/cmd/secret/secret.go +++ b/pkg/cmd/secret/secret.go @@ -15,7 +15,7 @@ func NewCmdSecret(f *cmdutil.Factory) *cobra.Command { Short: "Manage GitHub secrets", Long: heredoc.Docf(` Secrets can be set at the repository, or organization level for use in - GitHub Actions or Dependabot. User, organization, and repository secrets can be set for + GitHub Actions, Agents, or Dependabot. User, organization, and repository secrets can be set for use in GitHub Codespaces. Environment secrets can be set for use in GitHub Actions. Run %[1]sgh help secret set%[1]s to learn how to get started. `, "`"), diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index 40f2fac0d0f..93cc14f219b 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -63,9 +63,9 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command Short: "Create or update secrets", Long: heredoc.Doc(` Set a value for a secret on one of the following levels: - - repository (default): available to GitHub Actions runs or Dependabot in a repository + - repository (default): available to GitHub Actions runs, Agents sessions, or Dependabot in a repository - environment: available to GitHub Actions runs for a deployment environment in a repository - - organization: available to GitHub Actions runs, Dependabot, or Codespaces within an organization + - organization: available to GitHub Actions runs, Agents sessions, Dependabot, or Codespaces within an organization - user: available to Codespaces for your user Organization and user secrets can optionally be restricted to only be available to @@ -195,7 +195,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "The value for the secret (reads from standard input if not specified)") cmd.Flags().BoolVar(&opts.DoNotStore, "no-store", false, "Print the encrypted, base64-encoded value instead of storing it on GitHub") cmd.Flags().StringVarP(&opts.EnvFile, "env-file", "f", "", "Load secret names and values from a dotenv-formatted `file`") - cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Codespaces, shared.Dependabot}, "Set the application for a secret") + cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Agents, shared.Codespaces, shared.Dependabot}, "Set the application for a secret") return cmd } diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go index 38c0fb5a9cf..237bc70e1dc 100644 --- a/pkg/cmd/secret/set/set_test.go +++ b/pkg/cmd/secret/set/set_test.go @@ -213,6 +213,29 @@ func TestNewCmdSet(t *testing.T) { Application: "Codespaces", }, }, + { + name: "Agents org", + args: `random_secret --org coolOrg --body "random value" --visibility selected --repos "coolRepo,cli/cli" --app Agents`, + wants: SetOptions{ + SecretName: "random_secret", + Visibility: shared.Selected, + RepositoryNames: []string{"coolRepo", "cli/cli"}, + Body: "random value", + OrgName: "coolOrg", + Application: "Agents", + }, + }, + { + name: "Agents repo", + args: `cool_secret --body "a secret" --app Agents`, + wants: SetOptions{ + SecretName: "cool_secret", + Visibility: shared.Private, + Body: "a secret", + OrgName: "", + Application: "Agents", + }, + }, } for _, tt := range tests { @@ -407,6 +430,13 @@ func Test_setRun_repo(t *testing.T) { }, wantApp: "actions", }, + { + name: "Agents", + opts: &SetOptions{ + Application: "agents", + }, + wantApp: "agents", + }, { name: "Dependabot", opts: &SetOptions{ @@ -573,6 +603,37 @@ func Test_setRun_org(t *testing.T) { wantRepositories: []int64{}, wantApp: "dependabot", }, + { + name: "Agents", + opts: &SetOptions{ + OrgName: "UmbrellaCorporation", + Visibility: shared.All, + Application: shared.Agents, + }, + wantApp: "agents", + }, + { + name: "Agents selected visibility", + opts: &SetOptions{ + OrgName: "UmbrellaCorporation", + Visibility: shared.Selected, + Application: shared.Agents, + RepositoryNames: []string{"birkin", "UmbrellaCorporation/wesker"}, + }, + wantRepositories: []int64{1, 2}, + wantApp: "agents", + }, + { + name: "Agents no repos visibility", + opts: &SetOptions{ + OrgName: "UmbrellaCorporation", + Visibility: shared.Selected, + Application: shared.Agents, + RepositoryNames: []string{}, + }, + wantRepositories: []int64{}, + wantApp: "agents", + }, } for _, tt := range tests { diff --git a/pkg/cmd/secret/shared/shared.go b/pkg/cmd/secret/shared/shared.go index 9fe6874164b..ddf4e67b574 100644 --- a/pkg/cmd/secret/shared/shared.go +++ b/pkg/cmd/secret/shared/shared.go @@ -20,6 +20,7 @@ type App string const ( Actions = "actions" + Agents = "agents" Codespaces = "codespaces" Dependabot = "dependabot" Unknown = "unknown" @@ -66,6 +67,8 @@ func GetSecretApp(app string, entity SecretEntity) (App, error) { switch strings.ToLower(app) { case Actions: return Actions, nil + case Agents: + return Agents, nil case Codespaces: return Codespaces, nil case Dependabot: @@ -84,6 +87,8 @@ func IsSupportedSecretEntity(app App, entity SecretEntity) bool { switch app { case Actions: return entity == Repository || entity == Organization || entity == Environment + case Agents: + return entity == Repository || entity == Organization case Codespaces: return entity == User || entity == Organization || entity == Repository case Dependabot: diff --git a/pkg/cmd/secret/shared/shared_test.go b/pkg/cmd/secret/shared/shared_test.go index eb121f0a853..91675c44c59 100644 --- a/pkg/cmd/secret/shared/shared_test.go +++ b/pkg/cmd/secret/shared/shared_test.go @@ -81,6 +81,11 @@ func TestGetSecretApp(t *testing.T) { app: "actions", want: Actions, }, + { + name: "Agents", + app: "agents", + want: Agents, + }, { name: "Codespaces", app: "codespaces", @@ -161,6 +166,19 @@ func TestIsSupportedSecretEntity(t *testing.T) { Unknown, }, }, + { + name: "Agents", + app: Agents, + supportedEntities: []SecretEntity{ + Repository, + Organization, + }, + unsupportedEntities: []SecretEntity{ + Environment, + User, + Unknown, + }, + }, { name: "Codespaces", app: Codespaces, From 87c8d1951868b689ac507f311a30e6bbee788e88 Mon Sep 17 00:00:00 2001 From: Melissa Xie Date: Fri, 22 May 2026 14:45:04 -0400 Subject: [PATCH 161/182] Link to Accessibility category for community discussions instead of ACR (#13481) * Link to Accessibility category for community discussions instead of ACR The Accessibility Conformance Report (ACR) is only valuable in that it outlines how GitHub CLI measures up against the accessibility standards. However, it's confusing to link to that when the CTA in `gh a11y` is to "join the conversation" and share feedback. In this case, it's more appropriate to route users directly to the community discussions instead. * Adjust whitespace to align constant assignments --- pkg/cmd/accessibility/accessibility.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/cmd/accessibility/accessibility.go b/pkg/cmd/accessibility/accessibility.go index c5de6c1a481..98105ec14b1 100644 --- a/pkg/cmd/accessibility/accessibility.go +++ b/pkg/cmd/accessibility/accessibility.go @@ -12,7 +12,8 @@ import ( ) const ( - webURL = "https://accessibility.github.com/conformance/cli/" + acrURL = "https://accessibility.github.com/conformance/cli/" + a11yDiscussionsURL = "https://github.com/orgs/community/discussions/categories/accessibility" ) type AccessibilityOptions struct { @@ -36,9 +37,9 @@ func NewCmdAccessibility(f *cmdutil.Factory) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { if opts.Web { if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(webURL)) + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(acrURL)) } - return opts.Browser.Browse(webURL) + return opts.Browser.Browse(acrURL) } return cmd.Help() @@ -138,5 +139,5 @@ func longDescription(io *iostreams.IOStreams) string { feedback and ideas through GitHub Accessibility feedback channels: %[7]s - `, "`", title, color, prompter, spinner, feedback, webURL) + `, "`", title, color, prompter, spinner, feedback, a11yDiscussionsURL) } From be4ad7e0aa6541f3d620bbb9265463f4567f4362 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 22 May 2026 15:25:08 -0600 Subject: [PATCH 162/182] bump golang.org/x/net Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 89f960d62a4..1353e2ef65c 100644 --- a/go.mod +++ b/go.mod @@ -180,7 +180,7 @@ require ( go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/tools v0.45.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260316180232-0b37fe3546d5 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect diff --git a/go.sum b/go.sum index f43b4a5501b..fafd9d6375f 100644 --- a/go.sum +++ b/go.sum @@ -581,8 +581,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From a5be6be4908a18a25c6a0f52fd2b4daa7b29e0d2 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 25 May 2026 11:58:13 -0600 Subject: [PATCH 163/182] docs: note immutable releases starting v2.93.0 Refs github/cli#1177 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e67a7effc04..f0961571b10 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ For information on all pre-installed tools, see [`actions/runner-images`](https: ### Verification of binaries +Starting with v2.93.0, releases of `gh` are published as immutable releases. For more information, see [Immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases). + Since version 2.50.0, `gh` has been producing [Build Provenance Attestation](https://github.blog/changelog/2024-06-25-artifact-attestations-is-generally-available/), enabling a cryptographically verifiable paper-trail back to the origin GitHub repository, git revision, and build instructions used. The build provenance attestations are signed and rely on Public Good [Sigstore](https://www.sigstore.dev/) for PKI. There are two common ways to verify a downloaded release, depending on whether `gh` is already installed or not. If `gh` is installed, it's trivial to verify a new release: From 2cd93ebe61c3408cd4c831b795dccd6554d44667 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 20:08:58 +0000 Subject: [PATCH 164/182] chore(deps): bump github.com/gdamore/tcell/v2 from 2.13.9 to 2.13.10 Bumps [github.com/gdamore/tcell/v2](https://github.com/gdamore/tcell) from 2.13.9 to 2.13.10. - [Release notes](https://github.com/gdamore/tcell/releases) - [Changelog](https://github.com/gdamore/tcell/blob/main/CHANGESv3.md) - [Commits](https://github.com/gdamore/tcell/compare/v2.13.9...v2.13.10) --- updated-dependencies: - dependency-name: github.com/gdamore/tcell/v2 dependency-version: 2.13.10 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1353e2ef65c..5c0d3d406f4 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,7 @@ require ( github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea github.com/distribution/reference v0.6.0 github.com/gabriel-vasile/mimetype v1.4.13 - github.com/gdamore/tcell/v2 v2.13.9 + github.com/gdamore/tcell/v2 v2.13.10 github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.21.6 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 diff --git a/go.sum b/go.sum index fafd9d6375f..c04cfed3738 100644 --- a/go.sum +++ b/go.sum @@ -203,8 +203,8 @@ github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9 github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.13.9 h1:uI5l3DYPcFvHINKlGft+en23evOKL+dwtD21QR8ejVA= -github.com/gdamore/tcell/v2 v2.13.9/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= +github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA= +github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= From 34f71fec5e3ea98f28c44b241fd44cc3570d63ed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 20:11:16 +0000 Subject: [PATCH 165/182] chore(deps): bump golangci/golangci-lint-action from 9.2.0 to 9.2.1 Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 9.2.0 to 9.2.1. - [Release notes](https://github.com/golangci/golangci-lint-action/releases) - [Commits](https://github.com/golangci/golangci-lint-action/compare/1e7e51e771db61008b38414a730f564565cf7c20...82606bf257cbaff209d206a39f5134f0cfbfd2ee) --- updated-dependencies: - dependency-name: golangci/golangci-lint-action dependency-version: 9.2.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3b6fc5b2283..49d10038ea7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -46,7 +46,7 @@ jobs: exit $STATUS - name: golangci-lint - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 with: version: v2.11.0 From e6dfcd3ce728af3af5d25839203a7b458b63bc06 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Wed, 27 May 2026 18:07:19 +0100 Subject: [PATCH 166/182] fix: use separate http client for non-github hosts Signed-off-by: Babak K. Shandiz Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> --- api/http_client.go | 44 +++++++++++ api/http_client_test.go | 49 ++++++++++++ internal/codespaces/api/api.go | 30 ++++---- internal/codespaces/codespaces.go | 6 +- internal/codespaces/codespaces_test.go | 4 +- pkg/cmd/attestation/api/client.go | 23 +++--- pkg/cmd/attestation/api/client_test.go | 52 ++++++------- pkg/cmd/attestation/download/download.go | 7 +- pkg/cmd/attestation/download/download_test.go | 9 +-- pkg/cmd/attestation/inspect/inspect.go | 15 ++-- .../attestation/trustedroot/trustedroot.go | 10 ++- .../trustedroot/trustedroot_test.go | 9 +++ pkg/cmd/attestation/verification/sigstore.go | 12 +-- pkg/cmd/attestation/verify/verify.go | 15 ++-- pkg/cmd/attestation/verify/verify_test.go | 3 + pkg/cmd/codespace/common.go | 2 +- pkg/cmd/codespace/mock_api.go | 74 +++++++++---------- pkg/cmd/codespace/ports_test.go | 2 +- pkg/cmd/factory/default.go | 11 +++ pkg/cmd/release/shared/attestation.go | 18 ++--- pkg/cmd/release/verify-asset/verify_asset.go | 15 ++-- .../release/verify-asset/verify_asset_test.go | 3 + pkg/cmd/release/verify/verify.go | 15 ++-- pkg/cmd/release/verify/verify_test.go | 3 + pkg/cmdutil/factory.go | 6 +- 25 files changed, 294 insertions(+), 143 deletions(-) diff --git a/api/http_client.go b/api/http_client.go index 206b8ed56a8..078a2a86c8a 100644 --- a/api/http_client.go +++ b/api/http_client.go @@ -86,6 +86,50 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) { return client, nil } +// ExternalHTTPClientOptions holds options for creating an external HTTP client. +type ExternalHTTPClientOptions struct { + AppVersion string + Log io.Writer + LogColorize bool + Transport http.RoundTripper +} + +// NewExternalHTTPClient creates an HTTP client for talking to non-GitHub hosts. +// It includes debug logging and a User-Agent header but does not attach any +// authentication tokens or GitHub-specific headers. +func NewExternalHTTPClient(opts ExternalHTTPClientOptions) (*http.Client, error) { + clientOpts := ghAPI.ClientOptions{ + Host: "none", + AuthToken: "none", + LogIgnoreEnv: true, + SkipDefaultHeaders: true, + Transport: opts.Transport, + } + + debugEnabled, debugValue := utils.IsDebugEnabled() + logVerboseHTTP := false + if strings.Contains(debugValue, "api") { + logVerboseHTTP = true + } + + if logVerboseHTTP || debugEnabled { + clientOpts.Log = opts.Log + clientOpts.LogColorize = opts.LogColorize + clientOpts.LogVerboseHTTP = logVerboseHTTP + } + + clientOpts.Headers = map[string]string{ + userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion), + } + + client, err := ghAPI.NewHTTPClient(clientOpts) + if err != nil { + return nil, err + } + + return client, nil +} + func NewCachedHTTPClient(httpClient *http.Client, ttl time.Duration) *http.Client { newClient := *httpClient newClient.Transport = AddCacheTTLHeader(httpClient.Transport, ttl) diff --git a/api/http_client_test.go b/api/http_client_test.go index 198c0849118..56be00af6b0 100644 --- a/api/http_client_test.go +++ b/api/http_client_test.go @@ -381,6 +381,55 @@ func TestNewHTTPClientWithoutTelemetryDisabler(t *testing.T) { assert.Equal(t, 204, res.StatusCode) } +func TestNewExternalHTTPClient(t *testing.T) { + tests := []struct { + name string + url string + }{ + { + name: "third-party host", + url: "https://example.com/path", + }, + { + // Even when talking to GitHub, the external client must not set + // authorization or any GitHub-specific headers. + name: "github.com host", + url: "https://api.github.com/repos/cli/cli", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotReq *http.Request + transport := &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { + gotReq = req + return &http.Response{StatusCode: 204, Body: io.NopCloser(strings.NewReader(""))}, nil + }} + + client, err := NewExternalHTTPClient(ExternalHTTPClientOptions{ + AppVersion: "v1.2.3", + Transport: transport, + }) + require.NoError(t, err) + + req, err := http.NewRequest("GET", tt.url, nil) + require.NoError(t, err) + + res, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, 204, res.StatusCode) + + // No headers should be set by default, except for User-Agent which should include the app version. + assert.Equal(t, []string{"GitHub CLI v1.2.3"}, gotReq.Header.Values("user-agent")) + assert.Empty(t, gotReq.Header.Values("authorization")) + assert.Empty(t, gotReq.Header.Values("x-github-api-version")) + assert.Empty(t, gotReq.Header.Values("accept")) + assert.Empty(t, gotReq.Header.Values("content-type")) + assert.Empty(t, gotReq.Header.Values("time-zone")) + }) + } +} + type fakeTelemetryDisabler struct { disabled bool } diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index 0d1eaf5b34f..2bd0a5e3be6 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -60,10 +60,11 @@ const ( // API is the interface to the codespace service. type API struct { - client func() (*http.Client, error) - githubAPI string - githubServer string - retryBackoff time.Duration + client func() (*http.Client, error) + externalClient func() (*http.Client, error) + githubAPI string + githubServer string + retryBackoff time.Duration } // New creates a new API client connecting to the configured endpoints with the HTTP client. @@ -93,10 +94,11 @@ func New(f *cmdutil.Factory) *API { } return &API{ - client: f.HttpClient, - githubAPI: strings.TrimSuffix(apiURL, "/"), - githubServer: strings.TrimSuffix(serverURL, "/"), - retryBackoff: 100 * time.Millisecond, + client: f.HttpClient, + externalClient: f.ExternalHttpClient, + githubAPI: strings.TrimSuffix(apiURL, "/"), + githubServer: strings.TrimSuffix(serverURL, "/"), + retryBackoff: 100 * time.Millisecond, } } @@ -1214,12 +1216,8 @@ func (a *API) withRetry(f func() (*http.Response, error)) (*http.Response, error }, backoff.WithMaxRetries(bo, 3)) } -// HTTPClient returns the HTTP client used to make requests to the API. -func (a *API) HTTPClient() (*http.Client, error) { - httpClient, err := a.client() - if err != nil { - return nil, err - } - - return httpClient, nil +// ExternalHTTPClient returns an HTTP client for requests to non-GitHub hosts. +// It must not carry GitHub authentication credentials. +func (a *API) ExternalHTTPClient() (*http.Client, error) { + return a.externalClient() } diff --git a/internal/codespaces/codespaces.go b/internal/codespaces/codespaces.go index 92185120a2e..c30e126abc6 100644 --- a/internal/codespaces/codespaces.go +++ b/internal/codespaces/codespaces.go @@ -39,7 +39,7 @@ func connectionReady(codespace *api.Codespace) bool { type apiClient interface { GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) StartCodespace(ctx context.Context, name string) error - HTTPClient() (*http.Client, error) + ExternalHTTPClient() (*http.Client, error) } type progressIndicator interface { @@ -66,12 +66,12 @@ func GetCodespaceConnection(ctx context.Context, progress progressIndicator, api progress.StartProgressIndicatorWithLabel("Connecting to codespace") defer progress.StopProgressIndicator() - httpClient, err := apiClient.HTTPClient() + externalHttpClient, err := apiClient.ExternalHTTPClient() if err != nil { return nil, fmt.Errorf("error getting http client: %w", err) } - return connection.NewCodespaceConnection(ctx, codespace, httpClient) + return connection.NewCodespaceConnection(ctx, codespace, externalHttpClient) } // waitUntilCodespaceConnectionReady waits for a Codespace to be running and is able to be connected to. diff --git a/internal/codespaces/codespaces_test.go b/internal/codespaces/codespaces_test.go index d931b96ef4b..aceb970483f 100644 --- a/internal/codespaces/codespaces_test.go +++ b/internal/codespaces/codespaces_test.go @@ -202,8 +202,8 @@ func (m *mockApiClient) GetCodespace(ctx context.Context, name string, includeCo return m.onGetCodespace() } -func (m *mockApiClient) HTTPClient() (*http.Client, error) { - panic("Not implemented") +func (m *mockApiClient) ExternalHTTPClient() (*http.Client, error) { + return nil, nil } type mockProgressIndicator struct{} diff --git a/pkg/cmd/attestation/api/client.go b/pkg/cmd/attestation/api/client.go index 469b1a1a1d9..41c713d7c0c 100644 --- a/pkg/cmd/attestation/api/client.go +++ b/pkg/cmd/attestation/api/client.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + neturl "net/url" "strings" "time" @@ -67,18 +68,18 @@ type Client interface { } type LiveClient struct { - githubAPI githubApiClient - httpClient httpClient - host string - logger *ioconfig.Handler + githubAPI githubApiClient + externalHttpClient httpClient + host string + logger *ioconfig.Handler } -func NewLiveClient(hc *http.Client, host string, l *ioconfig.Handler) *LiveClient { +func NewLiveClient(hc *http.Client, externalClient *http.Client, host string, l *ioconfig.Handler) *LiveClient { return &LiveClient{ - githubAPI: api.NewClientFromHTTP(hc), - host: strings.TrimSuffix(host, "/"), - httpClient: hc, - logger: l, + githubAPI: api.NewClientFromHTTP(hc), + host: strings.TrimSuffix(host, "/"), + externalHttpClient: externalClient, + logger: l, } } @@ -121,7 +122,7 @@ func (c *LiveClient) buildRequestURL(params FetchParams) (string, error) { // ref: https://github.com/cli/go-gh/blob/d32c104a9a25c9de3d7c7b07a43ae0091441c858/example_gh_test.go#L96 url = fmt.Sprintf("%s?per_page=%d", url, perPage) if params.PredicateType != "" { - url = fmt.Sprintf("%s&predicate_type=%s", url, params.PredicateType) + url = fmt.Sprintf("%s&predicate_type=%s", url, neturl.QueryEscape(params.PredicateType)) } return url, nil } @@ -225,7 +226,7 @@ func (c *LiveClient) getBundle(url string) (*bundle.Bundle, error) { var sgBundle *bundle.Bundle bo := backoff.NewConstantBackOff(getAttestationRetryInterval) err := backoff.Retry(func() error { - resp, err := c.httpClient.Get(url) + resp, err := c.externalHttpClient.Get(url) if err != nil { return fmt.Errorf("request to fetch bundle from URL failed: %w", err) } diff --git a/pkg/cmd/attestation/api/client_test.go b/pkg/cmd/attestation/api/client_test.go index d9381612dd2..e27297b51d2 100644 --- a/pkg/cmd/attestation/api/client_test.go +++ b/pkg/cmd/attestation/api/client_test.go @@ -28,8 +28,8 @@ func NewClientWithMockGHClient(hasNextPage bool) Client { githubAPI: mockAPIClient{ OnRESTWithNext: fetcher.OnRESTSuccessWithNextPage, }, - httpClient: httpClient, - logger: l, + externalHttpClient: httpClient, + logger: l, } } @@ -37,8 +37,8 @@ func NewClientWithMockGHClient(hasNextPage bool) Client { githubAPI: mockAPIClient{ OnRESTWithNext: fetcher.OnRESTSuccess, }, - httpClient: httpClient, - logger: l, + externalHttpClient: httpClient, + logger: l, } } @@ -137,8 +137,8 @@ func TestGetByDigest_NoAttestationsFound(t *testing.T) { githubAPI: mockAPIClient{ OnRESTWithNext: fetcher.OnRESTWithNextNoAttestations, }, - httpClient: httpClient, - logger: io.NewTestHandler(), + externalHttpClient: httpClient, + logger: io.NewTestHandler(), } attestations, err := c.GetByDigest(testFetchParamsWithRepo) @@ -167,8 +167,8 @@ func TestGetByDigest_Error(t *testing.T) { func TestFetchBundleFromAttestations_BundleURL(t *testing.T) { httpClient := &mockHttpClient{} client := LiveClient{ - httpClient: httpClient, - logger: io.NewTestHandler(), + externalHttpClient: httpClient, + logger: io.NewTestHandler(), } att1 := makeTestAttestation() @@ -184,8 +184,8 @@ func TestFetchBundleFromAttestations_BundleURL(t *testing.T) { func TestFetchBundleFromAttestations_MissingBundleAndBundleURLFields(t *testing.T) { httpClient := &mockHttpClient{} client := LiveClient{ - httpClient: httpClient, - logger: io.NewTestHandler(), + externalHttpClient: httpClient, + logger: io.NewTestHandler(), } // If both the BundleURL and Bundle fields are empty, the function should @@ -207,8 +207,8 @@ func TestFetchBundleFromAttestations_FailOnTheSecondAttestation(t *testing.T) { } c := &LiveClient{ - httpClient: mockHTTPClient, - logger: io.NewTestHandler(), + externalHttpClient: mockHTTPClient, + logger: io.NewTestHandler(), } att1 := makeTestAttestation() @@ -223,8 +223,8 @@ func TestFetchBundleFromAttestations_FailAfterRetrying(t *testing.T) { mockHTTPClient := &reqFailHttpClient{} c := &LiveClient{ - httpClient: mockHTTPClient, - logger: io.NewTestHandler(), + externalHttpClient: mockHTTPClient, + logger: io.NewTestHandler(), } a := makeTestAttestation() @@ -239,8 +239,8 @@ func TestFetchBundleFromAttestations_FallbackToBundleField(t *testing.T) { mockHTTPClient := &mockHttpClient{} c := &LiveClient{ - httpClient: mockHTTPClient, - logger: io.NewTestHandler(), + externalHttpClient: mockHTTPClient, + logger: io.NewTestHandler(), } // If the bundle URL is empty, the code will fallback to the bundle field @@ -257,8 +257,8 @@ func TestGetBundle(t *testing.T) { mockHTTPClient := &mockHttpClient{} c := &LiveClient{ - httpClient: mockHTTPClient, - logger: io.NewTestHandler(), + externalHttpClient: mockHTTPClient, + logger: io.NewTestHandler(), } b, err := c.getBundle("https://mybundleurl.com") @@ -276,8 +276,8 @@ func TestGetBundle_SuccessfulRetry(t *testing.T) { } c := &LiveClient{ - httpClient: mockHTTPClient, - logger: io.NewTestHandler(), + externalHttpClient: mockHTTPClient, + logger: io.NewTestHandler(), } b, err := c.getBundle("mybundleurl") @@ -290,8 +290,8 @@ func TestGetBundle_SuccessfulRetry(t *testing.T) { func TestGetBundle_PermanentBackoffFail(t *testing.T) { mockHTTPClient := &invalidBundleClient{} c := &LiveClient{ - httpClient: mockHTTPClient, - logger: io.NewTestHandler(), + externalHttpClient: mockHTTPClient, + logger: io.NewTestHandler(), } b, err := c.getBundle("mybundleurl") @@ -307,8 +307,8 @@ func TestGetBundle_RequestFail(t *testing.T) { mockHTTPClient := &reqFailHttpClient{} c := &LiveClient{ - httpClient: mockHTTPClient, - logger: io.NewTestHandler(), + externalHttpClient: mockHTTPClient, + logger: io.NewTestHandler(), } b, err := c.getBundle("mybundleurl") @@ -360,8 +360,8 @@ func TestGetAttestationsRetries(t *testing.T) { githubAPI: mockAPIClient{ OnRESTWithNext: fetcher.FlakyOnRESTSuccessWithNextPageHandler(), }, - httpClient: &mockHttpClient{}, - logger: io.NewTestHandler(), + externalHttpClient: &mockHttpClient{}, + logger: io.NewTestHandler(), } testFetchParamsWithRepo.Limit = 30 diff --git a/pkg/cmd/attestation/download/download.go b/pkg/cmd/attestation/download/download.go index 8d1d1dc0511..f0024018044 100644 --- a/pkg/cmd/attestation/download/download.go +++ b/pkg/cmd/attestation/download/download.go @@ -84,6 +84,11 @@ func NewDownloadCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Comman return err } + externalClient, err := f.ExternalHttpClient() + if err != nil { + return err + } + if opts.Hostname == "" { opts.Hostname, _ = ghauth.DefaultHost() } @@ -91,7 +96,7 @@ func NewDownloadCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Comman return err } - opts.APIClient = api.NewLiveClient(hc, opts.Hostname, opts.Logger) + opts.APIClient = api.NewLiveClient(hc, externalClient, opts.Hostname, opts.Logger) opts.OCIClient = oci.NewLiveClient() opts.Store = NewLiveStore("") diff --git a/pkg/cmd/attestation/download/download_test.go b/pkg/cmd/attestation/download/download_test.go index 11872daf900..d470c7afbce 100644 --- a/pkg/cmd/attestation/download/download_test.go +++ b/pkg/cmd/attestation/download/download_test.go @@ -15,7 +15,6 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/test" "github.com/cli/cli/v2/pkg/cmdutil" - "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -39,10 +38,10 @@ func TestNewDownloadCmd(t *testing.T) { f := &cmdutil.Factory{ IOStreams: testIO, HttpClient: func() (*http.Client, error) { - reg := &httpmock.Registry{} - client := &http.Client{} - httpmock.ReplaceTripper(client, reg) - return client, nil + return nil, nil + }, + ExternalHttpClient: func() (*http.Client, error) { + return nil, nil }, } diff --git a/pkg/cmd/attestation/inspect/inspect.go b/pkg/cmd/attestation/inspect/inspect.go index 9a2bb5d3f58..97aa149fb56 100644 --- a/pkg/cmd/attestation/inspect/inspect.go +++ b/pkg/cmd/attestation/inspect/inspect.go @@ -86,17 +86,18 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command return err } + externalClient, err := f.ExternalHttpClient() + if err != nil { + return err + } + config := verification.SigstoreConfig{ - HttpClient: hc, - Logger: opts.Logger, + ExternalHttpClient: externalClient, + Logger: opts.Logger, } if ghauth.IsTenancy(opts.Hostname) { - hc, err := f.HttpClient() - if err != nil { - return err - } - apiClient := api.NewLiveClient(hc, opts.Hostname, opts.Logger) + apiClient := api.NewLiveClient(hc, externalClient, opts.Hostname, opts.Logger) td, err := apiClient.GetTrustDomain() if err != nil { return fmt.Errorf("error getting trust domain, make sure you are authenticated against the host: %w", err) diff --git a/pkg/cmd/attestation/trustedroot/trustedroot.go b/pkg/cmd/attestation/trustedroot/trustedroot.go index 29dd6fcd9fd..242ebcd1fb3 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot.go @@ -71,6 +71,12 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com if err != nil { return err } + + externalClient, err := f.ExternalHttpClient() + if err != nil { + return err + } + if ghauth.IsTenancy(opts.Hostname) { c, err := f.Config() if err != nil { @@ -81,7 +87,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com return fmt.Errorf("not authenticated with %s", opts.Hostname) } logger := io.NewHandler(f.IOStreams) - apiClient := api.NewLiveClient(hc, opts.Hostname, logger) + apiClient := api.NewLiveClient(hc, externalClient, opts.Hostname, logger) td, err := apiClient.GetTrustDomain() if err != nil { return err @@ -93,7 +99,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com return runF(opts) } - if err := getTrustedRoot(tuf.New, opts, hc); err != nil { + if err := getTrustedRoot(tuf.New, opts, externalClient); err != nil { return fmt.Errorf("Failed to verify the TUF repository: %w", err) } diff --git a/pkg/cmd/attestation/trustedroot/trustedroot_test.go b/pkg/cmd/attestation/trustedroot/trustedroot_test.go index 0d67c44459f..02457a42d80 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot_test.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot_test.go @@ -34,6 +34,9 @@ func TestNewTrustedRootCmd(t *testing.T) { httpmock.ReplaceTripper(client, reg) return client, nil }, + ExternalHttpClient: func() (*http.Client, error) { + return nil, nil + }, } testcases := []struct { @@ -120,6 +123,9 @@ func TestNewTrustedRootWithTenancy(t *testing.T) { }, nil }, HttpClient: httpClientFunc, + ExternalHttpClient: func() (*http.Client, error) { + return nil, nil + }, } cmd := NewTrustedRootCmd(f, func(_ *Options) error { @@ -148,6 +154,9 @@ func TestNewTrustedRootWithTenancy(t *testing.T) { }, nil }, HttpClient: httpClientFunc, + ExternalHttpClient: func() (*http.Client, error) { + return nil, nil + }, } cmd := NewTrustedRootCmd(f, func(_ *Options) error { diff --git a/pkg/cmd/attestation/verification/sigstore.go b/pkg/cmd/attestation/verification/sigstore.go index e76d55a6b60..7ab3e083172 100644 --- a/pkg/cmd/attestation/verification/sigstore.go +++ b/pkg/cmd/attestation/verification/sigstore.go @@ -31,10 +31,10 @@ type AttestationProcessingResult struct { } type SigstoreConfig struct { - TrustedRoot string - Logger *io.Handler - NoPublicGood bool - HttpClient *http.Client + TrustedRoot string + Logger *io.Handler + NoPublicGood bool + ExternalHttpClient *http.Client // If tenancy mode is not used, trust domain is empty TrustDomain string // TUFMetadataDir @@ -76,7 +76,7 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro // No custom trusted root is set, so configure Public Good and GitHub verifiers if !config.NoPublicGood { - publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir, config.HttpClient) + publicGoodVerifier, err := newPublicGoodVerifier(config.TUFMetadataDir, config.ExternalHttpClient) if err != nil { // Log warning but continue - PGI unavailability should not block GitHub attestation verification config.Logger.VerbosePrintf("Warning: failed to initialize Sigstore Public Good verifier: %v\n", err) @@ -86,7 +86,7 @@ func NewLiveSigstoreVerifier(config SigstoreConfig) (*LiveSigstoreVerifier, erro } } - github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir, config.HttpClient) + github, err := newGitHubVerifier(config.TrustDomain, config.TUFMetadataDir, config.ExternalHttpClient) if err != nil { config.Logger.VerbosePrintf("Warning: failed to initialize GitHub verifier: %v\n", err) } else { diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index 90cc5643c36..120f94d6588 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -173,6 +173,11 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command return err } + externalClient, err := f.ExternalHttpClient() + if err != nil { + return err + } + opts.OCIClient = oci.NewLiveClient() if opts.Hostname == "" { @@ -183,13 +188,13 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command return err } - opts.APIClient = api.NewLiveClient(hc, opts.Hostname, opts.Logger) + opts.APIClient = api.NewLiveClient(hc, externalClient, opts.Hostname, opts.Logger) config := verification.SigstoreConfig{ - HttpClient: hc, - Logger: opts.Logger, - NoPublicGood: opts.NoPublicGood, - TrustedRoot: opts.TrustedRoot, + ExternalHttpClient: externalClient, + Logger: opts.Logger, + NoPublicGood: opts.NoPublicGood, + TrustedRoot: opts.TrustedRoot, } // Prepare for tenancy if detected diff --git a/pkg/cmd/attestation/verify/verify_test.go b/pkg/cmd/attestation/verify/verify_test.go index 2b821a435d9..295d4a30a30 100644 --- a/pkg/cmd/attestation/verify/verify_test.go +++ b/pkg/cmd/attestation/verify/verify_test.go @@ -54,6 +54,9 @@ func TestNewVerifyCmd(t *testing.T) { httpmock.ReplaceTripper(client, reg) return client, nil }, + ExternalHttpClient: func() (*http.Client, error) { + return nil, nil + }, } testcases := []struct { diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go index e56e6c0b86a..0acb89a511d 100644 --- a/pkg/cmd/codespace/common.go +++ b/pkg/cmd/codespace/common.go @@ -82,7 +82,7 @@ type apiClient interface { ListDevContainers(ctx context.Context, repoID int, branch string, limit int) (devcontainers []api.DevContainerEntry, err error) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*api.User, error) - HTTPClient() (*http.Client, error) + ExternalHTTPClient() (*http.Client, error) } var errNoCodespaces = errors.New("you have no codespaces") diff --git a/pkg/cmd/codespace/mock_api.go b/pkg/cmd/codespace/mock_api.go index e4de0ae7a90..7dc5bf6ce94 100644 --- a/pkg/cmd/codespace/mock_api.go +++ b/pkg/cmd/codespace/mock_api.go @@ -26,6 +26,9 @@ import ( // EditCodespaceFunc: func(ctx context.Context, codespaceName string, params *codespacesAPI.EditCodespaceParams) (*codespacesAPI.Codespace, error) { // panic("mock out the EditCodespace method") // }, +// ExternalHTTPClientFunc: func() (*http.Client, error) { +// panic("mock out the ExternalHTTPClient method") +// }, // GetCodespaceFunc: func(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error) { // panic("mock out the GetCodespace method") // }, @@ -53,9 +56,6 @@ import ( // GetUserFunc: func(ctx context.Context) (*codespacesAPI.User, error) { // panic("mock out the GetUser method") // }, -// HTTPClientFunc: func() (*http.Client, error) { -// panic("mock out the HTTPClient method") -// }, // ListCodespacesFunc: func(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error) { // panic("mock out the ListCodespaces method") // }, @@ -87,6 +87,9 @@ type apiClientMock struct { // EditCodespaceFunc mocks the EditCodespace method. EditCodespaceFunc func(ctx context.Context, codespaceName string, params *codespacesAPI.EditCodespaceParams) (*codespacesAPI.Codespace, error) + // ExternalHTTPClientFunc mocks the ExternalHTTPClient method. + ExternalHTTPClientFunc func() (*http.Client, error) + // GetCodespaceFunc mocks the GetCodespace method. GetCodespaceFunc func(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error) @@ -114,9 +117,6 @@ type apiClientMock struct { // GetUserFunc mocks the GetUser method. GetUserFunc func(ctx context.Context) (*codespacesAPI.User, error) - // HTTPClientFunc mocks the HTTPClient method. - HTTPClientFunc func() (*http.Client, error) - // ListCodespacesFunc mocks the ListCodespaces method. ListCodespacesFunc func(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error) @@ -161,6 +161,9 @@ type apiClientMock struct { // Params is the params argument value. Params *codespacesAPI.EditCodespaceParams } + // ExternalHTTPClient holds details about calls to the ExternalHTTPClient method. + ExternalHTTPClient []struct { + } // GetCodespace holds details about calls to the GetCodespace method. GetCodespace []struct { // Ctx is the ctx argument value. @@ -242,9 +245,6 @@ type apiClientMock struct { // Ctx is the ctx argument value. Ctx context.Context } - // HTTPClient holds details about calls to the HTTPClient method. - HTTPClient []struct { - } // ListCodespaces holds details about calls to the ListCodespaces method. ListCodespaces []struct { // Ctx is the ctx argument value. @@ -288,6 +288,7 @@ type apiClientMock struct { lockCreateCodespace sync.RWMutex lockDeleteCodespace sync.RWMutex lockEditCodespace sync.RWMutex + lockExternalHTTPClient sync.RWMutex lockGetCodespace sync.RWMutex lockGetCodespaceBillableOwner sync.RWMutex lockGetCodespaceRepoSuggestions sync.RWMutex @@ -297,7 +298,6 @@ type apiClientMock struct { lockGetOrgMemberCodespace sync.RWMutex lockGetRepository sync.RWMutex lockGetUser sync.RWMutex - lockHTTPClient sync.RWMutex lockListCodespaces sync.RWMutex lockListDevContainers sync.RWMutex lockServerURL sync.RWMutex @@ -425,6 +425,33 @@ func (mock *apiClientMock) EditCodespaceCalls() []struct { return calls } +// ExternalHTTPClient calls ExternalHTTPClientFunc. +func (mock *apiClientMock) ExternalHTTPClient() (*http.Client, error) { + if mock.ExternalHTTPClientFunc == nil { + panic("apiClientMock.ExternalHTTPClientFunc: method is nil but apiClient.ExternalHTTPClient was just called") + } + callInfo := struct { + }{} + mock.lockExternalHTTPClient.Lock() + mock.calls.ExternalHTTPClient = append(mock.calls.ExternalHTTPClient, callInfo) + mock.lockExternalHTTPClient.Unlock() + return mock.ExternalHTTPClientFunc() +} + +// ExternalHTTPClientCalls gets all the calls that were made to ExternalHTTPClient. +// Check the length with: +// +// len(mockedapiClient.ExternalHTTPClientCalls()) +func (mock *apiClientMock) ExternalHTTPClientCalls() []struct { +} { + var calls []struct { + } + mock.lockExternalHTTPClient.RLock() + calls = mock.calls.ExternalHTTPClient + mock.lockExternalHTTPClient.RUnlock() + return calls +} + // GetCodespace calls GetCodespaceFunc. func (mock *apiClientMock) GetCodespace(ctx context.Context, name string, includeConnection bool) (*codespacesAPI.Codespace, error) { if mock.GetCodespaceFunc == nil { @@ -785,33 +812,6 @@ func (mock *apiClientMock) GetUserCalls() []struct { return calls } -// HTTPClient calls HTTPClientFunc. -func (mock *apiClientMock) HTTPClient() (*http.Client, error) { - if mock.HTTPClientFunc == nil { - panic("apiClientMock.HTTPClientFunc: method is nil but apiClient.HTTPClient was just called") - } - callInfo := struct { - }{} - mock.lockHTTPClient.Lock() - mock.calls.HTTPClient = append(mock.calls.HTTPClient, callInfo) - mock.lockHTTPClient.Unlock() - return mock.HTTPClientFunc() -} - -// HTTPClientCalls gets all the calls that were made to HTTPClient. -// Check the length with: -// -// len(mockedapiClient.HTTPClientCalls()) -func (mock *apiClientMock) HTTPClientCalls() []struct { -} { - var calls []struct { - } - mock.lockHTTPClient.RLock() - calls = mock.calls.HTTPClient - mock.lockHTTPClient.RUnlock() - return calls -} - // ListCodespaces calls ListCodespacesFunc. func (mock *apiClientMock) ListCodespaces(ctx context.Context, opts codespacesAPI.ListCodespacesOptions) ([]*codespacesAPI.Codespace, error) { if mock.ListCodespacesFunc == nil { diff --git a/pkg/cmd/codespace/ports_test.go b/pkg/cmd/codespace/ports_test.go index 034c15eb6c6..bd5cfb4f388 100644 --- a/pkg/cmd/codespace/ports_test.go +++ b/pkg/cmd/codespace/ports_test.go @@ -159,7 +159,7 @@ func GetMockApi(allowOrgPorts bool) *apiClientMock { GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) { return nil, nil }, - HTTPClientFunc: func() (*http.Client, error) { + ExternalHTTPClientFunc: func() (*http.Client, error) { return connection.NewMockHttpClient() }, } diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index f61e51b452f..cc10075f203 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -34,6 +34,7 @@ func New(appVersion string, invokingAgent string, cfgFunc func() (gh.Config, err f.IOStreams = ios f.HttpClient = HttpClientFunc(cfgFunc, ios, appVersion, invokingAgent, telemetryDisabler) f.PlainHttpClient = plainHttpClientFunc(ios, appVersion, invokingAgent, telemetryDisabler) + f.ExternalHttpClient = externalHttpClientFunc(ios, appVersion) f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable f.Remotes = remotesFunc(f) // Depends on Config, and GitClient f.BaseRepo = BaseRepoFunc(f.Remotes) @@ -226,6 +227,16 @@ func plainHttpClientFunc(ios *iostreams.IOStreams, appVersion string, invokingAg } } +func externalHttpClientFunc(ios *iostreams.IOStreams, appVersion string) func() (*http.Client, error) { + return func() (*http.Client, error) { + return api.NewExternalHTTPClient(api.ExternalHTTPClientOptions{ + AppVersion: appVersion, + Log: ios.ErrOut, + LogColorize: ios.ColorEnabled(), + }) + } +} + func newGitClient(f *cmdutil.Factory) *git.Client { io := f.IOStreams client := &git.Client{ diff --git a/pkg/cmd/release/shared/attestation.go b/pkg/cmd/release/shared/attestation.go index 65990290b67..f26d8eb8b05 100644 --- a/pkg/cmd/release/shared/attestation.go +++ b/pkg/cmd/release/shared/attestation.go @@ -23,10 +23,10 @@ type Verifier interface { } type AttestationVerifier struct { - AttClient api.Client - HttpClient *http.Client - IO *iostreams.IOStreams - TrustedRoot string + AttClient api.Client + ExternalHttpClient *http.Client + IO *iostreams.IOStreams + TrustedRoot string } func (v *AttestationVerifier) VerifyAttestation(art *artifact.DigestedArtifact, att *api.Attestation) (*verification.AttestationProcessingResult, error) { @@ -36,11 +36,11 @@ func (v *AttestationVerifier) VerifyAttestation(art *artifact.DigestedArtifact, } verifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ - HttpClient: v.HttpClient, - Logger: att_io.NewHandler(v.IO), - NoPublicGood: true, - TrustDomain: td, - TrustedRoot: v.TrustedRoot, + ExternalHttpClient: v.ExternalHttpClient, + Logger: att_io.NewHandler(v.IO), + NoPublicGood: true, + TrustDomain: td, + TrustedRoot: v.TrustedRoot, }) if err != nil { return nil, err diff --git a/pkg/cmd/release/verify-asset/verify_asset.go b/pkg/cmd/release/verify-asset/verify_asset.go index 2cebce53bc8..9adacf2cae2 100644 --- a/pkg/cmd/release/verify-asset/verify_asset.go +++ b/pkg/cmd/release/verify-asset/verify_asset.go @@ -83,14 +83,19 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*VerifyAssetConfig) error) return err } + externalClient, err := f.ExternalHttpClient() + if err != nil { + return err + } + io := f.IOStreams - attClient := api.NewLiveClient(httpClient, baseRepo.RepoHost(), att_io.NewHandler(io)) + attClient := api.NewLiveClient(httpClient, externalClient, baseRepo.RepoHost(), att_io.NewHandler(io)) attVerifier := &shared.AttestationVerifier{ - AttClient: attClient, - HttpClient: httpClient, - IO: io, - TrustedRoot: opts.TrustedRoot, + AttClient: attClient, + ExternalHttpClient: externalClient, + IO: io, + TrustedRoot: opts.TrustedRoot, } config := &VerifyAssetConfig{ diff --git a/pkg/cmd/release/verify-asset/verify_asset_test.go b/pkg/cmd/release/verify-asset/verify_asset_test.go index dc881ec00a9..7535735aa40 100644 --- a/pkg/cmd/release/verify-asset/verify_asset_test.go +++ b/pkg/cmd/release/verify-asset/verify_asset_test.go @@ -54,6 +54,9 @@ func TestNewCmdVerifyAsset_Args(t *testing.T) { HttpClient: func() (*http.Client, error) { return nil, nil }, + ExternalHttpClient: func() (*http.Client, error) { + return nil, nil + }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index 39e27bbc50b..c1a9ae4a20a 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -79,14 +79,19 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(config *VerifyConfig) error) *co return err } + externalClient, err := f.ExternalHttpClient() + if err != nil { + return err + } + io := f.IOStreams - attClient := api.NewLiveClient(httpClient, baseRepo.RepoHost(), att_io.NewHandler(io)) + attClient := api.NewLiveClient(httpClient, externalClient, baseRepo.RepoHost(), att_io.NewHandler(io)) attVerifier := &shared.AttestationVerifier{ - AttClient: attClient, - HttpClient: httpClient, - IO: io, - TrustedRoot: opts.TrustedRoot, + AttClient: attClient, + ExternalHttpClient: externalClient, + IO: io, + TrustedRoot: opts.TrustedRoot, } config := &VerifyConfig{ diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go index ccb3b35a6ba..e2d29bb584b 100644 --- a/pkg/cmd/release/verify/verify_test.go +++ b/pkg/cmd/release/verify/verify_test.go @@ -43,6 +43,9 @@ func TestNewCmdVerify_Args(t *testing.T) { HttpClient: func() (*http.Client, error) { return nil, nil }, + ExternalHttpClient: func() (*http.Client, error) { + return nil, nil + }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index 1faf859f00c..200314038b2 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -39,5 +39,9 @@ type Factory struct { // auth and other headers. This is meant to be used in situations where the // client needs to specify the headers itself (e.g. during login). PlainHttpClient func() (*http.Client, error) - Remotes func() (context.Remotes, error) + // ExternalHttpClient is an HTTP client for talking to non-GitHub hosts + // It includes debug logging and a User-Agent header but does not attach any + // authentication tokens or GitHub-specific headers. + ExternalHttpClient func() (*http.Client, error) + Remotes func() (context.Remotes, error) } From 98d91db0e22d4ce7165a182652cc335454e32f7d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 27 May 2026 11:26:50 -0600 Subject: [PATCH 167/182] test(attestation): align integration tests with new external HTTP client Commit e6dfcd3ce ("fix: use separate http client for non-github hosts") renamed verification.SigstoreConfig.HttpClient to ExternalHttpClient, added an external *http.Client argument to api.NewLiveClient, and added the corresponding Factory.ExternalHttpClient field. It updated the non-tagged unit tests but missed every //go:build integration file in pkg/cmd/attestation/..., which broke trunk CI (build (ubuntu-latest) and build (macos-latest) in the Unit and Integration Tests workflow): - pkg/cmd/attestation/verification and pkg/cmd/attestation/verify failed to build (unknown field HttpClient; not enough arguments in call to api.NewLiveClient). - TestNewInspectCmd_PrintOutputJSONFormat panicked because the cmdutil.Factory literal it builds did not set ExternalHttpClient, so the new f.ExternalHttpClient() call in inspect.go dereferenced a nil func value. Rename the field at the integration-test call sites, pass http.DefaultClient as the new external client to api.NewLiveClient, and populate ExternalHttpClient on the inspect test factory. No production code changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../inspect/inspect_integration_test.go | 3 ++ .../verification/sigstore_integration_test.go | 32 ++++++++--------- .../verify/attestation_integration_test.go | 6 ++-- .../verify/verify_integration_test.go | 34 +++++++++---------- 4 files changed, 39 insertions(+), 36 deletions(-) diff --git a/pkg/cmd/attestation/inspect/inspect_integration_test.go b/pkg/cmd/attestation/inspect/inspect_integration_test.go index 6c56461afa9..7c0f1f65bb6 100644 --- a/pkg/cmd/attestation/inspect/inspect_integration_test.go +++ b/pkg/cmd/attestation/inspect/inspect_integration_test.go @@ -21,6 +21,9 @@ func TestNewInspectCmd_PrintOutputJSONFormat(t *testing.T) { HttpClient: func() (*http.Client, error) { return http.DefaultClient, nil }, + ExternalHttpClient: func() (*http.Client, error) { + return http.DefaultClient, nil + }, } t.Run("Print output in JSON format", func(t *testing.T) { diff --git a/pkg/cmd/attestation/verification/sigstore_integration_test.go b/pkg/cmd/attestation/verification/sigstore_integration_test.go index d37b94fc835..daa948b950c 100644 --- a/pkg/cmd/attestation/verification/sigstore_integration_test.go +++ b/pkg/cmd/attestation/verification/sigstore_integration_test.go @@ -52,9 +52,9 @@ func TestLiveSigstoreVerifier(t *testing.T) { for _, tc := range testcases { t.Run(tc.name, func(t *testing.T) { verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: io.NewTestHandler(), - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: io.NewTestHandler(), + TUFMetadataDir: o.Some(t.TempDir()), }) require.NoError(t, err) @@ -73,9 +73,9 @@ func TestLiveSigstoreVerifier(t *testing.T) { t.Run("with 2/3 verified attestations", func(t *testing.T) { verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: io.NewTestHandler(), - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: io.NewTestHandler(), + TUFMetadataDir: o.Some(t.TempDir()), }) require.NoError(t, err) @@ -92,9 +92,9 @@ func TestLiveSigstoreVerifier(t *testing.T) { t.Run("fail with 0/2 verified attestations", func(t *testing.T) { verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: io.NewTestHandler(), - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: io.NewTestHandler(), + TUFMetadataDir: o.Some(t.TempDir()), }) require.NoError(t, err) @@ -118,9 +118,9 @@ func TestLiveSigstoreVerifier(t *testing.T) { attestations := getAttestationsFor(t, "../test/data/github_provenance_demo-0.0.12-py3-none-any-bundle.jsonl") verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: io.NewTestHandler(), - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: io.NewTestHandler(), + TUFMetadataDir: o.Some(t.TempDir()), }) require.NoError(t, err) @@ -133,10 +133,10 @@ func TestLiveSigstoreVerifier(t *testing.T) { attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl") verifier, err := NewLiveSigstoreVerifier(SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: io.NewTestHandler(), - TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"), - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: io.NewTestHandler(), + TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"), + TUFMetadataDir: o.Some(t.TempDir()), }) require.NoError(t, err) diff --git a/pkg/cmd/attestation/verify/attestation_integration_test.go b/pkg/cmd/attestation/verify/attestation_integration_test.go index ec3eb271cb1..bb92489c63f 100644 --- a/pkg/cmd/attestation/verify/attestation_integration_test.go +++ b/pkg/cmd/attestation/verify/attestation_integration_test.go @@ -27,9 +27,9 @@ func getAttestationsFor(t *testing.T, bundlePath string) []*api.Attestation { func TestVerifyAttestations(t *testing.T) { sgVerifier, err := verification.NewLiveSigstoreVerifier(verification.SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: io.NewTestHandler(), - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: io.NewTestHandler(), + TUFMetadataDir: o.Some(t.TempDir()), }) require.NoError(t, err) diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index ed20a9007fd..195313b645e 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -25,9 +25,9 @@ func TestVerifyIntegration(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: logger, - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: logger, + TUFMetadataDir: o.Some(t.TempDir()), } ios, _, _, _ := iostreams.Test() @@ -45,7 +45,7 @@ func TestVerifyIntegration(t *testing.T) { sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) require.NoError(t, err) publicGoodOpts := Options{ - APIClient: api.NewLiveClient(hc, host, logger), + APIClient: api.NewLiveClient(hc, http.DefaultClient, host, logger), ArtifactPath: artifactPath, BundlePath: bundlePath, DigestAlgorithm: "sha512", @@ -120,7 +120,7 @@ func TestVerifyIntegration(t *testing.T) { sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) require.NoError(t, err) opts := Options{ - APIClient: api.NewLiveClient(hc, host, logger), + APIClient: api.NewLiveClient(hc, http.DefaultClient, host, logger), ArtifactPath: "oci://ghcr.io/github/artifact-attestations-helm-charts/policy-controller:v0.10.0-github9", UseBundleFromRegistry: true, DigestAlgorithm: "sha256", @@ -145,9 +145,9 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: logger, - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: logger, + TUFMetadataDir: o.Some(t.TempDir()), } ios, _, _, _ := iostreams.Test() @@ -165,7 +165,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) require.NoError(t, err) baseOpts := Options{ - APIClient: api.NewLiveClient(hc, host, logger), + APIClient: api.NewLiveClient(hc, http.DefaultClient, host, logger), ArtifactPath: artifactPath, BundlePath: bundlePath, DigestAlgorithm: "sha256", @@ -222,9 +222,9 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: logger, - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: logger, + TUFMetadataDir: o.Some(t.TempDir()), } cfg := config.NewBlankConfig() @@ -243,7 +243,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) require.NoError(t, err) baseOpts := Options{ - APIClient: api.NewLiveClient(hc, host, logger), + APIClient: api.NewLiveClient(hc, http.DefaultClient, host, logger), ArtifactPath: artifactPath, BundlePath: bundlePath, DigestAlgorithm: "sha256", @@ -319,9 +319,9 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { logger := io.NewTestHandler() sigstoreConfig := verification.SigstoreConfig{ - HttpClient: http.DefaultClient, - Logger: logger, - TUFMetadataDir: o.Some(t.TempDir()), + ExternalHttpClient: http.DefaultClient, + Logger: logger, + TUFMetadataDir: o.Some(t.TempDir()), } cfg := config.NewBlankConfig() @@ -340,7 +340,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { sigstoreVerifier, err := verification.NewLiveSigstoreVerifier(sigstoreConfig) require.NoError(t, err) baseOpts := Options{ - APIClient: api.NewLiveClient(hc, host, logger), + APIClient: api.NewLiveClient(hc, http.DefaultClient, host, logger), ArtifactPath: artifactPath, BundlePath: bundlePath, Config: func() (gh.Config, error) { From ecdbd6f9d9c8d7dababd10740355bc06700577df Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 28 May 2026 12:55:36 -0600 Subject: [PATCH 168/182] Sanitize terminal control characters in skill list output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/list/list.go | 19 +++++++++++++++++-- pkg/cmd/skills/list/list_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/skills/list/list.go b/pkg/cmd/skills/list/list.go index ca227425742..8047480a176 100644 --- a/pkg/cmd/skills/list/list.go +++ b/pkg/cmd/skills/list/list.go @@ -1,7 +1,9 @@ package list import ( + "bytes" "fmt" + "io" "os" "path/filepath" "sort" @@ -19,7 +21,9 @@ import ( "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/go-gh/v2/pkg/asciisanitizer" "github.com/spf13/cobra" + "golang.org/x/text/transform" ) var skillListFields = []string{ @@ -490,16 +494,27 @@ func renderTable(io *iostreams.IOStreams, skills []listedSkill) error { table := tableprinter.New(io, tableprinter.WithHeader("Name", "Agent", "Scope", "Source")) for _, skill := range skills { - table.AddField(skill.skillName) + table.AddField(sanitizeForTerminal(skill.skillName)) table.AddField(formatHosts(skill.agentHostIDs)) table.AddField(displayOrDash(skill.scope)) - table.AddField(displayOrDash(skill.source)) + table.AddField(displayOrDash(sanitizeForTerminal(skill.source))) table.EndRow() } return table.Render() } +// sanitizeForTerminal replaces ASCII control characters in s with inert +// caret-style stand-ins so untrusted content cannot inject terminal escapes. +func sanitizeForTerminal(s string) string { + var buf bytes.Buffer + r := transform.NewReader(bytes.NewReader([]byte(s)), &asciisanitizer.Sanitizer{}) + if _, err := io.Copy(&buf, r); err != nil { + return "Unknown" + } + return buf.String() +} + func displayOrDash(value string) string { if value == "" { return "-" diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go index b9eef019f26..958f00a0506 100644 --- a/pkg/cmd/skills/list/list_test.go +++ b/pkg/cmd/skills/list/list_test.go @@ -383,6 +383,30 @@ func TestListRun(t *testing.T) { }, wantJSON: "[]", }, + { + name: "sanitizes terminal escapes from skill frontmatter", + setup: func(t *testing.T, repoDir, homeDir string) { + customDir := filepath.Join(repoDir, "custom-skills") + writeSkill(t, customDir, "helper", heredoc.Doc(` + --- + name: helper + metadata: + local-path: "/src/\x1b[33munsanitized-src\x1b[0m" + github-path: "skills/\x1b[31munsanitized-name\x1b[0m/SKILL.md" + --- + Body + `)) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Dir: filepath.Join(repoDir, "custom-skills"), + } + }, + wantStdout: "^[[31munsanitized-name^[[0m\t-\tcustom\t/src/^[[33munsanitized-src^[[0m\n", + }, } for _, tt := range tests { From 989ee0cfe3e439bd41b3d850506501a9a3b0087c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 28 May 2026 13:04:27 -0600 Subject: [PATCH 169/182] Stat SKILL.md before reading in skill list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/list/list.go | 19 ++++++++++++--- pkg/cmd/skills/list/list_test.go | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/skills/list/list.go b/pkg/cmd/skills/list/list.go index 8047480a176..42875b147ff 100644 --- a/pkg/cmd/skills/list/list.go +++ b/pkg/cmd/skills/list/list.go @@ -338,7 +338,8 @@ func scanInstalledSkills(skillsDir string, agentHostIDs []string, scope string, // Flat layout: {dir}/{name}/SKILL.md. skillDir := filepath.Join(skillsDir, e.Name()) skillFile := filepath.Join(skillDir, "SKILL.md") - if data, readErr := os.ReadFile(skillFile); readErr == nil { + // TODO: maybe we should surface this error instead of a silent skip + if data, readErr := readSkillFile(skillFile); readErr == nil { skill, hasInstallMetadata := parseInstalledSkill(data, e.Name(), skillDir, agentHostIDs, scope) if shouldIncludeSkill(filter, hasInstallMetadata) { skills = append(skills, skill) @@ -357,7 +358,7 @@ func scanInstalledSkills(skillsDir string, agentHostIDs []string, scope string, } subSkillDir := filepath.Join(skillDir, sub.Name()) subSkillFile := filepath.Join(subSkillDir, "SKILL.md") - if data, readErr := os.ReadFile(subSkillFile); readErr == nil { + if data, readErr := readSkillFile(subSkillFile); readErr == nil { installName := e.Name() + "/" + sub.Name() skill, hasInstallMetadata := parseInstalledSkill(data, installName, subSkillDir, agentHostIDs, scope) if shouldIncludeSkill(filter, hasInstallMetadata) { @@ -370,6 +371,18 @@ func scanInstalledSkills(skillsDir string, agentHostIDs []string, scope string, return skills, nil } +// readSkillFile reads a SKILL.md file only if it resolves to a regular file. +func readSkillFile(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("SKILL.md is not a regular file: %s", path) + } + return os.ReadFile(path) +} + func shouldIncludeSkill(filter scanFilter, hasInstallMetadata bool) bool { switch filter { case scanInstalledOnly: @@ -505,7 +518,7 @@ func renderTable(io *iostreams.IOStreams, skills []listedSkill) error { } // sanitizeForTerminal replaces ASCII control characters in s with inert -// caret-style stand-ins so untrusted content cannot inject terminal escapes. +// caret-style stand-ins so frontmatter values cannot inject terminal escapes. func sanitizeForTerminal(s string) string { var buf bytes.Buffer r := transform.NewReader(bytes.NewReader([]byte(s)), &asciisanitizer.Sanitizer{}) diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go index 958f00a0506..ed8895aa6e9 100644 --- a/pkg/cmd/skills/list/list_test.go +++ b/pkg/cmd/skills/list/list_test.go @@ -383,6 +383,46 @@ func TestListRun(t *testing.T) { }, wantJSON: "[]", }, + { + name: "lists skill whose SKILL.md is a symlink to a regular file", + setup: func(t *testing.T, repoDir, homeDir string) { + customDir := filepath.Join(repoDir, "custom-skills") + skillDir := filepath.Join(customDir, "linked") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + target := filepath.Join(repoDir, "target.md") + require.NoError(t, os.WriteFile(target, []byte("---\nname: linked\nmetadata:\n local-path: /src/linked\n---\nBody\n"), 0o644)) + require.NoError(t, os.Symlink(target, filepath.Join(skillDir, "SKILL.md"))) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Dir: filepath.Join(repoDir, "custom-skills"), + } + }, + wantStdout: "linked\t-\tcustom\t/src/linked\n", + }, + { + name: "skips skill whose SKILL.md is not a regular file", + setup: func(t *testing.T, repoDir, homeDir string) { + customDir := filepath.Join(repoDir, "custom-skills") + skillDir := filepath.Join(customDir, "bogus") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + targetDir := filepath.Join(repoDir, "target-dir") + require.NoError(t, os.MkdirAll(targetDir, 0o755)) + require.NoError(t, os.Symlink(targetDir, filepath.Join(skillDir, "SKILL.md"))) + }, + opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { + return &ListOptions{ + IO: ios, + Telemetry: spy, + GitClient: &git.Client{RepoDir: repoDir}, + Dir: filepath.Join(repoDir, "custom-skills"), + } + }, + wantErr: "no installed skills found", + }, { name: "sanitizes terminal escapes from skill frontmatter", setup: func(t *testing.T, repoDir, homeDir string) { From ea42d46f41739af9391edb28fee3cabe782a569d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 28 May 2026 14:04:25 -0600 Subject: [PATCH 170/182] Rename hosts field and helpers to agentHosts in skill list Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/list/list.go | 28 ++++++++++++++-------------- pkg/cmd/skills/list/list_test.go | 4 ++-- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/pkg/cmd/skills/list/list.go b/pkg/cmd/skills/list/list.go index 42875b147ff..b2106e719fc 100644 --- a/pkg/cmd/skills/list/list.go +++ b/pkg/cmd/skills/list/list.go @@ -28,7 +28,7 @@ import ( var skillListFields = []string{ "skillName", - "hosts", + "agentHosts", "scope", "sourceURL", "version", @@ -87,7 +87,7 @@ func (s listedSkill) ExportData(fields []string) map[string]interface{} { switch f { case "skillName": data[f] = s.skillName - case "hosts": + case "agentHosts": data[f] = s.agentHostIDs case "scope": data[f] = s.scope @@ -220,7 +220,7 @@ func buildScanTargets(opts *ListOptions) ([]scanTarget, error) { gitRoot := installer.ResolveGitRoot(opts.GitClient) homeDir := installer.ResolveHomeDir() - agentHosts, err := selectedHosts(opts.Agent) + agentHosts, err := selectedAgentHosts(opts.Agent) if err != nil { return nil, err } @@ -262,7 +262,7 @@ func buildScanTargets(opts *ListOptions) ([]scanTarget, error) { return targets, nil } -func selectedHosts(agentID string) ([]*registry.AgentHost, error) { +func selectedAgentHosts(agentID string) ([]*registry.AgentHost, error) { if agentID != "" { host, err := registry.FindByID(agentID) if err != nil { @@ -271,11 +271,11 @@ func selectedHosts(agentID string) ([]*registry.AgentHost, error) { return []*registry.AgentHost{host}, nil } - hosts := make([]*registry.AgentHost, len(registry.Agents)) + agentHosts := make([]*registry.AgentHost, len(registry.Agents)) for i := range registry.Agents { - hosts[i] = ®istry.Agents[i] + agentHosts[i] = ®istry.Agents[i] } - return hosts, nil + return agentHosts, nil } func selectedScopes(scope string) []registry.Scope { @@ -496,8 +496,8 @@ func sortListedSkills(skills []listedSkill) { if skills[i].scope != skills[j].scope { return skills[i].scope < skills[j].scope } - if formatHosts(skills[i].agentHostIDs) != formatHosts(skills[j].agentHostIDs) { - return formatHosts(skills[i].agentHostIDs) < formatHosts(skills[j].agentHostIDs) + if formatAgentHosts(skills[i].agentHostIDs) != formatAgentHosts(skills[j].agentHostIDs) { + return formatAgentHosts(skills[i].agentHostIDs) < formatAgentHosts(skills[j].agentHostIDs) } return skills[i].path < skills[j].path }) @@ -508,7 +508,7 @@ func renderTable(io *iostreams.IOStreams, skills []listedSkill) error { for _, skill := range skills { table.AddField(sanitizeForTerminal(skill.skillName)) - table.AddField(formatHosts(skill.agentHostIDs)) + table.AddField(formatAgentHosts(skill.agentHostIDs)) table.AddField(displayOrDash(skill.scope)) table.AddField(displayOrDash(sanitizeForTerminal(skill.source))) table.EndRow() @@ -535,14 +535,14 @@ func displayOrDash(value string) string { return value } -func formatHosts(hosts []string) string { - if len(hosts) == 0 { +func formatAgentHosts(agentHostIDs []string) string { + if len(agentHostIDs) == 0 { return "-" } - if len(hosts) == 1 && hosts[0] == agentHostPublished { + if len(agentHostIDs) == 1 && agentHostIDs[0] == agentHostPublished { return agentHostPublishedDisplay } - return strings.Join(hosts, ", ") + return strings.Join(agentHostIDs, ", ") } func recordListTelemetry(opts *ListOptions, skillCount int) { diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go index ed8895aa6e9..b2b3a79a0e2 100644 --- a/pkg/cmd/skills/list/list_test.go +++ b/pkg/cmd/skills/list/list_test.go @@ -160,7 +160,7 @@ func TestListRun(t *testing.T) { }, opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { exporter := cmdutil.NewJSONExporter() - exporter.SetFields([]string{"skillName", "hosts", "scope", "sourceURL", "version", "pinned", "path"}) + exporter.SetFields([]string{"skillName", "agentHosts", "scope", "sourceURL", "version", "pinned", "path"}) return &ListOptions{ IO: ios, Telemetry: spy, @@ -173,7 +173,7 @@ func TestListRun(t *testing.T) { wantJSON: fmt.Sprintf(`[ { "skillName": "code-review", - "hosts": ["claude-code"], + "agentHosts": ["claude-code"], "scope": "user", "sourceURL": "https://github.com/monalisa/skills-repo", "version": "v2.0.0", From 69e6ecc5709f4d4d3a69ca8de9bd920c67a4100c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 28 May 2026 14:11:15 -0600 Subject: [PATCH 171/182] Use github-copilot agent ID in skill list tests and help text Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/skills/list/list.go | 4 ++-- pkg/cmd/skills/list/list_test.go | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/cmd/skills/list/list.go b/pkg/cmd/skills/list/list.go index b2106e719fc..c87f9829484 100644 --- a/pkg/cmd/skills/list/list.go +++ b/pkg/cmd/skills/list/list.go @@ -130,8 +130,8 @@ func NewCmdList(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, runF # List all installed skills $ gh skill list - # List skills installed for Claude Code - $ gh skill list --agent claude-code + # List skills installed for GitHub Copilot + $ gh skill list --agent github-copilot # List user-scope skills $ gh skill list --scope user diff --git a/pkg/cmd/skills/list/list_test.go b/pkg/cmd/skills/list/list_test.go index b2b3a79a0e2..94295c7ba6a 100644 --- a/pkg/cmd/skills/list/list_test.go +++ b/pkg/cmd/skills/list/list_test.go @@ -33,9 +33,9 @@ func TestNewCmdList(t *testing.T) { }, { name: "agent and scope filters", - cli: "--agent claude-code --scope user", + cli: "--agent github-copilot --scope user", wantOpts: ListOptions{ - Agent: "claude-code", + Agent: "github-copilot", Scope: "user", ScopeChanged: true, }, @@ -69,7 +69,7 @@ func TestNewCmdList(t *testing.T) { }, { name: "dir and agent are mutually exclusive", - cli: "--dir ./skills --agent claude-code", + cli: "--dir ./skills --agent github-copilot", wantErr: "--dir and --agent cannot be used together", }, { @@ -156,7 +156,7 @@ func TestListRun(t *testing.T) { { name: "lists user skill as json", setup: func(t *testing.T, repoDir, homeDir string) { - writeSkill(t, homeDir, ".claude/skills/code-review", remoteSkillFrontmatter("code-review", "skills/code-review", "refs/tags/v2.0.0", "v2.0.0")) + writeSkill(t, homeDir, ".copilot/skills/code-review", remoteSkillFrontmatter("code-review", "skills/code-review", "refs/tags/v2.0.0", "v2.0.0")) }, opts: func(ios *iostreams.IOStreams, repoDir, homeDir string, spy *telemetry.CommandRecorderSpy) *ListOptions { exporter := cmdutil.NewJSONExporter() @@ -166,21 +166,21 @@ func TestListRun(t *testing.T) { Telemetry: spy, GitClient: &git.Client{RepoDir: repoDir}, Exporter: exporter, - Agent: "claude-code", + Agent: "github-copilot", Scope: "user", } }, wantJSON: fmt.Sprintf(`[ { "skillName": "code-review", - "agentHosts": ["claude-code"], + "agentHosts": ["github-copilot"], "scope": "user", "sourceURL": "https://github.com/monalisa/skills-repo", "version": "v2.0.0", "pinned": true, "path": %q } - ]`, filepath.Join("HOME", ".claude", "skills", "code-review")), + ]`, filepath.Join("HOME", ".copilot", "skills", "code-review")), verify: func(t *testing.T, stdout string, spy *telemetry.CommandRecorderSpy) { assert.Equal(t, "json", spy.Events[0].Dimensions["format"]) }, From 678660b4a4a2677d7b2b0cb05ca05479f7ee83f8 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Mon, 1 Jun 2026 16:46:43 +0100 Subject: [PATCH 172/182] address review comments --- internal/skills/discovery/discovery.go | 16 +++++----- internal/skills/discovery/discovery_test.go | 17 +++++++++-- pkg/cmd/skills/install/install.go | 10 ++---- pkg/cmd/skills/install/install_test.go | 34 ++------------------- pkg/cmd/skills/preview/preview.go | 2 +- pkg/cmd/skills/preview/preview_test.go | 12 ++------ 6 files changed, 31 insertions(+), 60 deletions(-) diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 55d27447846..ff6f1286e6b 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -477,8 +477,8 @@ func matchSkillConventions(entry treeEntry) *skillMatch { // mirror the standard skills/ conventions, but only when a hidden segment // appears anywhere in the ancestor path: // -// - {prefix}/.{hidden}/{suffix}/skills/*/SKILL.md -> "hidden-dir" -// - {prefix}/.{hidden}/{suffix}/skills/{scope}/*/SKILL.md -> "hidden-dir-namespaced" +// - {prefix}/.{host}/{suffix}/skills/*/SKILL.md -> "hidden-dir" +// - {prefix}/.{host}/{suffix}/skills/{scope}/*/SKILL.md -> "hidden-dir-namespaced" func matchHiddenDirConventions(entry treeEntry) *skillMatch { if path.Base(entry.Path) != "SKILL.md" { return nil @@ -487,8 +487,8 @@ func matchHiddenDirConventions(entry treeEntry) *skillMatch { return nil } - // {prefix}/.{host}/skills/* - // {prefix}/.{host}/skills/{scope}/* + // {prefix}/.{host}/{suffix}/skills/* + // {prefix}/.{host}/{suffix}/skills/{scope}/* dir := path.Dir(entry.Path) skillName := path.Base(dir) @@ -496,16 +496,16 @@ func matchHiddenDirConventions(entry treeEntry) *skillMatch { return nil } - // {prefix}/.{host}/skills - // {prefix}/.{host}/skills/{scope} + // {prefix}/.{host}/{suffix}/skills + // {prefix}/.{host}/{suffix}/skills/{scope} parentDir := path.Dir(dir) - // {prefix}/.{host}/skills/*/SKILL.md + // {prefix}/.{host}/{suffix}/skills/*/SKILL.md if path.Base(parentDir) == "skills" { return &skillMatch{entry: entry, name: skillName, skillDir: dir, convention: "hidden-dir"} } - // {prefix}/.{host}/skills/{scope}/*/SKILL.md + // {prefix}/.{host}/{suffix}/skills/{scope}/*/SKILL.md grandparentDir := path.Dir(parentDir) if path.Base(grandparentDir) == "skills" { namespace := path.Base(parentDir) diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index d00f8d7acfb..8d1cff8c93e 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -246,6 +246,13 @@ func TestMatchHiddenDirConventions(t *testing.T) { wantName: "code-review", wantConvention: "hidden-dir", }, + { + name: "hidden dir with nested namespaced skills directory", + path: ".claude/nested/skills/monalisa/code-review/SKILL.md", + wantName: "code-review", + wantNamespace: "monalisa", + wantConvention: "hidden-dir-namespaced", + }, { name: "invalid skill name", path: ".claude/skills/../SKILL.md", @@ -1011,6 +1018,8 @@ func TestDiscoverSkillsWithOptions(t *testing.T) { "tree": []map[string]interface{}{ {"path": "foo/bar/.claude/skills/hidden-skill", "type": "tree", "sha": "tree-sha-1"}, {"path": "foo/bar/.claude/skills/hidden-skill/SKILL.md", "type": "blob", "sha": "blob-1"}, + {"path": "foo/bar/.claude/nested/skills/deep-hidden-skill", "type": "tree", "sha": "tree-sha-2"}, + {"path": "foo/bar/.claude/nested/skills/deep-hidden-skill/SKILL.md", "type": "blob", "sha": "blob-2"}, }, } @@ -1040,7 +1049,7 @@ func TestDiscoverSkillsWithOptions(t *testing.T) { { name: "nested hidden-dir tree returns hidden skill", tree: nestedHiddenTree, - wantSkills: []string{"hidden-skill"}, + wantSkills: []string{"deep-hidden-skill", "hidden-skill"}, }, { name: "no skills at all", @@ -1475,8 +1484,12 @@ func TestDiscoverLocalSkillsWithOptions(t *testing.T) { skillDir := filepath.Join(dir, "foo", "bar", ".claude", "skills", "hidden") require.NoError(t, os.MkdirAll(skillDir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# hidden"), 0o644)) + + deepDir := filepath.Join(dir, "foo", "bar", ".claude", "nested", "skills", "deep-hidden") + require.NoError(t, os.MkdirAll(deepDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(deepDir, "SKILL.md"), []byte("# deep-hidden"), 0o644)) }, - wantSkills: []string{"hidden"}, + wantSkills: []string{"deep-hidden", "hidden"}, }, { name: "no skills at all", diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index b426257f1ba..22628299ac3 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -158,7 +158,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru $ gh skill install github/awesome-copilot skills/monalisa/code-review # Install from a non-standard nested path (efficient, skips full discovery) - $ gh skill install oracle/netsuite-suitecloud-sdk packages/agent-skills/netsuite-ai-connector-instructions + $ gh skill install monalisa/skills-repo packages/agent-skills/code-review # Install from a local directory $ gh skill install ./my-skills-repo --from-local @@ -281,7 +281,7 @@ func installRun(opts *InstallOptions) error { var selectedSkills []discovery.Skill - if isSkillPath(opts.SkillName) { + if discovery.IsSkillPath(opts.SkillName) { opts.IO.StartProgressIndicatorWithLabel("Looking up skill") skill, err := discovery.DiscoverSkillByPath(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, opts.SkillName) opts.IO.StopProgressIndicator() @@ -548,12 +548,6 @@ func runLocalInstall(opts *InstallOptions) error { return nil } -// isSkillPath returns true if the argument looks like a repo-relative path -// rather than a simple skill name. -func isSkillPath(name string) bool { - return discovery.IsSkillPath(name) -} - func resolveRepoArg(skillSource string, canPrompt bool, p prompter.Prompter) (ghrepo.Interface, string, error) { if skillSource == "" { if !canPrompt { diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 54ea1d4554c..f914860cdfc 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -804,7 +804,7 @@ func TestInstallRun(t *testing.T) { stubs: func(reg *httpmock.Registry) { stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") stubSkillByPath(reg, "monalisa", "skills-repo", "abc123", - "packages/agent-skills/netsuite-ai-connector-instructions", "netsuite-ai-connector-instructions", "treeSHA") + "packages/agent-skills/code-review", "code-review", "treeSHA") // DiscoverSkillByPath: tree + blob (for fetchDescription) stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) // installer.Install: tree + blob (again, for writing files) @@ -817,14 +817,14 @@ func TestInstallRun(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", - SkillName: "packages/agent-skills/netsuite-ai-connector-instructions", + SkillName: "packages/agent-skills/code-review", Agent: "github-copilot", Scope: "project", ScopeChanged: true, Dir: t.TempDir(), } }, - wantStdout: "Installed netsuite-ai-connector-instructions", + wantStdout: "Installed code-review", }, { name: "remote install with URL repo argument", @@ -2131,34 +2131,6 @@ func TestRunLocalInstall(t *testing.T) { } } -func Test_isSkillPath(t *testing.T) { - tests := []struct { - name string - path string - want bool - }{ - {name: "empty string", path: "", want: false}, - {name: "plain skill name", path: "git-commit", want: false}, - {name: "bare SKILL.md", path: "SKILL.md", want: false}, - {name: "SKILL.md suffix", path: "skills/code-review/SKILL.md", want: true}, - {name: "starts with skills/", path: "skills/code-review", want: true}, - {name: "starts with plugins/", path: "plugins/hubot/skills/pr-summary", want: true}, - {name: "nested skills/ path", path: "terraform/code-generation/skills/terraform-style-guide", want: true}, - {name: "deeply nested skills/ path", path: "a/b/c/skills/my-skill", want: true}, - {name: "nested plugins/ path", path: "vendor/plugins/hubot/skills/pr-summary", want: true}, - {name: "arbitrary nested skill path", path: "packages/agent-skills/netsuite-ai-connector-instructions", want: true}, - {name: "arbitrary nested skill path with trailing slash", path: "skills-catalog/matlab-core/matlab-debugging/", want: true}, - {name: "name containing skills substring", path: "myskills", want: false}, - {name: "namespaced skill name", path: "monalisa/code-review", want: false}, - {name: "namespaced path", path: "skills/monalisa/issue-triage", want: true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, isSkillPath(tt.path)) - }) - } -} - func Test_printReviewHint(t *testing.T) { tests := []struct { name string diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 7f870b635c7..06d50154c91 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -89,7 +89,7 @@ func NewCmdPreview(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru $ gh skill preview github/awesome-copilot documentation-writer@abc123def456 # Preview from a non-standard nested path (efficient, skips full discovery) - $ gh skill preview oracle/netsuite-suitecloud-sdk packages/agent-skills/netsuite-ai-connector-instructions + $ gh skill preview monalisa/skills-repo packages/agent-skills/code-review # Browse and preview interactively $ gh skill preview github/awesome-copilot diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index 48d301f7c06..04fae62587e 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -266,7 +266,7 @@ func TestPreviewRun(t *testing.T) { tty: true, opts: &PreviewOptions{ repo: ghrepo.New("owner", "repo"), - SkillName: "packages/agent-skills/netsuite-ai-connector-instructions", + SkillName: "packages/agent-skills/code-review", }, httpStubs: func(reg *httpmock.Registry) { reg.Register( @@ -280,7 +280,7 @@ func TestPreviewRun(t *testing.T) { reg.Register( httpmock.REST("GET", "repos/owner/repo/contents/packages%2Fagent-skills"), httpmock.StringResponse(`[ - {"name": "netsuite-ai-connector-instructions", "path": "packages/agent-skills/netsuite-ai-connector-instructions", "sha": "treeSHA4", "type": "dir"} + {"name": "code-review", "path": "packages/agent-skills/code-review", "sha": "treeSHA4", "type": "dir"} ]`), ) reg.Register( @@ -291,14 +291,6 @@ func TestPreviewRun(t *testing.T) { ] }`), ) - reg.Register( - httpmock.REST("GET", "repos/owner/repo/git/trees/treeSHA4"), - httpmock.StringResponse(`{ - "tree": [ - {"path": "SKILL.md", "type": "blob", "sha": "blob999", "size": 50} - ] - }`), - ) reg.Register( httpmock.REST("GET", "repos/owner/repo/git/blobs/blob999"), httpmock.StringResponse(`{"sha": "blob999", "content": "`+encodedContent+`", "encoding": "base64"}`), From 76576d24161eaf28a3afd45bf4ed9e0274590663 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:52:04 +0000 Subject: [PATCH 173/182] chore(deps): bump github.com/mattn/go-colorable from 0.1.14 to 0.1.15 Bumps [github.com/mattn/go-colorable](https://github.com/mattn/go-colorable) from 0.1.14 to 0.1.15. - [Commits](https://github.com/mattn/go-colorable/compare/v0.1.14...v0.1.15) --- updated-dependencies: - dependency-name: github.com/mattn/go-colorable dependency-version: 0.1.15 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 5c0d3d406f4..28ef27daf00 100644 --- a/go.mod +++ b/go.mod @@ -39,7 +39,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/klauspost/compress v1.18.6 - github.com/mattn/go-colorable v0.1.14 + github.com/mattn/go-colorable v0.1.15 github.com/mattn/go-isatty v0.0.22 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/microsoft/dev-tunnels v0.1.19 diff --git a/go.sum b/go.sum index c04cfed3738..6d88337c2bb 100644 --- a/go.sum +++ b/go.sum @@ -378,8 +378,8 @@ github.com/letsencrypt/boulder v0.20260223.0/go.mod h1:r3aTSA7UZ7dbDfiGK+HLHJz0b github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= From 7381d1c36754d5e81a8a7e35e833cbb6a8923eea Mon Sep 17 00:00:00 2001 From: cli automation Date: Wed, 3 Jun 2026 04:36:26 +0000 Subject: [PATCH 174/182] Bump Go to 1.26.4 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 5c0d3d406f4..5db826a238e 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/cli/cli/v2 go 1.26.0 -toolchain go1.26.3 +toolchain go1.26.4 require ( charm.land/bubbles/v2 v2.1.0 From 20147bf81fed534faf1b5c9be12638983b8e5e82 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:27:11 -0600 Subject: [PATCH 175/182] Merge pull request #13057 from cli/kw/issues-2.0 Add Issues 2.0 support: issue types, sub-issues, and relationships --- acceptance/acceptance_test.go | 9 + .../issue-create-and-edit-issue-type.txtar | 28 + .../issue-create-and-edit-parent.txtar | 32 + .../issue-create-and-edit-relationships.txtar | 54 ++ .../issues-2.0/issue-edit-sub-issues.txtar | 35 + .../issue-list-filter-by-type.txtar | 21 + .../issue-view-issues-2.0-fields.txtar | 39 ++ api/export_pr.go | 65 ++ api/export_pr_test.go | 203 ++++++ api/queries_issue.go | 348 ++++++++++ api/query_builder.go | 18 + .../featuredetection/feature_detection.go | 42 +- .../feature_detection_test.go | 29 +- pkg/cmd/issue/create/create.go | 90 +++ pkg/cmd/issue/create/create_test.go | 345 +++++++++- pkg/cmd/issue/edit/edit.go | 185 +++++- pkg/cmd/issue/edit/edit_test.go | 624 ++++++++++++++++++ pkg/cmd/issue/list/list.go | 6 +- pkg/cmd/issue/list/list_test.go | 83 +++ pkg/cmd/issue/shared/lookup.go | 39 ++ pkg/cmd/issue/view/view.go | 107 ++- pkg/cmd/issue/view/view_test.go | 402 +++++++++++ pkg/cmd/pr/edit/edit.go | 2 +- pkg/cmd/pr/shared/editable.go | 63 +- pkg/cmd/pr/shared/params.go | 6 + pkg/cmd/pr/shared/params_test.go | 26 + pkg/cmd/pr/shared/survey_test.go | 33 + pkg/search/query.go | 30 +- pkg/search/query_test.go | 10 + 29 files changed, 2925 insertions(+), 49 deletions(-) create mode 100644 acceptance/testdata/issues-2.0/issue-create-and-edit-issue-type.txtar create mode 100644 acceptance/testdata/issues-2.0/issue-create-and-edit-parent.txtar create mode 100644 acceptance/testdata/issues-2.0/issue-create-and-edit-relationships.txtar create mode 100644 acceptance/testdata/issues-2.0/issue-edit-sub-issues.txtar create mode 100644 acceptance/testdata/issues-2.0/issue-list-filter-by-type.txtar create mode 100644 acceptance/testdata/issues-2.0/issue-view-issues-2.0-fields.txtar diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index f978cc732be..b6cf48b043a 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -74,6 +74,15 @@ func TestIssues(t *testing.T) { testscript.Run(t, testScriptParamsFor(tsEnv, "issue")) } +func TestIssues2_0(t *testing.T) { + var tsEnv testScriptEnv + if err := tsEnv.fromEnv(); err != nil { + t.Fatal(err) + } + + testscript.Run(t, testScriptParamsFor(tsEnv, "issues-2.0")) +} + func TestLabels(t *testing.T) { var tsEnv testScriptEnv if err := tsEnv.fromEnv(); err != nil { diff --git a/acceptance/testdata/issues-2.0/issue-create-and-edit-issue-type.txtar b/acceptance/testdata/issues-2.0/issue-create-and-edit-issue-type.txtar new file mode 100644 index 00000000000..a1fde33a96c --- /dev/null +++ b/acceptance/testdata/issues-2.0/issue-create-and-edit-issue-type.txtar @@ -0,0 +1,28 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +cd $SCRIPT_NAME-$RANDOM_STRING + +# Create an issue with --type +exec gh issue create --title 'with type' --body '' --type 'Bug' +stdout2env ISSUE_URL + +# Confirm the type stuck +exec gh issue view $ISSUE_URL --json issueType --jq .issueType.name +stdout '^Bug$' + +# Clear the type with --remove-type +exec gh issue edit $ISSUE_URL --remove-type +exec gh issue view $ISSUE_URL --json issueType --jq '.issueType // "null"' +stdout '^null$' + +# Set the type back with --type +exec gh issue edit $ISSUE_URL --type 'Bug' +exec gh issue view $ISSUE_URL --json issueType --jq .issueType.name +stdout '^Bug$' diff --git a/acceptance/testdata/issues-2.0/issue-create-and-edit-parent.txtar b/acceptance/testdata/issues-2.0/issue-create-and-edit-parent.txtar new file mode 100644 index 00000000000..707fc1df793 --- /dev/null +++ b/acceptance/testdata/issues-2.0/issue-create-and-edit-parent.txtar @@ -0,0 +1,32 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +cd $SCRIPT_NAME-$RANDOM_STRING + +# Create the parent issue +exec gh issue create --title 'parent' --body '' +stdout2env PARENT_URL + +# Create a child via --parent on create +exec gh issue create --title 'child via create' --body '' --parent $PARENT_URL +stdout2env CHILD_URL + +# Confirm parent is set +exec gh issue view $CHILD_URL --json parent --jq .parent.url +stdout $PARENT_URL + +# Clear the parent with --remove-parent +exec gh issue edit $CHILD_URL --remove-parent +exec gh issue view $CHILD_URL --json parent --jq '.parent // "null"' +stdout '^null$' + +# Set the parent back with --parent on edit +exec gh issue edit $CHILD_URL --parent $PARENT_URL +exec gh issue view $CHILD_URL --json parent --jq .parent.url +stdout $PARENT_URL diff --git a/acceptance/testdata/issues-2.0/issue-create-and-edit-relationships.txtar b/acceptance/testdata/issues-2.0/issue-create-and-edit-relationships.txtar new file mode 100644 index 00000000000..4ea8762a79c --- /dev/null +++ b/acceptance/testdata/issues-2.0/issue-create-and-edit-relationships.txtar @@ -0,0 +1,54 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +cd $SCRIPT_NAME-$RANDOM_STRING + +# Create the two helper issues that the main issue will block / be blocked by +exec gh issue create --title 'blocker' --body '' +stdout2env BLOCKER_URL + +exec gh issue create --title 'blocked' --body '' +stdout2env BLOCKED_URL + +# Create the main issue with both relationships set on create +exec gh issue create --title 'main' --body '' --blocked-by $BLOCKER_URL --blocking $BLOCKED_URL +stdout2env MAIN_URL + +# Confirm both relationships landed +exec gh issue view $MAIN_URL --json blockedBy --jq '.blockedBy.nodes[].url' +stdout $BLOCKER_URL + +exec gh issue view $MAIN_URL --json blocking --jq '.blocking.nodes[].url' +stdout $BLOCKED_URL + +# Add a second blocker / blocked via edit +exec gh issue create --title 'blocker 2' --body '' +stdout2env BLOCKER_2_URL + +exec gh issue create --title 'blocked 2' --body '' +stdout2env BLOCKED_2_URL + +exec gh issue edit $MAIN_URL --add-blocked-by $BLOCKER_2_URL --add-blocking $BLOCKED_2_URL + +exec gh issue view $MAIN_URL --json blockedBy --jq '.blockedBy.totalCount' +stdout '^2$' + +exec gh issue view $MAIN_URL --json blocking --jq '.blocking.totalCount' +stdout '^2$' + +# Remove the original blocker / blocked +exec gh issue edit $MAIN_URL --remove-blocked-by $BLOCKER_URL --remove-blocking $BLOCKED_URL + +exec gh issue view $MAIN_URL --json blockedBy --jq '.blockedBy.nodes[].title' +stdout '^blocker 2$' +! stdout '^blocker$' + +exec gh issue view $MAIN_URL --json blocking --jq '.blocking.nodes[].title' +stdout '^blocked 2$' +! stdout '^blocked$' diff --git a/acceptance/testdata/issues-2.0/issue-edit-sub-issues.txtar b/acceptance/testdata/issues-2.0/issue-edit-sub-issues.txtar new file mode 100644 index 00000000000..94d0c9621ae --- /dev/null +++ b/acceptance/testdata/issues-2.0/issue-edit-sub-issues.txtar @@ -0,0 +1,35 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +cd $SCRIPT_NAME-$RANDOM_STRING + +# Create three issues: parent A, parent B, candidate C +exec gh issue create --title 'parent A' --body '' +stdout2env PARENT_A_URL + +exec gh issue create --title 'parent B' --body '' +stdout2env PARENT_B_URL + +exec gh issue create --title 'candidate C' --body '' +stdout2env CANDIDATE_URL + +# Add C as a sub-issue of A +exec gh issue edit $PARENT_A_URL --add-sub-issue $CANDIDATE_URL +exec gh issue view $CANDIDATE_URL --json parent --jq .parent.url +stdout $PARENT_A_URL + +# Adding C as a sub-issue of B silently overwrites the existing parent +exec gh issue edit $PARENT_B_URL --add-sub-issue $CANDIDATE_URL +exec gh issue view $CANDIDATE_URL --json parent --jq .parent.url +stdout $PARENT_B_URL + +# Removing the sub-issue from B drops the parent +exec gh issue edit $PARENT_B_URL --remove-sub-issue $CANDIDATE_URL +exec gh issue view $CANDIDATE_URL --json parent --jq '.parent // "null"' +stdout '^null$' diff --git a/acceptance/testdata/issues-2.0/issue-list-filter-by-type.txtar b/acceptance/testdata/issues-2.0/issue-list-filter-by-type.txtar new file mode 100644 index 00000000000..5150857c6f5 --- /dev/null +++ b/acceptance/testdata/issues-2.0/issue-list-filter-by-type.txtar @@ -0,0 +1,21 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +cd $SCRIPT_NAME-$RANDOM_STRING + +# Create one Bug-typed issue and one untyped issue +exec gh issue create --title 'typed-bug' --body '' --type 'Bug' +exec gh issue create --title 'untyped' --body '' + +sleep 3 + +# Filtering by type returns only the typed issue +exec gh issue list --type 'Bug' +stdout 'typed-bug' +! stdout 'untyped' diff --git a/acceptance/testdata/issues-2.0/issue-view-issues-2.0-fields.txtar b/acceptance/testdata/issues-2.0/issue-view-issues-2.0-fields.txtar new file mode 100644 index 00000000000..f73043de2dd --- /dev/null +++ b/acceptance/testdata/issues-2.0/issue-view-issues-2.0-fields.txtar @@ -0,0 +1,39 @@ +# Create a repository with a file so it has a default branch +exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private + +# Defer repo cleanup +defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING + +# Clone the repo +exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING + +cd $SCRIPT_NAME-$RANDOM_STRING + +# Create a parent, a sub-issue, a blocker, and a blocked target +exec gh issue create --title 'parent' --body '' +stdout2env PARENT_URL + +exec gh issue create --title 'sub' --body '' +stdout2env SUB_URL + +exec gh issue create --title 'blocker' --body '' +stdout2env BLOCKER_URL + +exec gh issue create --title 'blocked' --body '' +stdout2env BLOCKED_URL + +# Create the main issue wired up to all four +exec gh issue create --title 'main' --body '' --type 'Bug' --parent $PARENT_URL --blocked-by $BLOCKER_URL --blocking $BLOCKED_URL +stdout2env MAIN_URL + +# Attach the sub-issue +exec gh issue edit $MAIN_URL --add-sub-issue $SUB_URL + +# Non-tty view should include all the new Issues 2.0 fields +exec gh issue view $MAIN_URL +stdout '^issue-type:\tBug$' +stdout '^parent:\t.+/.+#[0-9]+$' +stdout '^sub-issues:\t.+/.+#[0-9]+$' +stdout '^sub-issues-completed:\t0/1$' +stdout '^blocked-by:\t.+/.+#[0-9]+$' +stdout '^blocking:\t.+/.+#[0-9]+$' diff --git a/api/export_pr.go b/api/export_pr.go index 9b030c39ed7..53a921e43ae 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -46,6 +46,71 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} { }) } data[f] = items + case "issueType": + data[f] = issue.IssueType + case "parent": + if issue.Parent != nil { + data[f] = map[string]interface{}{ + "id": issue.Parent.ID, + "number": issue.Parent.Number, + "title": issue.Parent.Title, + "url": issue.Parent.URL, + "state": issue.Parent.State, + } + } else { + data[f] = nil + } + case "subIssues": + items := make([]map[string]interface{}, 0, len(issue.SubIssues.Nodes)) + for _, n := range issue.SubIssues.Nodes { + items = append(items, map[string]interface{}{ + "id": n.ID, + "number": n.Number, + "title": n.Title, + "url": n.URL, + "state": n.State, + }) + } + data[f] = map[string]interface{}{ + "nodes": items, + "totalCount": issue.SubIssues.TotalCount, + } + case "subIssuesSummary": + data[f] = map[string]interface{}{ + "total": issue.SubIssuesSummary.Total, + "completed": issue.SubIssuesSummary.Completed, + "percentCompleted": issue.SubIssuesSummary.PercentCompleted, + } + case "blockedBy": + items := make([]map[string]interface{}, 0, len(issue.BlockedBy.Nodes)) + for _, n := range issue.BlockedBy.Nodes { + items = append(items, map[string]interface{}{ + "id": n.ID, + "number": n.Number, + "title": n.Title, + "url": n.URL, + "state": n.State, + }) + } + data[f] = map[string]interface{}{ + "nodes": items, + "totalCount": issue.BlockedBy.TotalCount, + } + case "blocking": + items := make([]map[string]interface{}, 0, len(issue.Blocking.Nodes)) + for _, n := range issue.Blocking.Nodes { + items = append(items, map[string]interface{}{ + "id": n.ID, + "number": n.Number, + "title": n.Title, + "url": n.URL, + "state": n.State, + }) + } + data[f] = map[string]interface{}{ + "nodes": items, + "totalCount": issue.Blocking.TotalCount, + } default: sf := fieldByName(v, f) data[f] = sf.Interface() diff --git a/api/export_pr_test.go b/api/export_pr_test.go index ec7b002498c..db12ed0bf3d 100644 --- a/api/export_pr_test.go +++ b/api/export_pr_test.go @@ -197,6 +197,209 @@ func TestIssue_ExportData(t *testing.T) { ] } `), }, + { + name: "issue type", + fields: []string{"issueType"}, + inputJSON: heredoc.Doc(` + { "issueType": { + "id": "IT_1", + "name": "Bug", + "description": "Something is not working", + "color": "d73a4a" + } } + `), + outputJSON: heredoc.Doc(` + { + "issueType": { + "id": "IT_1", + "name": "Bug", + "description": "Something is not working", + "color": "d73a4a" + } + } + `), + }, + { + name: "issue type null", + fields: []string{"issueType"}, + inputJSON: `{}`, + outputJSON: heredoc.Doc(` + { "issueType": null } + `), + }, + { + name: "parent", + fields: []string{"parent"}, + inputJSON: heredoc.Doc(` + { "parent": { + "id": "I_100", + "number": 100, + "title": "Epic: Authentication overhaul", + "url": "https://github.com/OWNER/REPO/issues/100", + "state": "OPEN", + "repository": {"nameWithOwner": "OWNER/REPO"} + } } + `), + outputJSON: heredoc.Doc(` + { + "parent": { + "id": "I_100", + "number": 100, + "title": "Epic: Authentication overhaul", + "url": "https://github.com/OWNER/REPO/issues/100", + "state": "OPEN" + } + } + `), + }, + { + name: "parent null", + fields: []string{"parent"}, + inputJSON: `{}`, + outputJSON: heredoc.Doc(` + { "parent": null } + `), + }, + { + name: "sub-issues", + fields: []string{"subIssues"}, + inputJSON: heredoc.Doc(` + { "subIssues": { + "nodes": [ + { + "id": "I_101", + "number": 101, + "title": "Design auth module", + "url": "https://github.com/OWNER/REPO/issues/101", + "state": "CLOSED", + "repository": {"nameWithOwner": "OWNER/REPO"} + }, + { + "id": "I_102", + "number": 102, + "title": "Token refresh logic", + "url": "https://github.com/OWNER/REPO/issues/102", + "state": "OPEN", + "repository": {"nameWithOwner": "OWNER/REPO"} + } + ], + "totalCount": 2 + } } + `), + outputJSON: heredoc.Doc(` + { + "subIssues": { + "nodes": [ + { + "id": "I_101", + "number": 101, + "title": "Design auth module", + "url": "https://github.com/OWNER/REPO/issues/101", + "state": "CLOSED" + }, + { + "id": "I_102", + "number": 102, + "title": "Token refresh logic", + "url": "https://github.com/OWNER/REPO/issues/102", + "state": "OPEN" + } + ], + "totalCount": 2 + } + } + `), + }, + { + name: "sub-issues summary", + fields: []string{"subIssuesSummary"}, + inputJSON: heredoc.Doc(` + { "subIssuesSummary": { + "total": 4, + "completed": 1, + "percentCompleted": 25.0 + } } + `), + outputJSON: heredoc.Doc(` + { + "subIssuesSummary": { + "total": 4, + "completed": 1, + "percentCompleted": 25 + } + } + `), + }, + { + name: "blocked by", + fields: []string{"blockedBy"}, + inputJSON: heredoc.Doc(` + { "blockedBy": { + "nodes": [ + { + "id": "I_200", + "number": 200, + "title": "API rate limiting", + "url": "https://github.com/OWNER/REPO/issues/200", + "state": "OPEN", + "repository": {"nameWithOwner": "OWNER/REPO"} + } + ], + "totalCount": 1 + } } + `), + outputJSON: heredoc.Doc(` + { + "blockedBy": { + "nodes": [ + { + "id": "I_200", + "number": 200, + "title": "API rate limiting", + "url": "https://github.com/OWNER/REPO/issues/200", + "state": "OPEN" + } + ], + "totalCount": 1 + } + } + `), + }, + { + name: "blocking", + fields: []string{"blocking"}, + inputJSON: heredoc.Doc(` + { "blocking": { + "nodes": [ + { + "id": "I_300", + "number": 300, + "title": "Release v2.0", + "url": "https://github.com/OWNER/REPO/issues/300", + "state": "OPEN", + "repository": {"nameWithOwner": "OWNER/REPO"} + } + ], + "totalCount": 1 + } } + `), + outputJSON: heredoc.Doc(` + { + "blocking": { + "nodes": [ + { + "id": "I_300", + "number": 300, + "title": "Release v2.0", + "url": "https://github.com/OWNER/REPO/issues/300", + "state": "OPEN" + } + ], + "totalCount": 1 + } + } + `), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/api/queries_issue.go b/api/queries_issue.go index bff84029dc0..a13bb48cc84 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -2,10 +2,13 @@ package api import ( "encoding/json" + "errors" "fmt" + "sync" "time" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/shurcooL/githubv4" ) type IssuesPayload struct { @@ -46,9 +49,55 @@ type Issue struct { ReactionGroups ReactionGroups IsPinned bool + IssueType *IssueType + Parent *LinkedIssue + SubIssues SubIssues + SubIssuesSummary SubIssuesSummary + BlockedBy LinkedIssueConnection + Blocking LinkedIssueConnection + ClosedByPullRequestsReferences ClosedByPullRequestsReferences } +// IssueType represents an issue type configured for a repository. +type IssueType struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` +} + +// LinkedIssue represents a related issue (parent, sub-issue, or relationship target). +type LinkedIssue struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + State string `json:"state"` + Repository struct { + NameWithOwner string `json:"nameWithOwner"` + } `json:"repository"` +} + +// SubIssues is a connection of sub-issues with a total count. +type SubIssues struct { + Nodes []LinkedIssue `json:"nodes"` + TotalCount int `json:"totalCount"` +} + +// SubIssuesSummary contains completion stats for sub-issues. +type SubIssuesSummary struct { + Total int `json:"total"` + Completed int `json:"completed"` + PercentCompleted float64 `json:"percentCompleted"` +} + +// LinkedIssueConnection is a connection of related issues (blocked-by or blocking). +type LinkedIssueConnection struct { + Nodes []LinkedIssue `json:"nodes"` + TotalCount int `json:"totalCount"` +} + type ClosedByPullRequestsReferences struct { Nodes []struct { ID string @@ -431,3 +480,302 @@ func (i Issue) Identifier() string { func (i Issue) CurrentUserComments() []Comment { return i.Comments.CurrentUserComments() } + +// UpdateIssueIssueType sets or clears the issue type on an issue. Pass an +// empty issueTypeID to clear the issue type. +func UpdateIssueIssueType(client *Client, hostname string, issueID string, issueTypeID string) error { + type UpdateIssueIssueTypeInput struct { + IssueID githubv4.ID `json:"issueId"` + IssueTypeID *githubv4.ID `json:"issueTypeId"` + } + + var mutation struct { + UpdateIssueIssueType struct { + Issue struct { + ID string + } + } `graphql:"updateIssueIssueType(input: $input)"` + } + + var typeID *githubv4.ID + if issueTypeID != "" { + id := githubv4.ID(issueTypeID) + typeID = &id + } + + variables := map[string]interface{}{ + "input": UpdateIssueIssueTypeInput{ + IssueID: githubv4.ID(issueID), + IssueTypeID: typeID, + }, + } + + return client.Mutate(hostname, "UpdateIssueIssueType", &mutation, variables) +} + +// AddSubIssue adds a sub-issue to a parent issue. +func AddSubIssue(client *Client, hostname string, parentID string, subIssueID string, replaceParent bool) error { + type AddSubIssueInput struct { + IssueID githubv4.ID `json:"issueId"` + SubIssueID githubv4.ID `json:"subIssueId"` + ReplaceParent githubv4.Boolean `json:"replaceParent"` + } + + var mutation struct { + AddSubIssue struct { + Issue struct { + ID string + } + } `graphql:"addSubIssue(input: $input)"` + } + + variables := map[string]interface{}{ + "input": AddSubIssueInput{ + IssueID: githubv4.ID(parentID), + SubIssueID: githubv4.ID(subIssueID), + ReplaceParent: githubv4.Boolean(replaceParent), + }, + } + + return client.Mutate(hostname, "AddSubIssue", &mutation, variables) +} + +// RemoveSubIssue removes a sub-issue from a parent issue. +func RemoveSubIssue(client *Client, hostname string, parentID string, subIssueID string) error { + type RemoveSubIssueInput struct { + IssueID githubv4.ID `json:"issueId"` + SubIssueID githubv4.ID `json:"subIssueId"` + } + + var mutation struct { + RemoveSubIssue struct { + Issue struct { + ID string + } + } `graphql:"removeSubIssue(input: $input)"` + } + + variables := map[string]interface{}{ + "input": RemoveSubIssueInput{ + IssueID: githubv4.ID(parentID), + SubIssueID: githubv4.ID(subIssueID), + }, + } + + return client.Mutate(hostname, "RemoveSubIssue", &mutation, variables) +} + +// AddBlockedBy marks an issue as blocked by another issue. +func AddBlockedBy(client *Client, hostname string, issueID string, blockingIssueID string) error { + type AddBlockedByInput struct { + IssueID githubv4.ID `json:"issueId"` + BlockingIssueID githubv4.ID `json:"blockingIssueId"` + } + + var mutation struct { + AddBlockedBy struct { + Issue struct { + ID string + } + } `graphql:"addBlockedBy(input: $input)"` + } + + variables := map[string]interface{}{ + "input": AddBlockedByInput{ + IssueID: githubv4.ID(issueID), + BlockingIssueID: githubv4.ID(blockingIssueID), + }, + } + + return client.Mutate(hostname, "AddBlockedBy", &mutation, variables) +} + +// RemoveBlockedBy removes a "blocked by" relationship between two issues. +func RemoveBlockedBy(client *Client, hostname string, issueID string, blockingIssueID string) error { + type RemoveBlockedByInput struct { + IssueID githubv4.ID `json:"issueId"` + BlockingIssueID githubv4.ID `json:"blockingIssueId"` + } + + var mutation struct { + RemoveBlockedBy struct { + Issue struct { + ID string + } + } `graphql:"removeBlockedBy(input: $input)"` + } + + variables := map[string]interface{}{ + "input": RemoveBlockedByInput{ + IssueID: githubv4.ID(issueID), + BlockingIssueID: githubv4.ID(blockingIssueID), + }, + } + + return client.Mutate(hostname, "RemoveBlockedBy", &mutation, variables) +} + +// DeferredUpdateIssueOptions updates an issue with mutations unsupported by the +// standard issue update mutations. All ID fields are node IDs. +type DeferredUpdateIssueOptions struct { + IssueID string + Hostname string + + IssueTypeID string + RemoveIssueType bool + + ParentID string + ReplaceExistingParent bool + RemoveParentID string + + AddSubIssueIDs []string + RemoveSubIssueIDs []string + + AddBlockedByIDs []string + RemoveBlockedByIDs []string + + // AddBlockingIDs / RemoveBlockingIDs name issues that this issue + // blocks. They are applied via the addBlockedBy / removeBlockedBy + // mutations with the arguments swapped. + AddBlockingIDs []string + RemoveBlockingIDs []string +} + +// DeferredUpdateIssue runs issue mutations described by opts in +// parallel and returns any failures as a single joined error so a single +// failure does not abort the rest. +func DeferredUpdateIssue(client *Client, opts DeferredUpdateIssueOptions) error { + var mutations []func() error + + if opts.IssueTypeID != "" || opts.RemoveIssueType { + mutations = append(mutations, func() error { + return UpdateIssueIssueType(client, opts.Hostname, opts.IssueID, opts.IssueTypeID) + }) + } + + if opts.ParentID != "" { + mutations = append(mutations, func() error { + return AddSubIssue(client, opts.Hostname, opts.ParentID, opts.IssueID, opts.ReplaceExistingParent) + }) + } else if opts.RemoveParentID != "" { + mutations = append(mutations, func() error { + return RemoveSubIssue(client, opts.Hostname, opts.RemoveParentID, opts.IssueID) + }) + } + + for _, id := range opts.AddSubIssueIDs { + mutations = append(mutations, func() error { + return AddSubIssue(client, opts.Hostname, opts.IssueID, id, true) + }) + } + for _, id := range opts.RemoveSubIssueIDs { + mutations = append(mutations, func() error { + return RemoveSubIssue(client, opts.Hostname, opts.IssueID, id) + }) + } + + for _, id := range opts.AddBlockedByIDs { + mutations = append(mutations, func() error { + return AddBlockedBy(client, opts.Hostname, opts.IssueID, id) + }) + } + for _, id := range opts.RemoveBlockedByIDs { + mutations = append(mutations, func() error { + return RemoveBlockedBy(client, opts.Hostname, opts.IssueID, id) + }) + } + + for _, id := range opts.AddBlockingIDs { + mutations = append(mutations, func() error { + // blocking is the inverse of blocked-by: this issue blocks `id`, + // expressed as `id` is blocked by this issue. + return AddBlockedBy(client, opts.Hostname, id, opts.IssueID) + }) + } + for _, id := range opts.RemoveBlockingIDs { + mutations = append(mutations, func() error { + return RemoveBlockedBy(client, opts.Hostname, id, opts.IssueID) + }) + } + + if len(mutations) == 0 { + return nil + } + + errCh := make(chan error, len(mutations)) + var wg sync.WaitGroup + for _, m := range mutations { + wg.Add(1) + go func(m func() error) { + defer wg.Done() + if err := m(); err != nil { + errCh <- err + } + }(m) + } + wg.Wait() + close(errCh) + + var errs []error + for err := range errCh { + errs = append(errs, err) + } + return errors.Join(errs...) +} + +// RepoIssueTypes fetches the available issue types for a repository. +func RepoIssueTypes(client *Client, repo ghrepo.Interface) ([]IssueType, error) { + query := ` + query RepositoryIssueTypes($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + issueTypes(first: 50) { + nodes { id, name, description, color } + } + } + }` + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "name": repo.RepoName(), + } + var result struct { + Repository struct { + IssueTypes struct { + Nodes []IssueType + } + } + } + err := client.GraphQL(repo.RepoHost(), query, variables, &result) + if err != nil { + return nil, err + } + return result.Repository.IssueTypes.Nodes, nil +} + +// IssueNodeID fetches the node ID for an issue given its number and repository. +func IssueNodeID(client *Client, repo ghrepo.Interface, number int) (string, error) { + query := ` + query IssueNodeID($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + issue(number: $number) { + id + } + } + }` + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "name": repo.RepoName(), + "number": number, + } + var result struct { + Repository struct { + Issue struct { + ID string + } + } + } + err := client.GraphQL(repo.RepoHost(), query, variables, &result) + if err != nil { + return "", err + } + return result.Repository.Issue.ID, nil +} diff --git a/api/query_builder.go b/api/query_builder.go index 9c97e67e997..d988cdc67a5 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -340,6 +340,12 @@ var issueOnlyFields = []string{ "isPinned", "stateReason", "closedByPullRequestsReferences", + "issueType", + "parent", + "subIssues", + "subIssuesSummary", + "blockedBy", + "blocking", } var IssueFields = append(sharedIssuePRFields, issueOnlyFields...) @@ -436,6 +442,18 @@ func IssueGraphQL(fields []string) string { q = append(q, prClosingIssuesReferences) case "closedByPullRequestsReferences": q = append(q, issueClosedByPullRequestsReferences) + case "issueType": + q = append(q, `issueType{id,name,description,color}`) + case "parent": + q = append(q, `parent{id,number,title,url,state,repository{nameWithOwner}}`) + case "subIssues": + q = append(q, `subIssues(first:100){nodes{id,number,title,url,state,repository{nameWithOwner}},totalCount}`) + case "subIssuesSummary": + q = append(q, `subIssuesSummary{total,completed,percentCompleted}`) + case "blockedBy": + q = append(q, `blockedBy(first:50){nodes{id,number,title,url,state,repository{nameWithOwner}},totalCount}`) + case "blocking": + q = append(q, `blocking(first:50){nodes{id,number,title,url,state,repository{nameWithOwner}},totalCount}`) default: q = append(q, field) } diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index d4ef62070a8..98be5a46439 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -48,10 +48,18 @@ type IssueFeatures struct { // - The replaceActorsForAssignable mutation // - The requestReviewsByLogin mutation ApiActorsSupported bool + + // TODO IssueRelationshipsCleanup - remove when GHES 3.18 support ends (~October 2026) + // IssueRelationshipsSupported indicates the host supports issue + // relationships (blocked-by/blocking). Available on github.com and + // GHES 3.19+. Issue types and sub-issues are GA on all supported GHES + // versions (3.17+) and do not need feature detection. + IssueRelationshipsSupported bool } var allIssueFeatures = IssueFeatures{ - ApiActorsSupported: true, + ApiActorsSupported: true, + IssueRelationshipsSupported: true, } type PullRequestFeatures struct { @@ -159,9 +167,35 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { return allIssueFeatures, nil } - return IssueFeatures{ - ApiActorsSupported: false, // TODO ApiActorsSupported — actor-based mutations unavailable on GHES - }, nil + features := IssueFeatures{ + ApiActorsSupported: false, // TODO ApiActorsSupported - actor-based mutations unavailable on GHES + } + + // Detect issue relationship support (GHES 3.19+) via schema introspection. + // Issue types and sub-issues are GA on all supported GHES versions (3.17+) + // and do not need detection. + var featureDetection struct { + Issue struct { + Fields []struct { + Name string + } `graphql:"fields(includeDeprecated: true)"` + } `graphql:"Issue: __type(name: \"Issue\")"` + } + + gql := api.NewClientFromHTTP(d.httpClient) + err := gql.Query(d.host, "Issue_fields", &featureDetection, nil) + if err != nil { + return IssueFeatures{}, err + } + + for _, field := range featureDetection.Issue.Fields { + if field.Name == "blockedBy" { + features.IssueRelationshipsSupported = true + break + } + } + + return features, nil } func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) { diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index f24e31f4c73..6b6ed675180 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -12,6 +12,9 @@ import ( ) func TestIssueFeatures(t *testing.T) { + issueFieldsWithRelationships := `{"data":{"Issue":{"fields":[{"name":"title"},{"name":"body"},{"name":"blockedBy"}]}}}` + issueFieldsWithoutRelationships := `{"data":{"Issue":{"fields":[{"name":"title"},{"name":"body"}]}}}` + tests := []struct { name string hostname string @@ -23,7 +26,8 @@ func TestIssueFeatures(t *testing.T) { name: "github.com", hostname: "github.com", wantFeatures: IssueFeatures{ - ApiActorsSupported: true, + ApiActorsSupported: true, + IssueRelationshipsSupported: true, }, wantErr: false, }, @@ -31,15 +35,32 @@ func TestIssueFeatures(t *testing.T) { name: "ghec data residency (ghe.com)", hostname: "stampname.ghe.com", wantFeatures: IssueFeatures{ - ApiActorsSupported: true, + ApiActorsSupported: true, + IssueRelationshipsSupported: true, }, wantErr: false, }, { - name: "GHE", + name: "GHE with relationship support", hostname: "git.my.org", + queryResponse: map[string]string{ + `query Issue_fields`: issueFieldsWithRelationships, + }, + wantFeatures: IssueFeatures{ + ApiActorsSupported: false, + IssueRelationshipsSupported: true, + }, + wantErr: false, + }, + { + name: "GHE without relationship support", + hostname: "git.my.org", + queryResponse: map[string]string{ + `query Issue_fields`: issueFieldsWithoutRelationships, + }, wantFeatures: IssueFeatures{ - ApiActorsSupported: false, + ApiActorsSupported: false, + IssueRelationshipsSupported: false, }, wantErr: false, }, diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 8e6c6255f36..fb507cd5c57 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/text" + issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -47,6 +48,12 @@ type CreateOptions struct { Projects []string Milestone string Template string + + IssueType string + issueTypeID string // resolved during interactive flow to avoid double API call + Parent string + BlockedBy []string + Blocking []string } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { @@ -84,6 +91,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co $ gh issue create --assignee "@copilot" $ gh issue create --project "Roadmap" $ gh issue create --template "Bug Report" + $ gh issue create --type Bug + $ gh issue create --parent 100 + $ gh issue create --parent https://github.com/cli/go-gh/issues/42 + $ gh issue create --blocked-by 200,201 --blocking 300 `), Args: cmdutil.NoArgsQuoteReminder, Aliases: []string{"new"}, @@ -141,6 +152,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`") cmd.Flags().StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create") cmd.Flags().StringVarP(&opts.Template, "template", "T", "", "Template `name` to use as starting body text") + cmd.Flags().StringVar(&opts.IssueType, "type", "", "Set the issue type by `name`") + cmd.Flags().StringVar(&opts.Parent, "parent", "", "Add the new issue as a sub-issue of the specified parent `number` or URL") + cmd.Flags().StringSliceVar(&opts.BlockedBy, "blocked-by", nil, "Mark the new issue as blocked by these issue `numbers` or URLs") + cmd.Flags().StringSliceVar(&opts.Blocking, "blocking", nil, "Mark the new issue as blocking these issue `numbers` or URLs") return cmd } @@ -289,6 +304,24 @@ func createRun(opts *CreateOptions) (err error) { } } + // Interactive issue type selection + if opts.IssueType == "" { + issueTypes, typesErr := api.RepoIssueTypes(apiClient, baseRepo) + if typesErr == nil && len(issueTypes) > 0 { + typeNames := make([]string, len(issueTypes)) + for i, t := range issueTypes { + typeNames[i] = t.Name + } + var selected int + selected, err = opts.Prompter.Select("Issue type", "", typeNames) + if err != nil { + return + } + opts.IssueType = typeNames[selected] + opts.issueTypeID = issueTypes[selected].ID + } + } + openURL, err = generatePreviewURL(apiClient, baseRepo, tb, projectsV1Support) if err != nil { return @@ -379,6 +412,15 @@ func createRun(opts *CreateOptions) (err error) { return } + var updateOpts api.DeferredUpdateIssueOptions + updateOpts, err = deferredUpdateIssueOptions(apiClient, baseRepo, newIssue, opts) + if err != nil { + return + } + if err = api.DeferredUpdateIssue(apiClient, updateOpts); err != nil { + return + } + fmt.Fprintln(opts.IO.Out, newIssue.URL) } else { panic("Unreachable state") @@ -391,3 +433,51 @@ func generatePreviewURL(apiClient *api.Client, baseRepo ghrepo.Interface, tb prS openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new") return prShared.WithPrAndIssueQueryParams(apiClient, baseRepo, openURL, tb, projectsV1Support) } + +// deferredUpdateIssueOptions resolves the user-supplied --type / --parent / +// --blocked-by / --blocking flags into the IDs that DeferredUpdateIssue +// expects. +func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, issue *api.Issue, opts *CreateOptions) (api.DeferredUpdateIssueOptions, error) { + updateOpts := api.DeferredUpdateIssueOptions{ + IssueID: issue.ID, + Hostname: baseRepo.RepoHost(), + } + + if opts.IssueType != "" { + typeID := opts.issueTypeID + if typeID == "" { + var err error + typeID, err = issueShared.ResolveIssueTypeName(client, baseRepo, opts.IssueType) + if err != nil { + return updateOpts, err + } + } + updateOpts.IssueTypeID = typeID + } + + if opts.Parent != "" { + parentID, err := issueShared.ResolveIssueRef(client, baseRepo, opts.Parent) + if err != nil { + return updateOpts, fmt.Errorf("resolving --parent reference %q: %w", opts.Parent, err) + } + updateOpts.ParentID = parentID + } + + for _, ref := range opts.BlockedBy { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return updateOpts, fmt.Errorf("resolving --blocked-by reference %q: %w", ref, err) + } + updateOpts.AddBlockedByIDs = append(updateOpts.AddBlockedByIDs, id) + } + + for _, ref := range opts.Blocking { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return updateOpts, fmt.Errorf("resolving --blocking reference %q: %w", ref, err) + } + updateOpts.AddBlockingIDs = append(updateOpts.AddBlockingIDs, id) + } + + return updateOpts, nil +} diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 4a55a33263a..bd39dfd9bdb 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -198,6 +198,61 @@ func TestNewCmdCreate(t *testing.T) { cli: "--editor", wantsErr: true, }, + { + name: "type", + tty: false, + cli: `-t mytitle -b mybody --type Bug`, + wantsErr: false, + wantsOpts: CreateOptions{ + Title: "mytitle", + Body: "mybody", + IssueType: "Bug", + }, + }, + { + name: "parent by number", + tty: false, + cli: `-t mytitle -b mybody --parent 100`, + wantsErr: false, + wantsOpts: CreateOptions{ + Title: "mytitle", + Body: "mybody", + Parent: "100", + }, + }, + { + name: "parent by URL", + tty: false, + cli: `-t mytitle -b mybody --parent https://github.com/cli/go-gh/issues/42`, + wantsErr: false, + wantsOpts: CreateOptions{ + Title: "mytitle", + Body: "mybody", + Parent: "https://github.com/cli/go-gh/issues/42", + }, + }, + { + name: "blocked by multiple issues", + tty: false, + cli: `-t mytitle -b mybody --blocked-by 200,201`, + wantsErr: false, + wantsOpts: CreateOptions{ + Title: "mytitle", + Body: "mybody", + BlockedBy: []string{"200", "201"}, + }, + }, + { + name: "blocking another issue", + tty: false, + cli: `-t mytitle -b mybody --blocking 300`, + wantsErr: false, + wantsOpts: CreateOptions{ + Title: "mytitle", + Body: "mybody", + Blocking: []string{"300"}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -247,6 +302,10 @@ func TestNewCmdCreate(t *testing.T) { assert.Equal(t, tt.wantsOpts.WebMode, opts.WebMode) assert.Equal(t, tt.wantsOpts.Interactive, opts.Interactive) assert.Equal(t, tt.wantsOpts.Template, opts.Template) + assert.Equal(t, tt.wantsOpts.IssueType, opts.IssueType) + assert.Equal(t, tt.wantsOpts.Parent, opts.Parent) + assert.Equal(t, tt.wantsOpts.BlockedBy, opts.BlockedBy) + assert.Equal(t, tt.wantsOpts.Blocking, opts.Blocking) }) } } @@ -255,7 +314,7 @@ func Test_createRun(t *testing.T) { tests := []struct { name string opts CreateOptions - httpStubs func(*httpmock.Registry) + httpStubs func(*testing.T, *httpmock.Registry) promptStubs func(*prompter.PrompterMock) wantsStdout string wantsStderr string @@ -299,7 +358,7 @@ func Test_createRun(t *testing.T) { WebMode: true, Assignees: []string{"@me"}, }, - httpStubs: func(r *httpmock.Registry) { + httpStubs: func(_ *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query UserCurrent\b`), httpmock.StringResponse(` @@ -327,7 +386,7 @@ func Test_createRun(t *testing.T) { WebMode: true, Projects: []string{"cleanup"}, }, - httpStubs: func(r *httpmock.Registry) { + httpStubs: func(_ *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query RepositoryProjectList\b`), httpmock.StringResponse(` @@ -383,7 +442,7 @@ func Test_createRun(t *testing.T) { Detector: &fd.EnabledDetectorMock{}, WebMode: true, }, - httpStubs: func(r *httpmock.Registry) { + httpStubs: func(_ *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query IssueTemplates\b`), httpmock.StringResponse(` @@ -409,7 +468,7 @@ func Test_createRun(t *testing.T) { }, { name: "editor", - httpStubs: func(r *httpmock.Registry) { + httpStubs: func(_ *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` @@ -438,7 +497,7 @@ func Test_createRun(t *testing.T) { }, { name: "editor and template", - httpStubs: func(r *httpmock.Registry) { + httpStubs: func(_ *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` @@ -520,7 +579,7 @@ func Test_createRun(t *testing.T) { } } }, - httpStubs: func(r *httpmock.Registry) { + httpStubs: func(_ *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` @@ -593,7 +652,7 @@ func Test_createRun(t *testing.T) { } } }, - httpStubs: func(r *httpmock.Registry) { + httpStubs: func(_ *testing.T, r *httpmock.Registry) { r.Register( httpmock.GraphQL(`query IssueRepositoryInfo\b`), httpmock.StringResponse(` @@ -627,13 +686,254 @@ func Test_createRun(t *testing.T) { wantsStdout: "https://github.com/OWNER/REPO/issues/12\n", wantsStderr: "\nCreating issue in OWNER/REPO\n\n", }, + { + name: "create with type", + opts: CreateOptions{ + Detector: &fd.EnabledDetectorMock{}, + Title: "bug title", + Body: "bug body", + IssueType: "Bug", + }, + httpStubs: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`)) + r.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.StringResponse(` + { "data": { "createIssue": { "issue": { + "id": "ISSUE_ID_123", + "URL": "https://github.com/OWNER/REPO/issues/123" + } } } }`)) + r.Register( + httpmock.GraphQL(`query RepositoryIssueTypes\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTypes": { "nodes": [ + { "id": "IT_1", "name": "Bug", "description": "", "color": "d73a4a" }, + { "id": "IT_2", "name": "Feature", "description": "", "color": "0075ca" }, + { "id": "IT_3", "name": "Task", "description": "", "color": "e4e669" } + ] } } } }`)) + r.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "ISSUE_ID_123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "ISSUE_ID_123", inputs["issueId"]) + assert.Equal(t, "IT_1", inputs["issueTypeId"]) + })) + }, + wantsStdout: "https://github.com/OWNER/REPO/issues/123\n", + wantsStderr: "\nCreating issue in OWNER/REPO\n\n", + }, + { + name: "interactive prompts for type", + opts: CreateOptions{ + Interactive: true, + Detector: &fd.EnabledDetectorMock{}, + Title: "feature request", + Body: "would be nice to have", + }, + promptStubs: func(pm *prompter.PrompterMock) { + pm.SelectFunc = func(message, defaultValue string, options []string) (int, error) { + switch message { + case "Issue type": + return prompter.IndexFor(options, "Feature") + case "What's next?": + return prompter.IndexFor(options, "Submit") + default: + return 0, fmt.Errorf("unexpected select prompt: %s", message) + } + } + }, + httpStubs: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true, + "viewerPermission": "WRITE" + } } }`)) + // Issue types are fetched up front to power the interactive prompt. + r.Register( + httpmock.GraphQL(`query RepositoryIssueTypes\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTypes": { "nodes": [ + { "id": "IT_1", "name": "Bug", "description": "", "color": "d73a4a" }, + { "id": "IT_2", "name": "Feature", "description": "", "color": "0075ca" }, + { "id": "IT_3", "name": "Task", "description": "", "color": "e4e669" } + ] } } } }`)) + r.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.StringResponse(` + { "data": { "createIssue": { "issue": { + "id": "ISSUE_ID_123", + "URL": "https://github.com/OWNER/REPO/issues/123" + } } } }`)) + // Selected ID is reused without re-fetching the types list. + r.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "ISSUE_ID_123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "ISSUE_ID_123", inputs["issueId"]) + assert.Equal(t, "IT_2", inputs["issueTypeId"]) + })) + }, + wantsStdout: "https://github.com/OWNER/REPO/issues/123\n", + wantsStderr: "\nCreating issue in OWNER/REPO\n\n", + }, + { + name: "create with type not found", + opts: CreateOptions{ + Detector: &fd.EnabledDetectorMock{}, + Title: "bug title", + Body: "bug body", + IssueType: "Bugz", + }, + httpStubs: func(_ *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`)) + r.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.StringResponse(` + { "data": { "createIssue": { "issue": { + "id": "ISSUE_ID_123", + "URL": "https://github.com/OWNER/REPO/issues/123" + } } } }`)) + r.Register( + httpmock.GraphQL(`query RepositoryIssueTypes\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTypes": { "nodes": [ + { "id": "IT_1", "name": "Bug", "description": "", "color": "d73a4a" }, + { "id": "IT_2", "name": "Feature", "description": "", "color": "0075ca" }, + { "id": "IT_3", "name": "Task", "description": "", "color": "e4e669" } + ] } } } }`)) + }, + wantsErr: `type "Bugz" not found; available types: Bug, Feature, Task`, + }, + { + name: "create with parent", + opts: CreateOptions{ + Detector: &fd.EnabledDetectorMock{}, + Title: "child issue", + Body: "child body", + Parent: "100", + }, + httpStubs: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`)) + r.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.StringResponse(` + { "data": { "createIssue": { "issue": { + "id": "ISSUE_ID_123", + "URL": "https://github.com/OWNER/REPO/issues/123" + } } } }`)) + r.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { "id": "PARENT_ID_100" } } } }`)) + r.Register( + httpmock.GraphQL(`mutation AddSubIssue\b`), + httpmock.GraphQLMutation(` + { "data": { "addSubIssue": { "issue": { "id": "PARENT_ID_100" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "PARENT_ID_100", inputs["issueId"]) + assert.Equal(t, "ISSUE_ID_123", inputs["subIssueId"]) + assert.Equal(t, false, inputs["replaceParent"]) + })) + }, + wantsStdout: "https://github.com/OWNER/REPO/issues/123\n", + wantsStderr: "\nCreating issue in OWNER/REPO\n\n", + }, + { + name: "create with blocked-by and blocking", + opts: CreateOptions{ + Detector: &fd.EnabledDetectorMock{}, + Title: "blocked issue", + Body: "blocked body", + BlockedBy: []string{"200", "201"}, + Blocking: []string{"300", "301"}, + }, + httpStubs: func(t *testing.T, r *httpmock.Registry) { + r.Register( + httpmock.GraphQL(`query IssueRepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } }`)) + r.Register( + httpmock.GraphQL(`mutation IssueCreate\b`), + httpmock.StringResponse(` + { "data": { "createIssue": { "issue": { + "id": "ISSUE_ID_123", + "URL": "https://github.com/OWNER/REPO/issues/123" + } } } }`)) + // IssueNodeID lookups for each ref, routed by number so they + // don't depend on parallel ordering. + r.Register( + issueNodeIDByNumberMatcher(200), + httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "BLOCKER_ID_200" } } } }`)) + r.Register( + issueNodeIDByNumberMatcher(201), + httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "BLOCKER_ID_201" } } } }`)) + r.Register( + issueNodeIDByNumberMatcher(300), + httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "BLOCKED_ID_300" } } } }`)) + r.Register( + issueNodeIDByNumberMatcher(301), + httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "BLOCKED_ID_301" } } } }`)) + // AddBlockedBy mutations, routed by their inputs so they + // also don't depend on parallel ordering. + // --blocked-by N: this issue is blocked by N + r.Register( + httpmock.GraphQLMutationMatcher(`mutation AddBlockedBy\b`, func(input map[string]interface{}) bool { + return input["issueId"] == "ISSUE_ID_123" && input["blockingIssueId"] == "BLOCKER_ID_200" + }), + httpmock.StringResponse(`{ "data": { "addBlockedBy": { "issue": { "id": "ISSUE_ID_123" } } } }`)) + r.Register( + httpmock.GraphQLMutationMatcher(`mutation AddBlockedBy\b`, func(input map[string]interface{}) bool { + return input["issueId"] == "ISSUE_ID_123" && input["blockingIssueId"] == "BLOCKER_ID_201" + }), + httpmock.StringResponse(`{ "data": { "addBlockedBy": { "issue": { "id": "ISSUE_ID_123" } } } }`)) + // --blocking N: N is blocked by this issue (args swapped) + r.Register( + httpmock.GraphQLMutationMatcher(`mutation AddBlockedBy\b`, func(input map[string]interface{}) bool { + return input["issueId"] == "BLOCKED_ID_300" && input["blockingIssueId"] == "ISSUE_ID_123" + }), + httpmock.StringResponse(`{ "data": { "addBlockedBy": { "issue": { "id": "BLOCKED_ID_300" } } } }`)) + r.Register( + httpmock.GraphQLMutationMatcher(`mutation AddBlockedBy\b`, func(input map[string]interface{}) bool { + return input["issueId"] == "BLOCKED_ID_301" && input["blockingIssueId"] == "ISSUE_ID_123" + }), + httpmock.StringResponse(`{ "data": { "addBlockedBy": { "issue": { "id": "BLOCKED_ID_301" } } } }`)) + }, + wantsStdout: "https://github.com/OWNER/REPO/issues/123\n", + wantsStderr: "\nCreating issue in OWNER/REPO\n\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { httpReg := &httpmock.Registry{} defer httpReg.Verify(t) if tt.httpStubs != nil { - tt.httpStubs(httpReg) + tt.httpStubs(t, httpReg) } ios, _, stdout, stderr := iostreams.Test() @@ -1402,3 +1702,30 @@ func TestProjectsV1Deprecation(t *testing.T) { }) }) } + +// issueNodeIDByNumberMatcher matches an IssueNodeID GraphQL query whose +// number variable equals the given value. Used by tests that issue +// multiple IssueNodeID lookups and need stubs to route by issue number +// rather than by registration order. +func issueNodeIDByNumberMatcher(number int) httpmock.Matcher { + queryMatcher := httpmock.GraphQL(`query IssueNodeID\b`) + return func(req *http.Request) bool { + if !queryMatcher(req) { + return false + } + body, err := io.ReadAll(req.Body) + if err != nil { + return false + } + req.Body = io.NopCloser(bytes.NewReader(body)) + var b struct { + Variables struct { + Number int `json:"number"` + } `json:"variables"` + } + if err := json.Unmarshal(body, &b); err != nil { + return false + } + return b.Variables.Number == number + } +} diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 1d5455504e2..668ece3a5ad 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "sort" + "strings" "sync" "time" @@ -13,7 +14,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" - shared "github.com/cli/cli/v2/pkg/cmd/issue/shared" + issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -35,6 +36,17 @@ type EditOptions struct { IssueNumbers []int Interactive bool + RemoveIssueType bool + + Parent string + RemoveParent bool + AddSubIssues []string + RemoveSubIssues []string + AddBlockedBy []string + RemoveBlockedBy []string + AddBlocking []string + RemoveBlocking []string + prShared.Editable } @@ -76,10 +88,16 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman $ gh issue edit 23 --remove-milestone $ gh issue edit 23 --body-file body.txt $ gh issue edit 23 34 --add-label "help wanted" + $ gh issue edit 23 --type Bug + $ gh issue edit 23 --remove-type + $ gh issue edit 23 --parent 100 + $ gh issue edit 23 --remove-parent + $ gh issue edit 100 --add-sub-issue 123,124 + $ gh issue edit 123 --add-blocked-by 200 --add-blocking 300,301 `), Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - issueNumbers, baseRepo, err := shared.ParseIssuesFromArgs(args) + issueNumbers, baseRepo, err := issueShared.ParseIssuesFromArgs(args) if err != nil { return err } @@ -127,6 +145,22 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman return err } + if err := cmdutil.MutuallyExclusive( + "specify only one of `--type` or `--remove-type`", + flags.Changed("type"), + opts.RemoveIssueType, + ); err != nil { + return err + } + + if err := cmdutil.MutuallyExclusive( + "specify only one of --parent or --remove-parent", + flags.Changed("parent"), + opts.RemoveParent, + ); err != nil { + return err + } + if flags.Changed("title") { opts.Editable.Title.Edited = true } @@ -147,8 +181,18 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman // which results in milestone association removal. For reference, // see the `Editable.MilestoneId` method. } + if flags.Changed("type") { + opts.Editable.IssueType.Edited = true + } - if !opts.Editable.Dirty() { + hasDeferredFlags := opts.RemoveIssueType || + flags.Changed("parent") || opts.RemoveParent || + len(opts.AddSubIssues) > 0 || len(opts.RemoveSubIssues) > 0 || + len(opts.AddBlockedBy) > 0 || len(opts.RemoveBlockedBy) > 0 || + len(opts.AddBlocking) > 0 || len(opts.RemoveBlocking) > 0 + + // Drop into interactive mode only if the user passed no edit flags at all. + if !opts.Editable.Dirty() && !hasDeferredFlags { opts.Interactive = true } @@ -160,6 +204,10 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman return cmdutil.FlagErrorf("multiple issues cannot be edited interactively") } + if len(opts.IssueNumbers) > 1 && len(opts.AddSubIssues) > 0 { + return cmdutil.FlagErrorf("`--add-sub-issue` cannot be used when editing multiple issues") + } + if runF != nil { return runF(opts) } @@ -179,6 +227,16 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman cmd.Flags().StringSliceVar(&opts.Editable.Projects.Remove, "remove-project", nil, "Remove the issue from projects by `title`") cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the issue belongs to by `name`") cmd.Flags().BoolVar(&removeMilestone, "remove-milestone", false, "Remove the milestone association from the issue") + cmd.Flags().StringVar(&opts.Editable.IssueType.Value, "type", "", "Set the issue type by `name`") + cmd.Flags().BoolVar(&opts.RemoveIssueType, "remove-type", false, "Remove the issue type from the issue") + cmd.Flags().StringVar(&opts.Parent, "parent", "", "Set the parent issue by `number` or URL") + cmd.Flags().BoolVar(&opts.RemoveParent, "remove-parent", false, "Remove the parent issue") + cmd.Flags().StringSliceVar(&opts.AddSubIssues, "add-sub-issue", nil, "Add sub-issues by `number` or URL") + cmd.Flags().StringSliceVar(&opts.RemoveSubIssues, "remove-sub-issue", nil, "Remove sub-issues by `number` or URL") + cmd.Flags().StringSliceVar(&opts.AddBlockedBy, "add-blocked-by", nil, "Add 'blocked by' relationships by issue `number` or URL") + cmd.Flags().StringSliceVar(&opts.RemoveBlockedBy, "remove-blocked-by", nil, "Remove 'blocked by' relationships by issue `number` or URL") + cmd.Flags().StringSliceVar(&opts.AddBlocking, "add-blocking", nil, "Add 'blocking' relationships by issue `number` or URL") + cmd.Flags().StringSliceVar(&opts.RemoveBlocking, "remove-blocking", nil, "Remove 'blocking' relationships by issue `number` or URL") return cmd } @@ -196,6 +254,7 @@ func editRun(opts *EditOptions) error { // Prompt the user which fields they'd like to edit. editable := opts.Editable + editable.IssueType.Selectable = true if opts.Interactive { err = opts.FieldsToEditSurvey(opts.Prompter, &editable) if err != nil { @@ -239,9 +298,15 @@ func editRun(opts *EditOptions) error { if editable.Milestone.Edited { lookupFields = append(lookupFields, "milestone") } + if editable.IssueType.Edited { + lookupFields = append(lookupFields, "issueType") + } + if opts.Parent != "" || opts.RemoveParent { + lookupFields = append(lookupFields, "parent") + } // Get all specified issues and make sure they are within the same repo. - issues, err := shared.FindIssuesOrPRs(httpClient, baseRepo, opts.IssueNumbers, lookupFields) + issues, err := issueShared.FindIssuesOrPRs(httpClient, baseRepo, opts.IssueNumbers, lookupFields) if err != nil { return err } @@ -272,6 +337,16 @@ func editRun(opts *EditOptions) error { opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Updating %d issues", len(issues))) } + // Resolve issue type ID up front for non-interactive mode; interactive + // mode resolves after the survey sets the value (inside the loop). + var issueTypeID string + if !opts.Interactive { + issueTypeID, err = lookupIssueTypeID(&editable) + if err != nil { + return err + } + } + for _, issue := range issues { // Copy variables to capture in the go routine below. editable := editable.Clone() @@ -297,6 +372,9 @@ func editRun(opts *EditOptions) error { if issue.Milestone != nil { editable.Milestone.Default = issue.Milestone.Title } + if issue.IssueType != nil { + editable.IssueType.Default = issue.IssueType.Name + } // Allow interactive prompts for one issue; failed earlier if multiple issues specified. if opts.Interactive { @@ -308,17 +386,30 @@ func editRun(opts *EditOptions) error { if err != nil { return err } + issueTypeID, err = lookupIssueTypeID(&editable) + if err != nil { + return err + } } g.Add(1) go func(issue *api.Issue) { defer g.Done() - err := prShared.UpdateIssue(httpClient, baseRepo, issue.ID, issue.IsPullRequest(), editable) + if err := prShared.UpdateIssue(httpClient, baseRepo, issue.ID, issue.IsPullRequest(), editable); err != nil { + failedIssueChan <- fmt.Sprintf("failed to update %s: %s", issue.URL, err) + return + } + + mutations, err := deferredUpdateIssueOptions(apiClient, baseRepo, issue, opts, issueTypeID) if err != nil { failedIssueChan <- fmt.Sprintf("failed to update %s: %s", issue.URL, err) return } + if err := api.DeferredUpdateIssue(apiClient, mutations); err != nil { + failedIssueChan <- fmt.Sprintf("failed to update %s:\n%s", issue.URL, err) + return + } editedIssueChan <- issue.URL }(issue) @@ -359,3 +450,87 @@ func editRun(opts *EditOptions) error { return nil } + +// lookupIssueTypeID resolves the chosen issue type to its node ID using the +// map populated by FetchOptions. +func lookupIssueTypeID(editable *prShared.Editable) (string, error) { + if !editable.IssueType.Edited || editable.IssueType.Value == "" { + return "", nil + } + id, ok := editable.IssueTypeNameToID[editable.IssueType.Value] + if !ok { + return "", fmt.Errorf("type %q not found; available types: %s", + editable.IssueType.Value, + strings.Join(editable.IssueType.Options, ", ")) + } + return id, nil +} + +func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, issue *api.Issue, editOpts *EditOptions, issueTypeID string) (api.DeferredUpdateIssueOptions, error) { + updateOpts := api.DeferredUpdateIssueOptions{ + IssueID: issue.ID, + Hostname: baseRepo.RepoHost(), + IssueTypeID: issueTypeID, + RemoveIssueType: editOpts.RemoveIssueType, + ReplaceExistingParent: true, + } + + if editOpts.RemoveParent { + if issue.Parent != nil { + updateOpts.RemoveParentID = issue.Parent.ID + } + } else if editOpts.Parent != "" { + parentID, err := issueShared.ResolveIssueRef(client, baseRepo, editOpts.Parent) + if err != nil { + return updateOpts, fmt.Errorf("resolving --parent reference %q: %w", editOpts.Parent, err) + } + updateOpts.ParentID = parentID + } + + for _, ref := range editOpts.AddSubIssues { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return updateOpts, fmt.Errorf("resolving --add-sub-issue reference %q: %w", ref, err) + } + updateOpts.AddSubIssueIDs = append(updateOpts.AddSubIssueIDs, id) + } + for _, ref := range editOpts.RemoveSubIssues { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return updateOpts, fmt.Errorf("resolving --remove-sub-issue reference %q: %w", ref, err) + } + updateOpts.RemoveSubIssueIDs = append(updateOpts.RemoveSubIssueIDs, id) + } + + for _, ref := range editOpts.AddBlockedBy { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return updateOpts, fmt.Errorf("resolving --add-blocked-by reference %q: %w", ref, err) + } + updateOpts.AddBlockedByIDs = append(updateOpts.AddBlockedByIDs, id) + } + for _, ref := range editOpts.RemoveBlockedBy { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return updateOpts, fmt.Errorf("resolving --remove-blocked-by reference %q: %w", ref, err) + } + updateOpts.RemoveBlockedByIDs = append(updateOpts.RemoveBlockedByIDs, id) + } + + for _, ref := range editOpts.AddBlocking { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return updateOpts, fmt.Errorf("resolving --add-blocking reference %q: %w", ref, err) + } + updateOpts.AddBlockingIDs = append(updateOpts.AddBlockingIDs, id) + } + for _, ref := range editOpts.RemoveBlocking { + id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) + if err != nil { + return updateOpts, fmt.Errorf("resolving --remove-blocking reference %q: %w", ref, err) + } + updateOpts.RemoveBlockingIDs = append(updateOpts.RemoveBlockingIDs, id) + } + + return updateOpts, nil +} diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 626c28162da..d0a188b0c8e 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -2,7 +2,9 @@ package edit import ( "bytes" + "encoding/json" "fmt" + "io" "net/http" "os" "path/filepath" @@ -11,6 +13,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" fd "github.com/cli/cli/v2/internal/featuredetection" + "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/run" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -281,6 +284,106 @@ func TestNewCmdEdit(t *testing.T) { input: "23 34", wantsErr: true, }, + { + name: "type flag", + input: "23 --type Bug", + output: EditOptions{ + IssueNumbers: []int{23}, + Editable: prShared.Editable{ + IssueType: prShared.EditableString{ + Value: "Bug", + Edited: true, + }, + }, + }, + }, + { + name: "remove-type flag", + input: "23 --remove-type", + output: EditOptions{ + IssueNumbers: []int{23}, + RemoveIssueType: true, + }, + }, + { + name: "both type and remove-type flags", + input: "23 --type Bug --remove-type", + wantsErr: true, + }, + { + name: "parent flag", + input: "23 --parent 100", + output: EditOptions{ + IssueNumbers: []int{23}, + Parent: "100", + }, + }, + { + name: "remove-parent flag", + input: "23 --remove-parent", + output: EditOptions{ + IssueNumbers: []int{23}, + RemoveParent: true, + }, + }, + { + name: "both parent and remove-parent flags", + input: "23 --parent 100 --remove-parent", + wantsErr: true, + }, + { + name: "add-sub-issue flag", + input: "23 --add-sub-issue 123,124", + output: EditOptions{ + IssueNumbers: []int{23}, + AddSubIssues: []string{"123", "124"}, + }, + }, + { + name: "add-sub-issue rejected with multiple issues", + input: "23 24 --add-sub-issue 123", + wantsErr: true, + }, + { + name: "remove-sub-issue flag", + input: "23 --remove-sub-issue 50", + output: EditOptions{ + IssueNumbers: []int{23}, + RemoveSubIssues: []string{"50"}, + }, + }, + { + name: "add-blocked-by flag", + input: "23 --add-blocked-by 200", + output: EditOptions{ + IssueNumbers: []int{23}, + AddBlockedBy: []string{"200"}, + }, + }, + { + name: "remove-blocked-by flag", + input: "23 --remove-blocked-by 201", + output: EditOptions{ + IssueNumbers: []int{23}, + RemoveBlockedBy: []string{"201"}, + }, + }, + { + name: "add-blocking flag", + input: "23 --add-blocking 300,301", + output: EditOptions{ + IssueNumbers: []int{23}, + AddBlocking: []string{"300", "301"}, + }, + }, + { + name: "remove-blocking flag", + input: "23 --remove-blocking 300", + output: EditOptions{ + IssueNumbers: []int{23}, + RemoveBlocking: []string{"300"}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -322,6 +425,14 @@ func TestNewCmdEdit(t *testing.T) { assert.Equal(t, tt.output.IssueNumbers, gotOpts.IssueNumbers) assert.Equal(t, tt.output.Interactive, gotOpts.Interactive) assert.Equal(t, tt.output.Editable, gotOpts.Editable) + assert.Equal(t, tt.output.Parent, gotOpts.Parent) + assert.Equal(t, tt.output.RemoveParent, gotOpts.RemoveParent) + assert.Equal(t, tt.output.AddSubIssues, gotOpts.AddSubIssues) + assert.Equal(t, tt.output.RemoveSubIssues, gotOpts.RemoveSubIssues) + assert.Equal(t, tt.output.AddBlockedBy, gotOpts.AddBlockedBy) + assert.Equal(t, tt.output.RemoveBlockedBy, gotOpts.RemoveBlockedBy) + assert.Equal(t, tt.output.AddBlocking, gotOpts.AddBlocking) + assert.Equal(t, tt.output.RemoveBlocking, gotOpts.RemoveBlocking) if tt.expectedBaseRepo != nil { baseRepo, err := gotOpts.BaseRepo() require.NoError(t, err) @@ -720,6 +831,415 @@ func Test_editRun(t *testing.T) { }, stdout: "https://github.com/OWNER/REPO/issue/123\n", }, + { + name: "edit type", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + Editable: prShared.Editable{ + IssueType: prShared.EditableString{ + Value: "Bug", + Edited: true, + }, + }, + FetchOptions: prShared.FetchOptions, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`query RepositoryIssueTypes\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTypes": { "nodes": [ + { "id": "BUG_TYPE_ID", "name": "Bug", "description": "", "color": "" }, + { "id": "FEATURE_TYPE_ID", "name": "Feature", "description": "", "color": "" } + ] } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "123", inputs["issueId"]) + assert.Equal(t, "BUG_TYPE_ID", inputs["issueTypeId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "remove type", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + RemoveIssueType: true, + FetchOptions: prShared.FetchOptions, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "123", inputs["issueId"]) + assert.Nil(t, inputs["issueTypeId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "interactive edit type prompt", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: true, + FieldsToEditSurvey: func(_ prShared.EditPrompter, eo *prShared.Editable) error { + // Verify the survey is allowed to offer Type as an option for issue edit. + assert.True(t, eo.IssueType.Selectable) + eo.IssueType.Edited = true + return nil + }, + EditFieldsSurvey: func(_ prShared.EditPrompter, eo *prShared.Editable, _ string) error { + // FetchOptions populated Options and IssueTypeNameToID from + // the RepositoryIssueTypes stub below. + assert.Equal(t, []string{"Bug", "Feature"}, eo.IssueType.Options) + assert.Equal(t, "FEATURE_TYPE_ID", eo.IssueTypeNameToID["Feature"]) + eo.IssueType.Value = "Feature" + return nil + }, + FetchOptions: prShared.FetchOptions, + DetermineEditor: func() (string, error) { return "vim", nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`query RepositoryIssueTypes\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTypes": { "nodes": [ + { "id": "BUG_TYPE_ID", "name": "Bug", "description": "", "color": "" }, + { "id": "FEATURE_TYPE_ID", "name": "Feature", "description": "", "color": "" } + ] } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "123", inputs["issueId"]) + assert.Equal(t, "FEATURE_TYPE_ID", inputs["issueTypeId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "edit set parent", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + Parent: "100", + FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { "id": "PARENT_100_ID" } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation AddSubIssue\b`), + httpmock.GraphQLMutation(` + { "data": { "addSubIssue": { "issue": { "id": "PARENT_100_ID" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "PARENT_100_ID", inputs["issueId"]) + assert.Equal(t, "123", inputs["subIssueId"]) + assert.Equal(t, true, inputs["replaceParent"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "edit remove parent", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + RemoveParent: true, + FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "id": "123", + "number": 123, + "url": "https://github.com/OWNER/REPO/issue/123", + "parent": { + "id": "PARENT_100_ID", + "number": 100, + "title": "Parent Issue", + "url": "https://github.com/OWNER/REPO/issues/100", + "state": "OPEN", + "repository": { "nameWithOwner": "OWNER/REPO" } + } + } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation RemoveSubIssue\b`), + httpmock.GraphQLMutation(` + { "data": { "removeSubIssue": { "issue": { "id": "PARENT_100_ID" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "PARENT_100_ID", inputs["issueId"]) + assert.Equal(t, "123", inputs["subIssueId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "edit add sub-issues", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{100}, + Interactive: false, + AddSubIssues: []string{"123", "124"}, + FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueNumberGet(t, reg, 100) + reg.Register( + issueNodeIDByNumberMatcher(123), + httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "SUB_123_ID" } } } }`), + ) + reg.Register( + issueNodeIDByNumberMatcher(124), + httpmock.StringResponse(`{ "data": { "repository": { "issue": { "id": "SUB_124_ID" } } } }`), + ) + reg.Register( + httpmock.GraphQLMutationMatcher(`mutation AddSubIssue\b`, func(input map[string]interface{}) bool { + return input["subIssueId"] == "SUB_123_ID" + }), + httpmock.GraphQLMutation(`{ "data": { "addSubIssue": { "issue": { "id": "100" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "100", inputs["issueId"]) + assert.Equal(t, true, inputs["replaceParent"]) + }), + ) + reg.Register( + httpmock.GraphQLMutationMatcher(`mutation AddSubIssue\b`, func(input map[string]interface{}) bool { + return input["subIssueId"] == "SUB_124_ID" + }), + httpmock.GraphQLMutation(`{ "data": { "addSubIssue": { "issue": { "id": "100" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "100", inputs["issueId"]) + assert.Equal(t, true, inputs["replaceParent"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/100\n", + }, + { + name: "edit remove sub-issue", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{100}, + Interactive: false, + RemoveSubIssues: []string{"123"}, + FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueNumberGet(t, reg, 100) + reg.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { "id": "SUB_123_ID" } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation RemoveSubIssue\b`), + httpmock.GraphQLMutation(` + { "data": { "removeSubIssue": { "issue": { "id": "100" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "100", inputs["issueId"]) + assert.Equal(t, "SUB_123_ID", inputs["subIssueId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/100\n", + }, + { + name: "edit add and remove blocked-by", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + AddBlockedBy: []string{"200"}, + RemoveBlockedBy: []string{"201"}, + FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { "id": "BLOCKING_200_ID" } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation AddBlockedBy\b`), + httpmock.GraphQLMutation(` + { "data": { "addBlockedBy": { "issue": { "id": "123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "123", inputs["issueId"]) + assert.Equal(t, "BLOCKING_200_ID", inputs["blockingIssueId"]) + }), + ) + reg.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { "id": "BLOCKING_201_ID" } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation RemoveBlockedBy\b`), + httpmock.GraphQLMutation(` + { "data": { "removeBlockedBy": { "issue": { "id": "123" } } } }`, + func(inputs map[string]interface{}) { + assert.Equal(t, "123", inputs["issueId"]) + assert.Equal(t, "BLOCKING_201_ID", inputs["blockingIssueId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "edit add blocking swaps args", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + AddBlocking: []string{"300"}, + FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { "id": "BLOCKED_300_ID" } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation AddBlockedBy\b`), + httpmock.GraphQLMutation(` + { "data": { "addBlockedBy": { "issue": { "id": "BLOCKED_300_ID" } } } }`, + func(inputs map[string]interface{}) { + // --add-blocking swaps: OTHER issue is blocked BY this issue + assert.Equal(t, "BLOCKED_300_ID", inputs["issueId"]) + assert.Equal(t, "123", inputs["blockingIssueId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "edit remove blocking swaps args", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123}, + Interactive: false, + RemoveBlocking: []string{"300"}, + FetchOptions: func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + reg.Register( + httpmock.GraphQL(`query IssueNodeID\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issue": { "id": "BLOCKED_300_ID" } } } } + `), + ) + reg.Register( + httpmock.GraphQL(`mutation RemoveBlockedBy\b`), + httpmock.GraphQLMutation(` + { "data": { "removeBlockedBy": { "issue": { "id": "BLOCKED_300_ID" } } } }`, + func(inputs map[string]interface{}) { + // --remove-blocking swaps: OTHER issue is no longer blocked BY this issue + assert.Equal(t, "BLOCKED_300_ID", inputs["issueId"]) + assert.Equal(t, "123", inputs["blockingIssueId"]) + }), + ) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "batch edit type", + input: &EditOptions{ + Detector: &fd.EnabledDetectorMock{}, + IssueNumbers: []int{123, 456}, + Interactive: false, + Editable: prShared.Editable{ + IssueType: prShared.EditableString{ + Value: "Bug", + Edited: true, + }, + }, + FetchOptions: prShared.FetchOptions, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryIssueTypes\b`), + httpmock.StringResponse(` + { "data": { "repository": { "issueTypes": { "nodes": [ + { "id": "BUG_TYPE_ID", "name": "Bug", "description": "", "color": "" } + ] } } } } + `), + ) + mockIssueNumberGet(t, reg, 123) + mockIssueNumberGet(t, reg, 456) + reg.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "123" } } } }`, + func(inputs map[string]interface{}) {}), + ) + reg.Register( + httpmock.GraphQL(`mutation UpdateIssueIssueType\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssueIssueType": { "issue": { "id": "456" } } } }`, + func(inputs map[string]interface{}) {}), + ) + }, + stdout: heredoc.Doc(` + https://github.com/OWNER/REPO/issue/123 + https://github.com/OWNER/REPO/issue/456 + `), + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -935,6 +1455,83 @@ func mockProjectV2ItemUpdate(t *testing.T, reg *httpmock.Registry) { ) } +// Test_editRun_crossHostRelationshipRefs verifies that every relationship +// flag rejects a cross-host issue URL with the same clear error. Lives as +// its own table rather than additional cases in Test_editRun because each +// case shares identical setup and asserts the same error, varying only in +// which input field carries the cross-host URL. +func Test_editRun_crossHostRelationshipRefs(t *testing.T) { + const crossHostURL = "https://example.com/OWNER/REPO/issues/9" + + // Each case exercises one relationship-bearing flag with a cross-host + // URL. ResolveIssueRef should short-circuit before any GraphQL request, + // and the per-issue failure must surface to stderr. + tests := []struct { + name string + input *EditOptions + }{ + { + name: "set parent", + input: &EditOptions{Parent: crossHostURL}, + }, + { + name: "add sub-issue", + input: &EditOptions{AddSubIssues: []string{crossHostURL}}, + }, + { + name: "remove sub-issue", + input: &EditOptions{RemoveSubIssues: []string{crossHostURL}}, + }, + { + name: "add blocked-by", + input: &EditOptions{AddBlockedBy: []string{crossHostURL}}, + }, + { + name: "remove blocked-by", + input: &EditOptions{RemoveBlockedBy: []string{crossHostURL}}, + }, + { + name: "add blocking", + input: &EditOptions{AddBlocking: []string{crossHostURL}}, + }, + { + name: "remove blocking", + input: &EditOptions{RemoveBlocking: []string{crossHostURL}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ios, _, _, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + mockIssueGet(t, reg) + // No IssueNodeID stub on purpose: the cross-host guard must + // short-circuit before any resolution request goes out. + + tt.input.Detector = &fd.EnabledDetectorMock{} + tt.input.IssueNumbers = []int{123} + tt.input.Interactive = false + tt.input.FetchOptions = func(_ *api.Client, _ ghrepo.Interface, _ *prShared.Editable, _ gh.ProjectsV1Support) error { + return nil + } + tt.input.IO = ios + tt.input.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.input.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + } + + err := editRun(tt.input) + require.Error(t, err) + assert.Regexp(t, `belongs to a different host \(example\.com\) than the current repository \(github\.com\)`, stderr.String()) + }) + } +} + func TestApiActorsSupported(t *testing.T) { t.Run("when actors are assignable, query includes assignedActors", func(t *testing.T) { ios, _, _, _ := iostreams.Test() @@ -1098,3 +1695,30 @@ func TestProjectsV1Deprecation(t *testing.T) { reg.Verify(t) }) } + +// issueNodeIDByNumberMatcher matches an IssueNodeID GraphQL query whose +// number variable equals the given value. Used by tests that issue +// multiple IssueNodeID lookups and need stubs to route by issue number +// rather than by registration order. +func issueNodeIDByNumberMatcher(number int) httpmock.Matcher { + queryMatcher := httpmock.GraphQL(`query IssueNodeID\b`) + return func(req *http.Request) bool { + if !queryMatcher(req) { + return false + } + body, err := io.ReadAll(req.Body) + if err != nil { + return false + } + req.Body = io.NopCloser(bytes.NewReader(body)) + var b struct { + Variables struct { + Number int `json:"number"` + } `json:"variables"` + } + if err := json.Unmarshal(body, &b); err != nil { + return false + } + return b.Variables.Number == number + } +} diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index d58357ac475..f3e1f7871b9 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -37,6 +37,7 @@ type ListOptions struct { Mention string Milestone string Search string + IssueType string WebMode bool Exporter cmdutil.Exporter @@ -77,6 +78,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman $ gh issue list --milestone "The big 1.0" $ gh issue list --search "error no:assignee sort:created-asc" $ gh issue list --state all + $ gh issue list --type Bug `), Aliases: []string{"ls"}, Args: cmdutil.NoArgsQuoteReminder, @@ -113,6 +115,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVar(&opts.Mention, "mention", "", "Filter by mention") cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Filter by milestone number or title") cmd.Flags().StringVarP(&opts.Search, "search", "S", "", "Search issues with `query`") + cmd.Flags().StringVar(&opts.IssueType, "type", "", "Filter by issue type `name`") cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.IssueFields) return cmd @@ -158,6 +161,7 @@ func listRun(opts *ListOptions) error { Mention: opts.Mention, Milestone: opts.Milestone, Search: opts.Search, + IssueType: opts.IssueType, Fields: fields, } @@ -227,7 +231,7 @@ func listRun(opts *ListOptions) error { func issueList(client *http.Client, detector fd.Detector, repo ghrepo.Interface, filters prShared.FilterOptions, limit int) (*api.IssuesAndTotalCount, error) { apiClient := api.NewClientFromHTTP(client) - if filters.Search != "" || len(filters.Labels) > 0 || filters.Milestone != "" { + if filters.Search != "" || len(filters.Labels) > 0 || filters.Milestone != "" || filters.IssueType != "" { if milestoneNumber, err := strconv.ParseInt(filters.Milestone, 10, 32); err == nil { milestone, err := milestoneByNumber(client, repo, int32(milestoneNumber)) if err != nil { diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index 52f9ba00e25..af0879a6b5e 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -25,6 +25,54 @@ import ( "github.com/stretchr/testify/require" ) +func TestNewCmdList(t *testing.T) { + tests := []struct { + name string + cli string + wantsErr bool + wants ListOptions + }{ + { + name: "type flag", + cli: "--type Bug", + wants: ListOptions{ + IssueType: "Bug", + State: "open", + LimitResults: 30, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + var gotOpts *ListOptions + cmd := NewCmdList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + argv, err := shlex.Split(tt.cli) + require.NoError(t, err) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + _, err = cmd.ExecuteC() + if tt.wantsErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.NotNil(t, gotOpts) + + assert.Equal(t, tt.wants.IssueType, gotOpts.IssueType) + assert.Equal(t, tt.wants.State, gotOpts.State) + assert.Equal(t, tt.wants.LimitResults, gotOpts.LimitResults) + }) + } +} + func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(isTTY) @@ -484,6 +532,41 @@ func Test_issueList(t *testing.T) { })) }, }, + { + name: "with issue type", + args: args{ + // TODO advancedIssueSearchCleanup + // No need for feature detection once GHES 3.17 support ends. + detector: fd.AdvancedIssueSearchSupportedAsOptIn(), + limit: 30, + repo: ghrepo.New("OWNER", "REPO"), + filters: prShared.FilterOptions{ + Entity: "issue", + State: "open", + IssueType: "Bug", + }, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueSearch\b`), + httpmock.GraphQLQuery(` + { "data": { + "repository": { "hasIssuesEnabled": true }, + "search": { + "issueCount": 0, + "nodes": [] + } + } }`, func(_ string, params map[string]interface{}) { + assert.Equal(t, map[string]interface{}{ + "owner": "OWNER", + "repo": "REPO", + "limit": float64(30), + "query": "repo:OWNER/REPO state:open type:Bug type:issue", + "type": "ISSUE_ADVANCED", + }, params) + })) + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go index 63efd61f72e..8501bfcfaa5 100644 --- a/pkg/cmd/issue/shared/lookup.go +++ b/pkg/cmd/issue/shared/lookup.go @@ -208,3 +208,42 @@ func FindIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, f return resp.Repository.Issue, nil } + +// ResolveIssueRef parses an issue reference (number or URL) and returns its +// node ID. References that point at a different host than baseRepo are +// rejected because relationship mutations require IDs from the base host. +func ResolveIssueRef(client *api.Client, baseRepo ghrepo.Interface, ref string) (string, error) { + number, repo, err := ParseIssueFromArg(ref) + if err != nil { + return "", err + } + + targetRepo := baseRepo + if r, ok := repo.Value(); ok { + if r.RepoHost() != baseRepo.RepoHost() { + return "", fmt.Errorf("issue reference %q belongs to a different host (%s) than the current repository (%s)", ref, r.RepoHost(), baseRepo.RepoHost()) + } + targetRepo = r + } + + return api.IssueNodeID(client, targetRepo, number) +} + +// ResolveIssueTypeName resolves an issue type name to its node ID by +// fetching the repository's available types. +func ResolveIssueTypeName(client *api.Client, repo ghrepo.Interface, typeName string) (string, error) { + issueTypes, err := api.RepoIssueTypes(client, repo) + if err != nil { + return "", err + } + + typeNames := make([]string, len(issueTypes)) + for i, t := range issueTypes { + typeNames[i] = t.Name + if strings.EqualFold(t.Name, typeName) { + return t.ID, nil + } + } + + return "", fmt.Errorf("type %q not found; available types: %s", typeName, strings.Join(typeNames, ", ")) +} diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 5add5a71b1e..7d562f864b0 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -15,7 +15,6 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/text" - "github.com/cli/cli/v2/pkg/cmd/issue/shared" issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -58,7 +57,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman `, "`"), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - issueNumber, baseRepo, err := shared.ParseIssueFromArg(args[0]) + issueNumber, baseRepo, err := issueShared.ParseIssueFromArg(args[0]) if err != nil { return err } @@ -92,6 +91,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman var defaultFields = []string{ "number", "url", "state", "createdAt", "title", "body", "author", "milestone", "assignees", "labels", "reactionGroups", "lastComment", "stateReason", + "issueType", "parent", "subIssues", "subIssuesSummary", } func viewRun(opts *ViewOptions) error { @@ -129,6 +129,12 @@ func viewRun(opts *ViewOptions) error { if projectsV1Support == gh.ProjectsV1Supported { lookupFields.Add("projectCards") } + + // TODO IssueRelationshipsCleanup + issueFeatures, issueErr := opts.Detector.IssueFeatures() + if issueErr == nil && issueFeatures.IssueRelationshipsSupported { + lookupFields.AddValues([]string{"blockedBy", "blocking"}) + } } opts.IO.DetectTerminalTheme() @@ -207,6 +213,24 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error { milestoneTitle = issue.Milestone.Title } fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle) + var issueTypeName string + if issue.IssueType != nil { + issueTypeName = issue.IssueType.Name + } + fmt.Fprintf(out, "issue-type:\t%s\n", issueTypeName) + var parentRef string + if issue.Parent != nil { + parentRef = formatLinkedIssueRef(issue.Parent) + } + fmt.Fprintf(out, "parent:\t%s\n", parentRef) + fmt.Fprintf(out, "sub-issues:\t%s\n", formatLinkedIssueRefs(issue.SubIssues.Nodes)) + var subIssuesCompleted string + if issue.SubIssuesSummary.Total > 0 { + subIssuesCompleted = fmt.Sprintf("%d/%d", issue.SubIssuesSummary.Completed, issue.SubIssuesSummary.Total) + } + fmt.Fprintf(out, "sub-issues-completed:\t%s\n", subIssuesCompleted) + fmt.Fprintf(out, "blocked-by:\t%s\n", formatLinkedIssueRefs(issue.BlockedBy.Nodes)) + fmt.Fprintf(out, "blocking:\t%s\n", formatLinkedIssueRefs(issue.Blocking.Nodes)) fmt.Fprintf(out, "number:\t%d\n", issue.Number) fmt.Fprintln(out, "--") fmt.Fprintln(out, issue.Body) @@ -219,9 +243,15 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue // Header (Title and State) fmt.Fprintf(out, "%s %s#%d\n", cs.Bold(issue.Title), ghrepo.FullName(baseRepo), issue.Number) + + // State line - include issue type prefix when present + stateLine := issueStateTitleWithColor(cs, issue) + if issue.IssueType != nil { + stateLine = cs.Muted(issue.IssueType.Name) + " · " + stateLine + } fmt.Fprintf(out, "%s • %s opened %s • %s\n", - issueStateTitleWithColor(cs, issue), + stateLine, issue.Author.DisplayName(), text.FuzzyAgo(opts.Now(), issue.CreatedAt), text.Pluralize(issue.Comments.TotalCount, "comment"), @@ -242,6 +272,22 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue fmt.Fprint(out, cs.Bold("Labels: ")) fmt.Fprintln(out, labels) } + if issue.IssueType != nil { + fmt.Fprint(out, cs.Bold("Type: ")) + fmt.Fprintln(out, issue.IssueType.Name) + } + if issue.Parent != nil { + fmt.Fprint(out, cs.Bold("Parent: ")) + fmt.Fprintln(out, formatLinkedIssueRef(issue.Parent)+" "+issue.Parent.Title) + } + if blockedBy := formatLinkedIssueListWithTitle(issue.BlockedBy.Nodes); blockedBy != "" { + fmt.Fprint(out, cs.Bold("Blocked by: ")) + fmt.Fprintln(out, blockedBy) + } + if blocking := formatLinkedIssueListWithTitle(issue.Blocking.Nodes); blocking != "" { + fmt.Fprint(out, cs.Bold("Blocking: ")) + fmt.Fprintln(out, blocking) + } if projects := issueProjectList(*issue); projects != "" { fmt.Fprint(out, cs.Bold("Projects: ")) fmt.Fprintln(out, projects) @@ -266,6 +312,30 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue } fmt.Fprintf(out, "\n%s\n", md) + // Sub-issues section + if issue.SubIssuesSummary.Total > 0 { + fmt.Fprintf(out, "%s · %d/%d (%d%%)\n", + cs.Bold("Sub-issues"), + issue.SubIssuesSummary.Completed, + issue.SubIssuesSummary.Total, + int(issue.SubIssuesSummary.PercentCompleted), + ) + for _, sub := range issue.SubIssues.Nodes { + stateColor := cs.Green + stateLabel := "Open" + if sub.State == "CLOSED" { + stateColor = cs.Magenta + stateLabel = "Closed" + } + fmt.Fprintf(out, "%s %s %s\n", + stateColor(stateLabel), + formatLinkedIssueRef(&sub), + sub.Title, + ) + } + fmt.Fprintln(out) + } + // Comments if issue.Comments.TotalCount > 0 { preview := !opts.Comments @@ -282,6 +352,37 @@ func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue return nil } +// formatLinkedIssueRef formats an issue reference as owner/repo#N. +func formatLinkedIssueRef(issue *api.LinkedIssue) string { + return fmt.Sprintf("%s#%d", issue.Repository.NameWithOwner, issue.Number) +} + +// formatLinkedIssueRefs formats a comma-separated list of linked issue +// references without titles. +func formatLinkedIssueRefs(issues []api.LinkedIssue) string { + return joinLinkedIssues(issues, false) +} + +// formatLinkedIssueListWithTitle formats a comma-separated list of linked +// issue references with each title appended after the reference. +func formatLinkedIssueListWithTitle(issues []api.LinkedIssue) string { + return joinLinkedIssues(issues, true) +} + +func joinLinkedIssues(issues []api.LinkedIssue, withTitle bool) string { + if len(issues) == 0 { + return "" + } + parts := make([]string, len(issues)) + for i, issue := range issues { + parts[i] = formatLinkedIssueRef(&issue) + if withTitle { + parts[i] += " " + issue.Title + } + } + return strings.Join(parts, ", ") +} + func issueStateTitleWithColor(cs *iostreams.ColorScheme, issue *api.Issue) string { colorFunc := cs.ColorFromString(prShared.ColorForIssueState(*issue)) state := "Open" diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index aa6002563e8..d11afe8c0b4 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -2,6 +2,7 @@ package view import ( "bytes" + "encoding/json" "io" "net/http" "testing" @@ -21,6 +22,7 @@ import ( "github.com/cli/cli/v2/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestJSONFields(t *testing.T) { @@ -46,6 +48,12 @@ func TestJSONFields(t *testing.T) { "url", "isPinned", "stateReason", + "issueType", + "parent", + "subIssues", + "subIssuesSummary", + "blockedBy", + "blocking", }) } @@ -635,3 +643,397 @@ func mockV2ProjectItems(t *testing.T, r *httpmock.Registry) { } } } } } `)) } + +// issueResponseAllIssues2Fields returns a GraphQL response for an issue with all Issues 2.0 fields populated. +func issueResponseAllIssues2Fields() string { + return `{ "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "id": "ISSUE_123", + "number": 123, + "title": "Implement OAuth flow", + "state": "OPEN", + "stateReason": "", + "body": "The OAuth flow needs work.", + "author": {"login": "user1"}, + "createdAt": "2024-01-01T00:00:00Z", + "comments": {"nodes":[], "totalCount": 0}, + "assignees": {"nodes": [], "totalCount": 0}, + "labels": {"nodes": [], "totalCount": 0}, + "milestone": null, + "reactionGroups": [], + "projectCards": {"nodes": [], "totalCount": 0}, + "projectItems": {"nodes": [], "totalCount": 0}, + "url": "https://github.com/OWNER/REPO/issues/123", + "issueType": {"id":"IT_1","name":"Bug","description":"Something is not working","color":"d73a4a"}, + "parent": {"number":100,"title":"Epic: Authentication overhaul","url":"https://github.com/OWNER/REPO/issues/100","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}}, + "subIssues": { + "nodes": [ + {"number":101,"title":"Design auth module","url":"https://github.com/OWNER/REPO/issues/101","state":"CLOSED","repository":{"nameWithOwner":"OWNER/REPO"}}, + {"number":102,"title":"Token refresh logic","url":"https://github.com/OWNER/REPO/issues/102","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}} + ], + "totalCount": 2 + }, + "subIssuesSummary": {"total":2,"completed":1,"percentCompleted":50.0}, + "blockedBy": { + "nodes": [{"number":200,"title":"API rate limiting","url":"https://github.com/OWNER/REPO/issues/200","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}}], + "totalCount": 1 + }, + "blocking": { + "nodes": [{"number":300,"title":"Release v2.0","url":"https://github.com/OWNER/REPO/issues/300","state":"OPEN","repository":{"nameWithOwner":"OWNER/REPO"}}], + "totalCount": 1 + } + } } } }` +} + +// issueResponseNoIssues2Fields returns a GraphQL response for an issue with no Issues 2.0 fields. +func issueResponseNoIssues2Fields() string { + return `{ "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "id": "ISSUE_456", + "number": 456, + "title": "Fix login page", + "state": "OPEN", + "stateReason": "", + "body": "The login page is broken.", + "author": {"login": "user2"}, + "createdAt": "2024-01-01T00:00:00Z", + "comments": {"nodes":[], "totalCount": 2}, + "assignees": {"nodes": [], "totalCount": 0}, + "labels": {"nodes": [], "totalCount": 0}, + "milestone": null, + "reactionGroups": [], + "projectCards": {"nodes": [], "totalCount": 0}, + "projectItems": {"nodes": [], "totalCount": 0}, + "url": "https://github.com/OWNER/REPO/issues/456" + } } } }` +} + +func TestIssueView_tty_Issues2AllFields(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(issueResponseAllIssues2Fields()), + ) + mockEmptyV2ProjectItems(t, httpReg) + + opts := ViewOptions{ + IO: ios, + Now: func() time.Time { + t, _ := time.Parse(time.RFC822, "03 Nov 24 15:04 UTC") + return t + }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: httpReg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + IssueNumber: 123, + } + + err := viewRun(&opts) + require.NoError(t, err) + + assert.Equal(t, "", stderr.String()) + + out := stdout.String() + + // Title + assert.Contains(t, out, "Implement OAuth flow") + assert.Contains(t, out, "OWNER/REPO#123") + + // State line includes issue type prefix + assert.Contains(t, out, "Bug · Open") + + // Type metadata row + assert.Contains(t, out, "Type:") + assert.Contains(t, out, "Bug") + + // Parent metadata row + assert.Contains(t, out, "Parent:") + assert.Contains(t, out, "OWNER/REPO#100 Epic: Authentication overhaul") + + // Blocked by metadata row + assert.Contains(t, out, "Blocked by:") + assert.Contains(t, out, "OWNER/REPO#200 API rate limiting") + + // Blocking metadata row + assert.Contains(t, out, "Blocking:") + assert.Contains(t, out, "OWNER/REPO#300 Release v2.0") + + // Sub-issues section + assert.Contains(t, out, "Sub-issues") + assert.Contains(t, out, "1/2 (50%)") + assert.Contains(t, out, "OWNER/REPO#101") + assert.Contains(t, out, "Design auth module") + assert.Contains(t, out, "OWNER/REPO#102") + assert.Contains(t, out, "Token refresh logic") + + // Body + assert.Contains(t, out, "The OAuth flow needs work.") + + // Footer + assert.Contains(t, out, "View this issue on GitHub: https://github.com/OWNER/REPO/issues/123") +} + +func TestIssueView_nontty_Issues2AllFields(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(issueResponseAllIssues2Fields()), + ) + mockEmptyV2ProjectItems(t, httpReg) + + opts := ViewOptions{ + IO: ios, + Now: func() time.Time { + t, _ := time.Parse(time.RFC822, "03 Nov 24 15:04 UTC") + return t + }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: httpReg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + IssueNumber: 123, + } + + err := viewRun(&opts) + require.NoError(t, err) + + assert.Equal(t, "", stderr.String()) + + out := stdout.String() + + assert.Contains(t, out, "issue-type:\tBug\n") + assert.Contains(t, out, "parent:\tOWNER/REPO#100\n") + assert.Contains(t, out, "sub-issues:\tOWNER/REPO#101, OWNER/REPO#102\n") + assert.Contains(t, out, "sub-issues-completed:\t1/2\n") + assert.Contains(t, out, "blocked-by:\tOWNER/REPO#200\n") + assert.Contains(t, out, "blocking:\tOWNER/REPO#300\n") +} + +func TestIssueView_tty_Issues2NoFields(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + ios.SetStdoutTTY(true) + ios.SetStdinTTY(true) + ios.SetStderrTTY(true) + + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(issueResponseNoIssues2Fields()), + ) + mockEmptyV2ProjectItems(t, httpReg) + + opts := ViewOptions{ + IO: ios, + Now: func() time.Time { + t, _ := time.Parse(time.RFC822, "03 Nov 24 15:04 UTC") + return t + }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: httpReg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + IssueNumber: 456, + } + + err := viewRun(&opts) + require.NoError(t, err) + + assert.Equal(t, "", stderr.String()) + + out := stdout.String() + + // Standard fields are still present + assert.Contains(t, out, "Fix login page") + assert.Contains(t, out, "OWNER/REPO#456") + assert.Contains(t, out, "Open") + assert.Contains(t, out, "The login page is broken.") + assert.Contains(t, out, "View this issue on GitHub: https://github.com/OWNER/REPO/issues/456") + + // Issues 2.0 sections must NOT appear + assert.NotContains(t, out, "Type:") + assert.NotContains(t, out, "Parent:") + assert.NotContains(t, out, "Blocked by:") + assert.NotContains(t, out, "Blocking:") + assert.NotContains(t, out, "Sub-issues") +} + +func TestIssueView_nontty_Issues2NoFields(t *testing.T) { + ios, _, stdout, stderr := iostreams.Test() + + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(issueResponseNoIssues2Fields()), + ) + mockEmptyV2ProjectItems(t, httpReg) + + opts := ViewOptions{ + IO: ios, + Now: func() time.Time { + t, _ := time.Parse(time.RFC822, "03 Nov 24 15:04 UTC") + return t + }, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: httpReg}, nil + }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + IssueNumber: 456, + } + + err := viewRun(&opts) + require.NoError(t, err) + + assert.Equal(t, "", stderr.String()) + + out := stdout.String() + + // Issues 2.0 keys appear with empty values to keep line counts stable + // for `head | grep` workflows. + assert.Contains(t, out, "issue-type:\t\n") + assert.Contains(t, out, "parent:\t\n") + assert.Contains(t, out, "sub-issues:\t\n") + assert.Contains(t, out, "sub-issues-completed:\t\n") + assert.Contains(t, out, "blocked-by:\t\n") + assert.Contains(t, out, "blocking:\t\n") +} + +func TestIssueView_json_IssueType(t *testing.T) { + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(issueResponseAllIssues2Fields()), + ) + + output, err := runCommand(httpReg, false, `123 --json issueType`) + require.NoError(t, err) + + var data map[string]interface{} + require.NoError(t, json.Unmarshal(output.OutBuf.Bytes(), &data)) + + issueType, ok := data["issueType"].(map[string]interface{}) + require.True(t, ok, "issueType should be an object") + assert.Equal(t, "IT_1", issueType["id"]) + assert.Equal(t, "Bug", issueType["name"]) + assert.Equal(t, "Something is not working", issueType["description"]) + assert.Equal(t, "d73a4a", issueType["color"]) +} + +func TestIssueView_json_ParentSubIssues(t *testing.T) { + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(issueResponseAllIssues2Fields()), + ) + + output, err := runCommand(httpReg, false, `123 --json parent,subIssues,subIssuesSummary`) + require.NoError(t, err) + + var data map[string]interface{} + require.NoError(t, json.Unmarshal(output.OutBuf.Bytes(), &data)) + + // Parent + parent, ok := data["parent"].(map[string]interface{}) + require.True(t, ok, "parent should be an object") + assert.Equal(t, float64(100), parent["number"]) + assert.Equal(t, "Epic: Authentication overhaul", parent["title"]) + assert.Equal(t, "https://github.com/OWNER/REPO/issues/100", parent["url"]) + assert.Equal(t, "OPEN", parent["state"]) + + // Sub-issues + subIssuesObj, ok := data["subIssues"].(map[string]interface{}) + require.True(t, ok, "subIssues should be an object") + assert.Equal(t, float64(2), subIssuesObj["totalCount"]) + + subIssues, ok := subIssuesObj["nodes"].([]interface{}) + require.True(t, ok, "subIssues.nodes should be an array") + require.Len(t, subIssues, 2) + + sub0 := subIssues[0].(map[string]interface{}) + assert.Equal(t, float64(101), sub0["number"]) + assert.Equal(t, "Design auth module", sub0["title"]) + assert.Equal(t, "CLOSED", sub0["state"]) + + sub1 := subIssues[1].(map[string]interface{}) + assert.Equal(t, float64(102), sub1["number"]) + assert.Equal(t, "Token refresh logic", sub1["title"]) + assert.Equal(t, "OPEN", sub1["state"]) + + // Sub-issues summary + summary, ok := data["subIssuesSummary"].(map[string]interface{}) + require.True(t, ok, "subIssuesSummary should be an object") + assert.Equal(t, float64(2), summary["total"]) + assert.Equal(t, float64(1), summary["completed"]) + assert.Equal(t, float64(50), summary["percentCompleted"]) +} + +func TestIssueView_json_BlockedByBlocking(t *testing.T) { + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(issueResponseAllIssues2Fields()), + ) + + output, err := runCommand(httpReg, false, `123 --json blockedBy,blocking`) + require.NoError(t, err) + + var data map[string]interface{} + require.NoError(t, json.Unmarshal(output.OutBuf.Bytes(), &data)) + + // Blocked by + blockedByObj, ok := data["blockedBy"].(map[string]interface{}) + require.True(t, ok, "blockedBy should be an object") + assert.Equal(t, float64(1), blockedByObj["totalCount"]) + + blockedBy, ok := blockedByObj["nodes"].([]interface{}) + require.True(t, ok, "blockedBy.nodes should be an array") + require.Len(t, blockedBy, 1) + + blocked0 := blockedBy[0].(map[string]interface{}) + assert.Equal(t, float64(200), blocked0["number"]) + assert.Equal(t, "API rate limiting", blocked0["title"]) + assert.Equal(t, "https://github.com/OWNER/REPO/issues/200", blocked0["url"]) + assert.Equal(t, "OPEN", blocked0["state"]) + + // Blocking + blockingObj, ok := data["blocking"].(map[string]interface{}) + require.True(t, ok, "blocking should be an object") + assert.Equal(t, float64(1), blockingObj["totalCount"]) + + blocking, ok := blockingObj["nodes"].([]interface{}) + require.True(t, ok, "blocking.nodes should be an array") + require.Len(t, blocking, 1) + + blocking0 := blocking[0].(map[string]interface{}) + assert.Equal(t, float64(300), blocking0["number"]) + assert.Equal(t, "Release v2.0", blocking0["title"]) + assert.Equal(t, "https://github.com/OWNER/REPO/issues/300", blocking0["url"]) + assert.Equal(t, "OPEN", blocking0["state"]) +} diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 33a71154ad2..af5e04631f1 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -283,7 +283,7 @@ func editRun(opts *EditOptions) error { } editable := opts.Editable - editable.Reviewers.Allowed = true + editable.Reviewers.Selectable = true editable.Title.Default = pr.Title editable.Body.Default = pr.Body editable.Base.Default = pr.BaseRefName diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index d29b6d4c490..404b0e0cc74 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -21,6 +21,8 @@ type Editable struct { Labels EditableSlice Projects EditableProjects Milestone EditableString + IssueType EditableString + IssueTypeNameToID map[string]string Metadata api.RepoMetadataResult // TODO ApiActorsSupported @@ -36,6 +38,10 @@ type EditableString struct { Default string Options []string Edited bool + // Selectable controls whether the interactive survey offers this + // field as one of the things the user can choose to edit. Flag-only + // fields leave it false. + Selectable bool } type EditableSlice struct { @@ -45,7 +51,10 @@ type EditableSlice struct { Default []string Options []string Edited bool - Allowed bool + // Selectable controls whether the interactive survey offers this + // field as one of the things the user can choose to edit. Flag-only + // fields leave it false. + Selectable bool } // EditableAssignees is a special case of EditableSlice. @@ -75,7 +84,8 @@ func (e Editable) Dirty() bool { e.Assignees.Edited || e.Labels.Edited || e.Projects.Edited || - e.Milestone.Edited + e.Milestone.Edited || + e.IssueType.Edited } func (e Editable) TitleValue() *string { @@ -290,6 +300,8 @@ func (e *Editable) Clone() Editable { Labels: e.Labels.clone(), Projects: e.Projects.clone(), Milestone: e.Milestone.clone(), + IssueType: e.IssueType.clone(), + IssueTypeNameToID: e.IssueTypeNameToID, ApiActorsSupported: e.ApiActorsSupported, // Shallow copy since no mutation. Metadata: e.Metadata, @@ -298,9 +310,10 @@ func (e *Editable) Clone() Editable { func (es *EditableString) clone() EditableString { return EditableString{ - Value: es.Value, - Default: es.Default, - Edited: es.Edited, + Value: es.Value, + Default: es.Default, + Edited: es.Edited, + Selectable: es.Selectable, // Shallow copies since no mutation. Options: es.Options, } @@ -308,8 +321,8 @@ func (es *EditableString) clone() EditableString { func (es *EditableSlice) clone() EditableSlice { cpy := EditableSlice{ - Edited: es.Edited, - Allowed: es.Allowed, + Edited: es.Edited, + Selectable: es.Selectable, // Shallow copies since no mutation. Options: es.Options, // Copy mutable string slices. @@ -443,6 +456,16 @@ func EditFieldsSurvey(p EditPrompter, editable *Editable, editorCommand string) return err } } + if editable.IssueType.Edited { + if len(editable.IssueType.Options) > 0 { + var selected int + selected, err = p.Select("Type", editable.IssueType.Default, editable.IssueType.Options) + if err != nil { + return err + } + editable.IssueType.Value = editable.IssueType.Options[selected] + } + } confirm, err := p.Confirm("Submit?", true) if err != nil { return err @@ -465,10 +488,14 @@ func FieldsToEditSurvey(p EditPrompter, editable *Editable) error { } opts := []string{"Title", "Body"} - if editable.Reviewers.Allowed { + if editable.Reviewers.Selectable { opts = append(opts, "Reviewers") } - opts = append(opts, "Assignees", "Labels", "Projects", "Milestone") + opts = append(opts, "Assignees", "Labels") + if editable.IssueType.Selectable { + opts = append(opts, "Type") + } + opts = append(opts, "Projects", "Milestone") results, err := multiSelectSurvey(p, "What would you like to edit?", []string{}, opts) if err != nil { return err @@ -489,6 +516,9 @@ func FieldsToEditSurvey(p EditPrompter, editable *Editable) error { if contains(results, "Labels") { editable.Labels.Edited = true } + if contains(results, "Type") { + editable.IssueType.Edited = true + } if contains(results, "Projects") { editable.Projects.Edited = true } @@ -592,6 +622,21 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable, editable.Projects.Options = projects editable.Milestone.Options = milestones + // Fetch issue types if editing type + if editable.IssueType.Edited { + issueTypes, err := api.RepoIssueTypes(client, repo) + if err == nil { + typeNames := make([]string, len(issueTypes)) + ids := make(map[string]string, len(issueTypes)) + for i, t := range issueTypes { + typeNames[i] = t.Name + ids[t.Name] = t.ID + } + editable.IssueType.Options = typeNames + editable.IssueTypeNameToID = ids + } + } + return nil } diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 76f096efd7e..54854db8f29 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -176,6 +176,7 @@ type FilterOptions struct { Entity string Fields []string HeadBranch string + IssueType string Labels []string Mention string Milestone string @@ -212,6 +213,9 @@ func (opts *FilterOptions) IsDefault() bool { if opts.Search != "" { return false } + if opts.IssueType != "" { + return false + } return true } @@ -236,6 +240,7 @@ func SearchQueryBuild(options FilterOptions, advancedIssueSearchSyntax bool) str case "merged": is = "merged" } + query := search.Query{ Qualifiers: search.Qualifiers{ Assignee: options.Assignee, @@ -243,6 +248,7 @@ func SearchQueryBuild(options FilterOptions, advancedIssueSearchSyntax bool) str Base: options.BaseBranch, Draft: options.Draft, Head: options.HeadBranch, + IssueType: options.IssueType, Label: options.Labels, Mentions: options.Mention, Milestone: options.Milestone, diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go index ddb7a1b2f6b..b777cbc9d68 100644 --- a/pkg/cmd/pr/shared/params_test.go +++ b/pkg/cmd/pr/shared/params_test.go @@ -173,6 +173,32 @@ func Test_listURLWithQuery(t *testing.T) { want: "https://example.com/path?q=label%3A%22help+wanted%22+label%3Adocs+milestone%3A%22Codename+%5C%22What+Was+Missing%5C%22%22+state%3Aopen+type%3Apr", wantErr: false, }, + { + name: "issue type", + args: args{ + listURL: "https://example.com/path", + options: FilterOptions{ + Entity: "issue", + State: "open", + IssueType: "Bug", + }, + }, + want: "https://example.com/path?q=state%3Aopen+type%3ABug+type%3Aissue", + wantErr: false, + }, + { + name: "issue type with spaces is quoted", + args: args{ + listURL: "https://example.com/path", + options: FilterOptions{ + Entity: "issue", + State: "open", + IssueType: `Hot "Spicy" Bug`, + }, + }, + want: "https://example.com/path?q=state%3Aopen+type%3A%22Hot+%5C%22Spicy%5C%22+Bug%22+type%3Aissue", + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go index 384e5489500..fa0f7ae3b16 100644 --- a/pkg/cmd/pr/shared/survey_test.go +++ b/pkg/cmd/pr/shared/survey_test.go @@ -260,3 +260,36 @@ func TestTitleSurvey(t *testing.T) { }) } } + +func TestFieldsToEditSurvey_IssueOnlyFields(t *testing.T) { + t.Run("without Allowed flag omits Type", func(t *testing.T) { + pm := prompter.NewMockPrompter(t) + pm.RegisterMultiSelect("What would you like to edit?", []string{}, + // Type should NOT appear here + []string{"Title", "Body", "Assignees", "Labels", "Projects", "Milestone"}, + func(_ string, _, _ []string) ([]int, error) { + return []int{0}, nil + }) + + editable := &Editable{} + err := FieldsToEditSurvey(pm, editable) + require.NoError(t, err) + assert.True(t, editable.Title.Edited) + }) + + t.Run("with Allowed flag includes Type", func(t *testing.T) { + pm := prompter.NewMockPrompter(t) + pm.RegisterMultiSelect("What would you like to edit?", []string{}, + // Type should appear between Labels and Projects + []string{"Title", "Body", "Assignees", "Labels", "Type", "Projects", "Milestone"}, + func(_ string, _, _ []string) ([]int, error) { + return []int{4}, nil // select Type + }) + + editable := &Editable{} + editable.IssueType.Selectable = true + err := FieldsToEditSurvey(pm, editable) + require.NoError(t, err) + assert.True(t, editable.IssueType.Edited) + }) +} diff --git a/pkg/search/query.go b/pkg/search/query.go index f6e7fc05dbd..e45a4438a23 100644 --- a/pkg/search/query.go +++ b/pkg/search/query.go @@ -96,6 +96,7 @@ type Qualifiers struct { Topics string Tree string Type string + IssueType string `qualifier:"type"` Updated string User []string } @@ -234,39 +235,42 @@ func groupWithOR(qualifier string, vs []string) string { return fmt.Sprintf("(%s)", strings.Join(all, " OR ")) } +// Map turns the qualifiers into a slice-keyed map ready for query +// formatting. Multiple struct fields can share the same key when +// tagged with `qualifier:""`; in that case their values are +// concatenated under the shared key. func (q Qualifiers) Map() map[string][]string { m := map[string][]string{} v := reflect.ValueOf(q) t := reflect.TypeOf(q) for i := 0; i < v.NumField(); i++ { - fieldName := t.Field(i).Name - key := camelToKebab(fieldName) - typ := v.FieldByName(fieldName).Kind() - value := v.FieldByName(fieldName) - switch typ { + field := t.Field(i) + key := field.Tag.Get("qualifier") + if key == "" { + key = camelToKebab(field.Name) + } + value := v.Field(i) + switch value.Kind() { case reflect.Ptr: if value.IsNil() { continue } - v := reflect.Indirect(value) - m[key] = []string{fmt.Sprintf("%v", v)} + m[key] = append(m[key], fmt.Sprintf("%v", reflect.Indirect(value))) case reflect.Slice: if value.IsNil() { continue } - s := []string{} - for i := 0; i < value.Len(); i++ { - if value.Index(i).IsZero() { + for j := 0; j < value.Len(); j++ { + if value.Index(j).IsZero() { continue } - s = append(s, fmt.Sprintf("%v", value.Index(i))) + m[key] = append(m[key], fmt.Sprintf("%v", value.Index(j))) } - m[key] = s default: if value.IsZero() { continue } - m[key] = []string{fmt.Sprintf("%v", value)} + m[key] = append(m[key], fmt.Sprintf("%v", value)) } } return m diff --git a/pkg/search/query_test.go b/pkg/search/query_test.go index db6934ba0bb..1b957298083 100644 --- a/pkg/search/query_test.go +++ b/pkg/search/query_test.go @@ -308,6 +308,16 @@ func TestQualifiersMap(t *testing.T) { "user": {"user"}, }, }, + { + name: "concatenates fields that share a qualifier key", + qualifiers: Qualifiers{ + Type: "issue", + IssueType: "Bug", + }, + out: map[string][]string{ + "type": {"issue", "Bug"}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 7ef9c54bf87fb17516e93a7ddb932efb7c660a35 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:15:27 -0600 Subject: [PATCH 176/182] Auto-install official extension stubs in CI (#13581) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...elemetry-for-official-extension-stub.txtar | 12 ++++-- pkg/cmd/root/official_extension_stub.go | 42 ++++++++++--------- pkg/cmd/root/official_extension_stub_test.go | 27 +++++++++++- 3 files changed, 57 insertions(+), 24 deletions(-) diff --git a/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar b/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar index b200590bf27..603dd2ae183 100644 --- a/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar +++ b/acceptance/testdata/telemetry/telemetry-for-official-extension-stub.txtar @@ -6,10 +6,16 @@ env GH_TELEMETRY=log env GH_TELEMETRY_SAMPLE_RATE=100 +# Ensure CI auto-install behavior does not kick in for this test; +# we want the non-TTY "print install instructions and exit non-zero" path. +env CI='' +env BUILD_NUMBER='' +env RUN_ID='' + # `stack` is registered in extensions.OfficialExtensions. Since no real -# extension is installed, the hidden stub runs and, in a non-TTY session, -# prints install instructions without prompting. -exec gh stack +# extension is installed, the hidden stub runs and, in a non-TTY session +# outside CI, prints install instructions and exits non-zero. +! exec gh stack stderr 'gh extension install github/gh-stack' # The stub invocation records a command_invocation event for the stub's diff --git a/pkg/cmd/root/official_extension_stub.go b/pkg/cmd/root/official_extension_stub.go index af52e43663e..6aa08af936b 100644 --- a/pkg/cmd/root/official_extension_stub.go +++ b/pkg/cmd/root/official_extension_stub.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" @@ -38,25 +39,28 @@ func NewCmdOfficialExtensionStub(io *iostreams.IOStreams, p prompter.Prompter, e func officialExtensionStubRun(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) error { stderr := io.ErrOut - if !io.CanPrompt() { - fmt.Fprint(stderr, heredoc.Docf(` - %[1]s is available as an official extension. - To install it, run: - gh extension install %[2]s/%[3]s - `, fmt.Sprintf("gh %s", ext.Name), ext.Owner, ext.Repo)) - return nil - } - - prompt := heredoc.Docf(` - %[1]s is available as an official extension. - Would you like to install it now? - `, fmt.Sprintf("gh %s", ext.Name)) - confirmed, err := p.Confirm(prompt, true) - if err != nil { - return err - } - if !confirmed { - return nil + // In CI, skip the prompt so agents and CI runners don't block on Y/n. + if !ci.IsCI() { + if io.CanPrompt() { + prompt := heredoc.Docf(` + %[1]s is available as an official extension. + Would you like to install it now? + `, fmt.Sprintf("gh %s", ext.Name)) + confirmed, err := p.Confirm(prompt, true) + if err != nil { + return err + } + if !confirmed { + return nil + } + } else { + fmt.Fprint(stderr, heredoc.Docf(` + %[1]s is available as an official extension. + To install it, run: + gh extension install %[2]s/%[3]s + `, fmt.Sprintf("gh %s", ext.Name), ext.Owner, ext.Repo)) + return cmdutil.SilentError + } } repo := ext.Repository() diff --git a/pkg/cmd/root/official_extension_stub_test.go b/pkg/cmd/root/official_extension_stub_test.go index d2fd4624204..aac50a5c624 100644 --- a/pkg/cmd/root/official_extension_stub_test.go +++ b/pkg/cmd/root/official_extension_stub_test.go @@ -18,6 +18,7 @@ func TestOfficialExtensionStubRun(t *testing.T) { tests := []struct { name string isTTY bool + ciEnv string confirmResult bool confirmErr error installErr error @@ -26,9 +27,17 @@ func TestOfficialExtensionStubRun(t *testing.T) { wantInstalled bool }{ { - name: "non-TTY prints install instructions", + name: "non-TTY in CI auto-installs without prompting", + isTTY: false, + ciEnv: "1", + wantStderr: "Successfully installed github/gh-cool", + wantInstalled: true, + }, + { + name: "non-TTY outside CI prints install instructions and returns silent error", isTTY: false, wantStderr: "gh extension install github/gh-cool", + wantErr: "SilentError", }, { name: "TTY confirmed installs", @@ -37,6 +46,13 @@ func TestOfficialExtensionStubRun(t *testing.T) { wantStderr: "Successfully installed github/gh-cool", wantInstalled: true, }, + { + name: "TTY in CI auto-installs without prompting", + isTTY: true, + ciEnv: "1", + wantStderr: "Successfully installed github/gh-cool", + wantInstalled: true, + }, { name: "TTY declined does not install", isTTY: true, @@ -60,6 +76,13 @@ func TestOfficialExtensionStubRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("BUILD_NUMBER", "") + t.Setenv("RUN_ID", "") + if tt.ciEnv != "" { + t.Setenv("CI", tt.ciEnv) + } + ios, _, _, stderr := iostreams.Test() if tt.isTTY { ios.SetStdinTTY(true) @@ -97,7 +120,7 @@ func TestOfficialExtensionStubRun(t *testing.T) { assert.Equal(t, "github", repo.RepoOwner()) assert.Equal(t, "gh-cool", repo.RepoName()) assert.Equal(t, "github.com", repo.RepoHost()) - } else if tt.isTTY && !tt.confirmResult && tt.confirmErr == nil { + } else { assert.Empty(t, em.InstallCalls()) } }) From 0faf4b09c16dae7a59ce0794dd98308bba6b01c9 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:54:16 -0600 Subject: [PATCH 177/182] Clean up deferred issue update helper (#13584) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/issue/create/create.go | 8 ++++---- pkg/cmd/issue/edit/edit.go | 20 +++++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index fb507cd5c57..23f332d7048 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -449,7 +449,7 @@ func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, i var err error typeID, err = issueShared.ResolveIssueTypeName(client, baseRepo, opts.IssueType) if err != nil { - return updateOpts, err + return api.DeferredUpdateIssueOptions{}, err } } updateOpts.IssueTypeID = typeID @@ -458,7 +458,7 @@ func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, i if opts.Parent != "" { parentID, err := issueShared.ResolveIssueRef(client, baseRepo, opts.Parent) if err != nil { - return updateOpts, fmt.Errorf("resolving --parent reference %q: %w", opts.Parent, err) + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --parent reference %q: %w", opts.Parent, err) } updateOpts.ParentID = parentID } @@ -466,7 +466,7 @@ func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, i for _, ref := range opts.BlockedBy { id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) if err != nil { - return updateOpts, fmt.Errorf("resolving --blocked-by reference %q: %w", ref, err) + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --blocked-by reference %q: %w", ref, err) } updateOpts.AddBlockedByIDs = append(updateOpts.AddBlockedByIDs, id) } @@ -474,7 +474,7 @@ func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, i for _, ref := range opts.Blocking { id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) if err != nil { - return updateOpts, fmt.Errorf("resolving --blocking reference %q: %w", ref, err) + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --blocking reference %q: %w", ref, err) } updateOpts.AddBlockingIDs = append(updateOpts.AddBlockingIDs, id) } diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 668ece3a5ad..5cf52d92e60 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -185,6 +185,12 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman opts.Editable.IssueType.Edited = true } + // hasDeferredFlags covers edit flags that flow through the + // deferred update path rather than the prShared.Editable struct, + // so they would otherwise be invisible to Editable.Dirty() below. + // Note that --type (set) is intentionally absent: it lights up + // opts.Editable.IssueType.Edited above, which Editable.Dirty() + // already picks up. Only --remove-type needs to be listed here. hasDeferredFlags := opts.RemoveIssueType || flags.Changed("parent") || opts.RemoveParent || len(opts.AddSubIssues) > 0 || len(opts.RemoveSubIssues) > 0 || @@ -482,7 +488,7 @@ func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, i } else if editOpts.Parent != "" { parentID, err := issueShared.ResolveIssueRef(client, baseRepo, editOpts.Parent) if err != nil { - return updateOpts, fmt.Errorf("resolving --parent reference %q: %w", editOpts.Parent, err) + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --parent reference %q: %w", editOpts.Parent, err) } updateOpts.ParentID = parentID } @@ -490,14 +496,14 @@ func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, i for _, ref := range editOpts.AddSubIssues { id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) if err != nil { - return updateOpts, fmt.Errorf("resolving --add-sub-issue reference %q: %w", ref, err) + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --add-sub-issue reference %q: %w", ref, err) } updateOpts.AddSubIssueIDs = append(updateOpts.AddSubIssueIDs, id) } for _, ref := range editOpts.RemoveSubIssues { id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) if err != nil { - return updateOpts, fmt.Errorf("resolving --remove-sub-issue reference %q: %w", ref, err) + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --remove-sub-issue reference %q: %w", ref, err) } updateOpts.RemoveSubIssueIDs = append(updateOpts.RemoveSubIssueIDs, id) } @@ -505,14 +511,14 @@ func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, i for _, ref := range editOpts.AddBlockedBy { id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) if err != nil { - return updateOpts, fmt.Errorf("resolving --add-blocked-by reference %q: %w", ref, err) + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --add-blocked-by reference %q: %w", ref, err) } updateOpts.AddBlockedByIDs = append(updateOpts.AddBlockedByIDs, id) } for _, ref := range editOpts.RemoveBlockedBy { id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) if err != nil { - return updateOpts, fmt.Errorf("resolving --remove-blocked-by reference %q: %w", ref, err) + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --remove-blocked-by reference %q: %w", ref, err) } updateOpts.RemoveBlockedByIDs = append(updateOpts.RemoveBlockedByIDs, id) } @@ -520,14 +526,14 @@ func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, i for _, ref := range editOpts.AddBlocking { id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) if err != nil { - return updateOpts, fmt.Errorf("resolving --add-blocking reference %q: %w", ref, err) + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --add-blocking reference %q: %w", ref, err) } updateOpts.AddBlockingIDs = append(updateOpts.AddBlockingIDs, id) } for _, ref := range editOpts.RemoveBlocking { id, err := issueShared.ResolveIssueRef(client, baseRepo, ref) if err != nil { - return updateOpts, fmt.Errorf("resolving --remove-blocking reference %q: %w", ref, err) + return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --remove-blocking reference %q: %w", ref, err) } updateOpts.RemoveBlockingIDs = append(updateOpts.RemoveBlockingIDs, id) } From b8361f8648cf5a3467364151af9d5f2da6024742 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:03:19 +0000 Subject: [PATCH 178/182] chore(deps): bump charm.land/bubbletea/v2 from 2.0.6 to 2.0.7 Bumps [charm.land/bubbletea/v2](https://github.com/charmbracelet/bubbletea) from 2.0.6 to 2.0.7. - [Release notes](https://github.com/charmbracelet/bubbletea/releases) - [Commits](https://github.com/charmbracelet/bubbletea/compare/v2.0.6...v2.0.7) --- updated-dependencies: - dependency-name: charm.land/bubbletea/v2 dependency-version: 2.0.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 5db826a238e..6f771595a0d 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.26.4 require ( charm.land/bubbles/v2 v2.1.0 - charm.land/bubbletea/v2 v2.0.6 + charm.land/bubbletea/v2 v2.0.7 charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.3 github.com/AlecAivazis/survey/v2 v2.3.7 @@ -81,7 +81,7 @@ require ( github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect diff --git a/go.sum b/go.sum index c04cfed3738..9ee814ba48c 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= -charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= -charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= @@ -116,8 +116,8 @@ github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= -github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac= -github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= From def924bb2606b31a805ca12eac9a26339384c1b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:04:22 +0000 Subject: [PATCH 179/182] chore(deps): bump github/codeql-action from 4.36.0 to 4.36.1 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.36.0 to 4.36.1. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/7211b7c8077ea37d8641b6271f6a365a22a5fbfa...87557b9c84dde89fdd9b10e88954ac2f4248e463) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.36.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 6 +++--- .github/workflows/govulncheck.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9cb49337a19..3dc69c6d1a7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -34,20 +34,20 @@ jobs: go-version-file: "go.mod" - name: Initialize CodeQL - uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: languages: ${{ matrix.language }} queries: security-and-quality - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: category: "/language:${{ matrix.language }}" upload: false output: sarif-results - name: Upload filtered SARIF - uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: sarif_file: sarif-results/${{ matrix.language }}.sarif category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 65721bd6fd3..f8dd2807957 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -26,6 +26,6 @@ jobs: go run golang.org/x/vuln/cmd/govulncheck@d1f380186385b4f64e00313f31743df8e4b89a77 -format sarif ./... > gh.sarif - name: Upload SARIF report - uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 + uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1 with: sarif_file: gh.sarif From 8c582eba1ca40053fc4e64f2c2f7130d9cf4b28d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:04:43 +0000 Subject: [PATCH 180/182] chore(deps): bump actions/checkout from 6.0.2 to 6.0.3 Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 6.0.3. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 6.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/bump-go.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/deployment.yml | 10 +++++----- .github/workflows/detect-spam.yml | 2 +- .github/workflows/go.yml | 4 ++-- .github/workflows/govulncheck.yml | 2 +- .github/workflows/lint.yml | 4 ++-- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/bump-go.yml b/.github/workflows/bump-go.yml index cd9170872d2..71e8070b574 100644 --- a/.github/workflows/bump-go.yml +++ b/.github/workflows/bump-go.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9cb49337a19..d8ed680f55e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Setup Go if: matrix.language == 'go' diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index b19a523b60a..f01dabe88a6 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -46,7 +46,7 @@ jobs: if: contains(inputs.platforms, 'linux') steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: @@ -89,7 +89,7 @@ jobs: if: contains(inputs.platforms, 'macos') steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: @@ -169,7 +169,7 @@ jobs: if: contains(inputs.platforms, 'windows') steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: @@ -285,11 +285,11 @@ jobs: if: inputs.release steps: - name: Checkout cli/cli - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Merge built artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - name: Checkout documentation site - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: repository: github/cli.github.com path: site diff --git a/.github/workflows/detect-spam.yml b/.github/workflows/detect-spam.yml index 967a5013c9a..51dfe99bc91 100644 --- a/.github/workflows/detect-spam.yml +++ b/.github/workflows/detect-spam.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Run spam detection env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index ac9b732bfa3..237450c0510 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 @@ -45,7 +45,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 diff --git a/.github/workflows/govulncheck.yml b/.github/workflows/govulncheck.yml index 65721bd6fd3..d5879890e2b 100644 --- a/.github/workflows/govulncheck.yml +++ b/.github/workflows/govulncheck.yml @@ -12,7 +12,7 @@ jobs: security-events: write steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 49d10038ea7..aa597ff8658 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 @@ -67,7 +67,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 From 6b867633f3cc10cf97c6f8b3cc95108fc61bd6a0 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:18:49 -0600 Subject: [PATCH 181/182] feat(extension): alias `uninstall` to `remove` Register `uninstall` as an official cobra alias for `gh extension remove` so it shows up in `--help` and pairs naturally with `gh extension install`. Closes #13598 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/extension/command.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 3852a070593..6ec516533d0 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -458,9 +458,10 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { return cmd }(), &cobra.Command{ - Use: "remove ", - Short: "Remove an installed extension", - Args: cobra.ExactArgs(1), + Use: "remove ", + Short: "Remove an installed extension", + Aliases: []string{"uninstall"}, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { extName := normalizeExtensionSelector(args[0]) if err := m.Remove(extName); err != nil { From da68cb8f6f597cfc3838cf40f89ecc01f4e53233 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:42:52 -0600 Subject: [PATCH 182/182] Add terminal-mockup canvas extension for marketing screenshots (#13612) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/extensions/terminal-mockup/README.md | 51 ++ .../extensions/terminal-mockup/assets/ansi.js | 258 +++++++ .../extensions/terminal-mockup/assets/app.js | 634 ++++++++++++++++++ .../terminal-mockup/assets/html2canvas.min.js | 20 + .../terminal-mockup/assets/index.html | 151 +++++ .../terminal-mockup/assets/styles.css | 422 ++++++++++++ .../extensions/terminal-mockup/extension.mjs | 570 ++++++++++++++++ .../library/discussion-list-monas-cafe.json | 14 + .../library/discussion-view-monas-cafe.json | 14 + .../library/issue-create-monas-cafe.json | 14 + .../library/issue-view-json-monas-cafe.json | 14 + .../library/issue-view-monas-cafe.json | 14 + .../library/sample-issue-list.json | 14 + .../library/sample-pr-list.json | 14 + .../library/sample-pr-view-comments.json | 14 + .../library/sample-repo-view.json | 14 + .../library/sample-run-watch.json | 14 + 17 files changed, 2246 insertions(+) create mode 100644 .github/extensions/terminal-mockup/README.md create mode 100644 .github/extensions/terminal-mockup/assets/ansi.js create mode 100644 .github/extensions/terminal-mockup/assets/app.js create mode 100644 .github/extensions/terminal-mockup/assets/html2canvas.min.js create mode 100644 .github/extensions/terminal-mockup/assets/index.html create mode 100644 .github/extensions/terminal-mockup/assets/styles.css create mode 100644 .github/extensions/terminal-mockup/extension.mjs create mode 100644 .github/extensions/terminal-mockup/library/discussion-list-monas-cafe.json create mode 100644 .github/extensions/terminal-mockup/library/discussion-view-monas-cafe.json create mode 100644 .github/extensions/terminal-mockup/library/issue-create-monas-cafe.json create mode 100644 .github/extensions/terminal-mockup/library/issue-view-json-monas-cafe.json create mode 100644 .github/extensions/terminal-mockup/library/issue-view-monas-cafe.json create mode 100644 .github/extensions/terminal-mockup/library/sample-issue-list.json create mode 100644 .github/extensions/terminal-mockup/library/sample-pr-list.json create mode 100644 .github/extensions/terminal-mockup/library/sample-pr-view-comments.json create mode 100644 .github/extensions/terminal-mockup/library/sample-repo-view.json create mode 100644 .github/extensions/terminal-mockup/library/sample-run-watch.json diff --git a/.github/extensions/terminal-mockup/README.md b/.github/extensions/terminal-mockup/README.md new file mode 100644 index 00000000000..79c04bbafaf --- /dev/null +++ b/.github/extensions/terminal-mockup/README.md @@ -0,0 +1,51 @@ +# Terminal mockup canvas + +A [GitHub Copilot app](https://github.com/github/app) canvas extension +that renders mock-up `gh` output as VSCode-styled terminal screenshots. Built +for producing marketing imagery (blog posts, changelogs, social) where real +terminal recordings are impractical. + +## Using it + +Open the canvas from a Copilot app session. Pick a starting mockup from the +library dropdown, edit the content and toolbar options, and export a PNG via +the download button. Files download through the browser/runtime, which +typically lands them in the configured downloads directory. + +The toolbar controls font, font size, width, window chrome (macOS or none), +backdrop (subtle blue glow / grid / none), and an "auto-style" toggle that +colorizes common `gh` patterns without requiring inline tags. + +## Content markup + +Content can be authored as raw ANSI escapes, or with a more readable bracket +syntax that the renderer maps to the VSCode Dark+ palette: + +- Named colors: `[red]`, `[green]`, `[yellow]`, `[blue]`, `[magenta]`, + `[cyan]`, `[white]`, `[black]` (bright variants prefixed `br`, e.g. + `[brblue]`), plus `[muted]` for grayed-out text and `[link]` for blue + underlined link styling. +- Modifiers: `[bold]` (or `[b]`), `[italic]` (or `[i]`), `[underline]` + (or `[u]`), `[dim]`. +- Each tag closes with its matching `[/name]`, e.g. `[red]error[/red]`. + +When auto-style is on, the renderer also colorizes PR/issue states, labels, +checkboxes, timestamps, and similar conventional output without explicit tags. + +## Library + +Mockups live in two locations: + +- **Project library** at `./library/*.json`: committed to the repo, the + shared starting set. +- **User library** at `$COPILOT_HOME/extensions/terminal-mockup/artifacts/*.json`: + local-only, for personal experiments. + +Saving a new mockup writes to the user library by default; renaming an +existing one preserves its scope. The dropdown shows both, prefixed by scope. + +## Vendored dependencies + +[`assets/html2canvas.min.js`](./assets/html2canvas.min.js) is the unmodified +[html2canvas](https://github.com/niklasvh/html2canvas) 1.4.1 distribution +(MIT). Used to rasterize the rendered DOM into a PNG in-browser. diff --git a/.github/extensions/terminal-mockup/assets/ansi.js b/.github/extensions/terminal-mockup/assets/ansi.js new file mode 100644 index 00000000000..2942956be0e --- /dev/null +++ b/.github/extensions/terminal-mockup/assets/ansi.js @@ -0,0 +1,258 @@ +// ANSI SGR + bracket markup tokenizer. +// Produces a flat array of styled segments: { text, classes }. +// +// Supports: +// - ANSI CSI SGR sequences: \x1b[m (0, 1, 3, 4, 22, 23, 24, 30-37, 39, 90-97, 38;5;N, 38;2;R;G;B) +// - Bracket markup: [b]..[/b], [i]..[/i], [u]..[/u], [dim]..[/dim], [muted]..[/muted], [link]..[/link], +// [red] [green] [yellow] [blue] [magenta] [cyan] [white] [black] +// [brred] [brgreen] [bryellow] [brblue] [brmagenta] [brcyan] [brwhite] [brblack] +// - Plain text passthrough +// +// Bracket tags can nest. ANSI state machine handles standard SGR codes only; +// other CSI/OSC sequences are dropped silently. + +const ANSI_FG = { + 30: "black", 31: "red", 32: "green", 33: "yellow", + 34: "blue", 35: "magenta", 36: "cyan", 37: "white", + 90: "br-black", 91: "br-red", 92: "br-green", 93: "br-yellow", + 94: "br-blue", 95: "br-magenta", 96: "br-cyan", 97: "br-white", +}; + +const COLOR_NAMES = new Set([ + "red", "green", "yellow", "blue", "magenta", "cyan", "white", "black", + "brred", "brgreen", "bryellow", "brblue", "brmagenta", "brcyan", "brwhite", "brblack", +]); +const TAG_TO_FG = { + red: "red", green: "green", yellow: "yellow", blue: "blue", + magenta: "magenta", cyan: "cyan", white: "white", black: "black", + brred: "br-red", brgreen: "br-green", bryellow: "br-yellow", brblue: "br-blue", + brmagenta: "br-magenta", brcyan: "br-cyan", brwhite: "br-white", brblack: "br-black", +}; + +function classesFromState(state) { + const cls = []; + if (state.fg) cls.push(`fg-${state.fg}`); + if (state.bold) cls.push("bold"); + if (state.italic) cls.push("italic"); + if (state.underline) cls.push("underline"); + if (state.dim) cls.push("dim"); + return cls; +} + +function emit(out, text, state) { + if (!text) return; + out.push({ text, classes: classesFromState(state) }); +} + +// Step 1: parse ANSI escape codes into a flat segment list, ignoring brackets. +function parseAnsi(input) { + const segments = []; + const state = { fg: null, bold: false, italic: false, underline: false, dim: false }; + let buf = ""; + let i = 0; + while (i < input.length) { + const ch = input.charCodeAt(i); + if (ch === 0x1b && input[i + 1] === "[") { + if (buf) { emit(segments, buf, state); buf = ""; } + // Find terminator + let j = i + 2; + while (j < input.length) { + const c = input.charCodeAt(j); + // CSI parameter bytes: 0x30-0x3f; intermediates: 0x20-0x2f; final: 0x40-0x7e + if (c >= 0x40 && c <= 0x7e) break; + j++; + } + const final = input[j]; + const params = input.slice(i + 2, j); + if (final === "m") applySgr(state, params); + i = j + 1; + continue; + } + buf += input[i]; + i++; + } + if (buf) emit(segments, buf, state); + return segments; +} + +function applySgr(state, paramsStr) { + const tokens = paramsStr.split(";").map((t) => (t === "" ? 0 : Number(t))); + let i = 0; + while (i < tokens.length) { + const t = tokens[i]; + if (t === 0) { + state.fg = null; state.bold = false; state.italic = false; + state.underline = false; state.dim = false; + } else if (t === 1) state.bold = true; + else if (t === 2) state.dim = true; + else if (t === 3) state.italic = true; + else if (t === 4) state.underline = true; + else if (t === 22) { state.bold = false; state.dim = false; } + else if (t === 23) state.italic = false; + else if (t === 24) state.underline = false; + else if (t === 39) state.fg = null; + else if (ANSI_FG[t]) state.fg = ANSI_FG[t]; + else if (t === 38) { + const mode = tokens[i + 1]; + if (mode === 5) { + state.fg = map256(tokens[i + 2]); + i += 2; + } else if (mode === 2) { + // Truecolor not mapped to a named slot; skip params and leave fg unchanged. + i += 4; + } + } + // ignore 40-49, 48 etc (we don't render backgrounds for now) + i++; + } +} + +// Map 256-color cube to nearest named slot. Coarse but adequate. +function map256(n) { + if (n == null) return null; + if (n < 8) return ANSI_FG[30 + n] || null; + if (n < 16) return ANSI_FG[90 + (n - 8)] || null; + // Grayscale ramp (232 = near-black, 255 = near-white). The middle range + // is the "muted" gray that gh uses for footer URLs, bullet separators, etc. + if (n >= 232 && n <= 243) return "muted"; + if (n >= 244 && n <= 250) return "br-black"; // softer gray + // Color cube fallback: no good mapping, let the default fg apply. + return null; +} +// Step 2: walk segments and split on bracket markup, updating per-segment classes. +function parseBrackets(segments) { + const out = []; + const stack = []; // each entry: array of class strings added by this tag + const tagRe = /\[(\/?)([a-zA-Z]+)\]/g; + for (const seg of segments) { + const text = seg.text; + let last = 0; + tagRe.lastIndex = 0; + let m; + const baseClasses = seg.classes.slice(); + while ((m = tagRe.exec(text)) !== null) { + const before = text.slice(last, m.index); + if (before) out.push({ text: before, classes: mergeClasses(baseClasses, stack) }); + const closing = m[1] === "/"; + const tag = m[2].toLowerCase(); + const added = tagToClasses(tag); + if (added.length === 0) { + // Not a recognized tag; treat as literal text. + out.push({ text: m[0], classes: mergeClasses(baseClasses, stack) }); + } else if (closing) { + // Pop most recent matching frame. + for (let i = stack.length - 1; i >= 0; i--) { + if (stack[i].tag === tag) { stack.splice(i, 1); break; } + } + } else { + stack.push({ tag, classes: added }); + } + last = m.index + m[0].length; + } + const tail = text.slice(last); + if (tail) out.push({ text: tail, classes: mergeClasses(baseClasses, stack) }); + } + return out; +} + +function tagToClasses(tag) { + if (tag === "b" || tag === "bold") return ["bold"]; + if (tag === "i" || tag === "italic") return ["italic"]; + if (tag === "u" || tag === "underline") return ["underline"]; + if (tag === "dim") return ["dim"]; + if (tag === "muted") return ["fg-muted"]; + if (tag === "link") return ["fg-br-blue", "underline"]; + if (COLOR_NAMES.has(tag)) return [`fg-${TAG_TO_FG[tag]}`]; + return []; +} + +function mergeClasses(base, stack) { + const set = new Set(base); + for (const frame of stack) { + for (const c of frame.classes) set.add(c); + } + return Array.from(set); +} + +// Step 3: optional auto-styling for plain-looking segments. +// Operates only on segments that have no styling yet, to avoid clobbering +// user-specified colors. Splits on detected patterns and inserts styled spans. +function autoStyle(segments) { + const out = []; + for (const seg of segments) { + if (seg.classes.length > 0) { + out.push(seg); + continue; + } + autoStyleSegment(seg.text, out); + } + return out; +} + +function autoStyleSegment(text, out) { + // Process line by line so we can detect $ prompts. + const lines = text.split(/(\n)/); + for (const line of lines) { + if (line === "\n") { + out.push({ text: "\n", classes: [] }); + continue; + } + if (line === "") continue; + // Prompt line: leading `$ ` + const promptMatch = line.match(/^(\s*)(\$)( )(.*)$/); + if (promptMatch) { + const [, leading, dollar, space, rest] = promptMatch; + if (leading) out.push({ text: leading, classes: [] }); + out.push({ text: dollar, classes: ["fg-muted"] }); + out.push({ text: space, classes: [] }); + // Apply inline auto-stylers to the rest of the prompt line + autoStyleInline(rest, out); + continue; + } + autoStyleInline(line, out); + } +} + +function autoStyleInline(text, out) { + // Detect URLs and color/dim them; detect standalone +N/-N tokens for diff stats; detect #NNN refs. + // Single regex with alternation; iterate over matches. + const re = /(https?:\/\/[^\s)>\]]+)|(? last) out.push({ text: text.slice(last, m.index), classes: [] }); + if (m[1]) { + out.push({ text: m[1], classes: ["fg-muted"] }); + } else if (m[2]) { + const cls = m[2].startsWith("+") ? "fg-br-green" : "fg-br-red"; + out.push({ text: m[2], classes: [cls] }); + } else if (m[3]) { + out.push({ text: m[3], classes: ["fg-br-blue"] }); + } + last = m.index + m[0].length; + } + if (last < text.length) out.push({ text: text.slice(last), classes: [] }); +} + +export function parse(input, { autoStyle: enableAuto = true } = {}) { + const ansiSegments = parseAnsi(input ?? ""); + const bracketSegments = parseBrackets(ansiSegments); + return enableAuto ? autoStyle(bracketSegments) : bracketSegments; +} + +export function renderToDom(target, input, opts) { + const segments = parse(input, opts); + target.replaceChildren(); + const frag = document.createDocumentFragment(); + for (const seg of segments) { + if (seg.classes.length === 0) { + frag.appendChild(document.createTextNode(seg.text)); + } else { + const span = document.createElement("span"); + span.className = seg.classes.join(" "); + span.textContent = seg.text; + frag.appendChild(span); + } + } + target.appendChild(frag); +} diff --git a/.github/extensions/terminal-mockup/assets/app.js b/.github/extensions/terminal-mockup/assets/app.js new file mode 100644 index 00000000000..91521c549ae --- /dev/null +++ b/.github/extensions/terminal-mockup/assets/app.js @@ -0,0 +1,634 @@ +// App glue: wires editor + toolbar to the renderer, listens for state pushes +// from the extension over SSE, and handles PNG export via html2canvas. + +import { renderToDom } from "./ansi.js"; + +const $ = (sel) => document.querySelector(sel); + +const editor = $("#editor"); +const terminal = $("#terminal"); +const windowEl = $("#window"); +const mockup = $("#mockup"); +const fontSel = $("#ctl-font"); +const fontSize = $("#ctl-fontsize"); +const fontSizeOut = $("#ctl-fontsize-out"); +const widthIn = $("#ctl-width"); +const widthOut = $("#ctl-width-out"); +const chromeSel = $("#ctl-chrome"); +const backdropSel = $("#ctl-backdrop"); +const bodyGradCb = $("#ctl-bodygrad"); +const autoStyleCb = $("#ctl-autostyle"); +const downloadBtn = $("#btn-download"); +const savedSel = $("#ctl-saved"); +const saveAsBtn = $("#btn-save"); +const saveAsProjectBtn = $("#btn-save-project"); +const saveOverwriteBtn = $("#btn-save-overwrite"); +const deleteBtn = $("#btn-delete"); +const toast = $("#toast"); +const formatSel = $("#ctl-format"); + +let state = { + content: "", + options: { + font: "menlo", + fontSize: 14, + width: 800, + chrome: "none", + backdrop: "none", + bodyGradient: false, + autoStyle: true, + }, +}; + +function applyState() { + editor.value = state.content; + fontSel.value = state.options.font; + fontSize.value = String(state.options.fontSize); + fontSizeOut.textContent = `${state.options.fontSize}px`; + widthIn.value = String(state.options.width); + widthOut.textContent = `${state.options.width}px`; + chromeSel.value = state.options.chrome; + backdropSel.value = state.options.backdrop; + bodyGradCb.checked = !!state.options.bodyGradient; + autoStyleCb.checked = !!state.options.autoStyle; + rerender(); +} + +function rerender() { + // Apply visual options + windowEl.dataset.font = state.options.font; + windowEl.classList.toggle("has-chrome", state.options.chrome === "macos"); + windowEl.classList.toggle("no-chrome", state.options.chrome === "none"); + windowEl.classList.toggle("body-gradient", !!state.options.bodyGradient); + windowEl.style.setProperty("--mockup-width", `${state.options.width}px`); + terminal.style.setProperty("--term-fontsize", `${state.options.fontSize}px`); + + mockup.classList.remove("backdrop-grid", "backdrop-solid", "backdrop-none"); + mockup.classList.add(`backdrop-${state.options.backdrop}`); + + renderToDom(terminal, state.content, { autoStyle: state.options.autoStyle }); +} + +// Initial load: pull server-side state set via canvas open input or set_content action. +async function init() { + try { + const res = await fetch("/state", { cache: "no-store" }); + if (res.ok) { + const remote = await res.json(); + if (remote && typeof remote.content === "string" && remote.content.trim().length > 0) { + state.content = remote.content; + } + if (remote && remote.options && typeof remote.options === "object") { + state.options = { ...state.options, ...remote.options }; + } + } + } catch { + // ignore; fall back to defaults + } + applyState(); + connectSse(); +} + +function connectSse() { + let es; + const open = () => { + es = new EventSource("/events"); + es.onmessage = (evt) => { + try { + const data = JSON.parse(evt.data); + if (data && data.type === "library_changed") { + if (data.action === "saved" && typeof data.slug === "string" && (data.scope === "project" || data.scope === "user")) { + loadedSlug = data.slug; + loadedScope = data.scope; + loadedName = data.name || data.slug; + } else if (data.action === "deleted" && typeof data.slug === "string" && data.slug === loadedSlug && data.scope === loadedScope) { + loadedSlug = null; + loadedScope = null; + loadedName = null; + } + refreshLibrary().then(() => updateLoadedAffordances()); + return; + } + if (data && data.type === "batch_export") { + runBatchExport(data).catch((err) => showToast(`Batch export failed: ${err.message}`)); + return; + } + let changed = false; + if (typeof data.content === "string" && data.content !== state.content) { + state.content = data.content; + changed = true; + } + if (data.options && typeof data.options === "object") { + const next = { ...state.options, ...data.options }; + if (JSON.stringify(next) !== JSON.stringify(state.options)) { + state.options = next; + changed = true; + } + } + if (changed) applyState(); + } catch {} + }; + es.onerror = () => { + es.close(); + setTimeout(open, 1500); + }; + }; + open(); +} + +// Event wiring +editor.addEventListener("input", () => { + state.content = editor.value; + rerender(); +}); + +fontSel.addEventListener("change", () => { + state.options.font = fontSel.value; + rerender(); +}); + +fontSize.addEventListener("input", () => { + state.options.fontSize = Number(fontSize.value); + fontSizeOut.textContent = `${fontSize.value}px`; + rerender(); +}); + +widthIn.addEventListener("input", () => { + state.options.width = Number(widthIn.value); + widthOut.textContent = `${widthIn.value}px`; + rerender(); +}); + +chromeSel.addEventListener("change", () => { + state.options.chrome = chromeSel.value; + rerender(); +}); + +backdropSel.addEventListener("change", () => { + state.options.backdrop = backdropSel.value; + rerender(); +}); + +bodyGradCb.addEventListener("change", () => { + state.options.bodyGradient = bodyGradCb.checked; + rerender(); +}); + +autoStyleCb.addEventListener("change", () => { + state.options.autoStyle = autoStyleCb.checked; + rerender(); +}); + +// Saved-mockups library. Two scopes: +// project: .github/extensions/terminal-mockup/library/ (committed, shared) +// user: ~/.copilot/extensions/terminal-mockup/artifacts/ (per-user) +let loadedSlug = null; +let loadedScope = null; +let loadedName = null; + +function scopedId(scope, slug) { return `${scope}:${slug}`; } +function parseScopedId(value) { + if (!value) return null; + const i = value.indexOf(":"); + if (i < 1) return null; + const scope = value.slice(0, i); + const slug = value.slice(i + 1); + if (scope !== "project" && scope !== "user") return null; + if (!slug) return null; + return { scope, slug }; +} +function scopeLabel(scope) { return scope === "project" ? "Project" : "Local"; } + +function slugify(name) { + return String(name || "") + .toLowerCase() + .normalize("NFKD") + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .slice(0, 80); +} + +function updateLoadedAffordances() { + const has = !!loadedSlug; + saveOverwriteBtn.disabled = !has; + deleteBtn.disabled = !has; + if (has) { + const label = loadedName || loadedSlug; + const scopeTag = loadedScope === "project" ? " (Project)" : " (Local)"; + saveOverwriteBtn.textContent = `Save "${label}"${scopeTag}`; + deleteBtn.textContent = loadedScope === "project" ? "Delete from project" : "Delete"; + } else { + saveOverwriteBtn.textContent = "Save"; + deleteBtn.textContent = "Delete"; + } +} + +async function refreshLibrary() { + try { + const res = await fetch("/mockups", { cache: "no-store" }); + if (!res.ok) return; + const data = await res.json(); + const items = Array.isArray(data.items) ? data.items : []; + savedSel.innerHTML = ''; + const groups = { project: [], user: [] }; + for (const it of items) { + if (it && (it.scope === "project" || it.scope === "user")) groups[it.scope].push(it); + } + for (const scope of ["project", "user"]) { + if (groups[scope].length === 0) continue; + const og = document.createElement("optgroup"); + og.label = scopeLabel(scope); + for (const it of groups[scope]) { + const opt = document.createElement("option"); + opt.value = scopedId(scope, it.slug); + opt.textContent = it.name || it.slug; + og.appendChild(opt); + } + savedSel.appendChild(og); + } + if (loadedSlug && loadedScope && items.some((i) => i.scope === loadedScope && i.slug === loadedSlug)) { + savedSel.value = scopedId(loadedScope, loadedSlug); + } + } catch (e) { + // ignore; library just stays empty + } +} + +async function loadMockup(scope, slug) { + if (!slug || !scope) { + loadedSlug = null; + loadedScope = null; + loadedName = null; + updateLoadedAffordances(); + return; + } + try { + const res = await fetch(`/mockups/${encodeURIComponent(scope)}/${encodeURIComponent(slug)}`, { cache: "no-store" }); + if (!res.ok) throw new Error(`load failed: ${res.status}`); + const doc = await res.json(); + state.content = typeof doc.content === "string" ? doc.content : ""; + state.options = { ...state.options, ...(doc.options || {}) }; + loadedSlug = slug; + loadedScope = scope; + loadedName = doc.name || slug; + applyState(); + updateLoadedAffordances(); + showToast(`Loaded "${loadedName}" (${scopeLabel(scope)})`); + } catch (e) { + showToast(`Load failed: ${e.message}`); + } +} + +async function saveMockup(scope, name, slug) { + const body = { + name: name || slug, + content: state.content, + options: state.options, + }; + const url = slug + ? `/mockups/${encodeURIComponent(scope)}/${encodeURIComponent(slug)}` + : `/mockups/${encodeURIComponent(scope)}`; + if (!slug) body.name = name; + const res = await fetch(url, { + method: slug ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `save failed: ${res.status}`); + } + return await res.json(); +} + +savedSel.addEventListener("change", () => { + const parsed = parseScopedId(savedSel.value); + if (parsed) loadMockup(parsed.scope, parsed.slug); + else { + loadedSlug = null; + loadedScope = null; + loadedName = null; + updateLoadedAffordances(); + } +}); + +function promptSaveName(defaultValue, scope) { + return new Promise((resolve) => { + const dialog = document.getElementById("save-dialog"); + const input = document.getElementById("save-name"); + const cancel = document.getElementById("save-cancel"); + const form = document.getElementById("save-form"); + const heading = document.getElementById("save-heading"); + if (!dialog || typeof dialog.showModal !== "function") { + const v = window.prompt(`Save mockup to ${scopeLabel(scope)} library as:`, defaultValue || ""); + resolve(v && v.trim() ? v.trim() : null); + return; + } + if (heading) heading.textContent = scope === "project" ? "Save to project library" : "Save to local library"; + input.value = defaultValue || ""; + let settled = false; + const settle = (value) => { + if (settled) return; + settled = true; + form.removeEventListener("submit", onSubmit); + cancel.removeEventListener("click", onCancel); + dialog.removeEventListener("close", onClose); + resolve(value); + }; + const onSubmit = (e) => { + e.preventDefault(); + const value = (input.value || "").trim(); + settle(value || null); + dialog.close(value ? "ok" : ""); + }; + const onCancel = () => { + settle(null); + dialog.close(""); + }; + const onClose = () => settle(null); + form.addEventListener("submit", onSubmit); + cancel.addEventListener("click", onCancel); + dialog.addEventListener("close", onClose); + dialog.showModal(); + setTimeout(() => input.focus(), 0); + input.select(); + }); +} + +async function saveAs(scope, button) { + const name = await promptSaveName(loadedName || "", scope); + if (!name) return; + const slug = slugify(name); + if (!slug) { + showToast("Name needs at least one alphanumeric character"); + return; + } + button.disabled = true; + try { + const result = await saveMockup(scope, name, slug); + loadedScope = result.scope || scope; + loadedSlug = result.slug; + loadedName = result.doc?.name || name; + await refreshLibrary(); + savedSel.value = scopedId(loadedScope, loadedSlug); + updateLoadedAffordances(); + showToast(`Saved "${loadedName}" to ${scopeLabel(loadedScope)} library`); + } catch (e) { + showToast(`Save failed: ${e.message}`); + } finally { + button.disabled = false; + } +} + +saveAsBtn.addEventListener("click", () => saveAs("user", saveAsBtn)); +if (saveAsProjectBtn) { + saveAsProjectBtn.addEventListener("click", () => saveAs("project", saveAsProjectBtn)); +} + +saveOverwriteBtn.addEventListener("click", async () => { + if (!loadedSlug || !loadedScope) return; + saveOverwriteBtn.disabled = true; + try { + await saveMockup(loadedScope, loadedName || loadedSlug, loadedSlug); + showToast(`Saved "${loadedName || loadedSlug}" to ${scopeLabel(loadedScope)}`); + } catch (e) { + showToast(`Save failed: ${e.message}`); + } finally { + updateLoadedAffordances(); + } +}); + +deleteBtn.addEventListener("click", async () => { + if (!loadedSlug || !loadedScope) return; + const scopeMsg = loadedScope === "project" ? " from the project library (will show as a deleted file in git)" : ""; + if (!confirm(`Delete "${loadedName || loadedSlug}"${scopeMsg}?`)) return; + try { + const res = await fetch(`/mockups/${encodeURIComponent(loadedScope)}/${encodeURIComponent(loadedSlug)}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`delete failed: ${res.status}`); + showToast(`Deleted "${loadedName || loadedSlug}"`); + loadedSlug = null; + loadedScope = null; + loadedName = null; + await refreshLibrary(); + updateLoadedAffordances(); + } catch (e) { + showToast(`Delete failed: ${e.message}`); + } +}); + +// Refresh library on init +refreshLibrary(); + +// Export +function currentFormat() { + const v = (formatSel && formatSel.value) || "png"; + if (v === "jpg" || v === "jpeg") { + return { ext: "jpg", mime: "image/jpeg", quality: 0.92, label: "JPG", background: "#04060c" }; + } + return { ext: "png", mime: "image/png", quality: undefined, label: "PNG", background: null }; +} + +async function renderToCanvas(background) { + // Wait one tick so fonts settle if user just changed them + await document.fonts.ready; + const canvas = await html2canvas(mockup, { + backgroundColor: background ?? null, + scale: 3, + useCORS: true, + logging: false, + }); + return canvas; +} + +function updateExportLabels() { + const fmt = currentFormat(); + downloadBtn.textContent = `Download ${fmt.label}`; +} +if (formatSel) { + formatSel.addEventListener("change", updateExportLabels); + updateExportLabels(); +} + +function showToast(msg) { + toast.textContent = msg; + toast.hidden = false; + clearTimeout(showToast._t); + showToast._t = setTimeout(() => { toast.hidden = true; }, 2200); +} + +downloadBtn.addEventListener("click", async () => { + downloadBtn.disabled = true; + try { + const fmt = currentFormat(); + const canvas = await renderToCanvas(fmt.background); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, fmt.mime, fmt.quality)); + if (!blob) throw new Error(`Could not encode ${fmt.label}`); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${loadedSlug || "gh-terminal-mockup"}.${fmt.ext}`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + showToast(`Saved ${fmt.label}`); + } catch (e) { + showToast(`Export failed: ${e.message}`); + } finally { + downloadBtn.disabled = false; + } +}); + +async function runBatchExport({ slugs, suffix, format }) { + if (!Array.isArray(slugs) || slugs.length === 0) return; + const fmtOverride = format === "jpg" ? { ext: "jpg", mime: "image/jpeg", quality: 0.92, label: "JPG", background: "#04060c" } + : format === "png" ? { ext: "png", mime: "image/png", quality: undefined, label: "PNG", background: null } + : null; + const savedContent = state.content; + const savedSlugRef = loadedSlug; + const savedScopeRef = loadedScope; + const savedNameRef = loadedName; + downloadBtn.disabled = true; + try { + for (const entry of slugs) { + const parsed = parseScopedId(entry); + const slug = parsed ? parsed.slug : entry; + const url = parsed + ? `/mockups/${encodeURIComponent(parsed.scope)}/${encodeURIComponent(parsed.slug)}` + : `/mockups/${encodeURIComponent(slug)}`; + try { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) { + showToast(`Skipping "${slug}": ${res.status}`); + continue; + } + const doc = await res.json(); + state.content = typeof doc.content === "string" ? doc.content : ""; + applyState(); + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + const fmt = fmtOverride || currentFormat(); + const canvas = await renderToCanvas(fmt.background); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, fmt.mime, fmt.quality)); + if (!blob) throw new Error(`Could not encode ${fmt.label}`); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = blobUrl; + a.download = `${slug}${suffix || ""}.${fmt.ext}`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(blobUrl), 1500); + showToast(`Saved ${a.download}`); + await new Promise((r) => setTimeout(r, 800)); + } catch (e) { + showToast(`Export of "${slug}" failed: ${e.message}`); + } + } + } finally { + state.content = savedContent; + loadedSlug = savedSlugRef; + loadedScope = savedScopeRef; + loadedName = savedNameRef; + applyState(); + downloadBtn.disabled = false; + } +} + +// Resizable editor pane +const STORAGE_KEY = "terminal-mockup.editorHeight"; +const MIN_EDITOR = 80; +const MIN_PREVIEW = 160; +const appRoot = document.querySelector(".app"); +const resizeHandle = document.getElementById("resize-handle"); + +function clampHeight(h) { + const available = window.innerHeight - document.querySelector(".toolbar").offsetHeight - 6; + const max = Math.max(MIN_EDITOR, available - MIN_PREVIEW); + return Math.max(MIN_EDITOR, Math.min(max, h)); +} +function setEditorHeight(h) { + const clamped = clampHeight(h); + appRoot.style.setProperty("--editor-height", `${clamped}px`); + return clamped; +} +const saved = Number(localStorage.getItem(STORAGE_KEY)); +if (Number.isFinite(saved) && saved > 0) setEditorHeight(saved); + +let dragStartY = 0; +let dragStartHeight = 0; +function onPointerMove(e) { + const dy = e.clientY - dragStartY; + setEditorHeight(dragStartHeight - dy); +} +function onPointerUp(e) { + resizeHandle.classList.remove("dragging"); + resizeHandle.releasePointerCapture?.(e.pointerId); + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + const cur = parseInt(getComputedStyle(appRoot).getPropertyValue("--editor-height"), 10); + if (Number.isFinite(cur)) localStorage.setItem(STORAGE_KEY, String(cur)); +} +resizeHandle.addEventListener("pointerdown", (e) => { + e.preventDefault(); + dragStartY = e.clientY; + const cs = getComputedStyle(appRoot).getPropertyValue("--editor-height"); + dragStartHeight = parseInt(cs, 10) || 240; + resizeHandle.classList.add("dragging"); + resizeHandle.setPointerCapture?.(e.pointerId); + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); +}); +resizeHandle.addEventListener("dblclick", () => { + setEditorHeight(240); + localStorage.setItem(STORAGE_KEY, "240"); +}); +resizeHandle.addEventListener("keydown", (e) => { + const cs = getComputedStyle(appRoot).getPropertyValue("--editor-height"); + const cur = parseInt(cs, 10) || 240; + const step = e.shiftKey ? 40 : 12; + if (e.key === "ArrowUp") { setEditorHeight(cur + step); e.preventDefault(); } + else if (e.key === "ArrowDown") { setEditorHeight(cur - step); e.preventDefault(); } + else return; + const next = parseInt(getComputedStyle(appRoot).getPropertyValue("--editor-height"), 10); + if (Number.isFinite(next)) localStorage.setItem(STORAGE_KEY, String(next)); +}); +window.addEventListener("resize", () => { + const cs = getComputedStyle(appRoot).getPropertyValue("--editor-height"); + const cur = parseInt(cs, 10) || 240; + setEditorHeight(cur); +}); + +// Pane visibility toggles +const TOOLBAR_KEY = "terminal-mockup.toolbarCollapsed"; +const EDITOR_COLLAPSED_KEY = "terminal-mockup.editorCollapsed"; +const toggleToolbarBtn = document.getElementById("toggle-toolbar"); +const toggleEditorBtn = document.getElementById("toggle-editor"); + +function applyToolbarCollapsed(collapsed) { + appRoot.classList.toggle("toolbar-collapsed", collapsed); + toggleToolbarBtn.setAttribute("aria-pressed", String(collapsed)); + toggleToolbarBtn.title = collapsed ? "Show toolbar" : "Hide toolbar"; + toggleToolbarBtn.querySelector(".pane-toggle-icon").textContent = collapsed ? "▼" : "▲"; +} +function applyEditorCollapsed(collapsed) { + appRoot.classList.toggle("editor-collapsed", collapsed); + toggleEditorBtn.setAttribute("aria-pressed", String(collapsed)); + toggleEditorBtn.title = collapsed ? "Show content editor" : "Hide content editor"; + toggleEditorBtn.querySelector(".pane-toggle-icon").textContent = collapsed ? "▲" : "▼"; +} +applyToolbarCollapsed(localStorage.getItem(TOOLBAR_KEY) === "1"); +applyEditorCollapsed(localStorage.getItem(EDITOR_COLLAPSED_KEY) === "1"); +toggleToolbarBtn.addEventListener("click", () => { + const next = !appRoot.classList.contains("toolbar-collapsed"); + applyToolbarCollapsed(next); + localStorage.setItem(TOOLBAR_KEY, next ? "1" : "0"); +}); +toggleEditorBtn.addEventListener("click", () => { + const next = !appRoot.classList.contains("editor-collapsed"); + applyEditorCollapsed(next); + localStorage.setItem(EDITOR_COLLAPSED_KEY, next ? "1" : "0"); +}); + +init(); diff --git a/.github/extensions/terminal-mockup/assets/html2canvas.min.js b/.github/extensions/terminal-mockup/assets/html2canvas.min.js new file mode 100644 index 00000000000..aed6bfd70de --- /dev/null +++ b/.github/extensions/terminal-mockup/assets/html2canvas.min.js @@ -0,0 +1,20 @@ +/*! + * html2canvas 1.4.1 + * Copyright (c) 2022 Niklas von Hertzen + * Released under MIT License + */ +!function(A,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(A="undefined"!=typeof globalThis?globalThis:A||self).html2canvas=e()}(this,function(){"use strict"; +/*! ***************************************************************************** + Copyright (c) Microsoft Corporation. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + PERFORMANCE OF THIS SOFTWARE. + ***************************************************************************** */var r=function(A,e){return(r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(A,e){A.__proto__=e}||function(A,e){for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&(A[t]=e[t])})(A,e)};function A(A,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function t(){this.constructor=A}r(A,e),A.prototype=null===e?Object.create(e):(t.prototype=e.prototype,new t)}var h=function(){return(h=Object.assign||function(A){for(var e,t=1,r=arguments.length;ts[0]&&e[1]>10),s%1024+56320)),(B+1===t||16384>5],this.data[e=(e<<2)+(31&A)];if(A<=65535)return e=this.index[2048+(A-55296>>5)],this.data[e=(e<<2)+(31&A)];if(A>11)],e=this.index[e+=A>>5&63],this.data[e=(e<<2)+(31&A)];if(A<=1114111)return this.data[this.highValueIndex]}return this.errorValue},l);function l(A,e,t,r,B,n){this.initialValue=A,this.errorValue=e,this.highStart=t,this.highValueIndex=r,this.index=B,this.data=n}for(var C="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",u="undefined"==typeof Uint8Array?[]:new Uint8Array(256),F=0;F>4,i[o++]=(15&t)<<4|r>>2,i[o++]=(3&r)<<6|63&B;return n}(y="KwAAAAAAAAAACA4AUD0AADAgAAACAAAAAAAIABAAGABAAEgAUABYAGAAaABgAGgAYgBqAF8AZwBgAGgAcQB5AHUAfQCFAI0AlQCdAKIAqgCyALoAYABoAGAAaABgAGgAwgDKAGAAaADGAM4A0wDbAOEA6QDxAPkAAQEJAQ8BFwF1AH0AHAEkASwBNAE6AUIBQQFJAVEBWQFhAWgBcAF4ATAAgAGGAY4BlQGXAZ8BpwGvAbUBvQHFAc0B0wHbAeMB6wHxAfkBAQIJAvEBEQIZAiECKQIxAjgCQAJGAk4CVgJeAmQCbAJ0AnwCgQKJApECmQKgAqgCsAK4ArwCxAIwAMwC0wLbAjAA4wLrAvMC+AIAAwcDDwMwABcDHQMlAy0DNQN1AD0DQQNJA0kDSQNRA1EDVwNZA1kDdQB1AGEDdQBpA20DdQN1AHsDdQCBA4kDkQN1AHUAmQOhA3UAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AKYDrgN1AHUAtgO+A8YDzgPWAxcD3gPjA+sD8wN1AHUA+wMDBAkEdQANBBUEHQQlBCoEFwMyBDgEYABABBcDSARQBFgEYARoBDAAcAQzAXgEgASIBJAEdQCXBHUAnwSnBK4EtgS6BMIEyAR1AHUAdQB1AHUAdQCVANAEYABgAGAAYABgAGAAYABgANgEYADcBOQEYADsBPQE/AQEBQwFFAUcBSQFLAU0BWQEPAVEBUsFUwVbBWAAYgVgAGoFcgV6BYIFigWRBWAAmQWfBaYFYABgAGAAYABgAKoFYACxBbAFuQW6BcEFwQXHBcEFwQXPBdMF2wXjBeoF8gX6BQIGCgYSBhoGIgYqBjIGOgZgAD4GRgZMBmAAUwZaBmAAYABgAGAAYABgAGAAYABgAGAAYABgAGIGYABpBnAGYABgAGAAYABgAGAAYABgAGAAYAB4Bn8GhQZgAGAAYAB1AHcDFQSLBmAAYABgAJMGdQA9A3UAmwajBqsGqwaVALMGuwbDBjAAywbSBtIG1QbSBtIG0gbSBtIG0gbdBuMG6wbzBvsGAwcLBxMHAwcbByMHJwcsBywHMQcsB9IGOAdAB0gHTgfSBkgHVgfSBtIG0gbSBtIG0gbSBtIG0gbSBiwHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAdgAGAALAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAdbB2MHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsB2kH0gZwB64EdQB1AHUAdQB1AHUAdQB1AHUHfQdgAIUHjQd1AHUAlQedB2AAYAClB6sHYACzB7YHvgfGB3UAzgfWBzMB3gfmB1EB7gf1B/0HlQENAQUIDQh1ABUIHQglCBcDLQg1CD0IRQhNCEEDUwh1AHUAdQBbCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIcAh3CHoIMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwAIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIgggwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAALAcsBywHLAcsBywHLAcsBywHLAcsB4oILAcsB44I0gaWCJ4Ipgh1AHUAqgiyCHUAdQB1AHUAdQB1AHUAdQB1AHUAtwh8AXUAvwh1AMUIyQjRCNkI4AjoCHUAdQB1AO4I9gj+CAYJDgkTCS0HGwkjCYIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiAAIAAAAFAAYABgAGIAXwBgAHEAdQBFAJUAogCyAKAAYABgAEIA4ABGANMA4QDxAMEBDwE1AFwBLAE6AQEBUQF4QkhCmEKoQrhCgAHIQsAB0MLAAcABwAHAAeDC6ABoAHDCwMMAAcABwAHAAdDDGMMAAcAB6MM4wwjDWMNow3jDaABoAGgAaABoAGgAaABoAGgAaABoAGgAaABoAGgAaABoAGgAaABoAEjDqABWw6bDqABpg6gAaABoAHcDvwOPA+gAaABfA/8DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DpcPAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcAB9cPKwkyCToJMAB1AHUAdQBCCUoJTQl1AFUJXAljCWcJawkwADAAMAAwAHMJdQB2CX4JdQCECYoJjgmWCXUAngkwAGAAYABxAHUApgn3A64JtAl1ALkJdQDACTAAMAAwADAAdQB1AHUAdQB1AHUAdQB1AHUAowYNBMUIMAAwADAAMADICcsJ0wnZCRUE4QkwAOkJ8An4CTAAMAB1AAAKvwh1AAgKDwoXCh8KdQAwACcKLgp1ADYKqAmICT4KRgowADAAdQB1AE4KMAB1AFYKdQBeCnUAZQowADAAMAAwADAAMAAwADAAMAAVBHUAbQowADAAdQC5CXUKMAAwAHwBxAijBogEMgF9CoQKiASMCpQKmgqIBKIKqgquCogEDQG2Cr4KxgrLCjAAMADTCtsKCgHjCusK8Qr5CgELMAAwADAAMAB1AIsECQsRC3UANAEZCzAAMAAwADAAMAB1ACELKQswAHUANAExCzkLdQBBC0kLMABRC1kLMAAwADAAMAAwADAAdQBhCzAAMAAwAGAAYABpC3ELdwt/CzAAMACHC4sLkwubC58Lpwt1AK4Ltgt1APsDMAAwADAAMAAwADAAMAAwAL4LwwvLC9IL1wvdCzAAMADlC+kL8Qv5C/8LSQswADAAMAAwADAAMAAwADAAMAAHDDAAMAAwADAAMAAODBYMHgx1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1ACYMMAAwADAAdQB1AHUALgx1AHUAdQB1AHUAdQA2DDAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwAHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AD4MdQBGDHUAdQB1AHUAdQB1AEkMdQB1AHUAdQB1AFAMMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwAHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQBYDHUAdQB1AF8MMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUA+wMVBGcMMAAwAHwBbwx1AHcMfwyHDI8MMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAYABgAJcMMAAwADAAdQB1AJ8MlQClDDAAMACtDCwHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsB7UMLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AA0EMAC9DDAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAsBywHLAcsBywHLAcsBywHLQcwAMEMyAwsBywHLAcsBywHLAcsBywHLAcsBywHzAwwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwAHUAdQB1ANQM2QzhDDAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMABgAGAAYABgAGAAYABgAOkMYADxDGAA+AwADQYNYABhCWAAYAAODTAAMAAwADAAFg1gAGAAHg37AzAAMAAwADAAYABgACYNYAAsDTQNPA1gAEMNPg1LDWAAYABgAGAAYABgAGAAYABgAGAAUg1aDYsGVglhDV0NcQBnDW0NdQ15DWAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAlQCBDZUAiA2PDZcNMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAnw2nDTAAMAAwADAAMAAwAHUArw23DTAAMAAwADAAMAAwADAAMAAwADAAMAB1AL8NMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAB1AHUAdQB1AHUAdQDHDTAAYABgAM8NMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAA1w11ANwNMAAwAD0B5A0wADAAMAAwADAAMADsDfQN/A0EDgwOFA4wABsOMAAwADAAMAAwADAAMAAwANIG0gbSBtIG0gbSBtIG0gYjDigOwQUuDsEFMw7SBjoO0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIGQg5KDlIOVg7SBtIGXg5lDm0OdQ7SBtIGfQ6EDooOjQ6UDtIGmg6hDtIG0gaoDqwO0ga0DrwO0gZgAGAAYADEDmAAYAAkBtIGzA5gANIOYADaDokO0gbSBt8O5w7SBu8O0gb1DvwO0gZgAGAAxA7SBtIG0gbSBtIGYABgAGAAYAAED2AAsAUMD9IG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIGFA8sBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAccD9IGLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHJA8sBywHLAcsBywHLAccDywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywPLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAc0D9IG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIGLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAccD9IG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIGFA8sBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHPA/SBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gYUD0QPlQCVAJUAMAAwADAAMACVAJUAlQCVAJUAlQCVAEwPMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAA//8EAAQABAAEAAQABAAEAAQABAANAAMAAQABAAIABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQACgATABcAHgAbABoAHgAXABYAEgAeABsAGAAPABgAHABLAEsASwBLAEsASwBLAEsASwBLABgAGAAeAB4AHgATAB4AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQABYAGwASAB4AHgAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAWAA0AEQAeAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAAQABAAEAAQABAAFAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAJABYAGgAbABsAGwAeAB0AHQAeAE8AFwAeAA0AHgAeABoAGwBPAE8ADgBQAB0AHQAdAE8ATwAXAE8ATwBPABYAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAFAAUABQAFAAUABQAFAAUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAeAB4AHgAeAFAATwBAAE8ATwBPAEAATwBQAFAATwBQAB4AHgAeAB4AHgAeAB0AHQAdAB0AHgAdAB4ADgBQAFAAUABQAFAAHgAeAB4AHgAeAB4AHgBQAB4AUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAJAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAkACQAJAAkACQAJAAkABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAeAB4AHgAeAFAAHgAeAB4AKwArAFAAUABQAFAAGABQACsAKwArACsAHgAeAFAAHgBQAFAAUAArAFAAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQABAAEAAQABAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAUAAeAB4AHgAeAB4AHgBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAYAA0AKwArAB4AHgAbACsABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQADQAEAB4ABAAEAB4ABAAEABMABAArACsAKwArACsAKwArACsAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAKwArACsAKwBWAFYAVgBWAB4AHgArACsAKwArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AGgAaABoAGAAYAB4AHgAEAAQABAAEAAQABAAEAAQABAAEAAQAEwAEACsAEwATAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABABLAEsASwBLAEsASwBLAEsASwBLABoAGQAZAB4AUABQAAQAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQABMAUAAEAAQABAAEAAQABAAEAB4AHgAEAAQABAAEAAQABABQAFAABAAEAB4ABAAEAAQABABQAFAASwBLAEsASwBLAEsASwBLAEsASwBQAFAAUAAeAB4AUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwAeAFAABABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAFAAKwArACsAKwArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQAUABQAB4AHgAYABMAUAArACsABAAbABsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAFAABAAEAAQABAAEAFAABAAEAAQAUAAEAAQABAAEAAQAKwArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAArACsAHgArAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArACsAKwArACsAKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAB4ABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAUAAEAAQABAAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQAFAABAAEAA0ADQBLAEsASwBLAEsASwBLAEsASwBLAB4AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAArAFAAUABQAFAAUABQAFAAUAArACsAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUAArACsAKwBQAFAAUABQACsAKwAEAFAABAAEAAQABAAEAAQABAArACsABAAEACsAKwAEAAQABABQACsAKwArACsAKwArACsAKwAEACsAKwArACsAUABQACsAUABQAFAABAAEACsAKwBLAEsASwBLAEsASwBLAEsASwBLAFAAUAAaABoAUABQAFAAUABQAEwAHgAbAFAAHgAEACsAKwAEAAQABAArAFAAUABQAFAAUABQACsAKwArACsAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQACsAUABQACsAUABQACsAKwAEACsABAAEAAQABAAEACsAKwArACsABAAEACsAKwAEAAQABAArACsAKwAEACsAKwArACsAKwArACsAUABQAFAAUAArAFAAKwArACsAKwArACsAKwBLAEsASwBLAEsASwBLAEsASwBLAAQABABQAFAAUAAEAB4AKwArACsAKwArACsAKwArACsAKwAEAAQABAArAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQACsAUABQAFAAUABQACsAKwAEAFAABAAEAAQABAAEAAQABAAEACsABAAEAAQAKwAEAAQABAArACsAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAABAAEACsAKwBLAEsASwBLAEsASwBLAEsASwBLAB4AGwArACsAKwArACsAKwArAFAABAAEAAQABAAEAAQAKwAEAAQABAArAFAAUABQAFAAUABQAFAAUAArACsAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAArACsABAAEACsAKwAEAAQABAArACsAKwArACsAKwArAAQABAAEACsAKwArACsAUABQACsAUABQAFAABAAEACsAKwBLAEsASwBLAEsASwBLAEsASwBLAB4AUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArAAQAUAArAFAAUABQAFAAUABQACsAKwArAFAAUABQACsAUABQAFAAUAArACsAKwBQAFAAKwBQACsAUABQACsAKwArAFAAUAArACsAKwBQAFAAUAArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArAAQABAAEAAQABAArACsAKwAEAAQABAArAAQABAAEAAQAKwArAFAAKwArACsAKwArACsABAArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAUABQAFAAHgAeAB4AHgAeAB4AGwAeACsAKwArACsAKwAEAAQABAAEAAQAUABQAFAAUABQAFAAUABQACsAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAUAAEAAQABAAEAAQABAAEACsABAAEAAQAKwAEAAQABAAEACsAKwArACsAKwArACsABAAEACsAUABQAFAAKwArACsAKwArAFAAUAAEAAQAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAKwAOAFAAUABQAFAAUABQAFAAHgBQAAQABAAEAA4AUABQAFAAUABQAFAAUABQACsAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAKwArAAQAUAAEAAQABAAEAAQABAAEACsABAAEAAQAKwAEAAQABAAEACsAKwArACsAKwArACsABAAEACsAKwArACsAKwArACsAUAArAFAAUAAEAAQAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwBQAFAAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAFAABAAEAAQABAAEAAQABAArAAQABAAEACsABAAEAAQABABQAB4AKwArACsAKwBQAFAAUAAEAFAAUABQAFAAUABQAFAAUABQAFAABAAEACsAKwBLAEsASwBLAEsASwBLAEsASwBLAFAAUABQAFAAUABQAFAAUABQABoAUABQAFAAUABQAFAAKwAEAAQABAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQACsAUAArACsAUABQAFAAUABQAFAAUAArACsAKwAEACsAKwArACsABAAEAAQABAAEAAQAKwAEACsABAAEAAQABAAEAAQABAAEACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArAAQABAAeACsAKwArACsAKwArACsAKwArACsAKwArAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXAAqAFwAXAAqACoAKgAqACoAKgAqACsAKwArACsAGwBcAFwAXABcAFwAXABcACoAKgAqACoAKgAqACoAKgAeAEsASwBLAEsASwBLAEsASwBLAEsADQANACsAKwArACsAKwBcAFwAKwBcACsAXABcAFwAXABcACsAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACsAXAArAFwAXABcAFwAXABcAFwAXABcAFwAKgBcAFwAKgAqACoAKgAqACoAKgAqACoAXAArACsAXABcAFwAXABcACsAXAArACoAKgAqACoAKgAqACsAKwBLAEsASwBLAEsASwBLAEsASwBLACsAKwBcAFwAXABcAFAADgAOAA4ADgAeAA4ADgAJAA4ADgANAAkAEwATABMAEwATAAkAHgATAB4AHgAeAAQABAAeAB4AHgAeAB4AHgBLAEsASwBLAEsASwBLAEsASwBLAFAAUABQAFAAUABQAFAAUABQAFAADQAEAB4ABAAeAAQAFgARABYAEQAEAAQAUABQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQADQAEAAQABAAEAAQADQAEAAQAUABQAFAAUABQAAQABAAEAAQABAAEAAQABAAEAAQABAArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAArAA0ADQAeAB4AHgAeAB4AHgAEAB4AHgAeAB4AHgAeACsAHgAeAA4ADgANAA4AHgAeAB4AHgAeAAkACQArACsAKwArACsAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgBcAEsASwBLAEsASwBLAEsASwBLAEsADQANAB4AHgAeAB4AXABcAFwAXABcAFwAKgAqACoAKgBcAFwAXABcACoAKgAqAFwAKgAqACoAXABcACoAKgAqACoAKgAqACoAXABcAFwAKgAqACoAKgBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACoAKgAqACoAKgAqACoAKgAqACoAKgAqAFwAKgBLAEsASwBLAEsASwBLAEsASwBLACoAKgAqACoAKgAqAFAAUABQAFAAUABQACsAUAArACsAKwArACsAUAArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgBQAFAAUABQAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAUAArACsAUABQAFAAUABQAFAAUAArAFAAKwBQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAKwArAFAAUABQAFAAUABQAFAAKwBQACsAUABQAFAAUAArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsABAAEAAQAHgANAB4AHgAeAB4AHgAeAB4AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUAArACsADQBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAANAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAWABEAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAA0ADQANAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAAQABAAEACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAANAA0AKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUAArAAQABAArACsAKwArACsAKwArACsAKwArACsAKwBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqAA0ADQAVAFwADQAeAA0AGwBcACoAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwAeAB4AEwATAA0ADQAOAB4AEwATAB4ABAAEAAQACQArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUAAEAAQAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQAUAArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAArACsAKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArACsAHgArACsAKwATABMASwBLAEsASwBLAEsASwBLAEsASwBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXAArACsAXABcAFwAXABcACsAKwArACsAKwArACsAKwArACsAKwBcAFwAXABcAFwAXABcAFwAXABcAFwAXAArACsAKwArAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAXAArACsAKwAqACoAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAArACsAHgAeAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACoAKgAqACoAKgAqACoAKgAqACoAKwAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKwArAAQASwBLAEsASwBLAEsASwBLAEsASwArACsAKwArACsAKwBLAEsASwBLAEsASwBLAEsASwBLACsAKwArACsAKwArACoAKgAqACoAKgAqACoAXAAqACoAKgAqACoAKgArACsABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsABAAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABABQAFAAUABQAFAAUABQACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwANAA0AHgANAA0ADQANAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQABAAEAAQABAAEAAQAHgAeAB4AHgAeAB4AHgAeAB4AKwArACsABAAEAAQAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABABQAFAASwBLAEsASwBLAEsASwBLAEsASwBQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwAeAB4AHgAeAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArAA0ADQANAA0ADQBLAEsASwBLAEsASwBLAEsASwBLACsAKwArAFAAUABQAEsASwBLAEsASwBLAEsASwBLAEsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAA0ADQBQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUAAeAB4AHgAeAB4AHgAeAB4AKwArACsAKwArACsAKwArAAQABAAEAB4ABAAEAAQABAAEAAQABAAEAAQABAAEAAQABABQAFAAUABQAAQAUABQAFAAUABQAFAABABQAFAABAAEAAQAUAArACsAKwArACsABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsABAAEAAQABAAEAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwArAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAKwBQACsAUAArAFAAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACsAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArAB4AHgAeAB4AHgAeAB4AHgBQAB4AHgAeAFAAUABQACsAHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAUABQACsAKwAeAB4AHgAeAB4AHgArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwArAFAAUABQACsAHgAeAB4AHgAeAB4AHgAOAB4AKwANAA0ADQANAA0ADQANAAkADQANAA0ACAAEAAsABAAEAA0ACQANAA0ADAAdAB0AHgAXABcAFgAXABcAFwAWABcAHQAdAB4AHgAUABQAFAANAAEAAQAEAAQABAAEAAQACQAaABoAGgAaABoAGgAaABoAHgAXABcAHQAVABUAHgAeAB4AHgAeAB4AGAAWABEAFQAVABUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ADQAeAA0ADQANAA0AHgANAA0ADQAHAB4AHgAeAB4AKwAEAAQABAAEAAQABAAEAAQABAAEAFAAUAArACsATwBQAFAAUABQAFAAHgAeAB4AFgARAE8AUABPAE8ATwBPAFAAUABQAFAAUAAeAB4AHgAWABEAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArABsAGwAbABsAGwAbABsAGgAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGgAbABsAGwAbABoAGwAbABoAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAHgAeAFAAGgAeAB0AHgBQAB4AGgAeAB4AHgAeAB4AHgAeAB4AHgBPAB4AUAAbAB4AHgBQAFAAUABQAFAAHgAeAB4AHQAdAB4AUAAeAFAAHgBQAB4AUABPAFAAUAAeAB4AHgAeAB4AHgAeAFAAUABQAFAAUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAFAAHgBQAFAAUABQAE8ATwBQAFAAUABQAFAATwBQAFAATwBQAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAFAAUABQAFAATwBPAE8ATwBPAE8ATwBPAE8ATwBQAFAAUABQAFAAUABQAFAAUAAeAB4AUABQAFAAUABPAB4AHgArACsAKwArAB0AHQAdAB0AHQAdAB0AHQAdAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHgAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB4AHQAdAB4AHgAeAB0AHQAeAB4AHQAeAB4AHgAdAB4AHQAbABsAHgAdAB4AHgAeAB4AHQAeAB4AHQAdAB0AHQAeAB4AHQAeAB0AHgAdAB0AHQAdAB0AHQAeAB0AHgAeAB4AHgAeAB0AHQAdAB0AHgAeAB4AHgAdAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB4AHgAeAB0AHgAeAB4AHgAeAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB0AHgAeAB0AHQAdAB0AHgAeAB0AHQAeAB4AHQAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHQAeAB4AHQAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHQAeAB4AHgAdAB4AHgAeAB4AHgAeAB4AHQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AFAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeABYAEQAWABEAHgAeAB4AHgAeAB4AHQAeAB4AHgAeAB4AHgAeACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAWABEAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAFAAHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHgAeAB4AHgAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAeAB4AHQAdAB0AHQAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHQAeAB0AHQAdAB0AHQAdAB0AHgAeAB4AHgAeAB4AHgAeAB0AHQAeAB4AHQAdAB4AHgAeAB4AHQAdAB4AHgAeAB4AHQAdAB0AHgAeAB0AHgAeAB0AHQAdAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB0AHQAdAB4AHgAeAB4AHgAeAB4AHgAeAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAlAB4AHQAdAB4AHgAdAB4AHgAeAB4AHQAdAB4AHgAeAB4AJQAlAB0AHQAlAB4AJQAlACUAIAAlACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAeAB4AHgAeAB0AHgAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB0AHgAdAB0AHQAeAB0AJQAdAB0AHgAdAB0AHgAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHQAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAlACUAJQAlACUAJQAlACUAJQAdAB0AHQAdACUAHgAlACUAJQAdACUAJQAdAB0AHQAlACUAHQAdACUAHQAdACUAJQAlAB4AHQAeAB4AHgAeAB0AHQAlAB0AHQAdAB0AHQAdACUAJQAlACUAJQAdACUAJQAgACUAHQAdACUAJQAlACUAJQAlACUAJQAeAB4AHgAlACUAIAAgACAAIAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHgAeAB4AFwAXABcAFwAXABcAHgATABMAJQAeAB4AHgAWABEAFgARABYAEQAWABEAFgARABYAEQAWABEATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeABYAEQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAWABEAFgARABYAEQAWABEAFgARAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AFgARABYAEQAWABEAFgARABYAEQAWABEAFgARABYAEQAWABEAFgARABYAEQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAWABEAFgARAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AFgARAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB0AHQAdAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AUABQAFAAUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAEAAQABAAeAB4AKwArACsAKwArABMADQANAA0AUAATAA0AUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAUAANACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXAA0ADQANAA0ADQANAA0ADQAeAA0AFgANAB4AHgAXABcAHgAeABcAFwAWABEAFgARABYAEQAWABEADQANAA0ADQATAFAADQANAB4ADQANAB4AHgAeAB4AHgAMAAwADQANAA0AHgANAA0AFgANAA0ADQANAA0ADQANAA0AHgANAB4ADQANAB4AHgAeACsAKwArACsAKwArACsAKwArACsAKwArACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAKwArACsAKwArACsAKwArACsAKwArACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAlACUAJQAlACUAJQAlACUAJQAlACUAJQArACsAKwArAA0AEQARACUAJQBHAFcAVwAWABEAFgARABYAEQAWABEAFgARACUAJQAWABEAFgARABYAEQAWABEAFQAWABEAEQAlAFcAVwBXAFcAVwBXAFcAVwBXAAQABAAEAAQABAAEACUAVwBXAFcAVwA2ACUAJQBXAFcAVwBHAEcAJQAlACUAKwBRAFcAUQBXAFEAVwBRAFcAUQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFEAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBRAFcAUQBXAFEAVwBXAFcAVwBXAFcAUQBXAFcAVwBXAFcAVwBRAFEAKwArAAQABAAVABUARwBHAFcAFQBRAFcAUQBXAFEAVwBRAFcAUQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFEAVwBRAFcAUQBXAFcAVwBXAFcAVwBRAFcAVwBXAFcAVwBXAFEAUQBXAFcAVwBXABUAUQBHAEcAVwArACsAKwArACsAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAKwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAKwAlACUAVwBXAFcAVwAlACUAJQAlACUAJQAlACUAJQAlACsAKwArACsAKwArACsAKwArACsAKwArAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQArAFcAVwBXAFcAVwBXAFcAVwBXAFcAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQBPAE8ATwBPAE8ATwBPAE8AJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACUAJQAlAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAEcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAKwArACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAADQATAA0AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABLAEsASwBLAEsASwBLAEsASwBLAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAFAABAAEAAQABAAeAAQABAAEAAQABAAEAAQABAAEAAQAHgBQAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AUABQAAQABABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAeAA0ADQANAA0ADQArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAB4AHgAeAB4AHgAeAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAHgAeAB4AHgAeAB4AHgAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAeAB4AUABQAFAAUABQAFAAUABQAFAAUABQAAQAUABQAFAABABQAFAAUABQAAQAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAAeAB4AHgAeAAQAKwArACsAUABQAFAAUABQAFAAHgAeABoAHgArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAADgAOABMAEwArACsAKwArACsAKwArACsABAAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwANAA0ASwBLAEsASwBLAEsASwBLAEsASwArACsAKwArACsAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABABQAFAAUABQAFAAUAAeAB4AHgBQAA4AUABQAAQAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAA0ADQBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArACsAKwArACsAKwArACsAKwArAB4AWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYACsAKwArAAQAHgAeAB4AHgAeAB4ADQANAA0AHgAeAB4AHgArAFAASwBLAEsASwBLAEsASwBLAEsASwArACsAKwArAB4AHgBcAFwAXABcAFwAKgBcAFwAXABcAFwAXABcAFwAXABcAEsASwBLAEsASwBLAEsASwBLAEsAXABcAFwAXABcACsAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwArAFAAUABQAAQAUABQAFAAUABQAFAAUABQAAQABAArACsASwBLAEsASwBLAEsASwBLAEsASwArACsAHgANAA0ADQBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAKgAqACoAXAAqACoAKgBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXAAqAFwAKgAqACoAXABcACoAKgBcAFwAXABcAFwAKgAqAFwAKgBcACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFwAXABcACoAKgBQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAA0ADQBQAFAAUAAEAAQAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUAArACsAUABQAFAAUABQAFAAKwArAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQADQAEAAQAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAVABVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBUAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVACsAKwArACsAKwArACsAKwArACsAKwArAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAKwArACsAKwBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAKwArACsAKwAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACUAJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAJQAlACUAJQAlACUAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAKwArACsAKwArAFYABABWAFYAVgBWAFYAVgBWAFYAVgBWAB4AVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgArAFYAVgBWAFYAVgArAFYAKwBWAFYAKwBWAFYAKwBWAFYAVgBWAFYAVgBWAFYAVgBWAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAEQAWAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUAAaAB4AKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAGAARABEAGAAYABMAEwAWABEAFAArACsAKwArACsAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACUAJQAlACUAJQAWABEAFgARABYAEQAWABEAFgARABYAEQAlACUAFgARACUAJQAlACUAJQAlACUAEQAlABEAKwAVABUAEwATACUAFgARABYAEQAWABEAJQAlACUAJQAlACUAJQAlACsAJQAbABoAJQArACsAKwArAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAAcAKwATACUAJQAbABoAJQAlABYAEQAlACUAEQAlABEAJQBXAFcAVwBXAFcAVwBXAFcAVwBXABUAFQAlACUAJQATACUAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXABYAJQARACUAJQAlAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwAWACUAEQAlABYAEQARABYAEQARABUAVwBRAFEAUQBRAFEAUQBRAFEAUQBRAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAEcARwArACsAVwBXAFcAVwBXAFcAKwArAFcAVwBXAFcAVwBXACsAKwBXAFcAVwBXAFcAVwArACsAVwBXAFcAKwArACsAGgAbACUAJQAlABsAGwArAB4AHgAeAB4AHgAeAB4AKwArACsAKwArACsAKwArACsAKwAEAAQABAAQAB0AKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsADQANAA0AKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArAB4AHgAeAB4AHgAeAB4AHgAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAHgAeAB4AKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAAQAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAA0AUABQAFAAUAArACsAKwArAFAAUABQAFAAUABQAFAAUAANAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwAeACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAKwArAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUAArACsAKwBQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwANAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAeAB4AUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUAArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArAA0AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAUABQAFAAUABQAAQABAAEACsABAAEACsAKwArACsAKwAEAAQABAAEAFAAUABQAFAAKwBQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAAQABAAEACsAKwArACsABABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAA0ADQANAA0ADQANAA0ADQAeACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAeAFAAUABQAFAAUABQAFAAUAAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAArACsAKwArAFAAUABQAFAAUAANAA0ADQANAA0ADQAUACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsADQANAA0ADQANAA0ADQBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAB4AHgAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAAQABAAEAAQAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUAArAAQABAANACsAKwBQAFAAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAAQABAAEAAQABAAEAAQABAAEAAQABABQAFAAUABQAB4AHgAeAB4AHgArACsAKwArACsAKwAEAAQABAAEAAQABAAEAA0ADQAeAB4AHgAeAB4AKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsABABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAAEAAQABAAEAAQABAAeAB4AHgANAA0ADQANACsAKwArACsAKwArACsAKwArACsAKwAeACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArACsAKwBLAEsASwBLAEsASwBLAEsASwBLACsAKwArACsAKwArAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsASwBLAEsASwBLAEsASwBLAEsASwANAA0ADQANAFAABAAEAFAAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAeAA4AUAArACsAKwArACsAKwArACsAKwAEAFAAUABQAFAADQANAB4ADQAEAAQABAAEAB4ABAAEAEsASwBLAEsASwBLAEsASwBLAEsAUAAOAFAADQANAA0AKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAANAA0AHgANAA0AHgAEACsAUABQAFAAUABQAFAAUAArAFAAKwBQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAA0AKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsABAAEAAQABAArAFAAUABQAFAAUABQAFAAUAArACsAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQACsAUABQAFAAUABQACsABAAEAFAABAAEAAQABAAEAAQABAArACsABAAEACsAKwAEAAQABAArACsAUAArACsAKwArACsAKwAEACsAKwArACsAKwBQAFAAUABQAFAABAAEACsAKwAEAAQABAAEAAQABAAEACsAKwArAAQABAAEAAQABAArACsAKwArACsAKwArACsAKwArACsABAAEAAQABAAEAAQABABQAFAAUABQAA0ADQANAA0AHgBLAEsASwBLAEsASwBLAEsASwBLAA0ADQArAB4ABABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAAQABAAEAFAAUAAeAFAAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAArACsABAAEAAQABAAEAAQABAAEAAQADgANAA0AEwATAB4AHgAeAA0ADQANAA0ADQANAA0ADQANAA0ADQANAA0ADQANAFAAUABQAFAABAAEACsAKwAEAA0ADQAeAFAAKwArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAFAAKwArACsAKwArACsAKwBLAEsASwBLAEsASwBLAEsASwBLACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAKwArACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwBcAFwADQANAA0AKgBQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAeACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwBQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAKwArAFAAKwArAFAAUABQAFAAUABQAFAAUAArAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQAKwAEAAQAKwArAAQABAAEAAQAUAAEAFAABAAEAA0ADQANACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAArACsABAAEAAQABAAEAAQABABQAA4AUAAEACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAABAAEAAQABAAEAAQABAAEAAQABABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAFAABAAEAAQABAAOAB4ADQANAA0ADQAOAB4ABAArACsAKwArACsAKwArACsAUAAEAAQABAAEAAQABAAEAAQABAAEAAQAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAA0ADQANAFAADgAOAA4ADQANACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEACsABAAEAAQABAAEAAQABAAEAFAADQANAA0ADQANACsAKwArACsAKwArACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwAOABMAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQACsAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAArACsAKwAEACsABAAEACsABAAEAAQABAAEAAQABABQAAQAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAKwBQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQAKwAEAAQAKwAEAAQABAAEAAQAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAaABoAGgAaAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwArACsAKwArAA0AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsADQANAA0ADQANACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAASABIAEgAQwBDAEMAUABQAFAAUABDAFAAUABQAEgAQwBIAEMAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAASABDAEMAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwAJAAkACQAJAAkACQAJABYAEQArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABIAEMAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwANAA0AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAAQABAAEAAQABAANACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAA0ADQANAB4AHgAeAB4AHgAeAFAAUABQAFAADQAeACsAKwArACsAKwArACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwArAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAANAA0AHgAeACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwAEAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArACsAKwArACsAKwAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAARwBHABUARwAJACsAKwArACsAKwArACsAKwArACsAKwAEAAQAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACsAKwArACsAKwArACsAKwBXAFcAVwBXAFcAVwBXAFcAVwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUQBRAFEAKwArACsAKwArACsAKwArACsAKwArACsAKwBRAFEAUQBRACsAKwArACsAKwArACsAKwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUAArACsAHgAEAAQADQAEAAQABAAEACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwArAB4AHgAeAB4AHgAeAB4AKwArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAAQABAAEAAQABAAeAB4AHgAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAB4AHgAEAAQABAAEAAQABAAEAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQABAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQAHgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwBQAFAAKwArAFAAKwArAFAAUAArACsAUABQAFAAUAArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACsAUAArAFAAUABQAFAAUABQAFAAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwBQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAHgAeAFAAUABQAFAAUAArAFAAKwArACsAUABQAFAAUABQAFAAUAArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAeACsAKwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAeAB4AHgAeAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAeAB4AHgAeAB4AHgAeAB4ABAAeAB4AHgAeAB4AHgAeAB4AHgAeAAQAHgAeAA0ADQANAA0AHgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAAQABAAEAAQAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAAQABAAEAAQABAAEAAQAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArAAQABAAEAAQABAAEAAQAKwAEAAQAKwAEAAQABAAEAAQAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwAEAAQABAAEAAQABAAEAFAAUABQAFAAUABQAFAAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwBQAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArABsAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwArAB4AHgAeAB4ABAAEAAQABAAEAAQABABQACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwArACsAKwArABYAFgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAGgBQAFAAUAAaAFAAUABQAFAAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAKwBQACsAKwBQACsAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAKwBQACsAUAArACsAKwArACsAKwBQACsAKwArACsAUAArAFAAKwBQACsAUABQAFAAKwBQAFAAKwBQACsAKwBQACsAUAArAFAAKwBQACsAUAArAFAAUAArAFAAKwArAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQAFAAUAArAFAAUABQAFAAKwBQACsAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAUABQAFAAKwBQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8AJQAlACUAHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHgAeAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB4AHgAeACUAJQAlAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQApACkAKQApACkAKQApACkAKQApACkAKQApACkAKQApACkAKQApACkAKQApACkAKQApACkAJQAlACUAJQAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAeAB4AJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlAB4AHgAlACUAJQAlACUAHgAlACUAJQAlACUAIAAgACAAJQAlACAAJQAlACAAIAAgACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACEAIQAhACEAIQAlACUAIAAgACUAJQAgACAAIAAgACAAIAAgACAAIAAgACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAJQAlACUAIAAlACUAJQAlACAAIAAgACUAIAAgACAAJQAlACUAJQAlACUAJQAgACUAIAAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAHgAlAB4AJQAeACUAJQAlACUAJQAgACUAJQAlACUAHgAlAB4AHgAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlAB4AHgAeAB4AHgAeAB4AJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAIAAlACUAJQAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAJQAlACUAJQAgACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAHgAeAB4AHgAeAB4AHgAeACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAeAB4AHgAeAB4AHgAlACUAJQAlACUAJQAlACAAIAAgACUAJQAlACAAIAAgACAAIAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeABcAFwAXABUAFQAVAB4AHgAeAB4AJQAlACUAIAAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAIAAgACUAJQAlACUAJQAlACUAJQAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AJQAlACUAJQAlACUAJQAlACUAJQAlACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACUAJQAlACUAJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeACUAJQAlACUAJQAlAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAlACUAJQAlACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAgACUAJQAgACUAJQAlACUAJQAlACUAJQAgACAAIAAgACAAIAAgACAAJQAlACUAJQAlACUAIAAlACUAJQAlACUAJQAlACUAJQAgACAAIAAgACAAIAAgACAAIAAgACUAJQAgACAAIAAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAgACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAIAAlACAAIAAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAgACAAIAAlACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAJQAlAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAKwArAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACUAJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwAlACUAJQAlACUAJQAlACUAJQAlACUAVwBXACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAKwAEACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAA=="),L=Array.isArray(m)?function(A){for(var e=A.length,t=[],r=0;r=this._value.length?-1:this._value[A]},XA.prototype.consumeUnicodeRangeToken=function(){for(var A=[],e=this.consumeCodePoint();lA(e)&&A.length<6;)A.push(e),e=this.consumeCodePoint();for(var t=!1;63===e&&A.length<6;)A.push(e),e=this.consumeCodePoint(),t=!0;if(t)return{type:30,start:parseInt(g.apply(void 0,A.map(function(A){return 63===A?48:A})),16),end:parseInt(g.apply(void 0,A.map(function(A){return 63===A?70:A})),16)};var r=parseInt(g.apply(void 0,A),16);if(45===this.peekCodePoint(0)&&lA(this.peekCodePoint(1))){this.consumeCodePoint();for(var e=this.consumeCodePoint(),B=[];lA(e)&&B.length<6;)B.push(e),e=this.consumeCodePoint();return{type:30,start:r,end:parseInt(g.apply(void 0,B),16)}}return{type:30,start:r,end:r}},XA.prototype.consumeIdentLikeToken=function(){var A=this.consumeName();return"url"===A.toLowerCase()&&40===this.peekCodePoint(0)?(this.consumeCodePoint(),this.consumeUrlToken()):40===this.peekCodePoint(0)?(this.consumeCodePoint(),{type:19,value:A}):{type:20,value:A}},XA.prototype.consumeUrlToken=function(){var A=[];if(this.consumeWhiteSpace(),-1===this.peekCodePoint(0))return{type:22,value:""};var e,t=this.peekCodePoint(0);if(39===t||34===t){t=this.consumeStringToken(this.consumeCodePoint());return 0===t.type&&(this.consumeWhiteSpace(),-1===this.peekCodePoint(0)||41===this.peekCodePoint(0))?(this.consumeCodePoint(),{type:22,value:t.value}):(this.consumeBadUrlRemnants(),xA)}for(;;){var r=this.consumeCodePoint();if(-1===r||41===r)return{type:22,value:g.apply(void 0,A)};if(CA(r))return this.consumeWhiteSpace(),-1===this.peekCodePoint(0)||41===this.peekCodePoint(0)?(this.consumeCodePoint(),{type:22,value:g.apply(void 0,A)}):(this.consumeBadUrlRemnants(),xA);if(34===r||39===r||40===r||(0<=(e=r)&&e<=8||11===e||14<=e&&e<=31||127===e))return this.consumeBadUrlRemnants(),xA;if(92===r){if(!hA(r,this.peekCodePoint(0)))return this.consumeBadUrlRemnants(),xA;A.push(this.consumeEscapedCodePoint())}else A.push(r)}},XA.prototype.consumeWhiteSpace=function(){for(;CA(this.peekCodePoint(0));)this.consumeCodePoint()},XA.prototype.consumeBadUrlRemnants=function(){for(;;){var A=this.consumeCodePoint();if(41===A||-1===A)return;hA(A,this.peekCodePoint(0))&&this.consumeEscapedCodePoint()}},XA.prototype.consumeStringSlice=function(A){for(var e="";0>8,r=255&A>>16,A=255&A>>24;return e<255?"rgba("+A+","+r+","+t+","+e/255+")":"rgb("+A+","+r+","+t+")"}function Qe(A,e){if(17===A.type)return A.number;if(16!==A.type)return 0;var t=3===e?1:255;return 3===e?A.number/100*t:Math.round(A.number/100*t)}var ce=function(A,e){return 11===e&&12===A.type||(28===e&&29===A.type||2===e&&3===A.type)},ae={type:17,number:0,flags:4},ge={type:16,number:50,flags:4},we={type:16,number:100,flags:4},Ue=function(A,e){if(16===A.type)return A.number/100*e;if(WA(A))switch(A.unit){case"rem":case"em":return 16*A.number;default:return A.number}return A.number},le=function(A,e){if(15===e.type)switch(e.unit){case"deg":return Math.PI*e.number/180;case"grad":return Math.PI/200*e.number;case"rad":return e.number;case"turn":return 2*Math.PI*e.number}throw new Error("Unsupported angle type")},Ce=function(A){return Math.PI*A/180},ue=function(A,e){if(18===e.type){var t=me[e.name];if(void 0===t)throw new Error('Attempting to parse an unsupported color function "'+e.name+'"');return t(A,e.values)}if(5===e.type){if(3===e.value.length){var r=e.value.substring(0,1),B=e.value.substring(1,2),n=e.value.substring(2,3);return Fe(parseInt(r+r,16),parseInt(B+B,16),parseInt(n+n,16),1)}if(4===e.value.length){var r=e.value.substring(0,1),B=e.value.substring(1,2),n=e.value.substring(2,3),s=e.value.substring(3,4);return Fe(parseInt(r+r,16),parseInt(B+B,16),parseInt(n+n,16),parseInt(s+s,16)/255)}if(6===e.value.length){r=e.value.substring(0,2),B=e.value.substring(2,4),n=e.value.substring(4,6);return Fe(parseInt(r,16),parseInt(B,16),parseInt(n,16),1)}if(8===e.value.length){r=e.value.substring(0,2),B=e.value.substring(2,4),n=e.value.substring(4,6),s=e.value.substring(6,8);return Fe(parseInt(r,16),parseInt(B,16),parseInt(n,16),parseInt(s,16)/255)}}if(20===e.type){e=Le[e.value.toUpperCase()];if(void 0!==e)return e}return Le.TRANSPARENT},Fe=function(A,e,t,r){return(A<<24|e<<16|t<<8|Math.round(255*r)<<0)>>>0},he=function(A,e){e=e.filter($A);if(3===e.length){var t=e.map(Qe),r=t[0],B=t[1],t=t[2];return Fe(r,B,t,1)}if(4!==e.length)return 0;e=e.map(Qe),r=e[0],B=e[1],t=e[2],e=e[3];return Fe(r,B,t,e)};function de(A,e,t){return t<0&&(t+=1),1<=t&&--t,t<1/6?(e-A)*t*6+A:t<.5?e:t<2/3?6*(e-A)*(2/3-t)+A:A}function fe(A,e){return ue(A,JA.create(e).parseComponentValue())}function He(A,e){return A=ue(A,e[0]),(e=e[1])&&te(e)?{color:A,stop:e}:{color:A,stop:null}}function pe(A,t){var e=A[0],r=A[A.length-1];null===e.stop&&(e.stop=ae),null===r.stop&&(r.stop=we);for(var B=[],n=0,s=0;sA.optimumDistance)?{optimumCorner:e,optimumDistance:r}:A},{optimumDistance:s?1/0:-1/0,optimumCorner:null}).optimumCorner}var Ke=function(A,e){var t=e.filter($A),r=t[0],B=t[1],n=t[2],e=t[3],t=(17===r.type?Ce(r.number):le(A,r))/(2*Math.PI),A=te(B)?B.number/100:0,r=te(n)?n.number/100:0,B=void 0!==e&&te(e)?Ue(e,1):1;if(0==A)return Fe(255*r,255*r,255*r,1);n=r<=.5?r*(1+A):r+A-r*A,e=2*r-n,A=de(e,n,t+1/3),r=de(e,n,t),t=de(e,n,t-1/3);return Fe(255*A,255*r,255*t,B)},me={hsl:Ke,hsla:Ke,rgb:he,rgba:he},Le={ALICEBLUE:4042850303,ANTIQUEWHITE:4209760255,AQUA:16777215,AQUAMARINE:2147472639,AZURE:4043309055,BEIGE:4126530815,BISQUE:4293182719,BLACK:255,BLANCHEDALMOND:4293643775,BLUE:65535,BLUEVIOLET:2318131967,BROWN:2771004159,BURLYWOOD:3736635391,CADETBLUE:1604231423,CHARTREUSE:2147418367,CHOCOLATE:3530104575,CORAL:4286533887,CORNFLOWERBLUE:1687547391,CORNSILK:4294499583,CRIMSON:3692313855,CYAN:16777215,DARKBLUE:35839,DARKCYAN:9145343,DARKGOLDENROD:3095837695,DARKGRAY:2846468607,DARKGREEN:6553855,DARKGREY:2846468607,DARKKHAKI:3182914559,DARKMAGENTA:2332068863,DARKOLIVEGREEN:1433087999,DARKORANGE:4287365375,DARKORCHID:2570243327,DARKRED:2332033279,DARKSALMON:3918953215,DARKSEAGREEN:2411499519,DARKSLATEBLUE:1211993087,DARKSLATEGRAY:793726975,DARKSLATEGREY:793726975,DARKTURQUOISE:13554175,DARKVIOLET:2483082239,DEEPPINK:4279538687,DEEPSKYBLUE:12582911,DIMGRAY:1768516095,DIMGREY:1768516095,DODGERBLUE:512819199,FIREBRICK:2988581631,FLORALWHITE:4294635775,FORESTGREEN:579543807,FUCHSIA:4278255615,GAINSBORO:3705462015,GHOSTWHITE:4177068031,GOLD:4292280575,GOLDENROD:3668254975,GRAY:2155905279,GREEN:8388863,GREENYELLOW:2919182335,GREY:2155905279,HONEYDEW:4043305215,HOTPINK:4285117695,INDIANRED:3445382399,INDIGO:1258324735,IVORY:4294963455,KHAKI:4041641215,LAVENDER:3873897215,LAVENDERBLUSH:4293981695,LAWNGREEN:2096890111,LEMONCHIFFON:4294626815,LIGHTBLUE:2916673279,LIGHTCORAL:4034953471,LIGHTCYAN:3774873599,LIGHTGOLDENRODYELLOW:4210742015,LIGHTGRAY:3553874943,LIGHTGREEN:2431553791,LIGHTGREY:3553874943,LIGHTPINK:4290167295,LIGHTSALMON:4288707327,LIGHTSEAGREEN:548580095,LIGHTSKYBLUE:2278488831,LIGHTSLATEGRAY:2005441023,LIGHTSLATEGREY:2005441023,LIGHTSTEELBLUE:2965692159,LIGHTYELLOW:4294959359,LIME:16711935,LIMEGREEN:852308735,LINEN:4210091775,MAGENTA:4278255615,MAROON:2147483903,MEDIUMAQUAMARINE:1724754687,MEDIUMBLUE:52735,MEDIUMORCHID:3126187007,MEDIUMPURPLE:2473647103,MEDIUMSEAGREEN:1018393087,MEDIUMSLATEBLUE:2070474495,MEDIUMSPRINGGREEN:16423679,MEDIUMTURQUOISE:1221709055,MEDIUMVIOLETRED:3340076543,MIDNIGHTBLUE:421097727,MINTCREAM:4127193855,MISTYROSE:4293190143,MOCCASIN:4293178879,NAVAJOWHITE:4292783615,NAVY:33023,OLDLACE:4260751103,OLIVE:2155872511,OLIVEDRAB:1804477439,ORANGE:4289003775,ORANGERED:4282712319,ORCHID:3664828159,PALEGOLDENROD:4008225535,PALEGREEN:2566625535,PALETURQUOISE:2951671551,PALEVIOLETRED:3681588223,PAPAYAWHIP:4293907967,PEACHPUFF:4292524543,PERU:3448061951,PINK:4290825215,PLUM:3718307327,POWDERBLUE:2967529215,PURPLE:2147516671,REBECCAPURPLE:1714657791,RED:4278190335,ROSYBROWN:3163525119,ROYALBLUE:1097458175,SADDLEBROWN:2336560127,SALMON:4202722047,SANDYBROWN:4104413439,SEAGREEN:780883967,SEASHELL:4294307583,SIENNA:2689740287,SILVER:3233857791,SKYBLUE:2278484991,SLATEBLUE:1784335871,SLATEGRAY:1887473919,SLATEGREY:1887473919,SNOW:4294638335,SPRINGGREEN:16744447,STEELBLUE:1182971135,TAN:3535047935,TEAL:8421631,THISTLE:3636451583,TOMATO:4284696575,TRANSPARENT:0,TURQUOISE:1088475391,VIOLET:4001558271,WHEAT:4125012991,WHITE:4294967295,WHITESMOKE:4126537215,YELLOW:4294902015,YELLOWGREEN:2597139199},be={name:"background-clip",initialValue:"border-box",prefix:!1,type:1,parse:function(A,e){return e.map(function(A){if(_A(A))switch(A.value){case"padding-box":return 1;case"content-box":return 2}return 0})}},De={name:"background-color",initialValue:"transparent",prefix:!1,type:3,format:"color"},Ke=function(t,A){var r=Ce(180),B=[];return Ae(A).forEach(function(A,e){if(0===e){e=A[0];if(20===e.type&&-1!==["top","left","right","bottom"].indexOf(e.value))return void(r=se(A));if(ne(e))return void(r=(le(t,e)+Ce(270))%Ce(360))}A=He(t,A);B.push(A)}),{angle:r,stops:B,type:1}},ve="closest-side",xe="farthest-side",Me="closest-corner",Se="farthest-corner",Te="ellipse",Ge="contain",he=function(r,A){var B=0,n=3,s=[],o=[];return Ae(A).forEach(function(A,e){var t=!0;0===e?t=A.reduce(function(A,e){if(_A(e))switch(e.value){case"center":return o.push(ge),!1;case"top":case"left":return o.push(ae),!1;case"right":case"bottom":return o.push(we),!1}else if(te(e)||ee(e))return o.push(e),!1;return A},t):1===e&&(t=A.reduce(function(A,e){if(_A(e))switch(e.value){case"circle":return B=0,!1;case Te:return!(B=1);case Ge:case ve:return n=0,!1;case xe:return!(n=1);case Me:return!(n=2);case"cover":case Se:return!(n=3)}else if(ee(e)||te(e))return(n=!Array.isArray(n)?[]:n).push(e),!1;return A},t)),t&&(A=He(r,A),s.push(A))}),{size:n,shape:B,stops:s,position:o,type:2}},Oe=function(A,e){if(22===e.type){var t={url:e.value,type:0};return A.cache.addImage(e.value),t}if(18!==e.type)throw new Error("Unsupported image type "+e.type);t=ke[e.name];if(void 0===t)throw new Error('Attempting to parse an unsupported image function "'+e.name+'"');return t(A,e.values)};var Ve,ke={"linear-gradient":function(t,A){var r=Ce(180),B=[];return Ae(A).forEach(function(A,e){if(0===e){e=A[0];if(20===e.type&&"to"===e.value)return void(r=se(A));if(ne(e))return void(r=le(t,e))}A=He(t,A);B.push(A)}),{angle:r,stops:B,type:1}},"-moz-linear-gradient":Ke,"-ms-linear-gradient":Ke,"-o-linear-gradient":Ke,"-webkit-linear-gradient":Ke,"radial-gradient":function(B,A){var n=0,s=3,o=[],i=[];return Ae(A).forEach(function(A,e){var t,r=!0;0===e&&(t=!1,r=A.reduce(function(A,e){if(t)if(_A(e))switch(e.value){case"center":return i.push(ge),A;case"top":case"left":return i.push(ae),A;case"right":case"bottom":return i.push(we),A}else(te(e)||ee(e))&&i.push(e);else if(_A(e))switch(e.value){case"circle":return n=0,!1;case Te:return!(n=1);case"at":return!(t=!0);case ve:return s=0,!1;case"cover":case xe:return!(s=1);case Ge:case Me:return!(s=2);case Se:return!(s=3)}else if(ee(e)||te(e))return(s=!Array.isArray(s)?[]:s).push(e),!1;return A},r)),r&&(A=He(B,A),o.push(A))}),{size:s,shape:n,stops:o,position:i,type:2}},"-moz-radial-gradient":he,"-ms-radial-gradient":he,"-o-radial-gradient":he,"-webkit-radial-gradient":he,"-webkit-gradient":function(r,A){var e=Ce(180),B=[],n=1;return Ae(A).forEach(function(A,e){var t,A=A[0];if(0===e){if(_A(A)&&"linear"===A.value)return void(n=1);if(_A(A)&&"radial"===A.value)return void(n=2)}18===A.type&&("from"===A.name?(t=ue(r,A.values[0]),B.push({stop:ae,color:t})):"to"===A.name?(t=ue(r,A.values[0]),B.push({stop:we,color:t})):"color-stop"!==A.name||2===(A=A.values.filter($A)).length&&(t=ue(r,A[1]),A=A[0],ZA(A)&&B.push({stop:{type:16,number:100*A.number,flags:A.flags},color:t})))}),1===n?{angle:(e+Ce(180))%Ce(360),stops:B,type:n}:{size:3,shape:0,stops:B,position:[],type:n}}},Re={name:"background-image",initialValue:"none",type:1,prefix:!1,parse:function(e,A){if(0===A.length)return[];var t=A[0];return 20===t.type&&"none"===t.value?[]:A.filter(function(A){return $A(A)&&!(20===(A=A).type&&"none"===A.value||18===A.type&&!ke[A.name])}).map(function(A){return Oe(e,A)})}},Ne={name:"background-origin",initialValue:"border-box",prefix:!1,type:1,parse:function(A,e){return e.map(function(A){if(_A(A))switch(A.value){case"padding-box":return 1;case"content-box":return 2}return 0})}},Pe={name:"background-position",initialValue:"0% 0%",type:1,prefix:!1,parse:function(A,e){return Ae(e).map(function(A){return A.filter(te)}).map(re)}},Xe={name:"background-repeat",initialValue:"repeat",prefix:!1,type:1,parse:function(A,e){return Ae(e).map(function(A){return A.filter(_A).map(function(A){return A.value}).join(" ")}).map(Je)}},Je=function(A){switch(A){case"no-repeat":return 1;case"repeat-x":case"repeat no-repeat":return 2;case"repeat-y":case"no-repeat repeat":return 3;default:return 0}};(he=Ve=Ve||{}).AUTO="auto",he.CONTAIN="contain";function Ye(A,e){return _A(A)&&"normal"===A.value?1.2*e:17===A.type?e*A.number:te(A)?Ue(A,e):e}var We,Ze,_e={name:"background-size",initialValue:"0",prefix:!(he.COVER="cover"),type:1,parse:function(A,e){return Ae(e).map(function(A){return A.filter(qe)})}},qe=function(A){return _A(A)||te(A)},he=function(A){return{name:"border-"+A+"-color",initialValue:"transparent",prefix:!1,type:3,format:"color"}},je=he("top"),ze=he("right"),$e=he("bottom"),At=he("left"),he=function(A){return{name:"border-radius-"+A,initialValue:"0 0",prefix:!1,type:1,parse:function(A,e){return re(e.filter(te))}}},et=he("top-left"),tt=he("top-right"),rt=he("bottom-right"),Bt=he("bottom-left"),he=function(A){return{name:"border-"+A+"-style",initialValue:"solid",prefix:!1,type:2,parse:function(A,e){switch(e){case"none":return 0;case"dashed":return 2;case"dotted":return 3;case"double":return 4}return 1}}},nt=he("top"),st=he("right"),ot=he("bottom"),it=he("left"),he=function(A){return{name:"border-"+A+"-width",initialValue:"0",type:0,prefix:!1,parse:function(A,e){return WA(e)?e.number:0}}},Qt=he("top"),ct=he("right"),at=he("bottom"),gt=he("left"),wt={name:"color",initialValue:"transparent",prefix:!1,type:3,format:"color"},Ut={name:"direction",initialValue:"ltr",prefix:!1,type:2,parse:function(A,e){return"rtl"!==e?0:1}},lt={name:"display",initialValue:"inline-block",prefix:!1,type:1,parse:function(A,e){return e.filter(_A).reduce(function(A,e){return A|Ct(e.value)},0)}},Ct=function(A){switch(A){case"block":case"-webkit-box":return 2;case"inline":return 4;case"run-in":return 8;case"flow":return 16;case"flow-root":return 32;case"table":return 64;case"flex":case"-webkit-flex":return 128;case"grid":case"-ms-grid":return 256;case"ruby":return 512;case"subgrid":return 1024;case"list-item":return 2048;case"table-row-group":return 4096;case"table-header-group":return 8192;case"table-footer-group":return 16384;case"table-row":return 32768;case"table-cell":return 65536;case"table-column-group":return 131072;case"table-column":return 262144;case"table-caption":return 524288;case"ruby-base":return 1048576;case"ruby-text":return 2097152;case"ruby-base-container":return 4194304;case"ruby-text-container":return 8388608;case"contents":return 16777216;case"inline-block":return 33554432;case"inline-list-item":return 67108864;case"inline-table":return 134217728;case"inline-flex":return 268435456;case"inline-grid":return 536870912}return 0},ut={name:"float",initialValue:"none",prefix:!1,type:2,parse:function(A,e){switch(e){case"left":return 1;case"right":return 2;case"inline-start":return 3;case"inline-end":return 4}return 0}},Ft={name:"letter-spacing",initialValue:"0",prefix:!1,type:0,parse:function(A,e){return!(20===e.type&&"normal"===e.value||17!==e.type&&15!==e.type)?e.number:0}},ht={name:"line-break",initialValue:(he=We=We||{}).NORMAL="normal",prefix:!(he.STRICT="strict"),type:2,parse:function(A,e){return"strict"!==e?We.NORMAL:We.STRICT}},dt={name:"line-height",initialValue:"normal",prefix:!1,type:4},ft={name:"list-style-image",initialValue:"none",type:0,prefix:!1,parse:function(A,e){return 20===e.type&&"none"===e.value?null:Oe(A,e)}},Ht={name:"list-style-position",initialValue:"outside",prefix:!1,type:2,parse:function(A,e){return"inside"!==e?1:0}},pt={name:"list-style-type",initialValue:"none",prefix:!1,type:2,parse:function(A,e){switch(e){case"disc":return 0;case"circle":return 1;case"square":return 2;case"decimal":return 3;case"cjk-decimal":return 4;case"decimal-leading-zero":return 5;case"lower-roman":return 6;case"upper-roman":return 7;case"lower-greek":return 8;case"lower-alpha":return 9;case"upper-alpha":return 10;case"arabic-indic":return 11;case"armenian":return 12;case"bengali":return 13;case"cambodian":return 14;case"cjk-earthly-branch":return 15;case"cjk-heavenly-stem":return 16;case"cjk-ideographic":return 17;case"devanagari":return 18;case"ethiopic-numeric":return 19;case"georgian":return 20;case"gujarati":return 21;case"gurmukhi":case"hebrew":return 22;case"hiragana":return 23;case"hiragana-iroha":return 24;case"japanese-formal":return 25;case"japanese-informal":return 26;case"kannada":return 27;case"katakana":return 28;case"katakana-iroha":return 29;case"khmer":return 30;case"korean-hangul-formal":return 31;case"korean-hanja-formal":return 32;case"korean-hanja-informal":return 33;case"lao":return 34;case"lower-armenian":return 35;case"malayalam":return 36;case"mongolian":return 37;case"myanmar":return 38;case"oriya":return 39;case"persian":return 40;case"simp-chinese-formal":return 41;case"simp-chinese-informal":return 42;case"tamil":return 43;case"telugu":return 44;case"thai":return 45;case"tibetan":return 46;case"trad-chinese-formal":return 47;case"trad-chinese-informal":return 48;case"upper-armenian":return 49;case"disclosure-open":return 50;case"disclosure-closed":return 51;default:return-1}}},he=function(A){return{name:"margin-"+A,initialValue:"0",prefix:!1,type:4}},Et=he("top"),It=he("right"),yt=he("bottom"),Kt=he("left"),mt={name:"overflow",initialValue:"visible",prefix:!1,type:1,parse:function(A,e){return e.filter(_A).map(function(A){switch(A.value){case"hidden":return 1;case"scroll":return 2;case"clip":return 3;case"auto":return 4;default:return 0}})}},Lt={name:"overflow-wrap",initialValue:"normal",prefix:!1,type:2,parse:function(A,e){return"break-word"!==e?"normal":"break-word"}},he=function(A){return{name:"padding-"+A,initialValue:"0",prefix:!1,type:3,format:"length-percentage"}},bt=he("top"),Dt=he("right"),vt=he("bottom"),xt=he("left"),Mt={name:"text-align",initialValue:"left",prefix:!1,type:2,parse:function(A,e){switch(e){case"right":return 2;case"center":case"justify":return 1;default:return 0}}},St={name:"position",initialValue:"static",prefix:!1,type:2,parse:function(A,e){switch(e){case"relative":return 1;case"absolute":return 2;case"fixed":return 3;case"sticky":return 4}return 0}},Tt={name:"text-shadow",initialValue:"none",type:1,prefix:!1,parse:function(n,A){return 1===A.length&&jA(A[0],"none")?[]:Ae(A).map(function(A){for(var e={color:Le.TRANSPARENT,offsetX:ae,offsetY:ae,blur:ae},t=0,r=0;r>5],this.data[e=(e<<2)+(31&A)];if(A<=65535)return e=this.index[2048+(A-55296>>5)],this.data[e=(e<<2)+(31&A)];if(A>11)],e=this.index[e+=A>>5&63],this.data[e=(e<<2)+(31&A)];if(A<=1114111)return this.data[this.highValueIndex]}return this.errorValue},pr);function pr(A,e,t,r,B,n){this.initialValue=A,this.errorValue=e,this.highStart=t,this.highValueIndex=r,this.index=B,this.data=n}for(var Er="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",Ir="undefined"==typeof Uint8Array?[]:new Uint8Array(256),yr=0;yr>10),s%1024+56320)),(B+1===t||16384>4,i[o++]=(15&t)<<4|r>>2,i[o++]=(3&r)<<6|63&B;return n}(br="AAAAAAAAAAAAEA4AGBkAAFAaAAACAAAAAAAIABAAGAAwADgACAAQAAgAEAAIABAACAAQAAgAEAAIABAACAAQAAgAEAAIABAAQABIAEQATAAIABAACAAQAAgAEAAIABAAVABcAAgAEAAIABAACAAQAGAAaABwAHgAgACIAI4AlgAIABAAmwCjAKgAsAC2AL4AvQDFAMoA0gBPAVYBWgEIAAgACACMANoAYgFkAWwBdAF8AX0BhQGNAZUBlgGeAaMBlQGWAasBswF8AbsBwwF0AcsBYwHTAQgA2wG/AOMBdAF8AekB8QF0AfkB+wHiAHQBfAEIAAMC5gQIAAsCEgIIAAgAFgIeAggAIgIpAggAMQI5AkACygEIAAgASAJQAlgCYAIIAAgACAAKBQoFCgUTBRMFGQUrBSsFCAAIAAgACAAIAAgACAAIAAgACABdAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABoAmgCrwGvAQgAbgJ2AggAHgEIAAgACADnAXsCCAAIAAgAgwIIAAgACAAIAAgACACKAggAkQKZAggAPADJAAgAoQKkAqwCsgK6AsICCADJAggA0AIIAAgACAAIANYC3gIIAAgACAAIAAgACABAAOYCCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAkASoB+QIEAAgACAA8AEMCCABCBQgACABJBVAFCAAIAAgACAAIAAgACAAIAAgACABTBVoFCAAIAFoFCABfBWUFCAAIAAgACAAIAAgAbQUIAAgACAAIAAgACABzBXsFfQWFBYoFigWKBZEFigWKBYoFmAWfBaYFrgWxBbkFCAAIAAgACAAIAAgACAAIAAgACAAIAMEFCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAMgFCADQBQgACAAIAAgACAAIAAgACAAIAAgACAAIAO4CCAAIAAgAiQAIAAgACABAAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAD0AggACAD8AggACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIANYFCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAMDvwAIAAgAJAIIAAgACAAIAAgACAAIAAgACwMTAwgACAB9BOsEGwMjAwgAKwMyAwsFYgE3A/MEPwMIAEUDTQNRAwgAWQOsAGEDCAAIAAgACAAIAAgACABpAzQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFIQUoBSwFCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABtAwgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABMAEwACAAIAAgACAAIABgACAAIAAgACAC/AAgACAAyAQgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACACAAIAAwAAgACAAIAAgACAAIAAgACAAIAAAARABIAAgACAAIABQASAAIAAgAIABwAEAAjgCIABsAqAC2AL0AigDQAtwC+IJIQqVAZUBWQqVAZUBlQGVAZUBlQGrC5UBlQGVAZUBlQGVAZUBlQGVAXsKlQGVAbAK6wsrDGUMpQzlDJUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAfAKAAuZA64AtwCJALoC6ADwAAgAuACgA/oEpgO6AqsD+AAIAAgAswMIAAgACAAIAIkAuwP5AfsBwwPLAwgACAAIAAgACADRA9kDCAAIAOED6QMIAAgACAAIAAgACADuA/YDCAAIAP4DyQAIAAgABgQIAAgAXQAOBAgACAAIAAgACAAIABMECAAIAAgACAAIAAgACAD8AAQBCAAIAAgAGgQiBCoECAExBAgAEAEIAAgACAAIAAgACAAIAAgACAAIAAgACAA4BAgACABABEYECAAIAAgATAQYAQgAVAQIAAgACAAIAAgACAAIAAgACAAIAFoECAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAOQEIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAB+BAcACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAEABhgSMBAgACAAIAAgAlAQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAwAEAAQABAADAAMAAwADAAQABAAEAAQABAAEAAQABHATAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAdQMIAAgACAAIAAgACAAIAMkACAAIAAgAfQMIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACACFA4kDCAAIAAgACAAIAOcBCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAIcDCAAIAAgACAAIAAgACAAIAAgACAAIAJEDCAAIAAgACADFAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABgBAgAZgQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAbAQCBXIECAAIAHkECAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABAAJwEQACjBKoEsgQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAC6BMIECAAIAAgACAAIAAgACABmBAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAxwQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAGYECAAIAAgAzgQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAigWKBYoFigWKBYoFigWKBd0FXwUIAOIF6gXxBYoF3gT5BQAGCAaKBYoFigWKBYoFigWKBYoFigWKBYoFigXWBIoFigWKBYoFigWKBYoFigWKBYsFEAaKBYoFigWKBYoFigWKBRQGCACKBYoFigWKBQgACAAIANEECAAIABgGigUgBggAJgYIAC4GMwaKBYoF0wQ3Bj4GigWKBYoFigWKBYoFigWKBYoFigWKBYoFigUIAAgACAAIAAgACAAIAAgAigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWLBf///////wQABAAEAAQABAAEAAQABAAEAAQAAwAEAAQAAgAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAQADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAUAAAAFAAUAAAAFAAUAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEAAQABAAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUAAQAAAAUABQAFAAUABQAFAAAAAAAFAAUAAAAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAFAAUAAQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABwAFAAUABQAFAAAABwAHAAcAAAAHAAcABwAFAAEAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAcABwAFAAUAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAQABAAAAAAAAAAAAAAAFAAUABQAFAAAABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAcABwAHAAcAAAAHAAcAAAAAAAUABQAHAAUAAQAHAAEABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABwABAAUABQAFAAUAAAAAAAAAAAAAAAEAAQABAAEAAQABAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABwAFAAUAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUAAQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQABQANAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQABAAEAAQABAAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEAAQABAAEAAQABAAEAAQABAAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAABQAHAAUABQAFAAAAAAAAAAcABQAFAAUABQAFAAQABAAEAAQABAAEAAQABAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUAAAAFAAUABQAFAAUAAAAFAAUABQAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAAAAAAAAAAAAUABQAFAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAUAAAAHAAcABwAFAAUABQAFAAUABQAFAAUABwAHAAcABwAFAAcABwAAAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAUABwAHAAUABQAFAAUAAAAAAAcABwAAAAAABwAHAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAABQAFAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAABwAHAAcABQAFAAAAAAAAAAAABQAFAAAAAAAFAAUABQAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAFAAUABQAFAAUAAAAFAAUABwAAAAcABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAFAAUABwAFAAUABQAFAAAAAAAHAAcAAAAAAAcABwAFAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAcABwAAAAAAAAAHAAcABwAAAAcABwAHAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAABQAHAAcABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAHAAcABwAAAAUABQAFAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAcABQAHAAcABQAHAAcAAAAFAAcABwAAAAcABwAFAAUAAAAAAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAUABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAFAAcABwAFAAUABQAAAAUAAAAHAAcABwAHAAcABwAHAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAHAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAABwAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAUAAAAFAAAAAAAAAAAABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABwAFAAUABQAFAAUAAAAFAAUAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABwAFAAUABQAFAAUABQAAAAUABQAHAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABQAFAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAcABQAFAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAHAAUABQAFAAUABQAFAAUABwAHAAcABwAHAAcABwAHAAUABwAHAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABwAHAAcABwAFAAUABwAHAAcAAAAAAAAAAAAHAAcABQAHAAcABwAHAAcABwAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAcABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAHAAUABQAFAAUABQAFAAUAAAAFAAAABQAAAAAABQAFAAUABQAFAAUABQAFAAcABwAHAAcABwAHAAUABQAFAAUABQAFAAUABQAFAAUAAAAAAAUABQAFAAUABQAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABwAFAAcABwAHAAcABwAFAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAUABQAFAAUABwAHAAUABQAHAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAcABQAFAAcABwAHAAUABwAFAAUABQAHAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABwAHAAcABwAHAAUABQAFAAUABQAFAAUABQAHAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAcABQAFAAUABQAFAAUABQAAAAAAAAAAAAUAAAAAAAAAAAAAAAAABQAAAAAABwAFAAUAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUAAAAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAABQAAAAAAAAAFAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAUABQAHAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAHAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAHAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAcABwAFAAUABQAFAAcABwAFAAUABwAHAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAcABwAFAAUABwAHAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAFAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAFAAUABQAAAAAABQAFAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAFAAcABwAAAAAAAAAAAAAABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAFAAcABwAFAAcABwAAAAcABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAFAAUABQAAAAUABQAAAAAAAAAAAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABwAFAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABQAFAAUABQAFAAUABQAFAAUABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAHAAcABQAHAAUABQAAAAAAAAAAAAAAAAAFAAAABwAHAAcABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAHAAcABwAAAAAABwAHAAAAAAAHAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAAAAAAFAAUABQAFAAUABQAFAAAAAAAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAUABQAFAAUABwAHAAUABQAFAAcABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAcABQAFAAUABQAFAAUABwAFAAcABwAFAAcABQAFAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAcABQAFAAUABQAAAAAABwAHAAcABwAFAAUABwAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAHAAUABQAFAAUABQAFAAUABQAHAAcABQAHAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAFAAcABwAFAAUABQAFAAUABQAHAAUAAAAAAAAAAAAAAAAAAAAAAAcABwAFAAUABQAFAAcABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAUABQAFAAUABQAHAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAAAAAAFAAUABwAHAAcABwAFAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABwAHAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAFAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAcABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAAAHAAUABQAFAAUABQAFAAUABwAFAAUABwAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUAAAAAAAAABQAAAAUABQAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAHAAcAAAAFAAUAAAAHAAcABQAHAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAAAAAAAAAAAAAAAAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAUABQAFAAAAAAAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAABQAFAAUABQAFAAUABQAAAAUABQAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAFAAUABQAFAAUADgAOAA4ADgAOAA4ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAAAAAAAAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAMAAwADAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkAAAAAAAAAAAAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAAAAAAAAAAAAsADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwACwAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAADgAOAA4AAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAAAA4ADgAOAA4ADgAOAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAAAA4AAAAOAAAAAAAAAAAAAAAAAA4AAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAADgAAAAAAAAAAAA4AAAAOAAAAAAAAAAAADgAOAA4AAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AAAAAAA4ADgAOAA4ADgAOAA4ADgAOAAAADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4AAAAAAAAAAAAAAAAAAAAAAA4ADgAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAOAA4ADgAOAA4ADgAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAAAAAAAAA="),xr=Array.isArray(vr)?function(A){for(var e=A.length,t=[],r=0;rs.x||t.y>s.y;return s=t,0===e||A});return A.body.removeChild(e),t}(document);return Object.defineProperty(Xr,"SUPPORT_WORD_BREAKING",{value:A}),A},get SUPPORT_SVG_DRAWING(){var A=function(A){var e=new Image,t=A.createElement("canvas"),A=t.getContext("2d");if(!A)return!1;e.src="data:image/svg+xml,";try{A.drawImage(e,0,0),t.toDataURL()}catch(A){return!1}return!0}(document);return Object.defineProperty(Xr,"SUPPORT_SVG_DRAWING",{value:A}),A},get SUPPORT_FOREIGNOBJECT_DRAWING(){var A="function"==typeof Array.from&&"function"==typeof window.fetch?function(t){var A=t.createElement("canvas"),r=100;A.width=r,A.height=r;var B=A.getContext("2d");if(!B)return Promise.reject(!1);B.fillStyle="rgb(0, 255, 0)",B.fillRect(0,0,r,r);var e=new Image,n=A.toDataURL();e.src=n;e=Nr(r,r,0,0,e);return B.fillStyle="red",B.fillRect(0,0,r,r),Pr(e).then(function(A){B.drawImage(A,0,0);var e=B.getImageData(0,0,r,r).data;B.fillStyle="red",B.fillRect(0,0,r,r);A=t.createElement("div");return A.style.backgroundImage="url("+n+")",A.style.height="100px",Lr(e)?Pr(Nr(r,r,0,0,A)):Promise.reject(!1)}).then(function(A){return B.drawImage(A,0,0),Lr(B.getImageData(0,0,r,r).data)}).catch(function(){return!1})}(document):Promise.resolve(!1);return Object.defineProperty(Xr,"SUPPORT_FOREIGNOBJECT_DRAWING",{value:A}),A},get SUPPORT_CORS_IMAGES(){var A=void 0!==(new Image).crossOrigin;return Object.defineProperty(Xr,"SUPPORT_CORS_IMAGES",{value:A}),A},get SUPPORT_RESPONSE_TYPE(){var A="string"==typeof(new XMLHttpRequest).responseType;return Object.defineProperty(Xr,"SUPPORT_RESPONSE_TYPE",{value:A}),A},get SUPPORT_CORS_XHR(){var A="withCredentials"in new XMLHttpRequest;return Object.defineProperty(Xr,"SUPPORT_CORS_XHR",{value:A}),A},get SUPPORT_NATIVE_TEXT_SEGMENTATION(){var A=!("undefined"==typeof Intl||!Intl.Segmenter);return Object.defineProperty(Xr,"SUPPORT_NATIVE_TEXT_SEGMENTATION",{value:A}),A}},Jr=function(A,e){this.text=A,this.bounds=e},Yr=function(A,e){var t=e.ownerDocument;if(t){var r=t.createElement("html2canvaswrapper");r.appendChild(e.cloneNode(!0));t=e.parentNode;if(t){t.replaceChild(r,e);A=f(A,r);return r.firstChild&&t.replaceChild(r.firstChild,r),A}}return d.EMPTY},Wr=function(A,e,t){var r=A.ownerDocument;if(!r)throw new Error("Node has no owner document");r=r.createRange();return r.setStart(A,e),r.setEnd(A,e+t),r},Zr=function(A){if(Xr.SUPPORT_NATIVE_TEXT_SEGMENTATION){var e=new Intl.Segmenter(void 0,{granularity:"grapheme"});return Array.from(e.segment(A)).map(function(A){return A.segment})}return function(A){for(var e,t=mr(A),r=[];!(e=t.next()).done;)e.value&&r.push(e.value.slice());return r}(A)},_r=function(A,e){return 0!==e.letterSpacing?Zr(A):function(A,e){if(Xr.SUPPORT_NATIVE_TEXT_SEGMENTATION){var t=new Intl.Segmenter(void 0,{granularity:"word"});return Array.from(t.segment(A)).map(function(A){return A.segment})}return jr(A,e)}(A,e)},qr=[32,160,4961,65792,65793,4153,4241],jr=function(A,e){for(var t,r=wA(A,{lineBreak:e.lineBreak,wordBreak:"break-word"===e.overflowWrap?"break-word":e.wordBreak}),B=[];!(t=r.next()).done;)!function(){var A,e;t.value&&(A=t.value.slice(),A=Q(A),e="",A.forEach(function(A){-1===qr.indexOf(A)?e+=g(A):(e.length&&B.push(e),B.push(g(A)),e="")}),e.length&&B.push(e))}();return B},zr=function(A,e,t){var B,n,s,o,i;this.text=$r(e.data,t.textTransform),this.textBounds=(B=A,A=this.text,s=e,A=_r(A,n=t),o=[],i=0,A.forEach(function(A){var e,t,r;n.textDecorationLine.length||0e.height?new d(e.left+(e.width-e.height)/2,e.top,e.height,e.height):e.width"),Ln(this.referenceElement.ownerDocument,t,n),o.replaceChild(o.adoptNode(this.documentElement),o.documentElement),o.close(),A},fn.prototype.createElementClone=function(A){if(Cr(A,2),zB(A))return this.createCanvasClone(A);if(MB(A))return this.createVideoClone(A);if(SB(A))return this.createStyleClone(A);var e=A.cloneNode(!1);return $B(e)&&($B(A)&&A.currentSrc&&A.currentSrc!==A.src&&(e.src=A.currentSrc,e.srcset=""),"lazy"===e.loading&&(e.loading="eager")),TB(e)?this.createCustomElementClone(e):e},fn.prototype.createCustomElementClone=function(A){var e=document.createElement("html2canvascustomelement");return Kn(A.style,e),e},fn.prototype.createStyleClone=function(A){try{var e=A.sheet;if(e&&e.cssRules){var t=[].slice.call(e.cssRules,0).reduce(function(A,e){return e&&"string"==typeof e.cssText?A+e.cssText:A},""),r=A.cloneNode(!1);return r.textContent=t,r}}catch(A){if(this.context.logger.error("Unable to access cssRules property",A),"SecurityError"!==A.name)throw A}return A.cloneNode(!1)},fn.prototype.createCanvasClone=function(e){var A;if(this.options.inlineImages&&e.ownerDocument){var t=e.ownerDocument.createElement("img");try{return t.src=e.toDataURL(),t}catch(A){this.context.logger.info("Unable to inline canvas contents, canvas is tainted",e)}}t=e.cloneNode(!1);try{t.width=e.width,t.height=e.height;var r,B,n=e.getContext("2d"),s=t.getContext("2d");return s&&(!this.options.allowTaint&&n?s.putImageData(n.getImageData(0,0,e.width,e.height),0,0):(!(r=null!==(A=e.getContext("webgl2"))&&void 0!==A?A:e.getContext("webgl"))||!1===(null==(B=r.getContextAttributes())?void 0:B.preserveDrawingBuffer)&&this.context.logger.warn("Unable to clone WebGL context as it has preserveDrawingBuffer=false",e),s.drawImage(e,0,0))),t}catch(A){this.context.logger.info("Unable to clone canvas as it is tainted",e)}return t},fn.prototype.createVideoClone=function(e){var A=e.ownerDocument.createElement("canvas");A.width=e.offsetWidth,A.height=e.offsetHeight;var t=A.getContext("2d");try{return t&&(t.drawImage(e,0,0,A.width,A.height),this.options.allowTaint||t.getImageData(0,0,A.width,A.height)),A}catch(A){this.context.logger.info("Unable to clone video as it is tainted",e)}A=e.ownerDocument.createElement("canvas");return A.width=e.offsetWidth,A.height=e.offsetHeight,A},fn.prototype.appendChildNode=function(A,e,t){XB(e)&&("SCRIPT"===e.tagName||e.hasAttribute(hn)||"function"==typeof this.options.ignoreElements&&this.options.ignoreElements(e))||this.options.copyStyles&&XB(e)&&SB(e)||A.appendChild(this.cloneNode(e,t))},fn.prototype.cloneChildNodes=function(A,e,t){for(var r,B=this,n=(A.shadowRoot||A).firstChild;n;n=n.nextSibling)XB(n)&&rn(n)&&"function"==typeof n.assignedNodes?(r=n.assignedNodes()).length&&r.forEach(function(A){return B.appendChildNode(e,A,t)}):this.appendChildNode(e,n,t)},fn.prototype.cloneNode=function(A,e){if(PB(A))return document.createTextNode(A.data);if(!A.ownerDocument)return A.cloneNode(!1);var t=A.ownerDocument.defaultView;if(t&&XB(A)&&(JB(A)||YB(A))){var r=this.createElementClone(A);r.style.transitionProperty="none";var B=t.getComputedStyle(A),n=t.getComputedStyle(A,":before"),s=t.getComputedStyle(A,":after");this.referenceElement===A&&JB(r)&&(this.clonedReferenceElement=r),jB(r)&&Mn(r);t=this.counters.parse(new Ur(this.context,B)),n=this.resolvePseudoContent(A,r,n,gn.BEFORE);TB(A)&&(e=!0),MB(A)||this.cloneChildNodes(A,r,e),n&&r.insertBefore(n,r.firstChild);s=this.resolvePseudoContent(A,r,s,gn.AFTER);return s&&r.appendChild(s),this.counters.pop(t),(B&&(this.options.copyStyles||YB(A))&&!An(A)||e)&&Kn(B,r),0===A.scrollTop&&0===A.scrollLeft||this.scrolledElements.push([r,A.scrollLeft,A.scrollTop]),(en(A)||tn(A))&&(en(r)||tn(r))&&(r.value=A.value),r}return A.cloneNode(!1)},fn.prototype.resolvePseudoContent=function(o,A,e,t){var i=this;if(e){var r=e.content,Q=A.ownerDocument;if(Q&&r&&"none"!==r&&"-moz-alt-content"!==r&&"none"!==e.display){this.counters.parse(new Ur(this.context,e));var c=new wr(this.context,e),a=Q.createElement("html2canvaspseudoelement");Kn(e,a),c.content.forEach(function(A){if(0===A.type)a.appendChild(Q.createTextNode(A.value));else if(22===A.type){var e=Q.createElement("img");e.src=A.value,e.style.opacity="1",a.appendChild(e)}else if(18===A.type){var t,r,B,n,s;"attr"===A.name?(e=A.values.filter(_A)).length&&a.appendChild(Q.createTextNode(o.getAttribute(e[0].value)||"")):"counter"===A.name?(B=(r=A.values.filter($A))[0],r=r[1],B&&_A(B)&&(t=i.counters.getCounterValue(B.value),s=r&&_A(r)?pt.parse(i.context,r.value):3,a.appendChild(Q.createTextNode(Fn(t,s,!1))))):"counters"===A.name&&(B=(t=A.values.filter($A))[0],s=t[1],r=t[2],B&&_A(B)&&(B=i.counters.getCounterValues(B.value),n=r&&_A(r)?pt.parse(i.context,r.value):3,s=s&&0===s.type?s.value:"",s=B.map(function(A){return Fn(A,n,!1)}).join(s),a.appendChild(Q.createTextNode(s))))}else if(20===A.type)switch(A.value){case"open-quote":a.appendChild(Q.createTextNode(Xt(c.quotes,i.quoteDepth++,!0)));break;case"close-quote":a.appendChild(Q.createTextNode(Xt(c.quotes,--i.quoteDepth,!1)));break;default:a.appendChild(Q.createTextNode(A.value))}}),a.className=Dn+" "+vn;t=t===gn.BEFORE?" "+Dn:" "+vn;return YB(A)?A.className.baseValue+=t:A.className+=t,a}}},fn.destroy=function(A){return!!A.parentNode&&(A.parentNode.removeChild(A),!0)},fn);function fn(A,e,t){if(this.context=A,this.options=t,this.scrolledElements=[],this.referenceElement=e,this.counters=new Bn,this.quoteDepth=0,!e.ownerDocument)throw new Error("Cloned element does not have an owner document");this.documentElement=this.cloneNode(e.ownerDocument.documentElement,!1)}(he=gn=gn||{})[he.BEFORE=0]="BEFORE",he[he.AFTER=1]="AFTER";function Hn(e){return new Promise(function(A){!e.complete&&e.src?(e.onload=A,e.onerror=A):A()})}var pn=function(A,e){var t=A.createElement("iframe");return t.className="html2canvas-container",t.style.visibility="hidden",t.style.position="fixed",t.style.left="-10000px",t.style.top="0px",t.style.border="0",t.width=e.width.toString(),t.height=e.height.toString(),t.scrolling="no",t.setAttribute(hn,"true"),A.body.appendChild(t),t},En=function(A){return Promise.all([].slice.call(A.images,0).map(Hn))},In=function(B){return new Promise(function(e,A){var t=B.contentWindow;if(!t)return A("No window assigned for iframe");var r=t.document;t.onload=B.onload=function(){t.onload=B.onload=null;var A=setInterval(function(){0"),e},Ln=function(A,e,t){A&&A.defaultView&&(e!==A.defaultView.pageXOffset||t!==A.defaultView.pageYOffset)&&A.defaultView.scrollTo(e,t)},bn=function(A){var e=A[0],t=A[1],A=A[2];e.scrollLeft=t,e.scrollTop=A},Dn="___html2canvas___pseudoelement_before",vn="___html2canvas___pseudoelement_after",xn='{\n content: "" !important;\n display: none !important;\n}',Mn=function(A){Sn(A,"."+Dn+":before"+xn+"\n ."+vn+":after"+xn)},Sn=function(A,e){var t=A.ownerDocument;t&&((t=t.createElement("style")).textContent=e,A.appendChild(t))},Tn=(Gn.getOrigin=function(A){var e=Gn._link;return e?(e.href=A,e.href=e.href,e.protocol+e.hostname+e.port):"about:blank"},Gn.isSameOrigin=function(A){return Gn.getOrigin(A)===Gn._origin},Gn.setContext=function(A){Gn._link=A.document.createElement("a"),Gn._origin=Gn.getOrigin(A.location.href)},Gn._origin="about:blank",Gn);function Gn(){}var On=(Vn.prototype.addImage=function(A){var e=Promise.resolve();return this.has(A)||(Yn(A)||Pn(A))&&(this._cache[A]=this.loadImage(A)).catch(function(){}),e},Vn.prototype.match=function(A){return this._cache[A]},Vn.prototype.loadImage=function(s){return a(this,void 0,void 0,function(){var e,r,t,B,n=this;return H(this,function(A){switch(A.label){case 0:return(e=Tn.isSameOrigin(s),r=!Xn(s)&&!0===this._options.useCORS&&Xr.SUPPORT_CORS_IMAGES&&!e,t=!Xn(s)&&!e&&!Yn(s)&&"string"==typeof this._options.proxy&&Xr.SUPPORT_CORS_XHR&&!r,e||!1!==this._options.allowTaint||Xn(s)||Yn(s)||t||r)?(B=s,t?[4,this.proxy(B)]:[3,2]):[2];case 1:B=A.sent(),A.label=2;case 2:return this.context.logger.debug("Added image "+s.substring(0,256)),[4,new Promise(function(A,e){var t=new Image;t.onload=function(){return A(t)},t.onerror=e,(Jn(B)||r)&&(t.crossOrigin="anonymous"),t.src=B,!0===t.complete&&setTimeout(function(){return A(t)},500),0t.width+C?0:Math.max(0,n-C),Math.max(0,s-l),As.TOP_RIGHT):new Zn(t.left+t.width-C,t.top+l),this.bottomRightPaddingBox=0t.width+F+A?0:n-F+A,s-(l+h),As.TOP_RIGHT):new Zn(t.left+t.width-(C+d),t.top+l+h),this.bottomRightContentBox=0A.element.container.styles.zIndex.order?(s=e,!1):0=A.element.container.styles.zIndex.order?(o=e+1,!1):0 + + + +Terminal mockup + + + + + + +
+
+
Terminal mockup
+
+ + +
+
+
+
+ + + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ + + +
+

+      
+
+
+ + + +
+
+ Content + Raw ANSI or bracket markup: [b]…[/b] [cyan]…[/cyan] [muted]…[/muted] [link]…[/link] +
+ +
+ + + + +
+ + +
+ + +
+
+
+
+ + + + + diff --git a/.github/extensions/terminal-mockup/assets/styles.css b/.github/extensions/terminal-mockup/assets/styles.css new file mode 100644 index 00000000000..4ee5e213309 --- /dev/null +++ b/.github/extensions/terminal-mockup/assets/styles.css @@ -0,0 +1,422 @@ +:root { + /* VSCode Dark+ ANSI palette */ + --vsc-bg: #1E1E1E; + --vsc-fg: #CCCCCC; + --vsc-muted: #808080; + + --ansi-black: #000000; + --ansi-red: #CD3131; + --ansi-green: #0DBC79; + --ansi-yellow: #E5E510; + --ansi-blue: #2472C8; + --ansi-magenta: #BC3FBC; + --ansi-cyan: #11A8CD; + --ansi-white: #E5E5E5; + + --ansi-br-black: #666666; + --ansi-br-red: #F14C4C; + --ansi-br-green: #23D18B; + --ansi-br-yellow: #F5F543; + --ansi-br-blue: #3B8EEA; + --ansi-br-magenta: #D670D6; + --ansi-br-cyan: #29B8DB; + --ansi-br-white: #E5E5E5; + + /* Terminal typography (overridden by data-font attribute) */ + --term-font: 'Menlo', 'Monaco', 'Courier New', monospace; + --term-fontsize: 14px; + --term-lineheight: 1.55; + --term-padding: 28px 32px; + + /* App chrome */ + --app-bg: #0e1116; + --app-panel-bg: #161b22; + --app-border: #30363d; + --app-text: #e6edf3; + --app-text-muted: #8b949e; + --app-accent: #2f81f7; +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; height: 100%; } +body { + background: var(--app-bg); + color: var(--app-text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + font-size: 13px; +} + +.app { + display: grid; + grid-template-rows: auto auto 1fr 6px var(--editor-height, 240px); + grid-template-areas: + "topbar" + "toolbar" + "preview" + "handle" + "editor"; + height: 100vh; + min-height: 0; +} +.topbar { grid-area: topbar; } +.toolbar { grid-area: toolbar; } +.preview-pane { grid-area: preview; } +.resize-handle { grid-area: handle; } +.editor-pane { grid-area: editor; } + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 8px 16px; + background: var(--app-panel-bg); + border-bottom: 1px solid var(--app-border); +} +.topbar .title { + font-weight: 600; + font-size: 13px; + letter-spacing: 0.2px; + color: var(--app-text); +} + +/* Resize handle */ +.resize-handle { + background: var(--app-border); + cursor: row-resize; + position: relative; + transition: background 120ms ease; +} +.resize-handle:hover, +.resize-handle.dragging { + background: var(--app-accent); +} +.resize-handle::before { + content: ""; + position: absolute; + inset: -3px 0; +} +.resize-handle:focus-visible { + outline: 2px solid var(--app-accent); + outline-offset: -2px; +} + +/* Toolbar */ +.toolbar { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: var(--app-panel-bg); + border-bottom: 1px solid var(--app-border); + flex-wrap: wrap; +} +.toolbar .controls { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.ctl { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--app-text-muted); +} +.ctl > span { white-space: nowrap; } +.ctl select, .ctl input[type="range"] { + background: #0d1117; + color: var(--app-text); + border: 1px solid var(--app-border); + border-radius: 6px; + padding: 4px 6px; + font: inherit; +} +.ctl input[type="range"] { padding: 0; } +.ctl.checkbox { gap: 6px; cursor: pointer; user-select: none; } +.ctl output { font-variant-numeric: tabular-nums; min-width: 4ch; text-align: right; color: var(--app-text); } + +button { + background: #21262d; + color: var(--app-text); + border: 1px solid var(--app-border); + border-radius: 6px; + padding: 6px 12px; + font: inherit; + cursor: pointer; +} +button:hover { background: #2d333b; } +button.primary { background: var(--app-accent); border-color: var(--app-accent); color: white; } +button.primary:hover { background: #1f6feb; } +button:disabled { opacity: 0.5; cursor: not-allowed; } +.ctl-sep { + width: 1px; + align-self: stretch; + background: var(--app-border); + margin: 0 2px; +} + +/* Preview pane */ +.preview-pane { + position: relative; + display: flex; + align-items: safe center; + justify-content: safe center; + overflow: auto; + padding: 32px; + min-height: 0; + background: + radial-gradient(circle at 50% 0%, #1a2138 0%, #0e1116 60%); +} + +/* Pane toggles live inside the topbar so they stay visible even when the toolbar is hidden. */ +.pane-toggles { + display: flex; + gap: 6px; +} +.pane-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + font-size: 11px; + font-weight: 500; + color: var(--app-text); + background: #21262d; + border: 1px solid var(--app-border); + border-radius: 6px; + cursor: pointer; +} +.pane-toggle:hover { + border-color: var(--app-accent); + background: #2d333b; +} +.pane-toggle-icon { + font-size: 10px; + line-height: 1; + opacity: 0.9; +} +.app.toolbar-collapsed > .toolbar { display: none !important; } +.app.editor-collapsed > .resize-handle, +.app.editor-collapsed > .editor-pane { display: none !important; } +.app.toolbar-collapsed { grid-template-rows: auto 0 1fr 6px var(--editor-height, 240px); } +.app.editor-collapsed { grid-template-rows: auto auto 1fr 0 0; } +.app.toolbar-collapsed.editor-collapsed { grid-template-rows: auto 0 1fr 0 0; } + +/* The mockup root is what gets captured to PNG */ +.mockup { + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding: 56px 64px; + border-radius: 8px; +} +.grid-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + display: none; +} +.mockup.backdrop-grid .grid-svg { display: block; } +.mockup .window { position: relative; z-index: 1; } +.mockup.backdrop-none { + background: transparent; + padding: 0; +} +.mockup.backdrop-solid { + background: #0a0d14; +} +.mockup.backdrop-grid { + background: + radial-gradient(ellipse 80% 60% at 50% -15%, rgba(80,150,255,0.55) 0%, rgba(80,150,255,0) 60%), + linear-gradient(180deg, #0a1330 0%, #04060c 80%); +} + +/* Terminal window */ +.window { + width: var(--mockup-width, 800px); + background: var(--vsc-bg); + border-radius: 12px; + overflow: hidden; + box-shadow: + 0 1px 0 rgba(255,255,255,0.04) inset, + 0 0 0 1px rgba(255,255,255,0.06), + 0 30px 80px rgba(0,0,0,0.55), + 0 12px 24px rgba(0,0,0,0.35); +} +.window.no-chrome .titlebar { display: none; } +.window.no-chrome { border-radius: 8px; } + +.titlebar { + height: 36px; + display: flex; + align-items: center; + gap: 8px; + padding: 0 14px; + background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%); + border-bottom: 1px solid rgba(0,0,0,0.4); +} +.dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: #4a4a4a; +} +.dot.red, .dot.yellow, .dot.green { background: #4a4a4a; } + +.terminal { + margin: 0; + padding: var(--term-padding); + background: var(--vsc-bg); + color: var(--vsc-fg); + font-family: var(--term-font); + font-size: var(--term-fontsize); + line-height: var(--term-lineheight); + white-space: pre-wrap; + word-break: break-word; + font-variant-ligatures: none; +} +.window.body-gradient .terminal { + background: linear-gradient(180deg, #2a2a2a 0%, #1e1e1e 30%, #1a1a1a 100%); +} + +/* Style classes emitted by the parser */ +.fg-black { color: var(--ansi-black); } +.fg-red { color: var(--ansi-red); } +.fg-green { color: var(--ansi-green); } +.fg-yellow { color: var(--ansi-yellow); } +.fg-blue { color: var(--ansi-blue); } +.fg-magenta { color: var(--ansi-magenta); } +.fg-cyan { color: var(--ansi-cyan); } +.fg-white { color: var(--ansi-white); } +.fg-br-black { color: var(--ansi-br-black); } +.fg-br-red { color: var(--ansi-br-red); } +.fg-br-green { color: var(--ansi-br-green); } +.fg-br-yellow { color: var(--ansi-br-yellow); } +.fg-br-blue { color: var(--ansi-br-blue); } +.fg-br-magenta { color: var(--ansi-br-magenta); } +.fg-br-cyan { color: var(--ansi-br-cyan); } +.fg-br-white { color: var(--ansi-br-white); } +.fg-muted { color: var(--vsc-muted); } +.bold { font-weight: 700; } +.italic { font-style: italic; } +.underline { text-decoration: underline; } +.dim { opacity: 0.55; } + +/* Editor */ +.editor-pane { + display: grid; + grid-template-rows: auto 1fr; + border-top: 1px solid var(--app-border); + background: var(--app-panel-bg); + min-height: 0; + overflow: hidden; +} +.editor-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 16px; + font-size: 12px; + color: var(--app-text-muted); + border-bottom: 1px solid var(--app-border); +} +.editor-header .hint code { + font-family: var(--term-font); + background: #0d1117; + border: 1px solid var(--app-border); + border-radius: 4px; + padding: 1px 5px; + margin: 0 2px; + color: var(--app-text); +} +#editor { + width: 100%; + height: 100%; + border: none; + background: #0d1117; + color: var(--app-text); + padding: 12px 16px; + font-family: var(--term-font); + font-size: 13px; + line-height: 1.5; + resize: none; + outline: none; +} + +/* Toast */ +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: #21262d; + color: var(--app-text); + border: 1px solid var(--app-border); + padding: 8px 14px; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + font-size: 12px; + z-index: 1000; +} + +/* Font dropdown effective values */ +.window[data-font="menlo"] .terminal { font-family: 'Menlo', 'Monaco', 'Courier New', monospace; } +.window[data-font="sfmono"] .terminal { font-family: 'SF Mono', 'SFMono-Regular', ui-monospace, Menlo, monospace; } +.window[data-font="cascadia"] .terminal { font-family: 'Cascadia Code', 'Cascadia Mono', Consolas, monospace; } +.window[data-font="jetbrains"] .terminal { font-family: 'JetBrains Mono', monospace; } +.window[data-font="fira"] .terminal { font-family: 'Fira Code', monospace; } +.window[data-font="source"] .terminal { font-family: 'Source Code Pro', monospace; } +.window[data-font="roboto"] .terminal { font-family: 'Roboto Mono', monospace; } +.window[data-font="consolas"] .terminal { font-family: 'Consolas', 'Liberation Mono', monospace; } + +/* Save-as dialog */ +.save-dialog { + border: 1px solid var(--app-border); + background: #161b22; + color: var(--app-text); + border-radius: 10px; + padding: 0; + box-shadow: 0 24px 56px rgba(0,0,0,0.6); + max-width: 420px; + width: calc(100% - 48px); +} +.save-dialog::backdrop { + background: rgba(0,0,0,0.55); +} +.save-dialog form { + display: flex; + flex-direction: column; + gap: 12px; + padding: 18px 20px 16px; +} +.save-dialog label { + font-size: 12px; + color: var(--app-text-muted); + letter-spacing: 0.02em; + text-transform: uppercase; +} +.save-dialog input[type="text"] { + background: #0d1117; + border: 1px solid var(--app-border); + border-radius: 6px; + color: var(--app-text); + padding: 8px 10px; + font: inherit; + font-size: 13px; + outline: none; +} +.save-dialog input[type="text"]:focus { + border-color: #2f81f7; + box-shadow: 0 0 0 2px rgba(47,129,247,0.35); +} +.save-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} diff --git a/.github/extensions/terminal-mockup/extension.mjs b/.github/extensions/terminal-mockup/extension.mjs new file mode 100644 index 00000000000..18aeb44a4da --- /dev/null +++ b/.github/extensions/terminal-mockup/extension.mjs @@ -0,0 +1,570 @@ +// Extension: terminal-mockup +// Generate VSCode-style terminal screenshot mockups with dummy data +// for marketing materials. The canvas renders inside an iframe served +// by a loopback HTTP server; all editing, theming, and PNG export +// happen client-side in the iframe app. + +import { createServer } from "node:http"; +import { lstat, mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises"; +import { dirname, join, normalize } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ASSETS_DIR = join(__dirname, "assets"); +const PROJECT_DIR = join(__dirname, "library"); + +const COPILOT_HOME = process.env.COPILOT_HOME || join(homedir(), ".copilot"); +const USER_DIR = join(COPILOT_HOME, "extensions", "terminal-mockup", "artifacts"); + +const SCOPES = ["project", "user"]; +const SCOPE_DIRS = { project: PROJECT_DIR, user: USER_DIR }; +function isScope(s) { return s === "project" || s === "user"; } + +const MIME = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", +}; + +const instances = new Map(); + +function ensureInstanceState(instanceId) { + let state = instances.get(instanceId); + if (!state) { + state = { + content: "", + options: {}, + sse: new Set(), + }; + instances.set(instanceId, state); + } + return state; +} + +function sendSse(state, res, payload) { + if (res.destroyed || res.writableEnded) { + state.sse.delete(res); + return false; + } + try { + res.write(`data: ${payload}\n\n`); + return true; + } catch { + state.sse.delete(res); + return false; + } +} + +function pushUpdate(instanceId) { + const state = instances.get(instanceId); + if (!state) return; + const payload = JSON.stringify({ type: "state", content: state.content, options: state.options }); + for (const res of state.sse) sendSse(state, res, payload); +} + +function broadcastLibraryChanged(payload = {}) { + const event = JSON.stringify({ type: "library_changed", ...payload }); + for (const state of instances.values()) { + for (const res of state.sse) sendSse(state, res, event); + } +} + +function slugify(name) { + return String(name || "") + .toLowerCase() + .normalize("NFKD") + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .slice(0, 80); +} + +function isValidSlug(s) { + return typeof s === "string" && /^[a-z0-9][a-z0-9-]{0,79}$/.test(s); +} + +async function ensureDir(scope) { + await mkdir(SCOPE_DIRS[scope], { recursive: true }); +} + +async function listScope(scope) { + try { + await ensureDir(scope); + const entries = await readdir(SCOPE_DIRS[scope]); + const out = []; + for (const e of entries) { + if (!e.endsWith(".json")) continue; + const slug = e.slice(0, -5); + try { + const raw = await readFile(join(SCOPE_DIRS[scope], e), "utf8"); + const doc = JSON.parse(raw); + out.push({ scope, slug, name: doc.name || slug, savedAt: doc.savedAt }); + } catch { + out.push({ scope, slug, name: slug }); + } + } + return out; + } catch { + return []; + } +} + +async function listMockups() { + const [projectItems, userItems] = await Promise.all([listScope("project"), listScope("user")]); + const out = [...projectItems, ...userItems]; + out.sort((a, b) => { + if (a.scope !== b.scope) return a.scope === "project" ? -1 : 1; + return (a.name || "").localeCompare(b.name || ""); + }); + return out; +} + +async function readMockup(slug, scope) { + if (!isValidSlug(slug)) return null; + const order = scope && isScope(scope) ? [scope] : SCOPES; + for (const sc of order) { + try { + const raw = await readFile(join(SCOPE_DIRS[sc], `${slug}.json`), "utf8"); + const doc = JSON.parse(raw); + return { ...doc, scope: sc, slug }; + } catch {} + } + return null; +} + +async function refuseSymlink(path) { + try { + const stat = await lstat(path); + if (stat.isSymbolicLink()) { + throw new CanvasError("refused_symlink", `Refusing to operate on symlink: ${path}`); + } + } catch (err) { + if (err && err.code === "ENOENT") return; + throw err; + } +} + +async function writeMockup(slug, doc, scope) { + if (!isValidSlug(slug)) throw new Error("invalid slug"); + if (!isScope(scope)) throw new Error("invalid scope"); + await ensureDir(scope); + const target = join(SCOPE_DIRS[scope], `${slug}.json`); + await refuseSymlink(target); + await writeFile(target, JSON.stringify(doc, null, 2) + "\n", "utf8"); +} + +async function deleteMockup(slug, scope) { + if (!isValidSlug(slug)) return false; + if (!isScope(scope)) return false; + const target = join(SCOPE_DIRS[scope], `${slug}.json`); + try { + await refuseSymlink(target); + await unlink(target); + return true; + } catch { + return false; + } +} + +async function readJsonBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let bytes = 0; + req.on("data", (chunk) => { + bytes += chunk.length; + if (bytes > 5_000_000) { + reject(new Error("body too large")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + if (bytes === 0) return resolve({}); + const body = Buffer.concat(chunks).toString("utf8"); + try { resolve(JSON.parse(body)); } catch (e) { reject(e); } + }); + req.on("error", reject); + }); +} + +async function serveStatic(req, res) { + const url = new URL(req.url, "http://127.0.0.1"); + let path = decodeURIComponent(url.pathname); + if (path === "/" || path === "") path = "/index.html"; + const safe = normalize(path).replace(/^[/\\]+/, ""); + const filePath = join(ASSETS_DIR, safe); + if (!filePath.startsWith(ASSETS_DIR)) { + res.statusCode = 403; + res.end("Forbidden"); + return; + } + try { + const data = await readFile(filePath); + const ext = filePath.slice(filePath.lastIndexOf(".")); + res.setHeader("Content-Type", MIME[ext] || "application/octet-stream"); + res.setHeader("Cache-Control", "no-store"); + res.end(data); + } catch (err) { + res.statusCode = 404; + res.end("Not found"); + } +} + +function jsonResponse(res, status, body) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(JSON.stringify(body)); +} + +async function handleMockupsApi(req, res, urlPath, instanceId) { + // Routes: + // GET /mockups → list merged + // POST /mockups/ → create by name (server slugifies) + // GET /mockups// → read specific scope + // PUT /mockups// → write specific scope + // DELETE /mockups// → delete specific scope + // GET /mockups/ → read (search project then user, back-compat) + const method = req.method || "GET"; + const parts = urlPath.replace(/^\/mockups\/?/, "").split("/").filter(Boolean); + + try { + if (parts.length === 0 && method === "GET") { + return jsonResponse(res, 200, { items: await listMockups() }); + } + if (parts.length === 1 && method === "POST" && isScope(parts[0])) { + const scope = parts[0]; + const body = await readJsonBody(req); + const slugified = slugify(body.name || body.slug || ""); + if (!isValidSlug(slugified)) return jsonResponse(res, 400, { error: "invalid_name" }); + const doc = { + name: typeof body.name === "string" ? body.name : slugified, + savedAt: new Date().toISOString(), + content: typeof body.content === "string" ? body.content : "", + options: body.options && typeof body.options === "object" ? body.options : {}, + }; + await writeMockup(slugified, doc, scope); + return jsonResponse(res, 200, { ok: true, scope, slug: slugified, doc }); + } + if (parts.length === 2 && isScope(parts[0])) { + const [scope, slug] = parts; + if (method === "GET") { + const doc = await readMockup(slug, scope); + if (!doc) return jsonResponse(res, 404, { error: "not_found" }); + return jsonResponse(res, 200, doc); + } + if (method === "PUT") { + const body = await readJsonBody(req); + if (!isValidSlug(slug)) return jsonResponse(res, 400, { error: "invalid_slug" }); + const doc = { + name: typeof body.name === "string" ? body.name : slug, + savedAt: new Date().toISOString(), + content: typeof body.content === "string" ? body.content : "", + options: body.options && typeof body.options === "object" ? body.options : {}, + }; + await writeMockup(slug, doc, scope); + return jsonResponse(res, 200, { ok: true, scope, slug, doc }); + } + if (method === "DELETE") { + const ok = await deleteMockup(slug, scope); + return jsonResponse(res, ok ? 200 : 404, { ok }); + } + } + if (parts.length === 1 && method === "GET") { + // back-compat: GET /mockups/, search both scopes + const doc = await readMockup(parts[0]); + if (!doc) return jsonResponse(res, 404, { error: "not_found" }); + return jsonResponse(res, 200, doc); + } + return jsonResponse(res, 405, { error: "method_not_allowed" }); + } catch (err) { + return jsonResponse(res, 500, { error: "server_error", message: String(err.message || err) }); + } +} + +async function startServer(instanceId) { + const state = ensureInstanceState(instanceId); + let port = 0; + const server = createServer((req, res) => { + // Defense against DNS rebinding and same-port cross-origin loopback requests: + // reject any request whose Host header does not match the loopback bound port, + // or whose Origin (if present) is not loopback. Bound to 127.0.0.1, so the + // only way to reach here with a foreign Host is a rebound DNS name. + const host = req.headers.host || ""; + if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) { + res.statusCode = 403; + res.end("Forbidden"); + return; + } + const origin = req.headers.origin; + if (origin && !origin.startsWith("http://127.0.0.1:") && !origin.startsWith("http://localhost:")) { + res.statusCode = 403; + res.end("Forbidden"); + return; + } + const url = new URL(req.url, "http://127.0.0.1"); + if (url.pathname === "/state") { + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(JSON.stringify({ content: state.content, options: state.options })); + return; + } + if (url.pathname === "/events") { + res.statusCode = 200; + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-store"); + res.setHeader("Connection", "keep-alive"); + state.sse.add(res); + req.on("close", () => { + state.sse.delete(res); + }); + sendSse(state, res, JSON.stringify({ type: "state", content: state.content, options: state.options })); + return; + } + if (url.pathname === "/mockups" || url.pathname.startsWith("/mockups/")) { + handleMockupsApi(req, res, url.pathname, instanceId).catch((err) => { + jsonResponse(res, 500, { error: "server_error", message: String(err.message || err) }); + }); + return; + } + serveStatic(req, res).catch(() => { + res.statusCode = 500; + res.end("Server error"); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + port = typeof address === "object" && address ? address.port : 0; + return { server, url: `http://127.0.0.1:${port}/` }; +} + +await ensureDir("project").catch(() => {}); + +const session = await joinSession({ + canvases: [ + createCanvas({ + id: "terminal-mockup", + displayName: "Terminal mockup", + description: "Render dummy gh CLI output as a VSCode-styled terminal screenshot for marketing materials. Accepts raw ANSI or bracket markup. Supports a per-user saved-mockups library for managing multiple mockups in parallel.", + inputSchema: { + type: "object", + properties: { + content: { type: "string", description: "Initial terminal content. Supports raw ANSI escape codes and bracket markup like [b]...[/b], [cyan]...[/cyan]." }, + options: { type: "object", description: "Initial render options (chrome, backdrop, font, width)." }, + loadSlug: { type: "string", description: "If set, load this saved mockup by slug on open." }, + loadScope: { type: "string", enum: ["user", "project"], description: "Scope for loadSlug. If omitted, project is searched first then user." }, + }, + }, + actions: [ + { + name: "set_content", + description: "Replace the terminal content shown in the canvas. Supports ANSI escape codes and bracket markup.", + inputSchema: { + type: "object", + required: ["text"], + properties: { + text: { type: "string" }, + }, + }, + handler: async (ctx) => { + const state = instances.get(ctx.instanceId); + if (!state) throw new CanvasError("not_open", "Canvas instance is not open"); + const text = ctx.input && typeof ctx.input.text === "string" ? ctx.input.text : ""; + state.content = text; + pushUpdate(ctx.instanceId); + return { ok: true, length: text.length }; + }, + }, + { + name: "set_options", + description: "Adjust rendering options: chrome (none|macos), backdrop (none|solid|grid), font, fontSize, width, bodyGradient, autoStyle.", + inputSchema: { + type: "object", + properties: { + chrome: { type: "string", enum: ["none", "macos"] }, + backdrop: { type: "string", enum: ["none", "solid", "grid"] }, + font: { type: "string" }, + fontSize: { type: "number" }, + width: { type: "number" }, + bodyGradient: { type: "boolean" }, + autoStyle: { type: "boolean" }, + }, + }, + handler: async (ctx) => { + const state = instances.get(ctx.instanceId); + if (!state) throw new CanvasError("not_open", "Canvas instance is not open"); + state.options = { ...state.options, ...(ctx.input || {}) }; + pushUpdate(ctx.instanceId); + return { ok: true, options: state.options }; + }, + }, + { + name: "save_mockup", + description: "Save the current canvas content and options to a library. scope=\"user\" (default) writes to the per-user library; scope=\"project\" writes into the extension's committed library folder.", + inputSchema: { + type: "object", + required: ["name"], + properties: { + name: { type: "string", description: "Human-readable name. Slug is derived from this." }, + slug: { type: "string", description: "Optional explicit slug. Must match [a-z0-9-]+." }, + scope: { type: "string", enum: ["user", "project"], description: "Where to write. Defaults to user." }, + }, + }, + handler: async (ctx) => { + const state = instances.get(ctx.instanceId); + if (!state) throw new CanvasError("not_open", "Canvas instance is not open"); + const name = ctx.input && typeof ctx.input.name === "string" ? ctx.input.name : ""; + const explicit = ctx.input && typeof ctx.input.slug === "string" ? ctx.input.slug : null; + const scope = isScope(ctx.input?.scope) ? ctx.input.scope : "user"; + const slug = explicit && isValidSlug(explicit) ? explicit : slugify(name); + if (!isValidSlug(slug)) throw new CanvasError("invalid_name", "Name must contain at least one alphanumeric character"); + const doc = { + name: name || slug, + savedAt: new Date().toISOString(), + content: state.content, + options: state.options, + }; + try { + await writeMockup(slug, doc, scope); + } catch (err) { + throw new CanvasError("save_failed", String(err.message || err)); + } + broadcastLibraryChanged({ action: "saved", scope, slug, name: doc.name }); + return { ok: true, scope, slug, name: doc.name }; + }, + }, + { + name: "load_mockup", + description: "Load a saved mockup by slug and apply its content + options to the canvas. If scope is omitted, the project library is searched first, then the user library.", + inputSchema: { + type: "object", + required: ["slug"], + properties: { + slug: { type: "string" }, + scope: { type: "string", enum: ["user", "project"] }, + }, + }, + handler: async (ctx) => { + const state = instances.get(ctx.instanceId); + if (!state) throw new CanvasError("not_open", "Canvas instance is not open"); + const slug = ctx.input && typeof ctx.input.slug === "string" ? ctx.input.slug : ""; + const scope = isScope(ctx.input?.scope) ? ctx.input.scope : undefined; + const doc = await readMockup(slug, scope); + if (!doc) throw new CanvasError("not_found", `No saved mockup with slug "${slug}"`); + state.content = typeof doc.content === "string" ? doc.content : ""; + if (doc.options && typeof doc.options === "object") { + state.options = { ...state.options, ...doc.options }; + } + pushUpdate(ctx.instanceId); + return { ok: true, scope: doc.scope, slug, name: doc.name }; + }, + }, + { + name: "list_mockups", + description: "List all saved mockups from both the project (committed) library and the per-user library. Each item includes its scope.", + handler: async () => ({ items: await listMockups() }), + }, + { + name: "delete_mockup", + description: "Delete a saved mockup by slug from the given scope.", + inputSchema: { + type: "object", + required: ["slug", "scope"], + properties: { + slug: { type: "string" }, + scope: { type: "string", enum: ["user", "project"] }, + }, + }, + handler: async (ctx) => { + const slug = ctx.input && typeof ctx.input.slug === "string" ? ctx.input.slug : ""; + const scope = isScope(ctx.input?.scope) ? ctx.input.scope : "user"; + const ok = await deleteMockup(slug, scope); + if (ok) broadcastLibraryChanged({ action: "deleted", scope, slug }); + return { ok }; + }, + }, + { + name: "batch_export", + description: "Tell the open iframe to download a PNG (or JPG) for each named saved mockup. All exports render with the iframe's current toolbar options (chrome, backdrop, font, etc.), NOT each mockup's saved options. Each item is either a bare slug (defaults to searching project then user) or a scoped string like \"project:my-slug\" / \"user:my-slug\". Filenames are `.`.", + inputSchema: { + type: "object", + required: ["slugs"], + properties: { + slugs: { type: "array", items: { type: "string" }, description: "Bare slug or \":\"." }, + suffix: { type: "string", description: "Suffix appended to slug before the extension (e.g. \"-no-frame\")." }, + format: { type: "string", enum: ["png", "jpg"], description: "Optional. Defaults to whatever the toolbar has selected." }, + }, + }, + handler: async (ctx) => { + const state = instances.get(ctx.instanceId); + if (!state) throw new CanvasError("not_open", "Canvas instance is not open"); + const slugs = Array.isArray(ctx.input?.slugs) ? ctx.input.slugs.filter((s) => typeof s === "string") : []; + if (slugs.length === 0) throw new CanvasError("no_slugs", "Provide at least one slug to export"); + if (state.sse.size === 0) { + throw new CanvasError("iframe_not_connected", "No iframe is connected to receive the export request. Open the canvas first."); + } + const suffix = typeof ctx.input?.suffix === "string" ? ctx.input.suffix : ""; + const format = ctx.input?.format === "jpg" ? "jpg" : (ctx.input?.format === "png" ? "png" : null); + const payload = JSON.stringify({ type: "batch_export", slugs, suffix, format }); + let delivered = 0; + for (const res of state.sse) { + if (sendSse(state, res, payload)) delivered++; + } + if (delivered === 0) { + throw new CanvasError("iframe_not_connected", "All iframe connections were stale; no exports were dispatched."); + } + return { ok: true, count: slugs.length, delivered }; + }, + }, + ], + open: async (ctx) => { + const state = ensureInstanceState(ctx.instanceId); + if (ctx.input && typeof ctx.input === "object") { + if (typeof ctx.input.loadSlug === "string") { + const loadScope = isScope(ctx.input.loadScope) ? ctx.input.loadScope : undefined; + const doc = await readMockup(ctx.input.loadSlug, loadScope); + if (doc) { + state.content = typeof doc.content === "string" ? doc.content : state.content; + if (doc.options && typeof doc.options === "object") { + state.options = { ...state.options, ...doc.options }; + } + } + } + if (typeof ctx.input.content === "string") state.content = ctx.input.content; + if (ctx.input.options && typeof ctx.input.options === "object") { + state.options = { ...state.options, ...ctx.input.options }; + } + } + let entry = state.server; + if (!entry) { + entry = await startServer(ctx.instanceId); + state.server = entry; + } + pushUpdate(ctx.instanceId); + return { title: "Terminal mockup", url: entry.url }; + }, + onClose: async (ctx) => { + const state = instances.get(ctx.instanceId); + if (!state) return; + for (const res of state.sse) { + try { res.end(); } catch {} + } + state.sse.clear(); + if (state.server) { + state.server.server.closeAllConnections?.(); + await new Promise((resolve) => state.server.server.close(() => resolve())); + } + instances.delete(ctx.instanceId); + }, + }), + ], +}); diff --git a/.github/extensions/terminal-mockup/library/discussion-list-monas-cafe.json b/.github/extensions/terminal-mockup/library/discussion-list-monas-cafe.json new file mode 100644 index 00000000000..4841e466bb0 --- /dev/null +++ b/.github/extensions/terminal-mockup/library/discussion-list-monas-cafe.json @@ -0,0 +1,14 @@ +{ + "name": "Discussion list - Mona's Cafe", + "savedAt": "2026-06-06T19:28:53.327Z", + "content": "[muted]$[/muted] [b]gh discussion list --repo monalisa/monas-cafe --limit 6[/b]\n\nShowing 6 of 87 open discussions in [b]monalisa/monas-cafe[/b]\n\n[dim][u]ID [/u] [u]TITLE [/u] [u]CATEGORY [/u] [u]LABELS [/u] [u]ANSWERED[/u] [u]UPDATED [/u][/dim]\n[brgreen]#87[/brgreen] Sign-in flow desig... Q&A [brblue]Enhancement[/brblue] ✓ [muted]about 2 days ago[/muted]\n[brgreen]#82[/brgreen] Show and tell: lat... Show and tell [muted]about 4 days ago[/muted]\n[brgreen]#78[/brgreen] Custom CSS hooks f... Ideas [brblue]Enhancement[/brblue] [muted]about 1 week ago[/muted]\n[brgreen]#71[/brgreen] Roadmap for Mona's... Announcements [muted]about 2 weeks ago[/muted]\n[brgreen]#64[/brgreen] Failing on Apple S... Q&A [brred]Bug[/brred] ✓ [muted]about 3 weeks ago[/muted]\n[brgreen]#55[/brgreen] Welcome new contri... General [muted]about 1 month ago[/muted]\n[muted]And 81 more[/muted]", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "none", + "backdrop": "none", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/discussion-view-monas-cafe.json b/.github/extensions/terminal-mockup/library/discussion-view-monas-cafe.json new file mode 100644 index 00000000000..4f5ff805bb9 --- /dev/null +++ b/.github/extensions/terminal-mockup/library/discussion-view-monas-cafe.json @@ -0,0 +1,14 @@ +{ + "name": "Discussion view - Mona's Cafe", + "savedAt": "2026-06-06T19:21:43.055Z", + "content": "[muted]$[/muted] [b]gh discussion view 87 --repo monalisa/monas-cafe[/b]\n[b]Sign-in flow design feedback[/b] [brblue]#87[/brblue]\n[brgreen]Open[/brgreen] [muted]·[/muted] Q&A [muted]·[/muted] Asked by Mona [muted]·[/muted] about 2 days ago [muted]·[/muted] 6 comments\n\n Finalizing the sign-in flow for [b]Mona's Cafe[/b] v2 and would love\n community feedback on the OAuth callback design and error states.\n\n\n[muted]View this discussion on GitHub: https://github.com/monalisa/monas-cafe/discussions/87[/muted]", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "none", + "backdrop": "none", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/issue-create-monas-cafe.json b/.github/extensions/terminal-mockup/library/issue-create-monas-cafe.json new file mode 100644 index 00000000000..127fbebde5f --- /dev/null +++ b/.github/extensions/terminal-mockup/library/issue-create-monas-cafe.json @@ -0,0 +1,14 @@ +{ + "name": "Issue create - Mona's Cafe", + "savedAt": "2026-06-06T19:21:43.055Z", + "content": "[muted]$[/muted] [b]gh issue create \\[/b]\n [b]--title \"Recalibrate coffee brewing algorithm\" \\[/b]\n [b]--type Task \\[/b]\n [b]--parent 119 \\[/b]\n [b]--blocked-by 134 \\[/b]\n [b]--blocking 152[/b]\n\nCreating issue in monalisa/monas-cafe\n\n[muted]https://github.com/monalisa/monas-cafe/issues/156[/muted]", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "none", + "backdrop": "none", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/issue-view-json-monas-cafe.json b/.github/extensions/terminal-mockup/library/issue-view-json-monas-cafe.json new file mode 100644 index 00000000000..ae27dee6053 --- /dev/null +++ b/.github/extensions/terminal-mockup/library/issue-view-json-monas-cafe.json @@ -0,0 +1,14 @@ +{ + "name": "Issue view --json - Mona's Cafe", + "savedAt": "2026-06-06T20:00:00.000Z", + "content": "[muted]$[/muted] [b]gh issue view 142 --repo monalisa/monas-cafe \\[/b]\n [b]--json number,title,state,issueType,parent,\\[/b]\n [b]subIssuesSummary,blockedBy,blocking[/b]\n[b][white]{[/white][/b]\n [b][blue]\"blockedBy\"[/blue][/b][b][white]:[/white][/b] [b][white]{[/white][/b]\n [b][blue]\"nodes\"[/blue][/b][b][white]:[/white][/b] [b][white][[/white][/b]\n [b][white]{[/white][/b]\n [b][blue]\"number\"[/blue][/b][b][white]:[/white][/b] 128[b][white],[/white][/b]\n [b][blue]\"state\"[/blue][/b][b][white]:[/white][/b] [green]\"OPEN\"[/green][b][white],[/white][/b]\n [b][blue]\"title\"[/blue][/b][b][white]:[/white][/b] [green]\"Provision staging OAuth app credentials\"[/green][b][white],[/white][/b]\n [b][blue]\"url\"[/blue][/b][b][white]:[/white][/b] [green]\"https://github.com/monalisa/monas-cafe/issues/128\"[/green]\n [b][white]}[/white][/b]\n [b][white]][/white][/b][b][white],[/white][/b]\n [b][blue]\"totalCount\"[/blue][/b][b][white]:[/white][/b] 1\n [b][white]}[/white][/b][b][white],[/white][/b]\n [b][blue]\"blocking\"[/blue][/b][b][white]:[/white][/b] [b][white]{[/white][/b]\n [b][blue]\"nodes\"[/blue][/b][b][white]:[/white][/b] [b][white][[/white][/b]\n [b][white]{[/white][/b]\n [b][blue]\"number\"[/blue][/b][b][white]:[/white][/b] 161[b][white],[/white][/b]\n [b][blue]\"state\"[/blue][/b][b][white]:[/white][/b] [green]\"OPEN\"[/green][b][white],[/white][/b]\n [b][blue]\"title\"[/blue][/b][b][white]:[/white][/b] [green]\"Enable per-user order history sync\"[/green][b][white],[/white][/b]\n [b][blue]\"url\"[/blue][/b][b][white]:[/white][/b] [green]\"https://github.com/monalisa/monas-cafe/issues/161\"[/green]\n [b][white]}[/white][/b]\n [b][white]][/white][/b][b][white],[/white][/b]\n [b][blue]\"totalCount\"[/blue][/b][b][white]:[/white][/b] 1\n [b][white]}[/white][/b][b][white],[/white][/b]\n [b][blue]\"issueType\"[/blue][/b][b][white]:[/white][/b] [b][white]{[/white][/b]\n [b][blue]\"color\"[/blue][/b][b][white]:[/white][/b] [green]\"BLUE\"[/green][b][white],[/white][/b]\n [b][blue]\"description\"[/blue][/b][b][white]:[/white][/b] [green]\"New capability or enhancement\"[/green][b][white],[/white][/b]\n [b][blue]\"id\"[/blue][/b][b][white]:[/white][/b] [green]\"IT_example_feature_type_id\"[/green][b][white],[/white][/b]\n [b][blue]\"name\"[/blue][/b][b][white]:[/white][/b] [green]\"Feature\"[/green]\n [b][white]}[/white][/b][b][white],[/white][/b]\n [b][blue]\"number\"[/blue][/b][b][white]:[/white][/b] 142[b][white],[/white][/b]\n [b][blue]\"parent\"[/blue][/b][b][white]:[/white][/b] [b][white]{[/white][/b]\n [b][blue]\"number\"[/blue][/b][b][white]:[/white][/b] 119[b][white],[/white][/b]\n [b][blue]\"repository\"[/blue][/b][b][white]:[/white][/b] [b][white]{[/white][/b]\n [b][blue]\"nameWithOwner\"[/blue][/b][b][white]:[/white][/b] [green]\"monalisa/monas-cafe\"[/green]\n [b][white]}[/white][/b][b][white],[/white][/b]\n [b][blue]\"state\"[/blue][/b][b][white]:[/white][/b] [green]\"OPEN\"[/green][b][white],[/white][/b]\n [b][blue]\"title\"[/blue][/b][b][white]:[/white][/b] [green]\"Mona's Cafe v2 launch\"[/green][b][white],[/white][/b]\n [b][blue]\"url\"[/blue][/b][b][white]:[/white][/b] [green]\"https://github.com/monalisa/monas-cafe/issues/119\"[/green]\n [b][white]}[/white][/b][b][white],[/white][/b]\n [b][blue]\"state\"[/blue][/b][b][white]:[/white][/b] [green]\"OPEN\"[/green][b][white],[/white][/b]\n [b][blue]\"subIssuesSummary\"[/blue][/b][b][white]:[/white][/b] [b][white]{[/white][/b]\n [b][blue]\"completed\"[/blue][/b][b][white]:[/white][/b] 1[b][white],[/white][/b]\n [b][blue]\"percentCompleted\"[/blue][/b][b][white]:[/white][/b] 50[b][white],[/white][/b]\n [b][blue]\"total\"[/blue][/b][b][white]:[/white][/b] 2\n [b][white]}[/white][/b][b][white],[/white][/b]\n [b][blue]\"title\"[/blue][/b][b][white]:[/white][/b] [green]\"Ship GitHub sign-in for Mona's Cafe v2\"[/green]\n[b][white]}[/white][/b]", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "none", + "backdrop": "none", + "bodyGradient": false, + "autoStyle": false + } +} diff --git a/.github/extensions/terminal-mockup/library/issue-view-monas-cafe.json b/.github/extensions/terminal-mockup/library/issue-view-monas-cafe.json new file mode 100644 index 00000000000..d89b86325c5 --- /dev/null +++ b/.github/extensions/terminal-mockup/library/issue-view-monas-cafe.json @@ -0,0 +1,14 @@ +{ + "name": "Issue view - Mona's Cafe", + "savedAt": "2026-06-06T19:23:22.273Z", + "content": "[muted]$[/muted] [b]gh issue view 142[/b]\n[b]Ship GitHub sign-in for Mona's Cafe v2[/b] monalisa/monas-cafe#142\n[brgreen]Open[/brgreen] [muted]•[/muted] monalisa (Mona Lisa) opened about 2 hours ago [muted]•[/muted] 4 comments\n[b]Blocked by:[/b] monalisa/monas-cafe#128 Provision staging OAuth app credentials\n[b]Blocking:[/b] monalisa/monas-cafe#161 Enable per-user order history sync\n\n Let people sign in to [b]Mona's Cafe[/b] with their GitHub account 🚀\n\n\n[b]Sub-issues[/b] [muted]·[/muted] 1/2 (50%)\n[magenta]Closed[/magenta] monalisa/monas-cafe#137 Implement OAuth callback handler\n[brgreen]Open[/brgreen] monalisa/monas-cafe#145 Add sign-in button to landing page\n\n[muted]View this issue on GitHub: https://github.com/monalisa/monas-cafe/issues/142[/muted]", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "none", + "backdrop": "none", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/sample-issue-list.json b/.github/extensions/terminal-mockup/library/sample-issue-list.json new file mode 100644 index 00000000000..1401e619814 --- /dev/null +++ b/.github/extensions/terminal-mockup/library/sample-issue-list.json @@ -0,0 +1,14 @@ +{ + "name": "Sample: gh issue list", + "savedAt": "2026-06-06T19:17:45.740Z", + "content": "[muted]$[/muted] [b]gh issue list --label bug[/b]\n\nShowing 3 of 3 issues in [b]monalisa/my-project[/b] that match the search query\n\n[brgreen]#214[/brgreen] [b]Crash when token expires during long-running request[/b] [muted]bug, priority:high[/muted] 2h\n[brgreen]#198[/brgreen] [b]Incorrect error message on rate limit[/b] [muted]bug[/muted] 1d\n[brgreen]#191[/brgreen] [b]README example fails on Windows[/b] [muted]bug, docs[/muted] 3d", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "macos", + "backdrop": "grid", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/sample-pr-list.json b/.github/extensions/terminal-mockup/library/sample-pr-list.json new file mode 100644 index 00000000000..1a6253c5990 --- /dev/null +++ b/.github/extensions/terminal-mockup/library/sample-pr-list.json @@ -0,0 +1,14 @@ +{ + "name": "Sample: gh pr list", + "savedAt": "2026-06-06T19:17:45.739Z", + "content": "[muted]$[/muted] [b]gh pr list[/b]\n\nShowing 4 of 4 open pull requests in [b]monalisa/my-project[/b]\n\n[brgreen]#142[/brgreen] [b]Add support for OIDC tokens[/b] feature/oidc-tokens about 1 hour ago\n[brgreen]#138[/brgreen] [b]Fix race condition in token refresh[/b] fix/token-race about 3 hours ago\n[brgreen]#135[/brgreen] [b]Bump dependencies to latest[/b] chore/bump-deps yesterday\n[brgreen]#129[/brgreen] [b]Refactor http client error handling[/b] refactor/http-errors 2 days ago", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "macos", + "backdrop": "grid", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/sample-pr-view-comments.json b/.github/extensions/terminal-mockup/library/sample-pr-view-comments.json new file mode 100644 index 00000000000..a0155b7e6ed --- /dev/null +++ b/.github/extensions/terminal-mockup/library/sample-pr-view-comments.json @@ -0,0 +1,14 @@ +{ + "name": "Sample: gh pr view --comments", + "savedAt": "2026-06-06T19:17:45.738Z", + "content": "[muted]$[/muted] [b]gh pr edit --add-reviewer @copilot[/b]\nhttps://github.com/monalisa/my-project/pull/111\n\n[muted]...[/muted]\n\n[muted]$[/muted] [b]gh pr view --comments[/b]\n[b]Add new feature[/b] [muted]monalisa/my-project#111[/muted]\n[muted]Draft[/muted] • Copilot (AI) wants to merge 2 commits into main from feature-branch • [muted]about 2 hours ago[/muted]\n[brgreen]+47[/brgreen] [brred]-0[/brred] • [muted]No checks[/muted]\n[b]Reviewers:[/b] Copilot (AI) (Commented)\n[b]Assignees:[/b] MonaLisa (Mona Lisa), Copilot (AI)\n\n [muted]...[/muted]\n\n[b]Copilot (AI)[/b] commented • [b]3m[/b] • [link]Newest comment[/link]\n\n [muted]...[/muted]\n\n[muted]View this pull request on GitHub: https://github.com/monalisa/my-project/pull/111[/muted]", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "macos", + "backdrop": "grid", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/sample-repo-view.json b/.github/extensions/terminal-mockup/library/sample-repo-view.json new file mode 100644 index 00000000000..d1cb4eff7bb --- /dev/null +++ b/.github/extensions/terminal-mockup/library/sample-repo-view.json @@ -0,0 +1,14 @@ +{ + "name": "Sample: gh repo view", + "savedAt": "2026-06-06T19:17:45.741Z", + "content": "[muted]$[/muted] [b]gh repo view monalisa/my-project[/b]\n[b]monalisa/my-project[/b]\nA delightful little project for delightful little tasks.\n\n Built with care by [b]MonaLisa[/b] and 12 contributors.\n Licensed under [b]MIT[/b].\n\n[b]Languages:[/b] Go (78.4%) • TypeScript (14.2%) • Shell (7.4%)\n[b]Stars:[/b] 1,247\n[b]Watchers:[/b] 38\n[b]Forks:[/b] 92\n[b]Open issues:[/b] 21\n[b]Open PRs:[/b] 4\n\n[muted]View this repository on GitHub: https://github.com/monalisa/my-project[/muted]", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "macos", + "backdrop": "grid", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file diff --git a/.github/extensions/terminal-mockup/library/sample-run-watch.json b/.github/extensions/terminal-mockup/library/sample-run-watch.json new file mode 100644 index 00000000000..9d09df157c1 --- /dev/null +++ b/.github/extensions/terminal-mockup/library/sample-run-watch.json @@ -0,0 +1,14 @@ +{ + "name": "Sample: gh run watch", + "savedAt": "2026-06-06T19:17:45.741Z", + "content": "[muted]$[/muted] [b]gh run watch[/b]\n\nRefreshing run status every 3 seconds. Press Ctrl+C to quit.\n\n[brgreen]✓[/brgreen] trunk CI · [muted]4815162342[/muted]\nTriggered via push about 1 minute ago\n\n[brgreen]JOBS[/brgreen]\n[brgreen]✓[/brgreen] lint in 12s ([link]ID 8675309001[/link])\n[brgreen]✓[/brgreen] test (ubuntu-latest) in 1m4s ([link]ID 8675309002[/link])\n[brgreen]✓[/brgreen] test (macos-latest) in 1m22s ([link]ID 8675309003[/link])\n[brgreen]✓[/brgreen] test (windows-latest) in 1m41s ([link]ID 8675309004[/link])\n[brgreen]✓[/brgreen] build in 38s ([link]ID 8675309005[/link])\n\n[brgreen]✓[/brgreen] Run trunk CI completed with 'success'", + "options": { + "font": "menlo", + "fontSize": 14, + "width": 800, + "chrome": "macos", + "backdrop": "grid", + "bodyGradient": false, + "autoStyle": true + } +} \ No newline at end of file