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
4 changes: 4 additions & 0 deletions docs/server/docs.go

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

4 changes: 4 additions & 0 deletions docs/server/swagger.json

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

6 changes: 6 additions & 0 deletions docs/server/swagger.yaml

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

20 changes: 20 additions & 0 deletions pkg/skills/lock_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package skills

import "context"

//go:generate mockgen -destination=mocks/mock_lock_service.go -package=mocks -source=lock_service.go SkillLockService

// SkillLockService defines the interface for operations driven by a
// project's toolhive.lock.yaml. It is separate from [SkillService] because
// Sync and Upgrade operate over the whole lock file rather than a single
// named skill, per RFC THV-0080.
type SkillLockService interface {
// Sync restores the project's installed skills to match its lock file.
Sync(ctx context.Context, opts SyncOptions) (*SyncResult, error)
// Upgrade re-resolves each lock entry's source and installs newer content
// when the resolved digest has changed.
Upgrade(ctx context.Context, opts UpgradeOptions) (*UpgradeResult, error)
}
72 changes: 72 additions & 0 deletions pkg/skills/mocks/mock_lock_service.go

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

128 changes: 128 additions & 0 deletions pkg/skills/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,134 @@ type PushOptions struct {
Reference string `json:"reference"`
}

// SyncOptions configures the behavior of the Sync operation.
type SyncOptions struct {
// ProjectRoot is the project root path whose lock file should be synced.
ProjectRoot string `json:"project_root"`
// Clients lists target clients (e.g., "claude-code"). Empty means every
// skill-supporting client detected on this host.
Clients []string `json:"clients,omitempty"`
// Prune removes project-scoped skills that are installed but not present
// in the lock file. When false, such skills are only reported.
Prune bool `json:"prune,omitempty"`
// Check verifies on-disk content against contentDigest without installing
// or writing anything.
Check bool `json:"check,omitempty"`
// Adopt writes lock entries for existing unmanaged project-scope installs.
Adopt bool `json:"adopt,omitempty"`
}

// FailureReason is a typed failure reason for sync/upgrade operations, per
// RFC THV-0080's exit-code and automation contract. Only reasons the current
// feature set can actually produce are defined; the RFC's remaining values
// (the Sigstore verification reasons and ref-change-blocked, which surfaces
// as an UpgradeStatus rather than a failure) land together with the code
// that emits them.
type FailureReason string

// Typed failure reasons for sync/upgrade operations.
const (
// FailureReasonRegistryUnreachable means the skill's remote source — an
// OCI registry or a git host — could not be reached.
FailureReasonRegistryUnreachable FailureReason = "registry-unreachable"
FailureReasonDigestMissing FailureReason = "digest-missing"
FailureReasonValidationRejected FailureReason = "validation-rejected"
FailureReasonLockWriteFailed FailureReason = "lock-write-failed"
FailureReasonUnknown FailureReason = "unknown"
)

// SyncFailure describes a single skill that failed to sync.
type SyncFailure struct {
// Name is the skill name that failed.
Name string `json:"name"`
// Reason is a typed failure reason for CI and automation.
Reason FailureReason `json:"reason,omitempty"`
// Error is a human-readable description of the failure.
Error string `json:"error"`
}

// SyncResult contains the outcome of a Sync operation.
type SyncResult struct {
// Installed lists skills that were installed or reinstalled to match the lock file.
Installed []string `json:"installed,omitempty"`
// Drifted lists skills whose on-disk contentDigest differed from the lock
// file. Normally these are reinstalled to match it; when Check is set,
// nothing is written and this field reports the drift only.
Drifted []string `json:"drifted,omitempty"`
// AlreadyCurrent lists skills that already matched the lock file.
AlreadyCurrent []string `json:"already_current,omitempty"`
// NeverManaged lists project-scoped skills never recorded as lock-managed.
NeverManaged []string `json:"never_managed,omitempty"`
// RemovedFromLock lists previously managed skills absent from the lock file.
RemovedFromLock []string `json:"removed_from_lock,omitempty"`
// Pruned lists removed-from-lock skills that were uninstalled because Prune was set.
Pruned []string `json:"pruned,omitempty"`
// Failed lists skills that could not be synced, with the reason for each.
// Drift alone is never reported here — see Drifted.
Failed []SyncFailure `json:"failed,omitempty"`
}

// UpgradeOptions configures the behavior of the Upgrade operation.
type UpgradeOptions struct {
// ProjectRoot is the project root path whose lock file should be upgraded.
ProjectRoot string `json:"project_root"`
// Names restricts the upgrade to specific skill names. Empty means every
// entry in the lock file.
Names []string `json:"names,omitempty"`
// Preview reports what would change without installing (still fetches
// artifacts to compare digests).
Preview bool `json:"preview,omitempty"`
// FailOnChanges exits with an error when any mutable source would upgrade.
FailOnChanges bool `json:"fail_on_changes,omitempty"`
// AllowRefChange permits resolvedReference changes during upgrade.
AllowRefChange bool `json:"allow_ref_change,omitempty"`
// Clients lists target clients (e.g., "claude-code"). Empty means every
// skill-supporting client detected on this host.
Clients []string `json:"clients,omitempty"`
}

// UpgradeStatus represents the outcome of upgrading a single skill.
type UpgradeStatus string

