diff --git a/docs/install_linux.md b/docs/install_linux.md index 3d2f2980fec..ecfe0cdeb1a 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 updates 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/homebrew-core](https://github.com/Homebrew/homebrew-core/blob/main/Formula/g/gh.rb). To install: @@ -466,7 +466,7 @@ The [GitHub CLI package](https://webinstall.dev/gh/) is supported by the Webi co To install: ```shell -curl -sS https://webi.sh/gh \| sh +curl -sS https://webi.sh/gh | sh ``` To upgrade: diff --git a/docs/install_macos.md b/docs/install_macos.md index 14d68d96c6c..817c362dd69 100644 --- a/docs/install_macos.md +++ b/docs/install_macos.md @@ -6,7 +6,7 @@ [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/homebrew-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/homebrew-core](https://github.com/Homebrew/homebrew-core/blob/main/Formula/g/gh.rb). To install: @@ -113,7 +113,7 @@ The [GitHub CLI package](https://webinstall.dev/gh/) is supported by the Webi co To install: ```shell -curl -sS https://webi.sh/gh \| sh +curl -sS https://webi.sh/gh | sh ``` To upgrade: diff --git a/docs/install_windows.md b/docs/install_windows.md index 508afca3f16..ce5e3e6ce6a 100644 --- a/docs/install_windows.md +++ b/docs/install_windows.md @@ -68,7 +68,7 @@ conda update gh --channel conda-forge ### Scoop -The [GitHub CLI bucket](https://scoop.sh/#/apps?q=gh) is supported by the Scoop community with updated powered by [ScoopInstaller/Main](https://github.com/ScoopInstaller/Main/blob/master/bucket/gh.json). +The [GitHub CLI bucket](https://scoop.sh/#/apps?q=gh) is supported by the Scoop community with updates powered by [ScoopInstaller/Main](https://github.com/ScoopInstaller/Main/blob/master/bucket/gh.json). To install: @@ -91,7 +91,7 @@ The [GitHub CLI package](https://webinstall.dev/gh/) is supported by the Webi co To install: ```shell -curl -sS https://webi.sh/gh \| sh +curl -sS https://webi.sh/gh | sh ``` To upgrade: diff --git a/docs/primer/README.md b/docs/primer/README.md index 98625bbcedb..0de6a609bbc 100644 --- a/docs/primer/README.md +++ b/docs/primer/README.md @@ -4,7 +4,7 @@ These guidelines are a collection of principles, foundations and usage guideline ## [Components](components) -Design guidance on how we format content in in the Terminal through text formatting, color and font weights. +Design guidance on how we format content in the Terminal through text formatting, color and font weights. ## [Foundations](foundations) diff --git a/internal/codespaces/connection/connection.go b/internal/codespaces/connection/connection.go index 155e349bb8d..b56c1991d8e 100644 --- a/internal/codespaces/connection/connection.go +++ b/internal/codespaces/connection/connection.go @@ -30,6 +30,10 @@ type CodespaceConnection struct { Options *tunnels.TunnelRequestOptions Tunnel *tunnels.Tunnel AllowedPortPrivacySettings []string + + // ManagerMu serializes access to TunnelManager operations which mutate + // shared state on the Tunnel object and are not goroutine-safe. + ManagerMu sync.Mutex } // NewCodespaceConnection initializes a connection to a codespace. diff --git a/internal/codespaces/portforwarder/port_forwarder.go b/internal/codespaces/portforwarder/port_forwarder.go index 951ebdd8ea6..f682dc922c4 100644 --- a/internal/codespaces/portforwarder/port_forwarder.go +++ b/internal/codespaces/portforwarder/port_forwarder.go @@ -36,7 +36,7 @@ type ForwardPortOpts struct { } type CodespacesPortForwarder struct { - connection connection.CodespaceConnection + connection *connection.CodespaceConnection keepAliveReason chan string } @@ -54,7 +54,7 @@ type PortForwarder interface { // NewPortForwarder returns a new PortForwarder for the specified codespace. func NewPortForwarder(ctx context.Context, codespaceConnection *connection.CodespaceConnection) (fwd PortForwarder, err error) { return &CodespacesPortForwarder{ - connection: *codespaceConnection, + connection: codespaceConnection, keepAliveReason: make(chan string, 1), }, nil } @@ -108,6 +108,32 @@ func (fwd *CodespacesPortForwarder) ForwardPort(ctx context.Context, opts Forwar return fmt.Errorf("error converting port: %w", err) } + if err := fwd.createTunnelPort(ctx, port, opts); err != nil { + return err + } + + // Connect to the tunnel + err = fwd.connection.Connect(ctx) + if err != nil { + return fmt.Errorf("connect failed: %v", err) + } + + // Inform the host that we've forwarded the port locally + err = fwd.connection.TunnelClient.RefreshPorts(ctx) + if err != nil { + return fmt.Errorf("refresh ports failed: %v", err) + } + + return nil +} + +// createTunnelPort creates a tunnel port while holding the manager mutex. +// TunnelManager operations mutate shared state on the Tunnel object and are +// not goroutine-safe, so all calls are serialized under ManagerMu. +func (fwd *CodespacesPortForwarder) createTunnelPort(ctx context.Context, port uint16, opts ForwardPortOpts) error { + fwd.connection.ManagerMu.Lock() + defer fwd.connection.ManagerMu.Unlock() + // In v0.0.25 of dev-tunnels, the dev-tunnel manager `CreateTunnelPort` would "accept" requests that // change the port protocol but they would not result in any actual change. This has changed, resulting in // an error `Invalid arguments. The tunnel port protocol cannot be changed.`. It's not clear why the previous @@ -166,18 +192,6 @@ func (fwd *CodespacesPortForwarder) ForwardPort(ctx context.Context, opts Forwar return fmt.Errorf("create tunnel port failed: %v", err) } - // Connect to the tunnel - err = fwd.connection.Connect(ctx) - if err != nil { - return fmt.Errorf("connect failed: %v", err) - } - - // Inform the host that we've forwarded the port locally - err = fwd.connection.TunnelClient.RefreshPorts(ctx) - if err != nil { - return fmt.Errorf("refresh ports failed: %v", err) - } - return nil } @@ -243,6 +257,9 @@ func (fwd *CodespacesPortForwarder) ConnectToForwardedPort(ctx context.Context, // ListPorts fetches the list of ports that are currently forwarded. func (fwd *CodespacesPortForwarder) ListPorts(ctx context.Context) (ports []*tunnels.TunnelPort, err error) { + fwd.connection.ManagerMu.Lock() + defer fwd.connection.ManagerMu.Unlock() + ports, err = fwd.connection.TunnelManager.ListTunnelPorts(ctx, fwd.connection.Tunnel, fwd.connection.Options) if err != nil { return nil, fmt.Errorf("error listing ports: %w", err) @@ -253,22 +270,27 @@ func (fwd *CodespacesPortForwarder) ListPorts(ctx context.Context) (ports []*tun // UpdatePortVisibility changes the visibility (private, org, public) of the specified port. func (fwd *CodespacesPortForwarder) UpdatePortVisibility(ctx context.Context, remotePort int, visibility string) error { + fwd.connection.ManagerMu.Lock() tunnelPort, err := fwd.connection.TunnelManager.GetTunnelPort(ctx, fwd.connection.Tunnel, remotePort, fwd.connection.Options) if err != nil { + fwd.connection.ManagerMu.Unlock() return fmt.Errorf("error getting tunnel port: %w", err) } // If the port visibility isn't changing, don't do anything if AccessControlEntriesToVisibility(tunnelPort.AccessControl.Entries) == visibility { + fwd.connection.ManagerMu.Unlock() return nil } // Delete the existing tunnel port to update port, err := convertIntToUint16(remotePort) if err != nil { + fwd.connection.ManagerMu.Unlock() return fmt.Errorf("error converting port: %w", err) } err = fwd.connection.TunnelManager.DeleteTunnelPort(ctx, fwd.connection.Tunnel, port, fwd.connection.Options) + fwd.connection.ManagerMu.Unlock() if err != nil { return fmt.Errorf("error deleting tunnel port: %w", err) } diff --git a/internal/codespaces/portforwarder/port_forwarder_test.go b/internal/codespaces/portforwarder/port_forwarder_test.go index a951ed2b1d2..e6bfac01a24 100644 --- a/internal/codespaces/portforwarder/port_forwarder_test.go +++ b/internal/codespaces/portforwarder/port_forwarder_test.go @@ -7,6 +7,9 @@ import ( "github.com/cli/cli/v2/internal/codespaces/api" "github.com/cli/cli/v2/internal/codespaces/connection" "github.com/microsoft/dev-tunnels/go/tunnels" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sync/errgroup" ) func TestNewPortForwarder(t *testing.T) { @@ -31,26 +34,16 @@ func TestNewPortForwarder(t *testing.T) { // Create the mock HTTP client httpClient, err := connection.NewMockHttpClient() - if err != nil { - t.Fatalf("NewHttpClient returned an error: %v", err) - } + require.NoError(t, err) // Call the function being tested conn, err := connection.NewCodespaceConnection(ctx, codespace, httpClient) - if err != nil { - t.Fatalf("NewCodespaceConnection returned an error: %v", err) - } + require.NoError(t, err) // Create the new port forwarder portForwarder, err := NewPortForwarder(ctx, conn) - if err != nil { - t.Fatalf("NewPortForwarder returned an error: %v", err) - } - - // Check that the port forwarder was created successfully - if portForwarder == nil { - t.Fatal("NewPortForwarder returned nil") - } + require.NoError(t, err) + require.NotNil(t, portForwarder) } func TestAccessControlEntriesToVisibility(t *testing.T) { @@ -96,9 +89,7 @@ func TestAccessControlEntriesToVisibility(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { visibility := AccessControlEntriesToVisibility(test.accessControlEntries) - if visibility != test.expected { - t.Errorf("expected %q, got %q", test.expected, visibility) - } + assert.Equal(t, test.expected, visibility) }) } } @@ -131,9 +122,7 @@ func TestIsInternalPort(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { isInternal := IsInternalPort(test.port) - if isInternal != test.expected { - t.Errorf("expected %v, got %v", test.expected, isInternal) - } + assert.Equal(t, test.expected, isInternal) }) } } @@ -163,39 +152,70 @@ func TestForwardPortDefaultsToHTTPProtocol(t *testing.T) { httpClient, err := connection.NewMockHttpClient( connection.WithSpecificPorts(tunnelPorts), ) - if err != nil { - t.Fatalf("NewMockHttpClient returned an error: %v", err) - } + require.NoError(t, err) connection, err := connection.NewCodespaceConnection(t.Context(), codespace, httpClient) - if err != nil { - t.Fatalf("NewCodespaceConnection returned an error: %v", err) - } + require.NoError(t, err) fwd, err := NewPortForwarder(t.Context(), connection) - if err != nil { - t.Fatalf("NewPortForwarder returned an error: %v", err) - } + require.NoError(t, err) // When we forward a port without an existing one to use for a protocol, it should default to HTTP. - if err := fwd.ForwardPort(t.Context(), ForwardPortOpts{ + err = fwd.ForwardPort(t.Context(), ForwardPortOpts{ Port: 1337, - }); err != nil { - t.Fatalf("ForwardPort returned an error: %v", err) - } + }) + require.NoError(t, err) ports, err := fwd.ListPorts(t.Context()) - if err != nil { - t.Fatalf("ListPorts returned an error: %v", err) - } + require.NoError(t, err) + require.Len(t, ports, 1) + assert.Equal(t, string(tunnels.TunnelProtocolHttp), ports[0].Protocol) +} - if len(ports) != 1 { - t.Fatalf("expected 1 port, got %d", len(ports)) +func TestConcurrentForwardPortDoesNotRace(t *testing.T) { + codespace := &api.Codespace{ + Name: "codespace-name", + State: api.CodespaceStateAvailable, + Connection: api.CodespaceConnection{ + TunnelProperties: api.TunnelProperties{ + ConnectAccessToken: "tunnel access-token", + ManagePortsAccessToken: "manage-ports-token", + ServiceUri: "http://global.rel.tunnels.api.visualstudio.com/", + TunnelId: "tunnel-id", + ClusterId: "usw2", + Domain: "domain.com", + }, + }, + RuntimeConstraints: api.RuntimeConstraints{ + AllowedPortPrivacySettings: []string{"public", "private"}, + }, } - if ports[0].Protocol != string(tunnels.TunnelProtocolHttp) { - t.Fatalf("expected port protocol to be http, got %s", ports[0].Protocol) + tunnelPorts := map[int]tunnels.TunnelPort{} + + httpClient, err := connection.NewMockHttpClient( + connection.WithSpecificPorts(tunnelPorts), + ) + require.NoError(t, err) + + conn, err := connection.NewCodespaceConnection(t.Context(), codespace, httpClient) + require.NoError(t, err) + + // Forward multiple ports concurrently from the same connection, + // mirroring what ForwardPorts does in ports.go. + group, ctx := errgroup.WithContext(t.Context()) + for port := 3000; port < 3010; port++ { + fwd, err := NewPortForwarder(ctx, conn) + require.NoError(t, err) + + group.Go(func() error { + return fwd.ForwardPort(ctx, ForwardPortOpts{ + Port: port, + }) + }) } + + require.NoError(t, group.Wait()) } func TestForwardPortRespectsProtocolOfExistingTunneledPorts(t *testing.T) { @@ -230,38 +250,23 @@ func TestForwardPortRespectsProtocolOfExistingTunneledPorts(t *testing.T) { httpClient, err := connection.NewMockHttpClient( connection.WithSpecificPorts(tunnelPorts), ) - if err != nil { - t.Fatalf("NewMockHttpClient returned an error: %v", err) - } + require.NoError(t, err) connection, err := connection.NewCodespaceConnection(t.Context(), codespace, httpClient) - if err != nil { - t.Fatalf("NewCodespaceConnection returned an error: %v", err) - } + require.NoError(t, err) fwd, err := NewPortForwarder(t.Context(), connection) - if err != nil { - t.Fatalf("NewPortForwarder returned an error: %v", err) - } + require.NoError(t, err) // When we forward a port, it would typically default to HTTP, to which the mock server would respond with a 400, // but it should respect the existing port's protocol and forward it as HTTPS. - if err := fwd.ForwardPort(t.Context(), ForwardPortOpts{ + err = fwd.ForwardPort(t.Context(), ForwardPortOpts{ Port: 1337, - }); err != nil { - t.Fatalf("ForwardPort returned an error: %v", err) - } + }) + require.NoError(t, err) ports, err := fwd.ListPorts(t.Context()) - if err != nil { - t.Fatalf("ListPorts returned an error: %v", err) - } - - if len(ports) != 1 { - t.Fatalf("expected 1 port, got %d", len(ports)) - } - - if ports[0].Protocol != string(tunnels.TunnelProtocolHttps) { - t.Fatalf("expected port protocol to be https, got %s", ports[0].Protocol) - } + require.NoError(t, err) + require.Len(t, ports, 1) + assert.Equal(t, string(tunnels.TunnelProtocolHttps), ports[0].Protocol) } diff --git a/internal/codespaces/rpc/invoker.go b/internal/codespaces/rpc/invoker.go index 6ba8843acaa..09844b21a21 100644 --- a/internal/codespaces/rpc/invoker.go +++ b/internal/codespaces/rpc/invoker.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "net" + "net/url" "os" "regexp" "strconv" @@ -184,7 +185,11 @@ func (i *invoker) StartJupyterServer(ctx context.Context) (port int, serverUrl s return 0, "", fmt.Errorf("failed to parse JupyterLab port: %w", err) } - return port, response.ServerUrl, err + if !isJupyterServerURLValid(response.ServerUrl) { + return 0, "", fmt.Errorf("invalid JupyterLab server URL: %q", response.ServerUrl) + } + + return port, response.ServerUrl, nil } // Rebuilds the container using cached layers by default or from scratch if full is true @@ -311,3 +316,20 @@ func isUsernameValid(username string) bool { re := regexp.MustCompile(validUsernamePattern) return re.MatchString(username) } + +// Ensures that the Jupyter server URL is valid and points to a loopback http(s) URL +func isJupyterServerURLValid(serverURL string) bool { + u, err := url.Parse(serverURL) + if err != nil { + return false + } + if u.Scheme != "http" && u.Scheme != "https" { + return false + } + host := u.Hostname() + if strings.ToLower(host) == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/internal/codespaces/rpc/invoker_test.go b/internal/codespaces/rpc/invoker_test.go index d9b271c5493..54855b787d8 100644 --- a/internal/codespaces/rpc/invoker_test.go +++ b/internal/codespaces/rpc/invoker_test.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/internal/codespaces/rpc/jupyter" "github.com/cli/cli/v2/internal/codespaces/rpc/ssh" rpctest "github.com/cli/cli/v2/internal/codespaces/rpc/test" + "github.com/stretchr/testify/require" "google.golang.org/grpc" ) @@ -311,3 +312,58 @@ func TestStartSSHServerFailure(t *testing.T) { t.Fatalf("expected %s, got %s", "", user) } } + +func TestIsJupyterServerURLValid(t *testing.T) { + tests := []struct { + name string + serverURL string + want bool + }{ + { + name: "http loopback IPv4 with token", + serverURL: "http://127.0.0.1:1234/lab?token=abc", + want: true, + }, + { + name: "https localhost", + serverURL: "https://localhost:8888/", + want: true, + }, + { + name: "http loopback IPv6", + serverURL: "http://[::1]:9000/lab", + want: true, + }, + { + name: "vscode-insiders scheme", + serverURL: "vscode-insiders://ms-vsliveshare.vsliveshare/join?foo=bar", + want: false, + }, + { + name: "vscode scheme", + serverURL: "vscode://vscode.git/clone?url=https://example.com", + want: false, + }, + { + name: "non-loopback host", + serverURL: "http://cli.github.com/lab", + want: false, + }, + { + name: "file scheme", + serverURL: "file:///mona-home/document", + want: false, + }, + { + name: "empty string", + serverURL: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, isJupyterServerURLValid(tt.serverURL)) + }) + } +} diff --git a/internal/skills/registry/registry.go b/internal/skills/registry/registry.go index 6f27fc15601..e9e0c21c89a 100644 --- a/internal/skills/registry/registry.go +++ b/internal/skills/registry/registry.go @@ -77,12 +77,31 @@ var Agents = []AgentHost{ ProjectDir: sharedProjectSkillsDir, UserDir: ".gemini/skills", }, + + // Antigravity documents three surfaces that share the .agents/skills + // project dir but each read user-scope skills from a different global + // dir. Each UserDir below is the global path from that surface's docs. { + // https://antigravity.google/docs/ide/skills ID: "antigravity", Name: "Antigravity", ProjectDir: sharedProjectSkillsDir, UserDir: ".gemini/antigravity/skills", }, + { + // https://antigravity.google/docs/cli/plugins#agent-skills + ID: "antigravity-cli", + Name: "Antigravity CLI", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".gemini/antigravity-cli/skills", + }, + { + // https://antigravity.google/docs/skills + ID: "antigravity2.0", + Name: "Antigravity 2.0", + ProjectDir: sharedProjectSkillsDir, + UserDir: ".gemini/config/skills", + }, // All other supported agents, alphabetical by ID. { diff --git a/internal/skills/registry/registry_test.go b/internal/skills/registry/registry_test.go index f3bc0b59596..b0bf3694830 100644 --- a/internal/skills/registry/registry_test.go +++ b/internal/skills/registry/registry_test.go @@ -21,6 +21,8 @@ func TestFindByID(t *testing.T) { {name: "codex", id: "codex", wantName: "Codex"}, {name: "gemini-cli", id: "gemini-cli", wantName: "Gemini CLI"}, {name: "antigravity", id: "antigravity", wantName: "Antigravity"}, + {name: "antigravity-cli", id: "antigravity-cli", wantName: "Antigravity CLI"}, + {name: "antigravity2.0", id: "antigravity2.0", wantName: "Antigravity 2.0"}, {name: "unknown agent", id: "nonexistent", wantErr: "unknown agent"}, } for _, tt := range tests { @@ -125,6 +127,38 @@ func TestInstallDir(t *testing.T) { homeDir: "/home/monalisa", wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), }, + { + name: "antigravity-cli project scope", + hostID: "antigravity-cli", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, + { + name: "antigravity-cli user scope", + hostID: "antigravity-cli", + scope: ScopeUser, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/home/monalisa", ".gemini", "antigravity-cli", "skills"), + }, + { + name: "antigravity2.0 project scope", + hostID: "antigravity2.0", + scope: ScopeProject, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/tmp/monalisa-repo", ".agents", "skills"), + }, + { + name: "antigravity2.0 user scope", + hostID: "antigravity2.0", + scope: ScopeUser, + gitRoot: "/tmp/monalisa-repo", + homeDir: "/home/monalisa", + wantDir: filepath.Join("/home/monalisa", ".gemini", "config", "skills"), + }, { // Issue #13494: Universal must use the shared .agents/skills dir // at user scope so compliant clients (Copilot, Pi, OpenCode) pick up diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index 3ebb02413bc..e1e6a335183 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -96,6 +96,16 @@ func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Comman Additional %[1]sgit clone%[1]s flags can be passed after %[1]s--%[1]s. `, "`"), + Example: heredoc.Doc(` + # Fork a repository + $ gh repo fork owner/repo + + # Fork a repository and clone it locally + $ gh repo fork owner/repo --clone + + # Fork a repository without cloning it, skip the prompt + $ gh repo fork owner/repo --clone=false + `), RunE: func(cmd *cobra.Command, args []string) error { promptOk := opts.IO.CanPrompt() if len(args) > 0 { diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index 5adcf04bfaf..7579d5baa47 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -915,6 +915,16 @@ func resolveHosts(opts *InstallOptions, canPrompt bool) ([]*registry.AgentHost, return []*registry.AgentHost{h}, nil } + // --dir fixes the destination in resolveInstallDir regardless of the agent host, + // which then only affects post-install hint output; the default is a safe placeholder. + if opts.Dir != "" { + h, err := registry.FindByID(registry.DefaultAgentID) + if err != nil { + return nil, err + } + return []*registry.AgentHost{h}, nil + } + if !canPrompt { h, err := registry.FindByID(registry.DefaultAgentID) if err != nil { diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 1861539bb29..7056bea2cd1 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -473,6 +473,30 @@ func TestInstallRun(t *testing.T) { }, wantStdout: "Installed git-commit", }, + { + name: "remote install with --dir bypasses agent selection", + isTTY: true, + stubs: func(reg *httpmock.Registry) { + stubResolveVersion(reg, "monalisa", "skills-repo", "v1.0.0", "abc123") + stubDiscoverTree(reg, "monalisa", "skills-repo", "abc123", + singleSkillTreeJSON("git-commit", "treeSHA", "blobSHA")) + stubInstallFiles(reg, "monalisa", "skills-repo", "treeSHA", "blobSHA", gitCommitContent) + }, + opts: func(ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { + t.Helper() + return &InstallOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitClient: &git.Client{RepoDir: t.TempDir()}, + Prompter: &prompter.PrompterMock{}, + SkillSource: "monalisa/skills-repo", + SkillName: "git-commit", + Scope: "project", + Dir: t.TempDir(), + } + }, + wantStdout: "Installed git-commit", + }, { name: "remote install with --force overwrites existing skill", isTTY: true,