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 []", 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) } } }