Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
67ca1ab
Add project-level skills lock file with sync and upgrade commands
samuv Jul 3, 2026
6cd5f9a
Extend lockfile schema with contentDigest and validation
samuv Jul 7, 2026
66eaa9b
Split SkillLockService from SkillService
samuv Jul 7, 2026
03a3d8e
Add managed flag for lock-managed skill installs
samuv Jul 7, 2026
9aa81e3
Harden install hooks and materialize toolhive.requires
samuv Jul 7, 2026
519b8d3
Implement RFC sync with check, adopt, and prune
samuv Jul 7, 2026
ceefa50
Implement RFC upgrade with preview and ref-change guard
samuv Jul 7, 2026
bead6ba
Wire lock service through API, client, and CLI
samuv Jul 7, 2026
6b114b3
Document skills lock file sync and upgrade behavior
samuv Jul 7, 2026
b56cae5
Rename SyncResult UpToDate field to fix codespell
samuv Jul 7, 2026
b47acc9
Add provenance and unsigned fields to skills lockfile
samuv Jul 8, 2026
6075f32
Add Sigstore verifier and signer packages for skills
samuv Jul 8, 2026
d891e15
Store verified Sigstore bundles on installed skills
samuv Jul 8, 2026
f020fd7
Verify Sigstore signatures during project-scope installs
samuv Jul 8, 2026
e2d41aa
Re-verify stored bundles offline during skill sync
samuv Jul 8, 2026
c51def2
Block skill upgrades on signer identity changes
samuv Jul 8, 2026
382ab01
Sign OCI artifacts after skill push
samuv Jul 8, 2026
9d58866
Expose Sigstore install and push flags in API and CLI
samuv Jul 8, 2026
1f1db15
Document skills lock Sigstore trust model and update tests
samuv Jul 8, 2026
18d7dd1
Skip signing in registry-lookup install E2E push
samuv Jul 8, 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
49 changes: 49 additions & 0 deletions cmd/thv/app/exitcode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package app

import (
"errors"
"fmt"
)

// Exit codes for skill lock operations (RFC THV-0080).
const (
ExitCodeCheckFailure = 2
ExitCodePartialFailure = 3
ExitCodePolicyRejection = 4
)

// exitCodeError carries a specific process exit code for the CLI.
type exitCodeError struct {
code int
err error
}

func (e *exitCodeError) Error() string {
if e.err == nil {
return fmt.Sprintf("exit code %d", e.code)
}
return e.err.Error()
}

func (e *exitCodeError) Unwrap() error {
return e.err
}

func newExitCodeError(code int, err error) error {
if err == nil {
return &exitCodeError{code: code}
}
return &exitCodeError{code: code, err: err}
}

// ExitCodeFromError returns a custom exit code when err wraps exitCodeError.
func ExitCodeFromError(err error) (int, bool) {
var ec *exitCodeError
if errors.As(err, &ec) {
return ec.code, true
}
return 1, false
}
44 changes: 44 additions & 0 deletions cmd/thv/app/skill_confirm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package app

import (
"bufio"
"fmt"
"os"
"strings"

"golang.org/x/term"
)

func confirmAction(prompt string) (bool, error) {
fmt.Printf("\n%s [y/N]: ", prompt)
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return false, fmt.Errorf("failed to read user input: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
return response == "y" || response == "yes", nil
}

func requireInteractiveConfirmation(yes bool, summaryFn func()) error {
if yes {
return nil
}
interactive := term.IsTerminal(int(os.Stdin.Fd()))
if !interactive {
return newExitCodeError(ExitCodePolicyRejection,
fmt.Errorf("non-interactive terminal: pass --yes to proceed without confirmation"))
}
summaryFn()
confirmed, err := confirmAction("Install?")
if err != nil {
return err
}
if !confirmed {
return newExitCodeError(ExitCodePolicyRejection, fmt.Errorf("operation cancelled"))
}
return nil
}
16 changes: 16 additions & 0 deletions cmd/thv/app/skill_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/spf13/cobra"

tclient "github.com/stacklok/toolhive/pkg/client"
"github.com/stacklok/toolhive/pkg/skills"
skillclient "github.com/stacklok/toolhive/pkg/skills/client"
)
Expand Down Expand Up @@ -65,3 +66,18 @@ func validateProjectRootForScope(scopeVar, projectRootVar *string) func(*cobra.C
return nil
}
}

// resolveProjectRoot returns explicit unchanged when non-empty. Otherwise it
// auto-detects the project root by walking up from the current working
// directory looking for a .git directory or file, matching the lock file's
// commit-to-git-root assumption used by "thv skill sync" and "thv skill upgrade".
func resolveProjectRoot(explicit string) (string, error) {
if explicit != "" {
return explicit, nil
}
root, err := tclient.DetectProjectRoot("")
if err != nil {
return "", fmt.Errorf("%w; run inside a git repository or pass --project-root explicitly", err)
}
return root, nil
}
26 changes: 15 additions & 11 deletions cmd/thv/app/skill_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import (
)

