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
3 changes: 2 additions & 1 deletion .markdownlintignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ CLAUDE.md
# publish time (see npm/.gitignore); they are not authored here.
npm/

# Example runtime artifacts (browser-agent output, etc.) gitignored, not authored
# Example runtime artifacts (browser-agent output, etc.) - gitignored, not authored
examples/telegram-channel/agents/
examples/telegram-channel/tmp/
14 changes: 10 additions & 4 deletions cmd/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,14 +295,17 @@ var skillsInstallCmd = &cobra.Command{
You can pass any of the following:

- A skill name: skill-creator
→ https://github.com/inference-gateway/skills/tree/main/skills/skill-creator
→ the source recorded in the catalog, else
https://github.com/inference-gateway/skills/tree/main/skills/skill-creator
- An <org>/<skill> pair: acme/skill-creator
→ https://github.com/acme/skills/tree/main/skills/skill-creator
- A full GitHub tree URL: https://github.com/<owner>/<repo>/tree/<ref>/<path>

Shorthand forms assume the skill lives under skills/<name>/ inside a repo
named "skills" on the given org, and resolve against the "main" branch.
For any other layout, branch, or tag, use the full URL form.
A bare name resolves to its catalog source first, so a skill sourced from another
repo (e.g. under .agents/skills/<name>/) installs by name. When the catalog is
unavailable or does not list it, the bare name and the <org>/<skill> form assume
the skill lives under skills/<name>/ in a repo named "skills" on the given org,
on the "main" branch. For any other layout, branch, or tag, use the full URL form.

Examples:
infer skills install skill-creator
Expand Down Expand Up @@ -342,6 +345,9 @@ func installSkill(cmd *cobra.Command, args []string) error {
repository := config.DefaultSkillsRepository
if Cfg != nil {
repository = Cfg.Agent.Skills.SkillsRepository()
if src, ok := skills.NewCatalogClient(Cfg).ResolveInstallURL(cmd.Context(), rawURL); ok {
rawURL = src
}
}

dest, err := skills.NewInstaller(repository).
Expand Down
24 changes: 23 additions & 1 deletion internal/services/skills/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const (
type catalogEntry struct {
Name string `json:"name"`
Description string `json:"description"`
Source string `json:"source"`
}

// catalogResponse is the top-level response from the catalog index. Release
Expand Down Expand Up @@ -297,6 +298,22 @@ func (c *CatalogClient) Lookup(ctx context.Context, name string) (*catalogEntry,
return nil, false
}

// ResolveInstallURL maps a bare catalog skill name to the GitHub tree URL in its
// catalog `source`, so `skills install <name>` fetches the body from wherever the
// skill actually lives instead of the hardcoded <repo>/skills/<name> convention.
// ok is false for inputs that already carry their own location - a full URL or an
// "<org>/<skill>" shorthand, both of which contain "/" or ":" - and for names the
// catalog does not list, leaving the caller's shorthand expansion in charge.
func (c *CatalogClient) ResolveInstallURL(ctx context.Context, input string) (string, bool) {
if strings.ContainsAny(input, "/:") {
return "", false
}
if entry, ok := c.Lookup(ctx, input); ok && entry.Source != "" {
return entry.Source, true
}
return "", false
}

// dynamicSkillsDir returns the directory where dynamically downloaded skills
// are stored. It lives under the project's .infer/tmp/skills/ so it is
// ephemeral and cleaned up after the session.
Expand All @@ -317,8 +334,13 @@ func (c *CatalogClient) DownloadSkill(ctx context.Context, name string) (string,
return "", err
}

sourceURL := SkillTreeURL(c.repository, name)
if entry, ok := c.Lookup(ctx, name); ok && entry.Source != "" {
sourceURL = entry.Source
}

installer := NewInstaller(c.repository)
absPath, err := installer.InstallFromGitHub(ctx, SkillTreeURL(c.repository, name), destBase, false)
absPath, err := installer.InstallFromGitHub(ctx, sourceURL, destBase, false)
if err != nil {
return "", fmt.Errorf("failed to download skill %q from catalog: %w", name, err)
}
Expand Down
33 changes: 33 additions & 0 deletions internal/services/skills/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,39 @@ func discoveryService(srv *httptest.Server, scopes []scopedDir) *Service {

const twoSkillIndex = `{"skills":[{"name":"rust","description":"Idiomatic Rust."},{"name":"local-one","description":"Catalog copy."}]}`

// sourcedIndex mixes a skill sourced from another repo (adl) with one that lives
// in the default skills repo, so install-by-name resolution can be checked both ways.
const sourcedIndex = `{"skills":[` +
`{"name":"adl","description":"ADL.","source":"https://github.com/inference-gateway/adl/tree/main/.agents/skills/adl"},` +
`{"name":"local-one","description":"In repo.","source":"https://github.com/inference-gateway/skills/tree/main/skills/local-one"}` +
`]}`

func TestResolveInstallURL(t *testing.T) {
srv, _ := catalogServer(t, sourcedIndex)
c := testCatalog(srv)
ctx := context.Background()

tests := []struct {
name string
input string
wantURL string
wantOK bool
}{
{"external source resolves by name", "adl", "https://github.com/inference-gateway/adl/tree/main/.agents/skills/adl", true},
{"in-repo source resolves by name", "local-one", "https://github.com/inference-gateway/skills/tree/main/skills/local-one", true},
{"unknown name falls through to shorthand", "missing", "", false},
{"full URL is left to the installer", "https://github.com/acme/skills/tree/main/skills/x", "", false},
{"org/skill shorthand is left to the installer", "acme/x", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := c.ResolveInstallURL(ctx, tt.input)
require.Equal(t, tt.wantOK, ok)
require.Equal(t, tt.wantURL, got)
})
}
}

func TestNewCatalogClient_BaseURLFollowsRepository(t *testing.T) {
cfg := discoveryCfg()
cfg.Agent.Skills.Repository = "acme/internal-skills"
Expand Down