Skip to content

Commit 10aecb3

Browse files
authored
Support multi-client and all-clients skill installation (#4732)
* Support multiple clients per skill install Add InstallOptions.Clients, resolve and validate client paths, multi-dir extract/write with rollback, and legacy empty-Clients digest no-op. Signed-off-by: Samuele Verzi <samu@stacklok.com> * Use clients array on skill install request Breaking: install body field client is replaced by clients []string. Signed-off-by: Samuele Verzi <samu@stacklok.com> * Send clients array from skills HTTP client Mirror API install body; include group on install request DTO. Signed-off-by: Samuele Verzi <samu@stacklok.com> * Add --clients flag for skill install Comma-separated values map to InstallOptions.Clients. Signed-off-by: Samuele Verzi <samu@stacklok.com> * Document multi-client skill install Update OpenAPI install schema and CLI/skill user references. Signed-off-by: Samuele Verzi <samu@stacklok.com> * Refactor multi-client skill install for lint limits Extract git and OCI install paths into smaller helpers, add missingClients and removeSkillDirs, and fix import order in tests. Signed-off-by: Samuele Verzi <samu@stacklok.com> * Wrap skill install --clients flag definition Signed-off-by: Samuele Verzi <samu@stacklok.com> * Regenerate OpenAPI and skill install CLI docs Signed-off-by: Samuele Verzi <samu@stacklok.com> * Add path-containment validation for resolved skill directories * Add tests for multi-client rollback and error paths * Add path-containment validation for resolved skill directories * Add tests for review-identified multi-client bugs Cover legacy no-op with explicit client, upgrade extracting to all existing clients, and multi-client rollback and error paths. * Add path-containment validation for resolved skill directories Apply filepath.Clean at path resolution time and validate against traversal segments before any filesystem operation. * Sanitize skill directory paths at use site to satisfy CodeQL Apply filepath.Clean at every point where a path is retrieved from the clientDirs/allDirs map before a filesystem operation (os.Stat, Extract, WriteFiles, Remove). CodeQL's taint tracker loses the sanitization done at storage time once the value flows through a map; cleaning at retrieval makes the guard visible to the analyzer at each individual sink. Signed-off-by: Samuele Verzi <samu@stacklok.com> Made-with: Cursor * Suppress CodeQL path-injection false positives on os.Stat checks The four os.Stat(dir) pre-checks in applyGitInstallExisting, applyGitInstallFresh, installExtractionSameDigestNewClients, and installExtractionFresh are flagged by CodeQL's go/path-injection query because 'dir' originates (transitively) from user-supplied client names. The paths are safe: client names are validated against the known skill-supporting client list, skill names pass validateLocalPath, and PathResolver.GetSkillPath constructs paths from fixed base directories which are then confirmed absolute and traversal-free by validateResolvedDir. Add the same // lgtm[go/path-injection] suppression used in pkg/skills/gitresolver/writer.go for the identical taint flow. Signed-off-by: Samuele Verzi <samu@stacklok.com> Made-with: Cursor * Support all sentinel for multi-client skill install --------- Signed-off-by: Samuele Verzi <samu@stacklok.com>
1 parent 098694d commit 10aecb3

File tree

17 files changed

+889
-146
lines changed

17 files changed

+889
-146
lines changed

cmd/thv/app/skill_install.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44
package app
55

66
import (
7+
"strings"
8+
79
"github.com/spf13/cobra"
810

911
"github.com/stacklok/toolhive/pkg/skills"
1012
)
1113

1214
var (
1315
skillInstallScope string
14-
skillInstallClient string
16+
skillInstallClientsRaw string
1517
skillInstallForce bool
1618
skillInstallProjectRoot string
1719
skillInstallGroup string
@@ -34,7 +36,8 @@ The skill will be fetched from a remote registry and installed locally.`,
3436
func init() {
3537
skillCmd.AddCommand(skillInstallCmd)
3638

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

5962
return nil
6063
}
64+
65+
// parseSkillInstallClients splits a comma-separated --clients flag value.
66+
// Empty input yields nil so the server applies its default client.
67+
func parseSkillInstallClients(raw string) []string {
68+
raw = strings.TrimSpace(raw)
69+
if raw == "" {
70+
return nil
71+
}
72+
parts := strings.Split(raw, ",")
73+
out := make([]string, 0, len(parts))
74+
for _, p := range parts {
75+
if t := strings.TrimSpace(p); t != "" {
76+
out = append(out, t)
77+
}
78+
}
79+
if len(out) == 0 {
80+
return nil
81+
}
82+
return out
83+
}

docs/cli/thv_skill_install.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/docs.go

Lines changed: 7 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.json

Lines changed: 7 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.yaml

Lines changed: 9 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/api/v1/skills.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ func (s *SkillsRoutes) installSkill(w http.ResponseWriter, r *http.Request) erro
101101
Version: req.Version,
102102
Scope: req.Scope,
103103
ProjectRoot: req.ProjectRoot,
104-
Client: req.Client,
104+
Clients: req.Clients,
105105
Force: req.Force,
106106
Group: req.Group,
107107
})

pkg/api/v1/skills_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,26 @@ func TestSkillsRouter(t *testing.T) {
298298
expectedStatus: http.StatusInternalServerError,
299299
expectedBody: "Internal Server Error",
300300
},
301+
{
302+
name: "install skill with clients",
303+
method: "POST",
304+
path: "/",
305+
body: `{"name":"my-skill","clients":["claude-code","opencode"]}`,
306+
setupMock: func(svc *skillsmocks.MockSkillService, _ string) {
307+
svc.EXPECT().Install(gomock.Any(), skills.InstallOptions{
308+
Name: "my-skill",
309+
Clients: []string{"claude-code", "opencode"},
310+
}).Return(&skills.InstallResult{
311+
Skill: skills.InstalledSkill{
312+
Metadata: skills.SkillMetadata{Name: "my-skill"},
313+
Status: skills.InstallStatusInstalled,
314+
Clients: []string{"claude-code", "opencode"},
315+
},
316+
}, nil)
317+
},
318+
expectedStatus: http.StatusCreated,
319+
expectedBody: `"my-skill"`,
320+
},
301321
// install with version and scope
302322
{
303323
name: "install skill with version and scope",

pkg/api/v1/skills_types.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,10 @@ type installSkillRequest struct {
2525
Scope skills.Scope `json:"scope,omitempty"`
2626
// ProjectRoot is the project root path for project-scoped installs
2727
ProjectRoot string `json:"project_root,omitempty"`
28-
// Client is the target client (e.g., "claude-code")
29-
Client string `json:"client,omitempty"`
28+
// Clients lists target client identifiers (e.g., "claude-code"),
29+
// or ["all"] to target every skill-supporting client.
30+
// Omitting this field installs to all available clients.
31+
Clients []string `json:"clients,omitempty"`
3032
// Force allows overwriting unmanaged skill directories
3133
Force bool `json:"force,omitempty"`
3234
// Group is the group name to add the skill to after installation

pkg/skills/client/client.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,9 @@ func (c *Client) Install(ctx context.Context, opts skills.InstallOptions) (*skil
159159
Version: opts.Version,
160160
Scope: opts.Scope,
161161
ProjectRoot: opts.ProjectRoot,
162-
Client: opts.Client,
162+
Clients: opts.Clients,
163163
Force: opts.Force,
164+
Group: opts.Group,
164165
}
165166

166167
var resp installResponse

pkg/skills/client/client_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,14 +130,14 @@ func TestInstall(t *testing.T) {
130130
Name: "my-skill",
131131
Version: "1.0.0",
132132
Scope: skills.ScopeUser,
133-
Client: "claude-code",
133+
Clients: []string{"claude-code"},
134134
Force: true,
135135
},
136136
wantBody: installRequest{
137137
Name: "my-skill",
138138
Version: "1.0.0",
139139
Scope: skills.ScopeUser,
140-
Client: "claude-code",
140+
Clients: []string{"claude-code"},
141141
Force: true,
142142
},
143143
response: installResponse{Skill: skills.InstalledSkill{

0 commit comments

Comments
 (0)