var (
skillInstallScope string
skillInstallClientsRaw string
skillInstallForce bool
skillInstallProjectRoot string
skillInstallGroup string
skillInstallScope string
skillInstallClientsRaw string
skillInstallForce bool
skillInstallProjectRoot string
skillInstallGroup string
skillInstallAllowUnsigned bool
)

var skillInstallCmd = &cobra.Command{
Expand All @@ -42,18 +43,21 @@ func init() {
skillInstallCmd.Flags().BoolVar(&skillInstallForce, "force", false, "Overwrite existing skill directory")
skillInstallCmd.Flags().StringVar(&skillInstallProjectRoot, "project-root", "", "Project root path for project-scoped installs")
skillInstallCmd.Flags().StringVar(&skillInstallGroup, "group", "", "Group to add the skill to after installation")
skillInstallCmd.Flags().BoolVar(&skillInstallAllowUnsigned, "allow-unsigned", false,
"Permit unsigned artifacts for project scope (recorded in lock as unsigned: true)")
}

func skillInstallCmdFunc(cmd *cobra.Command, args []string) error {
c := newSkillClient(cmd.Context())

_, err := c.Install(cmd.Context(), skills.InstallOptions{
Name: args[0],
Scope: skills.Scope(skillInstallScope),
Clients: parseSkillInstallClients(skillInstallClientsRaw),
Force: skillInstallForce,
ProjectRoot: skillInstallProjectRoot,
Group: skillInstallGroup,
Name: args[0],
Scope: skills.Scope(skillInstallScope),
Clients: parseSkillInstallClients(skillInstallClientsRaw),
Force: skillInstallForce,
ProjectRoot: skillInstallProjectRoot,
Group: skillInstallGroup,
AllowUnsigned: skillInstallAllowUnsigned,
})
if err != nil {
return formatSkillError("install skill", err)
Expand Down
11 changes: 10 additions & 1 deletion cmd/thv/app/skill_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import (
"github.com/stacklok/toolhive/pkg/skills"
)

var (
skillPushKey string
skillPushNoSign bool
)

var skillPushCmd = &cobra.Command{
Use: "push [reference]",
Short: "Push a built skill",
Expand All @@ -19,13 +24,17 @@ var skillPushCmd = &cobra.Command{

func init() {
skillCmd.AddCommand(skillPushCmd)
skillPushCmd.Flags().StringVar(&skillPushKey, "key", "", "Path to cosign private key for signing")
skillPushCmd.Flags().BoolVar(&skillPushNoSign, "no-sign", false, "Skip post-push Sigstore signing")
}

func skillPushCmdFunc(cmd *cobra.Command, args []string) error {
c := newSkillClient(cmd.Context())

err := c.Push(cmd.Context(), skills.PushOptions{
Reference: args[0],
Reference: args[0],
Key: skillPushKey,
SkipSigning: skillPushNoSign,
})
if err != nil {
return formatSkillError("push skill", err)
Expand Down
190 changes: 190 additions & 0 deletions cmd/thv/app/skill_sync.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package app

import (
"encoding/json"
"fmt"

"github.com/spf13/cobra"

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

var (
skillSyncProjectRoot string
skillSyncClientsRaw string
skillSyncPrune bool
skillSyncCheck bool
skillSyncAdopt bool
skillSyncYes bool
skillSyncFormat string
)

var skillSyncCmd = &cobra.Command{
Use: "sync",
Short: "Install skills exactly as pinned in the project's lock file",
Long: `Sync installs every skill pinned in toolhive.lock.yaml at its exact
recorded digest and verifies contentDigest integrity. Project-scoped skills
installed outside of the lock file are reported as never-managed or
removed-from-lock; use --prune to uninstall the latter.

Use --check to verify on-disk content without installing. Use --adopt to write
lock entries for existing unmanaged installs.

The project root is auto-detected from the current directory (nearest
enclosing git repository) unless --project-root is given.

Pin history: git log -p -- toolhive.lock.yaml`,
PreRunE: chainPreRunE(ValidateFormat(&skillSyncFormat)),
RunE: skillSyncCmdFunc,
}

func init() {
skillCmd.AddCommand(skillSyncCmd)

skillSyncCmd.Flags().StringVar(&skillSyncClientsRaw, "clients", "",
`Comma-separated target client apps (e.g. claude-code,opencode), or "all" for every available client`)
skillSyncCmd.Flags().BoolVar(&skillSyncPrune, "prune", false,
"Uninstall previously lock-managed skills that are no longer in the lock file")
skillSyncCmd.Flags().BoolVar(&skillSyncCheck, "check", false,
"Verify on-disk content matches the lock file without installing")
skillSyncCmd.Flags().BoolVar(&skillSyncAdopt, "adopt", false,
"Write lock entries for existing unmanaged project-scope installs")
skillSyncCmd.Flags().BoolVar(&skillSyncYes, "yes", false,
"Skip the pre-install confirmation prompt")
skillSyncCmd.Flags().StringVar(&skillSyncProjectRoot, "project-root", "",
"Project root path (auto-detected from the current directory if omitted)")
AddFormatFlag(skillSyncCmd, &skillSyncFormat)
}

func skillSyncCmdFunc(cmd *cobra.Command, _ []string) error {
projectRoot, err := resolveProjectRoot(skillSyncProjectRoot)
if err != nil {
return err
}

c := newSkillClient(cmd.Context())
opts := skills.SyncOptions{
ProjectRoot: projectRoot,
Clients: parseSkillInstallClients(skillSyncClientsRaw),
Prune: skillSyncPrune,
Check: skillSyncCheck,
Adopt: skillSyncAdopt,
}

if skillSyncCheck || skillSyncAdopt {
result, err := c.Sync(cmd.Context(), opts)
if err != nil {
return formatSkillError("sync skills", err)
}
return finishSyncResult(result, skillSyncFormat)
}

// Two-phase gate: preview with check, then apply.
previewOpts := opts
previewOpts.Check = true
preview, err := c.Sync(cmd.Context(), previewOpts)
if err != nil {
return formatSkillError("sync skills", err)
}

if err := requireInteractiveConfirmation(skillSyncYes, func() {
printSyncPreflight(preview, skillSyncPrune)
}); err != nil {
return err
}

result, err := c.Sync(cmd.Context(), opts)
if err != nil {
return formatSkillError("sync skills", err)
}
return finishSyncResult(result, skillSyncFormat)
}

func finishSyncResult(result *skills.SyncResult, format string) error {
switch format {
case FormatJSON:
data, jsonErr := json.MarshalIndent(result, "", " ")
if jsonErr != nil {
return fmt.Errorf("failed to marshal JSON: %w", jsonErr)
}
fmt.Println(string(data))
default:
printSyncResultText(result)
}

exitCode := syncExitCode(result)
if exitCode != 0 {
return newExitCodeError(exitCode, nil)
}
return nil
}

func syncExitCode(result *skills.SyncResult) int {
if len(result.Failed) > 0 {
return ExitCodePartialFailure
}
if skillSyncCheck && (len(result.Failed) > 0 || len(result.Drifted) > 0) {
return ExitCodeCheckFailure
}
return 0
}

func printSyncPreflight(result *skills.SyncResult, prune bool) {
fmt.Println("Pre-flight summary:")
printSkillNameList(" To install/reinstall", append(append([]string{}, result.Drifted...), diffInstalled(result)...))
printSkillNameList(" Up to date", result.AlreadyCurrent)
printSkillNameList(" Never managed", result.NeverManaged)
printSkillNameList(" Removed from lock", result.RemovedFromLock)
if prune {
printSkillNameList(" Would prune", result.RemovedFromLock)
}
}

func diffInstalled(result *skills.SyncResult) []string {
// During check phase, skills needing install appear as failed with content-mismatch
// or we rely on non-up-to-date entries. Keep simple: show failed names as pending install.
names := make([]string, 0, len(result.Failed))
for _, f := range result.Failed {
if f.Reason == skills.FailureReasonContentMismatch {
names = append(names, f.Name)
}
}
return names
}

func printSyncResultText(result *skills.SyncResult) {
printSkillNameList("Installed", result.Installed)
printSkillNameList("Drifted (reinstalled)", result.Drifted)
printSkillNameList("Up to date", result.AlreadyCurrent)
printSkillNameList("Never managed", result.NeverManaged)
printSkillNameList("Removed from lock", result.RemovedFromLock)
printSkillNameList("Unmanaged (deprecated)", result.Unmanaged)
printSkillNameList("Pruned", result.Pruned)
if len(result.Failed) > 0 {
fmt.Println("Failed:")
for _, f := range result.Failed {
if f.Reason != "" {
fmt.Printf(" %s (%s): %s\n", f.Name, f.Reason, f.Error)
} else {
fmt.Printf(" %s: %s\n", f.Name, f.Error)
}
}
}
if len(result.Installed) == 0 && len(result.AlreadyCurrent) == 0 && len(result.NeverManaged) == 0 &&
len(result.RemovedFromLock) == 0 && len(result.Pruned) == 0 && len(result.Failed) == 0 {
fmt.Println("Nothing to sync: lock file is empty")
}
}

func printSkillNameList(label string, names []string) {
if len(names) == 0 {
return
}
fmt.Printf("%s:\n", label)
for _, name := range names {
fmt.Printf(" %s\n", name)
}
}
Loading
Loading