diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index f06d4c182..ce7c49ef2 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 4c3864809..0cbea6ae7 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)