Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d9fab03
add skill list command
tommaso-moro May 11, 2026
3216f18
Merge branch 'trunk' into tommy/skills-list
tommaso-moro May 13, 2026
216b7cf
fix linting
tommaso-moro May 13, 2026
1fac121
fix tests
tommaso-moro May 13, 2026
2a98757
fix test
tommaso-moro May 18, 2026
46c5aa7
Merge branch 'trunk' into tommy/skills-list
tommaso-moro May 18, 2026
d69921c
Merge branch 'trunk' into tommy/skills-list
tommaso-moro May 18, 2026
f311024
Merge branch 'trunk' into tommy/skills-list
tommaso-moro May 21, 2026
88b0c51
address bagtoad's feedback
tommaso-moro May 21, 2026
48ef6eb
handle skills in skills/ folder when running list command, by marking…
tommaso-moro May 22, 2026
958c063
Merge branch 'trunk' into tommy/skills-list
tommaso-moro May 22, 2026
4453f27
Merge branch 'trunk' into tommy/skills-list
tommaso-moro May 22, 2026
6bc74f8
Merge branch 'trunk' into tommy/skills-list
tommaso-moro May 22, 2026
ecdbd6f
Sanitize terminal control characters in skill list output
BagToad May 28, 2026
989ee0c
Stat SKILL.md before reading in skill list
BagToad May 28, 2026
ea42d46
Rename hosts field and helpers to agentHosts in skill list
BagToad May 28, 2026
69e6ecc
Use github-copilot agent ID in skill list tests and help text
BagToad May 28, 2026
4d011e0
Merge branch 'trunk' into tommy/skills-list
tommaso-moro Jun 1, 2026
b5e729c
Merge branch 'trunk' into tommy/skills-list
tommaso-moro Jun 1, 2026
7ef9c54
Auto-install official extension stubs in CI (#13581)
BagToad Jun 4, 2026
0faf4b0
Clean up deferred issue update helper (#13584)
BagToad Jun 4, 2026
674e02a
Merge pull request #13418 from cli/tommy/skills-list
BagToad Jun 4, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@
env GH_TELEMETRY=log
env GH_TELEMETRY_SAMPLE_RATE=100

# Ensure CI auto-install behavior does not kick in for this test;
# we want the non-TTY "print install instructions and exit non-zero" path.
env CI=''
env BUILD_NUMBER=''
env RUN_ID=''

# `stack` is registered in extensions.OfficialExtensions. Since no real
# extension is installed, the hidden stub runs and, in a non-TTY session,
# prints install instructions without prompting.
exec gh stack
# extension is installed, the hidden stub runs and, in a non-TTY session
# outside CI, prints install instructions and exits non-zero.
! exec gh stack
stderr 'gh extension install github/gh-stack'

# The stub invocation records a command_invocation event for the stub's
Expand Down
8 changes: 4 additions & 4 deletions pkg/cmd/issue/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,7 @@ func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, i
var err error
typeID, err = issueShared.ResolveIssueTypeName(client, baseRepo, opts.IssueType)
if err != nil {
return updateOpts, err
return api.DeferredUpdateIssueOptions{}, err
}
}
updateOpts.IssueTypeID = typeID
Expand All @@ -458,23 +458,23 @@ func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, i
if opts.Parent != "" {
parentID, err := issueShared.ResolveIssueRef(client, baseRepo, opts.Parent)
if err != nil {
return updateOpts, fmt.Errorf("resolving --parent reference %q: %w", opts.Parent, err)
return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --parent reference %q: %w", opts.Parent, err)
}
updateOpts.ParentID = parentID
}

for _, ref := range opts.BlockedBy {
id, err := issueShared.ResolveIssueRef(client, baseRepo, ref)
if err != nil {
return updateOpts, fmt.Errorf("resolving --blocked-by reference %q: %w", ref, err)
return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --blocked-by reference %q: %w", ref, err)
}
updateOpts.AddBlockedByIDs = append(updateOpts.AddBlockedByIDs, id)
}

for _, ref := range opts.Blocking {
id, err := issueShared.ResolveIssueRef(client, baseRepo, ref)
if err != nil {
return updateOpts, fmt.Errorf("resolving --blocking reference %q: %w", ref, err)
return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --blocking reference %q: %w", ref, err)
}
updateOpts.AddBlockingIDs = append(updateOpts.AddBlockingIDs, id)
}
Expand Down
20 changes: 13 additions & 7 deletions pkg/cmd/issue/edit/edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
opts.Editable.IssueType.Edited = true
}

