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>
536 lines
16 KiB
Go
536 lines
16 KiB
Go
package preview
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"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"
|
|
"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"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type PreviewOptions struct {
|
|
IO *iostreams.IOStreams
|
|
Telemetry ghtelemetry.EventRecorder
|
|
HttpClient func() (*http.Client, error)
|
|
Prompter prompter.Prompter
|
|
ExecutablePath string
|
|
RenderFile func(string, string) string
|
|
|
|
RepoArg string
|
|
SkillName string
|
|
Version string // resolved from @suffix on SkillName
|
|
AllowHiddenDirs bool // include skills in dot-prefixed directories
|
|
|
|
repo ghrepo.Interface
|
|
}
|
|
|
|
// NewCmdPreview creates the "skills preview" 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,
|
|
}
|
|
opts.RenderFile = func(filePath, content string) string {
|
|
return renderMarkdownPreview(opts.IO, filePath, content)
|
|
}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "preview <repository> [<skill>]",
|
|
Short: "Preview a skill from a GitHub repository (preview)",
|
|
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 %[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.
|
|
|
|
When run with only a repository argument, lists available skills and
|
|
prompts for selection.
|
|
|
|
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
|
|
|
|
# Preview a skill at a specific version
|
|
$ 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 documentation-writer@abc123def456
|
|
|
|
# Browse and preview interactively
|
|
$ gh skill 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]
|
|
}
|
|
|
|
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
|
|
}
|
|
opts.repo = repo
|
|
|
|
if runF != nil {
|
|
return runF(opts)
|
|
}
|
|
return previewRun(opts)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVar(&opts.AllowHiddenDirs, "allow-hidden-dirs", false, "Include skills in hidden directories (e.g. .claude/skills/, .agents/skills/)")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func previewRun(opts *PreviewOptions) error {
|
|
cs := opts.IO.ColorScheme()
|
|
|
|
repo := opts.repo
|
|
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 {
|
|
return err
|
|
}
|
|
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()
|
|
if err != nil {
|
|
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
|
|
}
|
|
|
|
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()
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
rendered := opts.renderFile("SKILL.md", 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 {
|
|
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)
|
|
}
|
|
|
|
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) {
|
|
|
|
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.Muted(fmt.Sprintf("(skipped remaining files, showing first %d)", maxFiles)))
|
|
break
|
|
}
|
|
if totalBytes+f.Size > maxTotalBytes {
|
|
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.Muted("(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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
|
|
// 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 // 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 = renderSelectedFilePreview(opts, selectedFile.Path, 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 (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
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
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
|
|
}
|
|
}
|
|
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.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.Muted(connector), node.name)
|
|
}
|
|
}
|
|
}
|