Skip to content

Commit 0777aee

Browse files
committed
Support all sentinel for multi-client skill install
1 parent 1d96648 commit 0777aee

File tree

8 files changed

+74
-12
lines changed

8 files changed

+74
-12
lines changed

cmd/thv/app/skill_install.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ func init() {
3737
skillCmd.AddCommand(skillInstallCmd)
3838

3939
skillInstallCmd.Flags().StringVar(&skillInstallClientsRaw, "clients", "",
40-
"Comma-separated target client apps (e.g. claude-code,opencode)")
40+
`Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client`)
4141
skillInstallCmd.Flags().StringVar(&skillInstallScope, "scope", string(skills.ScopeUser), "Installation scope (user, project)")
4242
skillInstallCmd.Flags().BoolVar(&skillInstallForce, "force", false, "Overwrite existing skill directory")
4343
skillInstallCmd.Flags().StringVar(&skillInstallProjectRoot, "project-root", "", "Project root path for project-scoped installs")

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: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/server/swagger.json

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/swagger.yaml

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

pkg/api/v1/skills_types.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ 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-
// Clients lists target client identifiers (e.g., "claude-code"). Omit for default client.
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.
2931
Clients []string `json:"clients,omitempty"`
3032
// Force allows overwriting unmanaged skill directories
3133
Force bool `json:"force,omitempty"`

pkg/skills/skillsvc/skillsvc.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,9 +1197,12 @@ func (s *service) extractOCIContent(ctx context.Context, d digest.Digest) ([]byt
11971197
return layerData, skillConfig, nil
11981198
}
11991199

1200+
// clientsAllSentinel is the reserved value that expands to all skill-supporting clients.
1201+
const clientsAllSentinel = "all"
1202+
12001203
// resolveAndValidateClients returns the deduplicated client list and a map of
1201-
// client identifier to install directory. Empty opts.Clients means the first
1202-
// skill-supporting client from the path resolver.
1204+
// client identifier to install directory. Empty opts.Clients (or the sentinel
1205+
// value "all") expands to every skill-supporting client returned by the path resolver.
12031206
func (s *service) resolveAndValidateClients(
12041207
opts skills.InstallOptions,
12051208
skillName string,
@@ -1214,23 +1217,30 @@ func (s *service) resolveAndValidateClients(
12141217
}
12151218

12161219
var requested []string
1217-
if len(opts.Clients) == 0 {
1220+
switch {
1221+
case len(opts.Clients) == 0 || (len(opts.Clients) == 1 && strings.EqualFold(opts.Clients[0], clientsAllSentinel)):
12181222
clients := s.pathResolver.ListSkillSupportingClients()
12191223
if len(clients) == 0 {
12201224
return nil, nil, httperr.WithCode(
12211225
errors.New("no skill-supporting clients configured"),
12221226
http.StatusInternalServerError,
12231227
)
12241228
}
1225-
requested = []string{clients[0]}
1226-
} else {
1229+
requested = clients
1230+
default:
12271231
for _, c := range opts.Clients {
12281232
if c == "" {
12291233
return nil, nil, httperr.WithCode(
12301234
errors.New("clients entries must be non-empty strings"),
12311235
http.StatusBadRequest,
12321236
)
12331237
}
1238+
if strings.EqualFold(c, clientsAllSentinel) {
1239+
return nil, nil, httperr.WithCode(
1240+
fmt.Errorf("%q cannot be combined with other client names", clientsAllSentinel),
1241+
http.StatusBadRequest,
1242+
)
1243+
}
12341244
}
12351245
requested = dedupeStringsPreserveOrder(opts.Clients)
12361246
}

pkg/skills/skillsvc/skillsvc_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,54 @@ func TestInstallWithExtraction(t *testing.T) {
483483
assert.Equal(t, http.StatusBadRequest, httperr.Code(err))
484484
})
485485

486+
t.Run("clients all sentinel expands to every skill-supporting client", func(t *testing.T) {
487+
t.Parallel()
488+
ctrl := gomock.NewController(t)
489+
store := storemocks.NewMockSkillStore(ctrl)
490+
pr := skillsmocks.NewMockPathResolver(ctrl)
491+
inst := skillsmocks.NewMockInstaller(ctrl)
492+
493+
dirA := filepath.Join(t.TempDir(), "a", "my-skill")
494+
dirB := filepath.Join(t.TempDir(), "b", "my-skill")
495+
pr.EXPECT().ListSkillSupportingClients().Return([]string{"claude-code", "opencode"})
496+
pr.EXPECT().GetSkillPath("claude-code", "my-skill", skills.ScopeUser, "").Return(dirA, nil)
497+
pr.EXPECT().GetSkillPath("opencode", "my-skill", skills.ScopeUser, "").Return(dirB, nil)
498+
store.EXPECT().Get(gomock.Any(), "my-skill", skills.ScopeUser, "").Return(skills.InstalledSkill{}, storage.ErrNotFound)
499+
inst.EXPECT().Extract(layerData, dirA, false).Return(&skills.ExtractResult{SkillDir: dirA, Files: 1}, nil)
500+
inst.EXPECT().Extract(layerData, dirB, false).Return(&skills.ExtractResult{SkillDir: dirB, Files: 1}, nil)
501+
store.EXPECT().Create(gomock.Any(), gomock.Any()).DoAndReturn(
502+
func(_ context.Context, sk skills.InstalledSkill) error {
503+
assert.ElementsMatch(t, []string{"claude-code", "opencode"}, sk.Clients)
504+
return nil
505+
})
506+
507+
svc := New(store, WithPathResolver(pr), WithInstaller(inst))
508+
_, err := svc.Install(t.Context(), skills.InstallOptions{
509+
Name: "my-skill",
510+
LayerData: layerData,
511+
Digest: "sha256:abc",
512+
Clients: []string{"all"},
513+
})
514+
require.NoError(t, err)
515+
})
516+
517+
t.Run("all sentinel mixed with named client returns bad request", func(t *testing.T) {
518+
t.Parallel()
519+
ctrl := gomock.NewController(t)
520+
store := storemocks.NewMockSkillStore(ctrl)
521+
pr := skillsmocks.NewMockPathResolver(ctrl)
522+
523+
svc := New(store, WithPathResolver(pr))
524+
_, err := svc.Install(t.Context(), skills.InstallOptions{
525+
Name: "my-skill",
526+
LayerData: layerData,
527+
Digest: "sha256:abc",
528+
Clients: []string{"all", "opencode"},
529+
})
530+
require.Error(t, err)
531+
assert.Equal(t, http.StatusBadRequest, httperr.Code(err))
532+
})
533+
486534
t.Run("fresh install rolls back extraction on store.Create failure", func(t *testing.T) {
487535
t.Parallel()
488536
ctrl := gomock.NewController(t)

0 commit comments

Comments
 (0)