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
102 changes: 102 additions & 0 deletions cmd/sjskills/local_source_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package main

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)

func writeLocalFixtureSkill(t *testing.T, path, name, body string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("---\nname: "+name+"\ndescription: Fixture\n---\n"+body), 0o644); err != nil {
t.Fatal(err)
}
}

func localPlanActions(t *testing.T, directory string, overrides map[string]string) (map[string]string, sjskillsEnvelope) {
t.Helper()
code, stdout, stderr := runCLIWithEnvironment(t, directory, overrides, "--json", "plan")
envelope := decodeEnvelope(t, stdout)
if code != 0 {
return nil, envelope
}
if stderr != "" {
t.Fatalf("plan stderr=%q", stderr)
}
actions := map[string]string{}
for _, operation := range envelope.Plan.Operations {
actions[operation.Skill+"@"+operation.Target] = operation.Action + "/" + operation.Reason
}
return actions, envelope
}

func TestExternalLocalDirectSourceLifecycle(t *testing.T) {
project := t.TempDir()
source := filepath.Join(project, "skills", "team-tool")
writeLocalFixtureSkill(t, source, "team-tool", "# v1\n")
if err := os.WriteFile(filepath.Join(project, "sjskills.toml"), []byte("version = 1\n\n[[direct]]\nname = \"team-tool\"\nsource = \"./skills/team-tool\"\n"), 0o644); err != nil {
t.Fatal(err)
}
overrides := isolatedExternalHomes(t)
// Run from a nested directory: relative sources resolve against the
// manifest, never the process working directory.
directory := filepath.Join(project, "skills")

expectActions := func(want string) {
t.Helper()
actions, envelope := localPlanActions(t, directory, overrides)
if actions == nil {
t.Fatalf("plan failed: %#v", envelope.Error)
}
if len(actions) != 2 || actions["[email protected]"] != want || actions["[email protected]"] != want {
t.Fatalf("plan actions = %#v, want %s for both targets", actions, want)
}
}
apply := func() {
t.Helper()
if code, stdout, stderr := runCLIWithEnvironment(t, directory, overrides, "--json", "apply", "--yes"); code != 0 {
t.Fatalf("apply code=%d stdout=%s stderr=%s", code, stdout, stderr)
}
}

expectActions("install/expected-entry-absent")
apply()
expectActions("unchanged/verified-exact")
for _, target := range []string{".agents", ".claude"} {
data, err := os.ReadFile(filepath.Join(project, target, "skills", "team-tool", "SKILL.md"))
if err != nil || !strings.HasSuffix(string(data), "# v1\n") {
t.Fatalf("%s placement = %q err=%v", target, data, err)
}
}
var state struct {
Records []struct {
SourceIdentity string `json:"sourceIdentity"`
} `json:"records"`
}
data, err := os.ReadFile(filepath.Join(project, ".sjskills", "state.json"))
if err != nil || json.Unmarshal(data, &state) != nil || len(state.Records) != 2 || state.Records[0].SourceIdentity != "local:./skills/team-tool" {
t.Fatalf("provenance = %s err=%v", data, err)
}

writeLocalFixtureSkill(t, source, "team-tool", "# v2\n")
expectActions("update/verified-update")
apply()
expectActions("unchanged/verified-exact")

if err := os.RemoveAll(source); err != nil {
t.Fatal(err)
}
actions, envelope := localPlanActions(t, directory, overrides)
if actions != nil || envelope.Result != "invalid" || envelope.Error == nil || envelope.Error.Code != "invalid_source" ||
!strings.Contains(envelope.Error.Message, `local source "./skills/team-tool" does not exist`) || !strings.Contains(envelope.Error.Message, "[[direct]]") {
t.Fatalf("missing source plan = %#v error=%#v", envelope, envelope.Error)
}
if _, err := os.Stat(filepath.Join(project, ".claude", "skills", "team-tool", "SKILL.md")); err != nil {
t.Fatalf("blocked plan changed the placement: %v", err)
}
}
14 changes: 14 additions & 0 deletions cmd/sjskills/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ func (a *application) prepare(ctx context.Context, global bool, operation sjskil
return nil, a.invalid(operation, readErr)
}
request.Manifest = &manifest
request.ProjectRoot = discovered.Root
project = &discovered
envelope.Path = discovered.ManifestPath
}
Expand All @@ -385,6 +386,19 @@ func (a *application) prepare(ctx context.Context, global bool, operation sjskil
ctx = context.Background()
}
materialized, materializeErr := materialize(ctx, plan.Desired.Skills)
var localErr *sjskills.LocalSourceError
if errors.As(materializeErr, &localErr) {
// A missing or malformed local source is a manifest/working-tree
// problem, not an outage: report it as invalid input so it is fixed
// rather than retried.
if materialized != nil {
_ = a.materializationCleanup()(materialized)
}
envelope := prepared.envelope
envelope.Result = sjskills.ResultInvalid
envelope.Error = &sjskills.Issue{Code: sjskills.IssueInvalidSource, Path: "manifest.direct." + localErr.Skill + ".source", Message: localErr.Error()}
return nil, envelope
}
if materializeErr != nil {
if materialized != nil {
prepared.materialized = materialized
Expand Down
3 changes: 2 additions & 1 deletion docs/sjskills-releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ The installers require no checkout, Go, or GitHub CLI. macOS uses system shell,
`curl`, `tar`, and `shasum`; Windows uses Windows PowerShell 5.1 or newer and
.NET. `plan` and `apply` require network access to the published registry,
Bun (`bunx`) for the pinned Skills CLI, and Git, which fetches agent-scripts
skills at the registry's commit. Help and version perform no status checks. Profiles and manifest
skills at the registry's commit; a selection made only of local direct sources
needs neither. Help and version perform no status checks. Profiles and manifest
initialization retain their primary behavior without Bun; their incidental
notices may report unavailable upstream evidence. Bare/named `status` also
succeeds with explicit unavailable skill evidence when no cached evidence or Bun
Expand Down
27 changes: 26 additions & 1 deletion docs/skill-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,30 @@ boundary. Skills CLI-managed sources must be Git shorthand
credentials, URL query strings, npm specifiers, and other schemes are rejected
for that manager.

A project manifest's `[[direct]]` entry may instead name a local skill
directory, so a project can install a skill it develops in-repo. The registry
and the fixed global baseline stay remote-only. Local sources use forward
slashes and are either relative to the directory containing `sjskills.toml`
(starting `./` or `../`) or absolute (`/path` or `C:/path`); home-relative and
backslash spellings are rejected, as are `access` other than public and
`full_depth`. Paths outside the project resolve but warn
(`machine-specific-source`), because other checkouts need the same path.
`sjskills` copies the directory into staging itself, without Skills CLI, and
then treats it like any fetched tree. Provenance records
`local:<normalized manifest path>` with the tree hash, so editing the source
produces an ordinary update. The directory must be a real directory with a
`SKILL.md` whose frontmatter `name` matches the entry, contain only regular
files and directories, not lie inside a generated `.agents/skills`,
`.claude/skills`, or `.sjskills` directory, and neither contain generated
skill roots nor be or contain a project (any `sjskills.toml`). Checks use the
symlink-resolved path, and the copy is read from that same path. A missing or invalid source makes `plan` and
`apply` fail as invalid input (exit 65) with a message naming it; status
reports the project scope unavailable with the same message. Switching an
installed skill between remote and local sources, or respelling its path,
changes its provenance identity and blocks as `provenance-source-mismatch`:
remove the entry and apply, then re-add it and apply. Provenance containing
`local:` identities requires an `sjskills` build that supports local sources.

Public selections use the pinned Skills CLI's remote-fetch path and do not
invoke gh. Skills CLI cannot clone a commit, so public GitHub sources pinned to
a full commit, including every agent-scripts skill, are fetched anonymously
Expand Down Expand Up @@ -233,7 +257,8 @@ reused. “No drift detected” does not verify externally provisioned manual or
workflow tools. Protected locations retain their existing ownership boundaries.

Disposable complete expected-hash snapshots live under the platform user-cache
directory in `sjskills/status/`. Identity includes the sorted Skills CLI-managed
directory in `sjskills/status/`. Local direct sources are never cached; each
check hashes them afresh. Identity includes the sorted Skills CLI-managed
selection's names, exact source strings, effective access, copy mode, full-depth
options, and the Skills CLI, tree-hash, and cache format versions. Matching selections share
evidence across roots and scopes. Placement targets, profile names, and unrelated
Expand Down
8 changes: 4 additions & 4 deletions internal/sjskills/access_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (

func TestAccessProfileSelectionAndPublicCompatibility(t *testing.T) {
r := fixtureRegistry(t)
public, err := ResolveProject(r, Manifest{Version: 1, Profiles: []string{"kicpa"}})
public, err := ResolveProject(r, Manifest{Version: 1, Profiles: []string{"kicpa"}}, "")
if err != nil {
t.Fatal(err)
}
Expand All @@ -20,7 +20,7 @@ func TestAccessProfileSelectionAndPublicCompatibility(t *testing.T) {
t.Fatal(s)
}
}
mixed, err := ResolveProject(r, Manifest{Version: 1, Profiles: []string{"kicpa", "kicpa-private"}})
mixed, err := ResolveProject(r, Manifest{Version: 1, Profiles: []string{"kicpa", "kicpa-private"}}, "")
if err != nil {
t.Fatal(err)
}
Expand All @@ -47,7 +47,7 @@ func TestAccessProfileSelectionAndPublicCompatibility(t *testing.T) {
r.Profiles[name] = profile
}
}
omitted, err := ResolveProject(r, Manifest{Version: 1, Profiles: []string{"kicpa"}})
omitted, err := ResolveProject(r, Manifest{Version: 1, Profiles: []string{"kicpa"}}, "")
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -76,7 +76,7 @@ func TestAccessDecodingRejectsMalformedAndPreservesDirect(t *testing.T) {
if err != nil {
t.Fatal(err)
}
desired, err := ResolveProject(fixtureRegistry(t), m)
desired, err := ResolveProject(fixtureRegistry(t), m, "")
if err != nil {
t.Fatal(err)
}
Expand Down
9 changes: 8 additions & 1 deletion internal/sjskills/classify.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ func validateProjectClassificationInputs(desired DesiredState, expected map[stri
issues = append(issues, Issue{Code: IssueInvalidMode, Path: path + ".mode", Message: "skills-cli installation must use copy mode"})
}
if _, ok := canonicalProjectSourceIdentity(skill.Source); !ok {
issues = append(issues, Issue{Code: IssueInvalidSource, Path: path + ".source", Message: "must be a credential-free remote source"})
issues = append(issues, Issue{Code: IssueInvalidSource, Path: path + ".source", Message: "must be a credential-free remote source or local path"})
}
case ManagerManual, ManagerWorkflow:
case ManagerNone:
Expand Down Expand Up @@ -485,10 +485,14 @@ func detachProjectClassification(classification ProjectClassification) ProjectCl
// compare as ownership evidence. GitHub URLs intentionally use repository
// identity like the legacy reconciler; shorthand catalog suffixes remain part
// of the identity so two shorthand paths cannot silently claim one another.
// Local sources use their normalized manifest spelling under `local:`.
func canonicalProjectSourceIdentity(source string) (string, bool) {
if source == "" || strings.TrimSpace(source) != source || strings.ContainsRune(source, 0) {
return "", false
}
if IsLocalSource(source) {
return localSourceIdentity(source)
}
if !strings.Contains(source, "://") {
parts := strings.Split(source, "/")
if len(parts) < 2 {
Expand Down Expand Up @@ -579,6 +583,9 @@ func canonicalProjectSourceIdentity(source string) (string, bool) {
}

func isCanonicalProjectSourceIdentity(identity string) bool {
if strings.HasPrefix(identity, localSourceIdentityPrefix) {
return isCanonicalLocalSourceIdentity(identity)
}
if strings.HasPrefix(identity, "github:") {
rest := strings.TrimPrefix(identity, "github:")
if !projectCanonicalIdentityPattern.MatchString(rest) || strings.ToLower(rest) != rest {
Expand Down
14 changes: 7 additions & 7 deletions internal/sjskills/contracts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func TestCanonicalRegistryAndProfiles(t *testing.T) {
func TestProjectResolutionDevGoAndDirect(t *testing.T) {
registry := fixtureRegistry(t)
manifest := fixtureManifest(t, "dev-go.toml")
state, err := ResolveProject(registry, manifest)
state, err := ResolveProject(registry, manifest, "")
if err != nil {
t.Fatal(err)
}
Expand All @@ -94,7 +94,7 @@ func TestProjectResolutionDevGoAndDirect(t *testing.T) {
}

manifest = fixtureManifest(t, "direct-third-party.toml")
state, err = ResolveProject(registry, manifest)
state, err = ResolveProject(registry, manifest, "")
if err != nil {
t.Fatal(err)
}
Expand All @@ -118,7 +118,7 @@ func TestCanonicalManifestIncludesDirectSourceIdentity(t *testing.T) {
if len(manifest.Profiles) != 2 || len(manifest.Direct) != 1 {
t.Fatalf("manifest = %#v", manifest)
}
state, err := ResolveProject(fixtureRegistry(t), manifest)
state, err := ResolveProject(fixtureRegistry(t), manifest, "")
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -161,7 +161,7 @@ func TestBuildPlanKeepsWarningsAndEvidenceAtPureBoundary(t *testing.T) {
}

func TestKicpaAndManagerBoundaries(t *testing.T) {
state, err := ResolveProject(fixtureRegistry(t), fixtureManifest(t, "kicpa.toml"))
state, err := ResolveProject(fixtureRegistry(t), fixtureManifest(t, "kicpa.toml"), "")
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -190,7 +190,7 @@ func TestKicpaAndManagerBoundaries(t *testing.T) {
registry.Profiles["dev"] = Profile{Skills: append(registry.Profiles["dev"].Skills, "context7-mcp")}
slices.Sort(registry.Profiles["dev"].Skills)
dev := fixtureManifest(t, "dev-go.toml")
state, err = ResolveProject(registry, dev)
state, err = ResolveProject(registry, dev, "")
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -233,7 +233,7 @@ func TestManifestAndRegistryInvalidCases(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := ResolveProject(registry, manifest); err == nil || !issueCode(err, IssueCollision) {
if _, err := ResolveProject(registry, manifest, ""); err == nil || !issueCode(err, IssueCollision) {
t.Fatalf("%s resolution error = %v, want collision", name, err)
}
}
Expand Down Expand Up @@ -343,7 +343,7 @@ func TestSelectionScopedCollisionsAndUnselectedDirectCatalog(t *testing.T) {
if err := ValidateManifest(registry, manifest); err != nil {
t.Fatalf("unselected catalog direct should be allowed: %v", err)
}
state, err := ResolveProject(registry, manifest)
state, err := ResolveProject(registry, manifest, "")
if err != nil {
t.Fatal(err)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/sjskills/global_inventory.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ func decodeGlobalProvenanceState(data []byte) (GlobalProvenanceState, GlobalProv
}
identity, ok := canonicalProjectSourceIdentity(record.Source)
key := projectPlacementKey(target, record.Skill)
if !ok || !isPortableName(record.Skill) || record.HashAlgorithm != TreeHashAlgorithmSHA256V2 || !lowercaseDigestPattern.MatchString(record.Hash) || record.RecordedAt.IsZero() {
if !ok || IsLocalSource(record.Source) || !isPortableName(record.Skill) || record.HashAlgorithm != TreeHashAlgorithmSHA256V2 || !lowercaseDigestPattern.MatchString(record.Hash) || record.RecordedAt.IsZero() {
return GlobalProvenanceState{}, "", false
}
if _, duplicate := seen[key]; duplicate {
Expand Down Expand Up @@ -343,7 +343,7 @@ func validGlobalProvenanceRecords(records []ProvenanceRecord) bool {
key := projectPlacementKey(record.Target, record.Skill)
if record.Scope != ScopeGlobal || !isPortableName(record.Skill) ||
(record.Target != TargetAgents && record.Target != TargetClaude) ||
!isCanonicalProjectSourceIdentity(record.SourceIdentity) ||
!isCanonicalProjectSourceIdentity(record.SourceIdentity) || isCanonicalLocalSourceIdentity(record.SourceIdentity) ||
record.TreeHashAlgorithm != TreeHashAlgorithmSHA256V2 ||
!lowercaseDigestPattern.MatchString(record.TreeHash) || record.RecordedAt.IsZero() {
return false
Expand Down
2 changes: 1 addition & 1 deletion internal/sjskills/global_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ func validateGlobalClassificationInputs(registry Registry, desired DesiredState,
if skill.Mode != ModeCopy {
issues = append(issues, Issue{Code: IssueInvalidMode, Path: path + ".mode", Message: "skills-cli installation must use copy mode"})
}
if _, ok := canonicalProjectSourceIdentity(skill.Source); !ok {
if _, ok := canonicalProjectSourceIdentity(skill.Source); !ok || IsLocalSource(skill.Source) {
issues = append(issues, Issue{Code: IssueInvalidSource, Path: path + ".source", Message: "must be a credential-free remote source"})
}
} else if skill.Manager != ManagerManual && skill.Manager != ManagerWorkflow {
Expand Down
Loading
Loading