Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/docs-noob-tester.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/src/content/docs/reference/aw-yml-package-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ The package root is the folder that contains `aw.yml`.
| `min-version` | string | No | Minimum compatible `gh aw` version in `vMAJOR.minor.patch` form, such as `v0.38.0`. |
| `name` | string | Yes | Human-readable package name. Must be non-empty after trimming whitespace. |
| `emoji` | string | No | Optional package emoji for display in package metadata. |
| `icon` | string | No | Optional package icon: an emoji, a GitHub primer octicon name in `:name:` format (e.g. `:check-circle:`), or a package resource path to an SVG file. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Good addition! Consider adding an example value to make the icon format clearer for end users. Something like: emoji: "🚀" or octicon: ":rocket:" or svg: "assets/icon.svg".

| `description` | string | No | Optional package description. `gh aw add` warns when it exceeds 255 characters. |
| `private` | boolean | No | Marks the package as unavailable for installation. Defaults to `false`; `gh aw add` refuses packages set to `true`. |
| `experimental` | boolean | No | Marks the package as experimental. Defaults to `false`; `gh aw add` displays a warning when set to `true`. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ The manifest document MUST be a YAML mapping. Unknown top-level fields MUST be r
| `min-version` | string | No | Minimum supported `gh-aw` version. |
| `name` | string | Yes | Human-readable package name. |
| `emoji` | string | No | Optional package emoji for display in package metadata. |
| `icon` | string | No | Optional package icon: an emoji, a GitHub primer octicon name (`:...:`), or an SVG resource path. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📝 The icon field specification looks good! It would be helpful to mention validation rules — for example, does the SVG path need to be relative to the package root? Clarifying constraints in the spec would prevent implementation ambiguity.

| `description` | string | No | Human-readable package description. |
| `license` | string | No | SPDX license identifier or license name for the package. |
| `private` | boolean | No | Whether the package is unavailable for installation. Defaults to `false`. |
Expand Down Expand Up @@ -75,6 +76,14 @@ If the running compiler version is lower than `min-version`, validation MUST fai

If present, `emoji` MUST be a string.

### 4.5.1 `icon`

If present, `icon` MUST be a non-empty string that matches one of the following formats:

1. **Emoji**: A single or sequence of Unicode emoji characters.
2. **GitHub Primer Octicon**: A GitHub Primer octicon name enclosed in colons using `:name:` format (for example, `:check-circle:`).
3. **SVG Package Resource**: A path to an `.svg` file that is declared as a package resource in the `resources` section of `aw.yml`.

### 4.6 `description`

