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 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: 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..47978cf4dce --- /dev/null +++ b/acceptance/testdata/workflow/run-view-log-escape-sequences.txtar @@ -0,0 +1,70 @@ +# 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 +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/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", 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: diff --git a/go.mod b/go.mod index 78f0185df8a..92681afac7f 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,14 @@ module github.com/cli/cli/v2 -go 1.26.1 +go 1.26.0 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.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 @@ -27,9 +27,9 @@ 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.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 @@ -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.20 + 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 @@ -80,9 +80,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/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // 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 github.com/charmbracelet/x/exp/slice v0.0.0-20250630141444-821143405392 // indirect @@ -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 @@ -141,8 +141,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 @@ -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..9980facc36e 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,11 @@ 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.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/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= 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= @@ -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= @@ -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= @@ -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= @@ -377,18 +377,18 @@ 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= 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.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.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= @@ -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= @@ -602,7 +602,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= @@ -624,8 +623,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= 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/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/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index e27d35f5b46..005681cac54 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)) } } @@ -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/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/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")) 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"}, 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" diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index a22249225da..caaa7221490 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) } @@ -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()) @@ -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) @@ -1180,10 +1186,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 +1203,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/install/install_test.go b/pkg/cmd/skills/install/install_test.go index b7aa956c5a9..b126f1ac788 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", @@ -1177,7 +1173,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", @@ -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", }, { @@ -2141,11 +2130,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 +2172,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 +2195,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 { 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..3bc98fece56 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) }) } } @@ -403,7 +413,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) { @@ -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") + }) +} 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.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..c30eff0a7a1 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") }, }, { @@ -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", },