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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions cmd/thv/app/skill_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
package app

import (
"strings"

"github.com/spf13/cobra"

"github.com/stacklok/toolhive/pkg/skills"
)

var (
skillInstallScope string
skillInstallClient string
skillInstallClientsRaw string
skillInstallForce bool
skillInstallProjectRoot string
skillInstallGroup string
Expand All @@ -34,7 +36,8 @@ The skill will be fetched from a remote registry and installed locally.`,
func init() {
skillCmd.AddCommand(skillInstallCmd)

skillInstallCmd.Flags().StringVar(&skillInstallClient, "client", "", "Target client application (e.g. claude-code)")
skillInstallCmd.Flags().StringVar(&skillInstallClientsRaw, "clients", "",
`Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client`)
skillInstallCmd.Flags().StringVar(&skillInstallScope, "scope", string(skills.ScopeUser), "Installation scope (user, project)")
skillInstallCmd.Flags().BoolVar(&skillInstallForce, "force", false, "Overwrite existing skill directory")
skillInstallCmd.Flags().StringVar(&skillInstallProjectRoot, "project-root", "", "Project root path for project-scoped installs")
Expand All @@ -47,7 +50,7 @@ func skillInstallCmdFunc(cmd *cobra.Command, args []string) error {
_, err := c.Install(cmd.Context(), skills.InstallOptions{
Name: args[0],
Scope: skills.Scope(skillInstallScope),
Client: skillInstallClient,
Clients: parseSkillInstallClients(skillInstallClientsRaw),
Force: skillInstallForce,
ProjectRoot: skillInstallProjectRoot,
Group: skillInstallGroup,
Expand All @@ -58,3 +61,23 @@ func skillInstallCmdFunc(cmd *cobra.Command, args []string) error {

return nil
}

// parseSkillInstallClients splits a comma-separated --clients flag value.
// Empty input yields nil so the server applies its default client.
func parseSkillInstallClients(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
if len(out) == 0 {
return nil
}
return out
}
2 changes: 1 addition & 1 deletion docs/cli/thv_skill_install.md

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

10 changes: 7 additions & 3 deletions docs/server/docs.go

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

10 changes: 7 additions & 3 deletions docs/server/swagger.json

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

12 changes: 9 additions & 3 deletions docs/server/swagger.yaml

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

2 changes: 1 addition & 1 deletion pkg/api/v1/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func (s *SkillsRoutes) installSkill(w http.ResponseWriter, r *http.Request) erro
Version: req.Version,
Scope: req.Scope,
ProjectRoot: req.ProjectRoot,
Client: req.Client,
Clients: req.Clients,
Force: req.Force,
Group: req.Group,
})
Expand Down
20 changes: 20 additions & 0 deletions pkg/api/v1/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,26 @@ func TestSkillsRouter(t *testing.T) {
expectedStatus: http.StatusInternalServerError,
expectedBody: "Internal Server Error",
},
{
name: "install skill with clients",
method: "POST",
path: "/",
body: `{"name":"my-skill","clients":["claude-code","opencode"]}`,
setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
svc.EXPECT().Install(gomock.Any(), skills.InstallOptions{
Name: "my-skill",
Clients: []string{"claude-code", "opencode"},
}).Return(&skills.InstallResult{
Skill: skills.InstalledSkill{
Metadata: skills.SkillMetadata{Name: "my-skill"},
Status: skills.InstallStatusInstalled,
Clients: []string{"claude-code", "opencode"},
},
}, nil)
},
expectedStatus: http.StatusCreated,
expectedBody: `"my-skill"`,
},
// install with version and scope
{
name: "install skill with version and scope",
Expand Down
6 changes: 4 additions & 2 deletions pkg/api/v1/skills_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ type installSkillRequest struct {
Scope skills.Scope `json:"scope,omitempty"`
// ProjectRoot is the project root path for project-scoped installs
ProjectRoot string `json:"project_root,omitempty"`
// Client is the target client (e.g., "claude-code")
Client string `json:"client,omitempty"`
// Clients lists target client identifiers (e.g., "claude-code"),
// or ["all"] to target every skill-supporting client.
// Omitting this field installs to all available clients.
Clients []string `json:"clients,omitempty"`
// Force allows overwriting unmanaged skill directories
Force bool `json:"force,omitempty"`
// Group is the group name to add the skill to after installation
Expand Down
3 changes: 2 additions & 1 deletion pkg/skills/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,9 @@ func (c *Client) Install(ctx context.Context, opts skills.InstallOptions) (*skil
Version: opts.Version,
Scope: opts.Scope,
ProjectRoot: opts.ProjectRoot,
Client: opts.Client,
Clients: opts.Clients,
Force: opts.Force,
Group: opts.Group,
}

var resp installResponse
Expand Down
4 changes: 2 additions & 2 deletions pkg/skills/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,14 @@ func TestInstall(t *testing.T) {
Name: "my-skill",
Version: "1.0.0",
Scope: skills.ScopeUser,
Client: "claude-code",
Clients: []string{"claude-code"},
Force: true,
},
wantBody: installRequest{
Name: "my-skill",
Version: "1.0.0",
Scope: skills.ScopeUser,
Client: "claude-code",
Clients: []string{"claude-code"},
Force: true,
},
response: installResponse{Skill: skills.InstalledSkill{
Expand Down
3 changes: 2 additions & 1 deletion pkg/skills/client/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ type installRequest struct {
Version string `json:"version,omitempty"`
Scope skills.Scope `json:"scope,omitempty"`
ProjectRoot string `json:"project_root,omitempty"`
Client string `json:"client,omitempty"`
Clients []string `json:"clients,omitempty"`
Force bool `json:"force,omitempty"`
Group string `json:"group,omitempty"`
}

type validateRequest struct {
Expand Down
4 changes: 2 additions & 2 deletions pkg/skills/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ type InstallOptions struct {
Version string `json:"version,omitempty"`
// Scope is the installation scope.
Scope Scope `json:"scope,omitempty"`
// Client is the target client (e.g., "claude-code"). Empty means first skill-supporting client.
Client string `json:"client,omitempty"`
// Clients lists target clients (e.g., "claude-code"). Empty means first skill-supporting client.
Clients []string `json:"clients,omitempty"`
// Force allows overwriting unmanaged skill directories.
Force bool `json:"force,omitempty"`
// ProjectRoot is the project root path for project-scoped installs.
Expand Down
Loading
Loading