diff --git a/cmd/ax/internal/cliutil/cliutil.go b/cmd/ax/internal/cliutil/cliutil.go index 04b1b12..1b4285b 100644 --- a/cmd/ax/internal/cliutil/cliutil.go +++ b/cmd/ax/internal/cliutil/cliutil.go @@ -26,7 +26,9 @@ import ( "github.com/google/ax/internal/harness/antigravity" "github.com/google/ax/internal/harness/antigravityinteractions" "github.com/google/ax/internal/harness/substrate" + "github.com/google/ax/internal/skills" "github.com/google/ax/internal/skills/geminienterprise" + "github.com/google/ax/internal/skills/local" ) // Controller is the active controller type for this build. @@ -65,20 +67,28 @@ func NewControllerFromConfig(ctx context.Context, cfg *Config) (*controller.Cont var defaultHarnessID string var err error - // Materialize registry skills once, up front (skills config is top-level and + // Resolve skills once, up front (skills config is top-level and // harness-agnostic; each actor runs a single harness that consumes the - // materialized folder). Unconditional when configured. Fail-safe: a registry - // error degrades capability but never blocks harness creation. The - // interactions harness (no SKILLS_DIR concept) is told where the materialized - // skills are via a pointer appended to its system instruction. Only the local - // path materializes; substrate/pod does not yet read ax.yaml. + // resulting folder). Two parallel sources feed the same result: + // - Registries: fetched from the Gemini Enterprise Skill Registry and + // materialized to their target_dir. + // - Local: directories already on disk, used as-is. + // Both are unconditional when configured and fail-safe: a source error + // degrades capability but never blocks harness creation. The interactions + // harness (no SKILLS_DIR concept) is told where the skills are via a pointer + // appended to its system instruction. Only the local process path resolves + // skills; substrate/pod does not yet read ax.yaml. // // TODO(joycel): wire the Antigravity SDK harness too. It discovers skills via - // SKILLS_DIR, so its SKILLS_DIR needs to be pointed at the materialized - // target_dir; currently only the interactions harness is fully wired. + // SKILLS_DIR, so its SKILLS_DIR needs to be pointed at the resolved skill + // directories (registry target_dir(s) and/or local path(s)); currently only + // the interactions harness is fully wired. var skillsPointer string if !substrateMode { - skillsPointer = antigravityinteractions.SkillsSystemInstruction(geminienterprise.Materialize(ctx, cfg.Skills)) + var avail skills.Available + avail.Groups = append(avail.Groups, geminienterprise.Materialize(ctx, cfg.Skills).Groups...) + avail.Groups = append(avail.Groups, local.Discover(cfg.Skills).Groups...) + skillsPointer = antigravityinteractions.SkillsSystemInstruction(avail) } // Built-in Antigravity harness. diff --git a/internal/config/config.go b/internal/config/config.go index 58ec500..9130bb1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,6 +19,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/google/ax/internal/harness" "github.com/google/ax/internal/harness/substrate" @@ -48,10 +49,10 @@ type Config struct { Server ServerConfig `yaml:"server"` EventLog EventLogConfig `yaml:"eventlog"` Harnesses HarnessesConfig `yaml:"harnesses,omitempty"` - // Skills sources skills from the Gemini Enterprise Skill Registry into on-disk folders - // before the harness starts. It is harness-agnostic: each actor runs exactly - // one harness, which consumes the materialized folder(s). Optional; disabled - // when no registry is enabled. + // Skills makes agent skills available on disk before the harness starts. It + // is harness-agnostic: each actor runs exactly one harness, which consumes + // the resulting folder(s). Optional; disabled when no source is enabled. See + // SkillsConfig for the available sources. Skills SkillsConfig `yaml:"skills,omitempty"` Telemetry TelemetryConfig `yaml:"telemetry,omitempty"` } @@ -115,11 +116,19 @@ type AntigravityInteractionsHarnessConfig struct { } // SkillsConfig configures optional skill sources (top-level, harness-agnostic). -// Today the only source type is the Gemini Enterprise Skill Registry; it may source from more -// than one registry (e.g. a shared org-wide registry plus a team-specific one), -// each with its own project/location, selection, and target directory. +// There are two parallel source types: +// - Registries source skills from the Gemini Enterprise Skill Registry, +// materializing them into a target directory. +// - Local points at directories that already contain skills on disk (each a +// parent dir holding /SKILL.md subfolders, matching the layout of +// examples/skills and a registry's target_dir). Nothing is fetched; the +// directory is used as-is. +// +// Both may be configured together, and both feed the same downstream consumers +// (e.g. a harness system-instruction pointer). type SkillsConfig struct { Registries []SkillsRegistryConfig `yaml:"registries,omitempty"` + Local []LocalSkillsConfig `yaml:"local,omitempty"` } // Validate checks the (top-level) skills config. @@ -129,6 +138,36 @@ func (s SkillsConfig) Validate() error { return err } } + for i := range s.Local { + if err := s.Local[i].validate(i); err != nil { + return err + } + } + return nil +} + +// LocalSkillsConfig sources skills from a directory already present on disk. The +// path is a parent directory containing one subfolder per skill (each with a +// SKILL.md); the subfolder name is the skill id. This mirrors the layout of +// examples/skills and a registry's target_dir, so nothing is downloaded. +type LocalSkillsConfig struct { + Enabled bool `yaml:"enabled,omitempty"` + // Path is the parent directory holding /SKILL.md subfolders. + // Required when Enabled. + Path string `yaml:"path,omitempty"` +} + +// validate checks one local skills source. idx is its index within the local +// list, for error context. Existence/readability of the path is intentionally +// not checked here: discovery is fail-safe (a bad path degrades capability +// rather than blocking harness creation), so it is validated at discovery time. +func (lc LocalSkillsConfig) validate(idx int) error { + if !lc.Enabled { + return nil + } + if strings.TrimSpace(lc.Path) == "" { + return fmt.Errorf("skills.local[%d] requires path (a directory containing /SKILL.md subfolders)", idx) + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index baebb03..413cfa9 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -345,3 +345,51 @@ func TestValidate_SkillsSelectionOneof(t *testing.T) { } }) } + +func TestValidate_LocalSkills(t *testing.T) { + withLocal := func(lcs ...LocalSkillsConfig) *Config { + c := validConfig() + c.Skills.Local = lcs + return c + } + + t.Run("disabled skips validation", func(t *testing.T) { + if err := withLocal(LocalSkillsConfig{Enabled: false}).Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil", err) + } + }) + + t.Run("enabled without path is an error", func(t *testing.T) { + err := withLocal(LocalSkillsConfig{Enabled: true}).Validate() + if err == nil || !strings.Contains(err.Error(), "path") { + t.Fatalf("Validate() = %v, want path error", err) + } + }) + + t.Run("enabled with path is valid", func(t *testing.T) { + if err := withLocal(LocalSkillsConfig{Enabled: true, Path: "./examples/skills"}).Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil", err) + } + }) + + t.Run("second local invalid is caught", func(t *testing.T) { + err := withLocal( + LocalSkillsConfig{Enabled: true, Path: "/tmp/a"}, + LocalSkillsConfig{Enabled: true}, // no path + ).Validate() + if err == nil || !strings.Contains(err.Error(), "local[1]") { + t.Fatalf("Validate() = %v, want error citing local[1]", err) + } + }) + + t.Run("registries and local can coexist", func(t *testing.T) { + c := validConfig() + c.Skills.Registries = []SkillsRegistryConfig{ + {Enabled: true, Project: "p", TargetDir: "/tmp/a", All: true}, + } + c.Skills.Local = []LocalSkillsConfig{{Enabled: true, Path: "/tmp/local"}} + if err := c.Validate(); err != nil { + t.Fatalf("Validate() = %v, want nil", err) + } + }) +} diff --git a/internal/harness/antigravityinteractions/skills.go b/internal/harness/antigravityinteractions/skills.go index 5e0d57f..20f6689 100644 --- a/internal/harness/antigravityinteractions/skills.go +++ b/internal/harness/antigravityinteractions/skills.go @@ -18,29 +18,31 @@ import ( "fmt" "strings" - "github.com/google/ax/internal/skills/geminienterprise" + "github.com/google/ax/internal/skills" ) // SkillsSystemInstruction builds a system-instruction pointer telling the agent -// where its materialized skills live and lists them. +// where its skills live and lists them. It is source-agnostic: the skills may +// come from the Gemini Enterprise Skill Registry or from a local directory -- +// both produce the same skills.Available shape. // // This is discovery logic specific to the Antigravity Interactions harness: it // has no SKILLS_DIR concept, so it must be told where to find skills via its // system instruction (its built-in file tools then read that directory). // Harnesses that auto-discover a skills directory (e.g. the Antigravity SDK // harness via SKILLS_DIR) do not use this. -func SkillsSystemInstruction(res geminienterprise.Result) string { - if res.Empty() { +func SkillsSystemInstruction(avail skills.Available) string { + if avail.Empty() { return "" } var b strings.Builder - for _, w := range res.Written { + for _, g := range avail.Groups { if b.Len() > 0 { b.WriteString("\n") } - fmt.Fprintf(&b, "Agent skills are available under %s. Available skills:", w.Dir) - for _, s := range w.Skills { - fmt.Fprintf(&b, " %s", s.SkillID) + fmt.Fprintf(&b, "Agent skills are available under %s. Available skills:", g.Dir) + for _, s := range g.Skills { + fmt.Fprintf(&b, " %s", s.ID) } b.WriteString(". Read a skill's SKILL.md before using it.") } diff --git a/internal/harness/antigravityinteractions/skills_test.go b/internal/harness/antigravityinteractions/skills_test.go index 151e993..02003df 100644 --- a/internal/harness/antigravityinteractions/skills_test.go +++ b/internal/harness/antigravityinteractions/skills_test.go @@ -18,33 +18,33 @@ import ( "strings" "testing" - "github.com/google/ax/internal/skills/geminienterprise" + "github.com/google/ax/internal/skills" ) func TestSkillsSystemInstruction(t *testing.T) { t.Run("empty result yields empty pointer", func(t *testing.T) { - if got := SkillsSystemInstruction(geminienterprise.Result{}); got != "" { + if got := SkillsSystemInstruction(skills.Available{}); got != "" { t.Errorf("empty result pointer = %q, want empty", got) } }) t.Run("mentions dir and skill ids", func(t *testing.T) { - res := geminienterprise.Result{Written: []geminienterprise.Written{{ + avail := skills.Available{Groups: []skills.Group{{ Dir: "/workspace", - Skills: []geminienterprise.MaterializedSkill{{SkillID: "emoji"}, {SkillID: "lowercase"}}, + Skills: []skills.Skill{{ID: "emoji"}, {ID: "lowercase"}}, }}} - got := SkillsSystemInstruction(res) + got := SkillsSystemInstruction(avail) if !strings.Contains(got, "/workspace") || !strings.Contains(got, "emoji") || !strings.Contains(got, "lowercase") { t.Errorf("pointer = %q, want it to mention dir and skill ids", got) } }) - t.Run("multiple registries produce multiple lines", func(t *testing.T) { - res := geminienterprise.Result{Written: []geminienterprise.Written{ - {Dir: "/a", Skills: []geminienterprise.MaterializedSkill{{SkillID: "s1"}}}, - {Dir: "/b", Skills: []geminienterprise.MaterializedSkill{{SkillID: "s2"}}}, + t.Run("multiple groups produce multiple lines", func(t *testing.T) { + avail := skills.Available{Groups: []skills.Group{ + {Dir: "/a", Skills: []skills.Skill{{ID: "s1"}}}, + {Dir: "/b", Skills: []skills.Skill{{ID: "s2"}}}, }} - got := SkillsSystemInstruction(res) + got := SkillsSystemInstruction(avail) if !strings.Contains(got, "/a") || !strings.Contains(got, "/b") || !strings.Contains(got, "\n") { t.Errorf("pointer = %q, want both dirs on separate lines", got) } diff --git a/internal/skills/geminienterprise/client.go b/internal/skills/geminienterprise/client.go index 93e4f72..2e385e9 100644 --- a/internal/skills/geminienterprise/client.go +++ b/internal/skills/geminienterprise/client.go @@ -144,7 +144,7 @@ type skillRef struct { // fetchResult reports the outcome of a fetch call. fetch is fail-safe: a skill // that cannot be fetched or unzipped is recorded in skipped. type fetchResult struct { - materialized []MaterializedSkill + materialized []materializedSkill skipped []skippedSkill } @@ -215,7 +215,7 @@ func (c *client) resolveSelection(ctx context.Context, sel selection) ([]skillRe } // fetchAndWrite fetches one skill's payload (latest or pinned) and unzips it. -func (c *client) fetchAndWrite(ctx context.Context, ref skillRef, targetDir string) (*MaterializedSkill, error) { +func (c *client) fetchAndWrite(ctx context.Context, ref skillRef, targetDir string) (*materializedSkill, error) { if ref.SkillID == "" { return nil, errors.New("skill ref has empty SkillID") } @@ -237,7 +237,7 @@ func (c *client) fetchAndWrite(ctx context.Context, ref skillRef, targetDir stri _ = os.RemoveAll(skillDir) return nil, fmt.Errorf("unzipping: %w", err) } - return &MaterializedSkill{SkillID: ref.SkillID, Revision: revision, Dir: skillDir}, nil + return &materializedSkill{SkillID: ref.SkillID, Revision: revision, Dir: skillDir}, nil } // fetchPayload returns the base64 zippedFilesystem and the concrete revision id diff --git a/internal/skills/geminienterprise/materialize.go b/internal/skills/geminienterprise/materialize.go index 9c2f942..4d9de0d 100644 --- a/internal/skills/geminienterprise/materialize.go +++ b/internal/skills/geminienterprise/materialize.go @@ -17,9 +17,10 @@ // Skill Registry (a managed, versioned catalog exposed over the Vertex AI // v1beta1 REST API) and writes each skill to //. // -// It is harness-agnostic: it only writes files and reports what it wrote (see -// Result). It knows nothing about specific harnesses, SKILLS_DIR, or discovery -// pointers — callers decide how a given harness is told where its skills are. +// It is harness-agnostic: it only writes files and reports what it wrote (as a +// mode-agnostic skills.Available). It knows nothing about specific harnesses, +// SKILLS_DIR, or discovery pointers — callers decide how a given harness is told +// where its skills are. // // Scope: read-only. This package never creates, updates, or deletes registry // skills (authoring is out of scope). @@ -32,6 +33,7 @@ import ( "strings" "github.com/google/ax/internal/config" + "github.com/google/ax/internal/skills" ) // Environment fallbacks for project/location (the registry target_dir is a @@ -43,24 +45,10 @@ const ( defaultRegistryLocation = "us-central1" ) -// Result reports what Materialize wrote: one Written entry per registry that -// produced skills, grouping the skills with the directory they landed in. -type Result struct { - Written []Written -} - -// Written groups the skills materialized from one registry with the directory -// they were written into (each skill at //). -type Written struct { - Dir string - Skills []MaterializedSkill -} - -// Empty reports whether nothing was materialized. -func (r Result) Empty() bool { return len(r.Written) == 0 } - -// MaterializedSkill records one skill written to disk. -type MaterializedSkill struct { +// materializedSkill records one skill written to disk. It carries the resolved +// revision (registry-specific detail) internally; Materialize converts these to +// the mode-agnostic skills.Available shape at its boundary. +type materializedSkill struct { SkillID string Revision string Dir string // path of the written skill folder (//) @@ -77,8 +65,8 @@ type MaterializedSkill struct { // registry's selection, or across registries sharing a dir), the FIRST writer // wins and later duplicates are skipped with a warning. Substrate/pod // materialization is a separate, later path; this wires the local flow only. -func Materialize(ctx context.Context, sc config.SkillsConfig) Result { - var res Result +func Materialize(ctx context.Context, sc config.SkillsConfig) skills.Available { + var avail skills.Available // claimed tracks (target_dir, skill-id) pairs already written across all // registries so the FIRST writer of an id into a dir wins; later duplicates // (within one registry's selection, or across registries sharing a dir) are @@ -118,9 +106,13 @@ func Materialize(ctx context.Context, sc config.SkillsConfig) Result { } log.Printf("skills: registries[%d] materialized %d skill(s) into %s (skipped %d)", i, len(out.materialized), rc.TargetDir, len(out.skipped)) - res.Written = append(res.Written, Written{Dir: rc.TargetDir, Skills: out.materialized}) + group := skills.Group{Dir: rc.TargetDir} + for _, m := range out.materialized { + group.Skills = append(group.Skills, skills.Skill{ID: m.SkillID, Dir: m.Dir}) + } + avail.Groups = append(avail.Groups, group) } - return res + return avail } // claimSet tracks which (dir, skill-id) pairs have already been materialized, so diff --git a/internal/skills/local/local.go b/internal/skills/local/local.go new file mode 100644 index 0000000..3dfc105 --- /dev/null +++ b/internal/skills/local/local.go @@ -0,0 +1,102 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package local discovers agent skills that already exist on disk. It is the +// local-directory counterpart to internal/skills/geminienterprise: instead of +// fetching from the Gemini Enterprise Skill Registry, it treats a configured +// directory as a ready-made skills folder and reports what it finds. +// +// A local skills path is a parent directory containing one subfolder per skill, +// each with a SKILL.md (the same layout as examples/skills and a registry's +// target_dir). Nothing is written; the directory is used as-is. +// +// It is harness-agnostic and produces the mode-agnostic skills.Available shape, +// so the same consumers that handle registry skills handle local ones. +package local + +import ( + "log" + "os" + "path/filepath" + + "github.com/google/ax/internal/config" + "github.com/google/ax/internal/skills" +) + +// skillManifest is the file that marks a subdirectory as a skill. +const skillManifest = "SKILL.md" + +// Discover enumerates every enabled local skills path in sc and reports the +// skills found on disk. Each enabled path is a parent directory whose immediate +// subfolders containing a SKILL.md are treated as skills (subfolder name = skill +// id). +// +// It is fail-safe, mirroring geminienterprise.Materialize: disabled entries are +// skipped, and any path that is missing, unreadable, not a directory, or +// contains no skills is logged and skipped so a local-skills problem never +// blocks harness creation. Paths are resolved to absolute form so the reported +// directories are stable regardless of the process working directory. +func Discover(sc config.SkillsConfig) skills.Available { + var avail skills.Available + for i := range sc.Local { + lc := sc.Local[i] + if !lc.Enabled { + continue + } + group, ok := discoverOne(lc.Path, i) + if !ok { + continue + } + avail.Groups = append(avail.Groups, group) + } + return avail +} + +// discoverOne scans a single local skills path. idx is the entry's index within +// the local list, for log context. It returns ok=false (and logs the reason) +// when the path yields no usable skills. +func discoverOne(path string, idx int) (skills.Group, bool) { + abs, err := filepath.Abs(path) + if err != nil { + log.Printf("skills: local[%d] %q: resolving absolute path: %v; skipping", idx, path, err) + return skills.Group{}, false + } + entries, err := os.ReadDir(abs) + if err != nil { + log.Printf("skills: local[%d] %q: reading directory: %v; skipping", idx, abs, err) + return skills.Group{}, false + } + + group := skills.Group{Dir: abs} + for _, e := range entries { + if !e.IsDir() { + continue + } + skillDir := filepath.Join(abs, e.Name()) + manifest := filepath.Join(skillDir, skillManifest) + if info, err := os.Stat(manifest); err != nil || info.IsDir() { + // No SKILL.md (or it is a directory): not a skill. Silently skip so + // non-skill entries (e.g. a README) don't produce log noise. + continue + } + group.Skills = append(group.Skills, skills.Skill{ID: e.Name(), Dir: skillDir}) + } + + if len(group.Skills) == 0 { + log.Printf("skills: local[%d] %q: no skills found (no /%s subfolders); skipping", idx, abs, skillManifest) + return skills.Group{}, false + } + log.Printf("skills: local[%d] discovered %d skill(s) in %s", idx, len(group.Skills), abs) + return group, true +} diff --git a/internal/skills/local/local_test.go b/internal/skills/local/local_test.go new file mode 100644 index 0000000..7d78e21 --- /dev/null +++ b/internal/skills/local/local_test.go @@ -0,0 +1,161 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package local + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/google/ax/internal/config" +) + +// writeSkillDir creates //SKILL.md so the id is discovered as a +// skill. +func writeSkillDir(t *testing.T, parent, id string) { + t.Helper() + dir := filepath.Join(parent, id) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, skillManifest), []byte("# "+id+"\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestDiscover_FindsSkillSubdirs(t *testing.T) { + root := t.TempDir() + writeSkillDir(t, root, "emoji") + writeSkillDir(t, root, "lowercase") + // A non-skill subdir (no SKILL.md) and a stray file must be ignored. + if err := os.MkdirAll(filepath.Join(root, "not-a-skill"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + + avail := Discover(config.SkillsConfig{Local: []config.LocalSkillsConfig{ + {Enabled: true, Path: root}, + }}) + if avail.Empty() { + t.Fatal("expected skills, got none") + } + if len(avail.Groups) != 1 { + t.Fatalf("groups = %d, want 1", len(avail.Groups)) + } + g := avail.Groups[0] + // Dir is reported as an absolute path. + if !filepath.IsAbs(g.Dir) { + t.Errorf("group Dir = %q, want absolute", g.Dir) + } + var got []string + for _, s := range g.Skills { + got = append(got, s.ID) + wantDir := filepath.Join(g.Dir, s.ID) + if s.Dir != wantDir { + t.Errorf("skill %q Dir = %q, want %q", s.ID, s.Dir, wantDir) + } + } + sort.Strings(got) + if len(got) != 2 || got[0] != "emoji" || got[1] != "lowercase" { + t.Errorf("skill ids = %v, want [emoji lowercase]", got) + } +} + +func TestDiscover_DisabledIsSkipped(t *testing.T) { + root := t.TempDir() + writeSkillDir(t, root, "emoji") + avail := Discover(config.SkillsConfig{Local: []config.LocalSkillsConfig{ + {Enabled: false, Path: root}, + }}) + if !avail.Empty() { + t.Errorf("disabled entry produced skills: %+v", avail) + } +} + +func TestDiscover_MissingPathDegrades(t *testing.T) { + // A non-existent path must not panic or error; it degrades to no skills. + avail := Discover(config.SkillsConfig{Local: []config.LocalSkillsConfig{ + {Enabled: true, Path: filepath.Join(t.TempDir(), "does-not-exist")}, + }}) + if !avail.Empty() { + t.Errorf("missing path produced skills: %+v", avail) + } +} + +func TestDiscover_EmptyDirDegrades(t *testing.T) { + // A directory with no /SKILL.md subfolders yields no skills. + avail := Discover(config.SkillsConfig{Local: []config.LocalSkillsConfig{ + {Enabled: true, Path: t.TempDir()}, + }}) + if !avail.Empty() { + t.Errorf("empty dir produced skills: %+v", avail) + } +} + +func TestDiscover_MultiplePaths(t *testing.T) { + a := t.TempDir() + b := t.TempDir() + writeSkillDir(t, a, "s1") + writeSkillDir(t, b, "s2") + avail := Discover(config.SkillsConfig{Local: []config.LocalSkillsConfig{ + {Enabled: true, Path: a}, + {Enabled: true, Path: b}, + }}) + if len(avail.Groups) != 2 { + t.Fatalf("groups = %d, want 2", len(avail.Groups)) + } + var got []string + for _, g := range avail.Groups { + for _, s := range g.Skills { + got = append(got, s.ID) + } + } + sort.Strings(got) + if len(got) != 2 || got[0] != "s1" || got[1] != "s2" { + t.Errorf("skill ids = %v, want [s1 s2]", got) + } +} + +// TestDiscover_ExamplesSkillsFixture points at the repo's real examples/skills +// directory to guard the expected on-disk layout (parent dir of /SKILL.md +// subfolders, with a README.md that must be ignored). +func TestDiscover_ExamplesSkillsFixture(t *testing.T) { + // This test file lives at internal/skills/local/; examples/skills is at the + // repo root. + examples := filepath.Join("..", "..", "..", "examples", "skills") + if _, err := os.Stat(examples); err != nil { + t.Skipf("examples/skills not found (%v); skipping", err) + } + avail := Discover(config.SkillsConfig{Local: []config.LocalSkillsConfig{ + {Enabled: true, Path: examples}, + }}) + if avail.Empty() { + t.Fatal("expected skills from examples/skills, got none") + } + var got []string + for _, g := range avail.Groups { + for _, s := range g.Skills { + got = append(got, s.ID) + } + } + sort.Strings(got) + // emoji and lowercase ship in examples/skills; README.md must be ignored. + if len(got) != 2 || got[0] != "emoji" || got[1] != "lowercase" { + t.Errorf("examples/skills ids = %v, want [emoji lowercase]", got) + } +} diff --git a/internal/skills/skills.go b/internal/skills/skills.go new file mode 100644 index 0000000..f52f93a --- /dev/null +++ b/internal/skills/skills.go @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package skills defines the mode-agnostic result shape that describes which +// agent skills are available on disk and where. Both skill sources -- the +// Gemini Enterprise Skill Registry (internal/skills/geminienterprise) and the +// local directory mode (internal/skills/local) -- produce this same shape so a +// single consumer (e.g. a harness system-instruction builder) can serve both +// without depending on either source package. +package skills + +// Available reports the skills discovered/materialized on disk, grouped by the +// directory they live in. It is the common currency between skill sources and +// consumers. +type Available struct { + // Groups is one entry per source that produced skills (a registry's + // target_dir, or a local skills directory). + Groups []Group +} + +// Group associates a set of skills with the directory that contains them. Each +// skill lives at // (with a SKILL.md inside). +type Group struct { + Dir string + Skills []Skill +} + +// Skill identifies one on-disk skill. +type Skill struct { + // ID is the skill identifier, which is also its directory name under the + // group's Dir. + ID string + // Dir is the absolute path of the skill's own folder (//). + Dir string +} + +// Empty reports whether nothing is available. +func (a Available) Empty() bool { return len(a.Groups) == 0 }