If present, `description` MUST be a string.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ require (
github.com/goccy/go-yaml v1.19.2
github.com/google/jsonschema-go v0.4.3
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/rivo/uniseg v0.4.7
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
github.com/sourcegraph/conc v0.3.0
github.com/spf13/cobra v1.10.2
Expand Down Expand Up @@ -88,7 +89,6 @@ require (
github.com/openai/openai-go/v3 v3.52.0 // indirect
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/rhysd/actionlint v1.7.12 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/securego/gosec/v2 v2.29.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
Expand Down
1 change: 1 addition & 0 deletions pkg/cli/add_package_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ type resolvedRepositoryPackage struct {
ResolvedRef string
Name string
Emoji string
Icon string
Description string
License string
Private bool
Expand Down
143 changes: 143 additions & 0 deletions pkg/cli/add_package_manifest_parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ package cli

import (
"fmt"
"path"
"path/filepath"
"regexp"
"strings"
"unicode"

"github.com/rivo/uniseg"

"github.com/goccy/go-yaml"

Expand All @@ -19,6 +24,7 @@ type repositoryPackageManifest struct {
MinVersion string
Name string
Emoji string
Icon string
Description string
License string
Private bool
Expand Down Expand Up @@ -152,6 +158,9 @@ func populateRepositoryPackageManifestMetadata(manifest *repositoryPackageManife
}
manifest.Resources = resources
}
if err := extractRepositoryPackageManifestIcon(manifest, root, manifestPath); err != nil {
return nil, err
}
if skillsValue, ok := root["skills"]; ok {
skills, skillWarnings := extractManifestSkillDirs(skillsValue, manifestPath)
manifest.Skills = skills
Expand All @@ -173,6 +182,21 @@ func populateRepositoryPackageManifestMetadata(manifest *repositoryPackageManife
return warnings, nil
}

func extractRepositoryPackageManifestIcon(manifest *repositoryPackageManifest, root map[string]any, manifestPath string) error {
if iconVal, ok := root["icon"]; ok {
icon, isStr := stringValue(iconVal)
if !isStr {
return fmt.Errorf("invalid Agentic Workflow manifest %q: icon must be a string", manifestPath)
}
icon = strings.TrimSpace(icon)
manifest.Icon = icon
if err := validateRepositoryPackageManifestIcon(icon, manifest.Resources, manifestPath); err != nil {
return err
}
}
return nil
}

func validateRepositoryPackageVisibility(manifest *repositoryPackageManifest, packageID string) ([]string, error) {
if manifest.Private {
return nil, fmt.Errorf("package %q is private and cannot be added", packageID)
Expand Down Expand Up @@ -255,3 +279,122 @@ func validateUniqueManifestWorkflowFilenames(installables []resolvedPackageInsta
}
return nil
}

var octiconNameRegexp = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)

func validateRepositoryPackageManifestIcon(icon string, resources []repositoryPackageResource, manifestPath string) error {
iconStr := strings.TrimSpace(icon)
if iconStr == "" {
return fmt.Errorf("invalid Agentic Workflow manifest %q: icon must be a non-empty string", manifestPath)
}

// 1. GitHub primer octicon (:name: syntax)
if strings.HasPrefix(iconStr, ":") && strings.HasSuffix(iconStr, ":") {
inner := strings.TrimPrefix(strings.TrimSuffix(iconStr, ":"), ":")
if !octiconNameRegexp.MatchString(inner) {
return fmt.Errorf("invalid Agentic Workflow manifest %q: icon octicon name must use :name: syntax with lowercase letters, numbers, and hyphens, got %q", manifestPath, iconStr)
}
return nil
}

// 2. Emoji
if isEmojiString(iconStr) {
return nil
}

// 3. Package resource location (SVG only)
if isResourcePathMatch(iconStr, resources) {
if !strings.HasSuffix(strings.ToLower(iconStr), ".svg") {
return fmt.Errorf("invalid Agentic Workflow manifest %q: icon file %q in package resources must be an SVG file (.svg)", manifestPath, iconStr)
}
return nil
}

// Look like a path or SVG file but not in resources?
if strings.HasSuffix(strings.ToLower(iconStr), ".svg") || strings.Contains(iconStr, "/") {
return fmt.Errorf("invalid Agentic Workflow manifest %q: icon file %q must be declared in package resources", manifestPath, iconStr)
}

return fmt.Errorf("invalid Agentic Workflow manifest %q: icon %q is invalid: must be an emoji, a GitHub primer octicon name (e.g. :check-circle:), or an SVG file declared in package resources", manifestPath, iconStr)
}

func isResourcePathMatch(iconPath string, resources []repositoryPackageResource) bool {
cleanedIcon, err := cleanManifestRelativePath(iconPath)
if err != nil {
cleanedIcon = path.Clean(filepath.ToSlash(iconPath))
}
lowerIcon := strings.ToLower(cleanedIcon)
for _, res := range resources {
cleanSource, err1 := cleanManifestRelativePath(res.Source)
if err1 != nil {
cleanSource = path.Clean(filepath.ToSlash(res.Source))
}
cleanDest, err2 := cleanManifestRelativePath(res.Destination)
if err2 != nil {
cleanDest = path.Clean(filepath.ToSlash(res.Destination))
}
if lowerIcon == strings.ToLower(cleanSource) || lowerIcon == strings.ToLower(cleanDest) {
return true
}
}
return false
}

func isEmojiString(s string) bool {
if s == "" {
return false
}
graphemes := uniseg.NewGraphemes(s)
count := 0
for graphemes.Next() {
if !isEmojiGrapheme(graphemes.Runes()) {
return false
}
count++
}
return count > 0
}

func isEmojiGrapheme(runes []rune) bool {
if len(runes) == 0 {
return false
}
if len(runes) == 2 && isRegionalIndicator(runes[0]) && isRegionalIndicator(runes[1]) {
return true
}
if (runes[0] == '#' || runes[0] == '*' || unicode.IsDigit(runes[0])) &&
len(runes) >= 2 && runes[len(runes)-1] == '\u20e3' {
return len(runes) == 2 || (len(runes) == 3 && runes[1] == '\ufe0f')
}

expectBase := true
hasBase := false
for _, r := range runes {
switch {
case expectBase && isEmojiBase(r):
expectBase = false
hasBase = true
case !expectBase && (r == '\ufe0f' || isEmojiModifier(r) || unicode.Is(unicode.M, r)):
case !expectBase && r == '\u200d':
expectBase = true
default:
return false
}
}
return hasBase && !expectBase
}

func isEmojiBase(r rune) bool {
return (r >= 0x1f000 && r <= 0x1faff) ||
(r >= 0x2300 && r <= 0x23ff) ||
(r >= 0x2600 && r <= 0x27bf) ||
r == 0x00a9 || r == 0x00ae || r == 0x203c || r == 0x2049
}

func isEmojiModifier(r rune) bool {
return r >= 0x1f3fb && r <= 0x1f3ff
}

func isRegionalIndicator(r rune) bool {
return r >= 0x1f1e6 && r <= 0x1f1ff
}
1 change: 1 addition & 0 deletions pkg/cli/add_package_manifest_resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ func newResolvedRepositoryPackage(manifestPath, ref, docsPath string, manifest *
ResolvedRef: ref,
Name: manifest.Name,
Emoji: manifest.Emoji,
Icon: manifest.Icon,
Description: manifest.Description,
License: manifest.License,
Private: manifest.Private,
Expand Down
126 changes: 126 additions & 0 deletions pkg/cli/add_package_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -57,6 +59,117 @@ private: "true"
})
}

func TestRepositoryPackageIcon(t *testing.T) {
t.Run("omitted icon field", func(t *testing.T) {
manifest, warnings, err := parseRepositoryPackageManifest("aw.yml", []byte("name: Public Package\n"))
require.NoError(t, err)
assert.Empty(t, manifest.Icon)
assert.Empty(t, warnings)
})

t.Run("valid emoji icon", func(t *testing.T) {
manifest, _, err := parseRepositoryPackageManifest("aw.yml", []byte("name: Emoji Package\nicon: \"🚀\"\n"))
require.NoError(t, err)
assert.Equal(t, "🚀", manifest.Icon)
})
t.Run("rejects invalid emoji sequences", func(t *testing.T) {
for _, icon := range []string{"\u200d", "\ufe0f", "🚀123", "123"} {
_, _, err := parseRepositoryPackageManifest("aw.yml", []byte("name: Invalid Package\nicon: \""+icon+"\"\n"))
require.Error(t, err, icon)
}
})
t.Run("trims icon value", func(t *testing.T) {
manifest, _, err := parseRepositoryPackageManifest("aw.yml", []byte("name: Emoji Package\nicon: \" 🚀 \"\n"))
require.NoError(t, err)
assert.Equal(t, "🚀", manifest.Icon)
})

t.Run("valid primer octicon syntax", func(t *testing.T) {
manifest, _, err := parseRepositoryPackageManifest("aw.yml", []byte("name: Octicon Package\nicon: \":check-circle:\"\n"))
require.NoError(t, err)
assert.Equal(t, ":check-circle:", manifest.Icon)

manifest, _, err = parseRepositoryPackageManifest("aw.yml", []byte("name: Octicon Package\nicon: \":git-pull-request:\"\n"))
require.NoError(t, err)
assert.Equal(t, ":git-pull-request:", manifest.Icon)
})

t.Run("invalid octicon syntax", func(t *testing.T) {
_, _, err := parseRepositoryPackageManifest("aw.yml", []byte("name: Invalid Package\nicon: \"::\"\n"))
require.ErrorContains(t, err, "icon octicon name must use :name: syntax")

_, _, err = parseRepositoryPackageManifest("aw.yml", []byte("name: Invalid Package\nicon: \":invalid name:\"\n"))
require.ErrorContains(t, err, "icon octicon name must use :name: syntax")
})

t.Run("valid SVG resource file matching source", func(t *testing.T) {
yamlContent := `name: Resource Package
resources:
- source: assets/logo.svg
destination: .github/aw/logo.svg
icon: assets/logo.svg
`
manifest, _, err := parseRepositoryPackageManifest("aw.yml", []byte(yamlContent))
require.NoError(t, err)
assert.Equal(t, "assets/logo.svg", manifest.Icon)
})

t.Run("valid SVG resource file matching destination", func(t *testing.T) {
yamlContent := `name: Resource Package
resources:
- source: assets/logo.svg
destination: .github/aw/logo.svg
icon: .github/aw/logo.svg
`
manifest, _, err := parseRepositoryPackageManifest("aw.yml", []byte(yamlContent))
require.NoError(t, err)
assert.Equal(t, ".github/aw/logo.svg", manifest.Icon)
})

t.Run("non-SVG resource file", func(t *testing.T) {
yamlContent := `name: Non SVG Package
resources:
- source: assets/logo.png
destination: .github/aw/logo.png
icon: assets/logo.png
`
_, _, err := parseRepositoryPackageManifest("aw.yml", []byte(yamlContent))
require.ErrorContains(t, err, `icon file "assets/logo.png" in package resources must be an SVG file (.svg)`)
})

t.Run("SVG icon file not declared in package resources", func(t *testing.T) {
yamlContent := `name: Undeclared Package
icon: assets/missing.svg
`
_, _, err := parseRepositoryPackageManifest("aw.yml", []byte(yamlContent))
require.ErrorContains(t, err, `icon file "assets/missing.svg" must be declared in package resources`)
})

t.Run("invalid plain text icon", func(t *testing.T) {
yamlContent := `name: Invalid Icon Package
icon: plain_text_icon
`
_, _, err := parseRepositoryPackageManifest("aw.yml", []byte(yamlContent))
require.ErrorContains(t, err, `icon "plain_text_icon" is invalid: must be an emoji, a GitHub primer octicon name (e.g. :check-circle:), or an SVG file declared in package resources`)
})

t.Run("empty icon string", func(t *testing.T) {
yamlContent := `name: Empty Icon Package
icon: ""
`
_, _, err := parseRepositoryPackageManifest("aw.yml", []byte(yamlContent))
require.ErrorContains(t, err, `icon must be a non-empty string`)
})

t.Run("non-string icon value", func(t *testing.T) {
yamlContent := `name: Non String Icon Package
icon: 123
`
_, _, err := parseRepositoryPackageManifest("aw.yml", []byte(yamlContent))
require.Error(t, err)
})
}

func TestResolveRepositoryPackage(t *testing.T) {
originalVersion := GetVersion()
originalDownload := downloadPackageFileFromGitHubForHost
Expand Down Expand Up @@ -890,6 +1003,19 @@ files:
})
}

func TestResolveLocalRepositoryPackagePreservesIcon(t *testing.T) {
packageDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(packageDir, "aw.yml"), []byte("name: Local Package\nicon: \"📦\"\n"), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(packageDir, "README.md"), []byte("# Local Package\n"), 0o644))
require.NoError(t, os.Mkdir(filepath.Join(packageDir, "workflows"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(packageDir, "workflows", "review.md"), []byte("---\non: issues\n---\n# Review\n"), 0o644))

pkg, err := resolveLocalRepositoryPackage(packageDir)
require.NoError(t, err)
require.NotNil(t, pkg)
assert.Equal(t, "📦", pkg.Icon)
}

func TestResolveWorkflows_RepositoryPackage(t *testing.T) {
originalFetchFn := fetchWorkflowFromSourceWithContextFn
originalDownload := downloadPackageFileFromGitHubForHost
Expand Down
Loading
Loading