const (
// UpgradeStatusUpgraded indicates the skill was installed at a new digest.
UpgradeStatusUpgraded UpgradeStatus = "upgraded"
// UpgradeStatusUpToDate indicates the resolved source still points at the pinned digest.
UpgradeStatusUpToDate UpgradeStatus = "up-to-date"
// UpgradeStatusNotUpgradable indicates the entry is pinned to an immutable
// reference (an OCI digest or a full git commit hash) and cannot be upgraded.
UpgradeStatusNotUpgradable UpgradeStatus = "not-upgradable"
// UpgradeStatusRefChangeBlocked indicates re-resolution changed resolvedReference.
UpgradeStatusRefChangeBlocked UpgradeStatus = "ref-change-blocked"
// UpgradeStatusFailed indicates the upgrade attempt failed.
UpgradeStatusFailed UpgradeStatus = "failed"
)

// UpgradeOutcome describes the result of attempting to upgrade one skill.
type UpgradeOutcome struct {
// Name is the skill name.
Name string `json:"name"`
// Status is the outcome of the upgrade attempt.
Status UpgradeStatus `json:"status"`
// OldDigest is the digest pinned in the lock file before this operation.
OldDigest string `json:"old_digest,omitempty"`
// NewDigest is the digest the source currently resolves to. Equal to
// OldDigest when Status is UpgradeStatusUpToDate.
NewDigest string `json:"new_digest,omitempty"`
// NewResolvedReference is the new resolvedReference when it changed.
NewResolvedReference string `json:"new_resolved_reference,omitempty"`
// Reason is a typed failure reason when Status is UpgradeStatusFailed.
Reason FailureReason `json:"reason,omitempty"`
// Error is a human-readable description of the failure, set only when Status is UpgradeStatusFailed.
Error string `json:"error,omitempty"`
}

// UpgradeResult contains the outcome of an Upgrade operation.
type UpgradeResult struct {
// Outcomes contains one entry per skill considered for upgrade.
Outcomes []UpgradeOutcome `json:"outcomes"`
}

// LocalBuild represents a locally-built OCI skill artifact in the local store.
type LocalBuild struct {
// Tag is the OCI tag or name used to reference the artifact.
Expand Down
4 changes: 4 additions & 0 deletions pkg/skills/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,10 @@ type InstalledSkill struct {
Clients []string `json:"clients,omitempty"`
// Dependencies is the list of external skill dependencies.
Dependencies []Dependency `json:"dependencies,omitempty"`
// Managed indicates this install is tracked in the project's
// toolhive.lock.yaml. Only ever true for project-scoped installs. No
// omitempty: false is an observable state (unmanaged), not an absence.
Managed bool `json:"managed"`
}

// SkillIndexEntry represents a single skill entry in a remote skill index.
Expand Down
7 changes: 7 additions & 0 deletions pkg/storage/sqlite/migrations/003_add_managed_flag.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- +goose Up

ALTER TABLE installed_skills ADD COLUMN managed INTEGER NOT NULL DEFAULT 0;

-- +goose Down

ALTER TABLE installed_skills DROP COLUMN managed;
45 changes: 45 additions & 0 deletions pkg/storage/sqlite/migrations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,51 @@ func newGooseProvider(t *testing.T, db *sql.DB) *goose.Provider {
return provider
}

// TestMigrations_ManagedFlagAppliesOverPriorState verifies migration 003 adds
// installed_skills.managed with a default of 0 (false) so rows created by an
// earlier version of the schema (001/002) are not implicitly "managed" once
// the column appears.
func TestMigrations_ManagedFlagAppliesOverPriorState(t *testing.T) {
t.Parallel()
ctx := t.Context()
dbPath := filepath.Join(t.TempDir(), "test.db")

db, err := Open(ctx, dbPath)
require.NoError(t, err)
defer db.Close()

provider := newGooseProvider(t, db.DB())

// Roll back to just after migration 002, before "managed" existed.
_, err = provider.DownTo(ctx, 2)
require.NoError(t, err)

_, err = db.DB().Exec(`INSERT INTO entries (entry_type, name) VALUES ('skill', 'pre-migration-skill')`)
require.NoError(t, err)
_, err = db.DB().Exec(`INSERT INTO installed_skills (entry_id, scope) VALUES (1, 'user')`)
require.NoError(t, err)

// Re-apply Up: migration 003 adds the column to the pre-existing row.
_, err = provider.Up(ctx)
require.NoError(t, err)

var managed int
err = db.DB().QueryRow(`SELECT managed FROM installed_skills WHERE entry_id = 1`).Scan(&managed)
require.NoError(t, err)
assert.Equal(t, 0, managed, "a row created before migration 003 must default to unmanaged")

// Down for 003 removes the column without disturbing 001/002 tables.
_, err = provider.DownTo(ctx, 2)
require.NoError(t, err)
var count int
err = db.DB().QueryRow(
`SELECT COUNT(*) FROM pragma_table_info('installed_skills') WHERE name = 'managed'`,
).Scan(&count)
require.NoError(t, err)
assert.Equal(t, 0, count, "managed column should be dropped by 003 Down")
assert.True(t, tableExists(t, db.DB(), "installed_skills"), "installed_skills table itself must remain")
}

// TestMigrations_DownDropsPluginTables verifies that goose's Down for migration
// 002 drops the plugin tables (installed_plugins, plugin_dependencies) while
// leaving migration 001's tables (entries, installed_skills, skill_dependencies,
Expand Down
Loading
Loading