// hasDeferredFlags covers edit flags that flow through the
// deferred update path rather than the prShared.Editable struct,
// so they would otherwise be invisible to Editable.Dirty() below.
// Note that --type (set) is intentionally absent: it lights up
// opts.Editable.IssueType.Edited above, which Editable.Dirty()
// already picks up. Only --remove-type needs to be listed here.
hasDeferredFlags := opts.RemoveIssueType ||
flags.Changed("parent") || opts.RemoveParent ||
len(opts.AddSubIssues) > 0 || len(opts.RemoveSubIssues) > 0 ||
Expand Down Expand Up @@ -482,52 +488,52 @@ func deferredUpdateIssueOptions(client *api.Client, baseRepo ghrepo.Interface, i
} else if editOpts.Parent != "" {
parentID, err := issueShared.ResolveIssueRef(client, baseRepo, editOpts.Parent)
if err != nil {
return updateOpts, fmt.Errorf("resolving --parent reference %q: %w", editOpts.Parent, err)
return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --parent reference %q: %w", editOpts.Parent, err)
}
updateOpts.ParentID = parentID
}

for _, ref := range editOpts.AddSubIssues {
id, err := issueShared.ResolveIssueRef(client, baseRepo, ref)
if err != nil {
return updateOpts, fmt.Errorf("resolving --add-sub-issue reference %q: %w", ref, err)
return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --add-sub-issue reference %q: %w", ref, err)
}
updateOpts.AddSubIssueIDs = append(updateOpts.AddSubIssueIDs, id)
}
for _, ref := range editOpts.RemoveSubIssues {
id, err := issueShared.ResolveIssueRef(client, baseRepo, ref)
if err != nil {
return updateOpts, fmt.Errorf("resolving --remove-sub-issue reference %q: %w", ref, err)
return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --remove-sub-issue reference %q: %w", ref, err)
}
updateOpts.RemoveSubIssueIDs = append(updateOpts.RemoveSubIssueIDs, id)
}

for _, ref := range editOpts.AddBlockedBy {
id, err := issueShared.ResolveIssueRef(client, baseRepo, ref)
if err != nil {
return updateOpts, fmt.Errorf("resolving --add-blocked-by reference %q: %w", ref, err)
return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --add-blocked-by reference %q: %w", ref, err)
}
updateOpts.AddBlockedByIDs = append(updateOpts.AddBlockedByIDs, id)
}
for _, ref := range editOpts.RemoveBlockedBy {
id, err := issueShared.ResolveIssueRef(client, baseRepo, ref)
if err != nil {
return updateOpts, fmt.Errorf("resolving --remove-blocked-by reference %q: %w", ref, err)
return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --remove-blocked-by reference %q: %w", ref, err)
}
updateOpts.RemoveBlockedByIDs = append(updateOpts.RemoveBlockedByIDs, id)
}

for _, ref := range editOpts.AddBlocking {
id, err := issueShared.ResolveIssueRef(client, baseRepo, ref)
if err != nil {
return updateOpts, fmt.Errorf("resolving --add-blocking reference %q: %w", ref, err)
return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --add-blocking reference %q: %w", ref, err)
}
updateOpts.AddBlockingIDs = append(updateOpts.AddBlockingIDs, id)
}
for _, ref := range editOpts.RemoveBlocking {
id, err := issueShared.ResolveIssueRef(client, baseRepo, ref)
if err != nil {
return updateOpts, fmt.Errorf("resolving --remove-blocking reference %q: %w", ref, err)
return api.DeferredUpdateIssueOptions{}, fmt.Errorf("resolving --remove-blocking reference %q: %w", ref, err)
}
updateOpts.RemoveBlockingIDs = append(updateOpts.RemoveBlockingIDs, id)
}
Expand Down
42 changes: 23 additions & 19 deletions pkg/cmd/root/official_extension_stub.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"

