Skip to content
Merged
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
27 changes: 27 additions & 0 deletions pkg/skills/feature_gate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package skills

import (
"os"
"strings"
)

// LockFileEnvVar gates the project-level skills lock file feature (RFC
// THV-0080) while it lands across multiple PRs. The feature is inert on
// main until every PR in the stack — lock file, sync, upgrade, and Sigstore
// signing/verification — has merged; this keeps each PR mergeable on its own
// without exposing partial, unsigned-by-default behavior in between.
//
// This is intentionally a plain env var, not persisted config or a CLI flag:
// it exists only for the duration of the rollout and is expected to be
// removed once the feature ships, matching the existing TOOLHIVE_DEV /
// TOOLHIVE_REMOTE_HEALTHCHECKS precedent for staged/dev-only behavior.
const LockFileEnvVar = "TOOLHIVE_SKILLS_LOCK_ENABLED"

// LockFileFeatureEnabled reports whether the project-level skills lock file
// feature is enabled for this process.
func LockFileFeatureEnabled() bool {
return strings.EqualFold(os.Getenv(LockFileEnvVar), "true")
}
33 changes: 33 additions & 0 deletions pkg/skills/feature_gate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package skills

import "testing"

func TestLockFileFeatureEnabled(t *testing.T) {
tests := []struct {
name string
value string
want bool
}{
{name: "unset defaults to disabled", value: "", want: false},
{name: "true enables", value: "true", want: true},
{name: "mixed case true enables", value: "True", want: true},
{name: "false stays disabled", value: "false", want: false},
{name: "arbitrary value stays disabled", value: "1", want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.value == "" {
t.Setenv(LockFileEnvVar, "")
} else {
t.Setenv(LockFileEnvVar, tt.value)
}
if got := LockFileFeatureEnabled(); got != tt.want {
t.Errorf("LockFileFeatureEnabled() = %v, want %v", got, tt.want)
}
})
}
}
28 changes: 28 additions & 0 deletions pkg/skills/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,34 @@ type InstallOptions struct {
Reference string `json:"-"`
// Digest is the OCI digest for upgrade detection.
Digest string `json:"-"`
// LockSource overrides the value recorded as the lock entry's Source. When
// empty, the entry's Source is Name as given by the caller before any
// internal resolution. Set by Sync/Upgrade, which pass an already-resolved
// Name that must not overwrite the entry's original Source. Internal use
// only — NOT exposed via HTTP API.
LockSource string `json:"-"`
// RequiredByParent is set when this install is a transitively materialized
// dependency (toolhive.requires) of another skill, naming that parent.
// Empty means the user explicitly requested this install. Internal use
// only — NOT exposed via HTTP API.
RequiredByParent string `json:"-"`
// Visited tracks skill names already materialized in this dependency
// tree, preventing infinite recursion on a requires cycle. Left nil by
// external callers; Install initializes it on first entry and threads it
// through recursive dependency installs. Internal use only — NOT exposed
// via HTTP API.
Visited map[string]struct{} `json:"-"`
}

// InstallResult contains the outcome of an Install operation.
type InstallResult struct {
// Skill is the installed skill.
Skill InstalledSkill `json:"skill"`
// PreExisting is the store record as it was before this install, or nil
// when this install created the record. Rollback uses it to restore the
// previous state instead of destructively deleting a record this call
// did not create. Internal use only — NOT exposed via HTTP API.
PreExisting *InstalledSkill `json:"-"`
}

// UninstallOptions configures the behavior of the Uninstall operation.
Expand All @@ -53,6 +75,12 @@ type UninstallOptions struct {
Scope Scope `json:"scope,omitempty"`
// ProjectRoot is the project root path for project-scoped skills.
ProjectRoot string `json:"project_root,omitempty"`
// Visited tracks skill names already removed in this cascade-uninstall
// tree, preventing infinite recursion on a requiredBy cycle. Left nil by
// external callers; Uninstall initializes it on first entry and threads
// it through recursive cascade removals. Internal use only — NOT exposed
// via HTTP API.
Visited map[string]struct{} `json:"-"`
}

// InfoOptions configures the behavior of the Info operation.
Expand Down
73 changes: 73 additions & 0 deletions pkg/skills/skillsvc/content_digest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package skillsvc

import (
"fmt"
"os"

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

// skillMDFileName is the well-known skill definition file every installed
// skill directory contains.
const skillMDFileName = "SKILL.md"

// computeContentDigest hashes the on-disk file set for an installed skill,
// for lock file integrity verification. Every client directory a skill is
// installed into is written from the same source, so any one of them is
// representative; the first client in the skill's Clients list is used.
func computeContentDigest(pathResolver skills.PathResolver, sk skills.InstalledSkill) (string, error) {
dir, err := installedSkillDir(pathResolver, sk)
if err != nil {
return "", err
}
digest, err := lockfile.ContentDigestFromDir(dir)
if err != nil {
return "", fmt.Errorf("computing content digest: %w", err)
}
return digest, nil
}

// readSkillMD reads and parses SKILL.md from an installed skill's directory,
// for discovering toolhive.requires dependencies at install time. The
// artifact source (OCI labels or a git checkout) is not normalized to expose
// requires before extraction, but every installed skill has SKILL.md on disk
// once extraction succeeds, so reading it back is the uniform way to recover
// dependency declarations regardless of source type.
func readSkillMD(pathResolver skills.PathResolver, sk skills.InstalledSkill) (*skills.ParseResult, error) {
dir, err := installedSkillDir(pathResolver, sk)
if err != nil {
return nil, err
}
root, err := os.OpenRoot(dir)
if err != nil {
return nil, fmt.Errorf("opening skill directory: %w", err)
}
defer func() { _ = root.Close() }()

content, err := root.ReadFile(skillMDFileName)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", skillMDFileName, err)
}
parsed, err := skills.ParseSkillMD(content)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", skillMDFileName, err)
}
return parsed, nil
}

// installedSkillDir resolves the filesystem path for one of sk's installed
// clients, used as a representative directory for reading back on-disk state.
func installedSkillDir(pathResolver skills.PathResolver, sk skills.InstalledSkill) (string, error) {
if len(sk.Clients) == 0 {
return "", fmt.Errorf("skill %q has no installed clients", sk.Metadata.Name)
}
dir, err := pathResolver.GetSkillPath(sk.Clients[0], sk.Metadata.Name, sk.Scope, sk.ProjectRoot)
if err != nil {
return "", fmt.Errorf("resolving skill path: %w", err)
}
return dir, nil
}
Loading
Loading