"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/ci"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
Expand Down Expand Up @@ -38,25 +39,28 @@ func NewCmdOfficialExtensionStub(io *iostreams.IOStreams, p prompter.Prompter, e
func officialExtensionStubRun(io *iostreams.IOStreams, p prompter.Prompter, em extensions.ExtensionManager, ext *extensions.OfficialExtension) error {
stderr := io.ErrOut

if !io.CanPrompt() {
fmt.Fprint(stderr, heredoc.Docf(`
%[1]s is available as an official extension.
To install it, run:
gh extension install %[2]s/%[3]s
`, fmt.Sprintf("gh %s", ext.Name), ext.Owner, ext.Repo))
return nil
}

prompt := heredoc.Docf(`
%[1]s is available as an official extension.
Would you like to install it now?
`, fmt.Sprintf("gh %s", ext.Name))
confirmed, err := p.Confirm(prompt, true)
if err != nil {
return err
}
if !confirmed {
return nil
// In CI, skip the prompt so agents and CI runners don't block on Y/n.
if !ci.IsCI() {
if io.CanPrompt() {
prompt := heredoc.Docf(`
%[1]s is available as an official extension.
Would you like to install it now?
`, fmt.Sprintf("gh %s", ext.Name))
confirmed, err := p.Confirm(prompt, true)
if err != nil {
return err
}
if !confirmed {
return nil
}
} else {
fmt.Fprint(stderr, heredoc.Docf(`
%[1]s is available as an official extension.
To install it, run:
gh extension install %[2]s/%[3]s
`, fmt.Sprintf("gh %s", ext.Name), ext.Owner, ext.Repo))
return cmdutil.SilentError
}
}

repo := ext.Repository()
Expand Down
27 changes: 25 additions & 2 deletions pkg/cmd/root/official_extension_stub_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func TestOfficialExtensionStubRun(t *testing.T) {
tests := []struct {
name string
isTTY bool
ciEnv string
confirmResult bool
confirmErr error
installErr error
Expand All @@ -26,9 +27,17 @@ func TestOfficialExtensionStubRun(t *testing.T) {
wantInstalled bool
}{
{
name: "non-TTY prints install instructions",
name: "non-TTY in CI auto-installs without prompting",
isTTY: false,
ciEnv: "1",
wantStderr: "Successfully installed github/gh-cool",
wantInstalled: true,
},
{
name: "non-TTY outside CI prints install instructions and returns silent error",
isTTY: false,
wantStderr: "gh extension install github/gh-cool",
wantErr: "SilentError",
},
{
name: "TTY confirmed installs",
Expand All @@ -37,6 +46,13 @@ func TestOfficialExtensionStubRun(t *testing.T) {
wantStderr: "Successfully installed github/gh-cool",
wantInstalled: true,
},
{
name: "TTY in CI auto-installs without prompting",
isTTY: true,
ciEnv: "1",
wantStderr: "Successfully installed github/gh-cool",
wantInstalled: true,
},
{
name: "TTY declined does not install",
isTTY: true,
Expand All @@ -60,6 +76,13 @@ func TestOfficialExtensionStubRun(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("CI", "")
t.Setenv("BUILD_NUMBER", "")
t.Setenv("RUN_ID", "")
if tt.ciEnv != "" {
t.Setenv("CI", tt.ciEnv)
}

ios, _, _, stderr := iostreams.Test()
if tt.isTTY {
ios.SetStdinTTY(true)
Expand Down Expand Up @@ -97,7 +120,7 @@ func TestOfficialExtensionStubRun(t *testing.T) {
assert.Equal(t, "github", repo.RepoOwner())
assert.Equal(t, "gh-cool", repo.RepoName())
assert.Equal(t, "github.com", repo.RepoHost())
} else if tt.isTTY && !tt.confirmResult && tt.confirmErr == nil {
} else {
assert.Empty(t, em.InstallCalls())
}
})
Expand Down
Loading
Loading