Skip to content

Commit 2bf61d8

Browse files
Add skill import from URL/git (#43) (#73)
* Add skill import from URL/git (#43) Add `skern skill import <url>` command to import skills from GitHub repository directories and gists. Supports --name override, --scope, --force, overlap detection, companion files, and JSON output. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Harden skill import: lint, HTTP timeout, size cap, path-traversal guard - Add 30s default HTTP timeout for skill import (was using http.DefaultClient with no timeout — slow/malicious endpoints could hang the CLI indefinitely). - Cap downloaded payloads at 10 MiB via io.LimitReader on the contents API JSON, gist API JSON, and each companion file. - Reject companion filenames containing path separators, "..", or absolute paths in Registry.Import. The contents API normally returns base names, but the gist API echoes user-supplied filenames, so validate defensively at the registry boundary. - Convert ParseImportURL switch to a tagged switch (staticcheck QF1002) and document that refs containing slashes cannot be unambiguously parsed from the tree-URL form. - Drop the misleading "github repo with branch ref" test that asserted the parser's incorrect behavior on slashed refs. - Fix errcheck: wrap unchecked Encode/Write returns in test servers and resp.Body.Close() defers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5ca33c2 commit 2bf61d8

10 files changed

Lines changed: 1184 additions & 8 deletions

File tree

internal/cli/context.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,19 @@ import (
66
"github.com/devrimcavusoglu/skern/internal/output"
77
"github.com/devrimcavusoglu/skern/internal/platform"
88
"github.com/devrimcavusoglu/skern/internal/registry"
9+
"github.com/devrimcavusoglu/skern/internal/skill"
910
"github.com/spf13/cobra"
1011
)
1112

1213
type contextKey struct{}
1314

1415
// CommandContext holds injectable dependencies for CLI commands.
1516
type CommandContext struct {
16-
Printer *output.Printer
17-
NewRegistry func() (*registry.Registry, error)
18-
NewDetector func() (*platform.Detector, error)
17+
Printer *output.Printer
18+
NewRegistry func() (*registry.Registry, error)
19+
NewDetector func() (*platform.Detector, error)
20+
HTTPClient skill.HTTPClient // optional; defaults to http.DefaultClient
21+
GitHubBaseURL string // optional; override GitHub API base URL (for testing)
1922
}
2023

2124
func setContext(cmd *cobra.Command, cc *CommandContext) {

internal/cli/skill.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ func newSkillCmd() *cobra.Command {
2323
cmd.AddCommand(newSkillRecommendCmd())
2424
cmd.AddCommand(newSkillDiffCmd())
2525
cmd.AddCommand(newSkillVersionCmd())
26+
cmd.AddCommand(newSkillImportCmd())
2627

2728
return cmd
2829
}

internal/cli/skill_import.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"time"
7+
8+
"github.com/devrimcavusoglu/skern/internal/output"
9+
"github.com/devrimcavusoglu/skern/internal/overlap"
10+
"github.com/devrimcavusoglu/skern/internal/skill"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
// importHTTPTimeout caps individual HTTP requests during skill import so a
15+
// slow or malicious endpoint cannot hang the CLI indefinitely.
16+
const importHTTPTimeout = 30 * time.Second
17+
18+
func newSkillImportCmd() *cobra.Command {
19+
var (
20+
scope string
21+
name string
22+
force bool
23+
)
24+
25+
cmd := &cobra.Command{
26+
Use: "import <url>",
27+
Short: "Import a skill from a remote URL",
28+
Long: "Import a skill from a GitHub repository directory or gist into the local registry.",
29+
Args: cobra.ExactArgs(1),
30+
RunE: func(cmd *cobra.Command, args []string) error {
31+
ctx := getContext(cmd)
32+
rawURL := args[0]
33+
34+
src, err := skill.ParseImportURL(rawURL)
35+
if err != nil {
36+
return &ValidationError{Message: err.Error()}
37+
}
38+
39+
scopeVal, err := parseScope(scope)
40+
if err != nil {
41+
return err
42+
}
43+
44+
// Fetch skill files from remote
45+
ctx.Printer.Print("Fetching skill from %s...\n", rawURL)
46+
httpClient := ctx.HTTPClient
47+
if httpClient == nil {
48+
httpClient = &http.Client{Timeout: importHTTPTimeout}
49+
}
50+
var fetched *skill.FetchedSkill
51+
if ctx.GitHubBaseURL != "" {
52+
fetched, err = skill.FetchSkillWithBaseURL(httpClient, src, ctx.GitHubBaseURL)
53+
} else {
54+
fetched, err = skill.FetchSkill(httpClient, src)
55+
}
56+
if err != nil {
57+
return fmt.Errorf("fetching skill: %w", err)
58+
}
59+
60+
// Parse the downloaded SKILL.md
61+
manifestData, ok := fetched.Files["SKILL.md"]
62+
if !ok {
63+
return fmt.Errorf("no SKILL.md found in fetched files")
64+
}
65+
66+
s, err := skill.ParseManifestFromBytes(manifestData)
67+
if err != nil {
68+
return fmt.Errorf("parsing imported manifest: %w", err)
69+
}
70+
71+
// Apply --name override
72+
if name != "" {
73+
if err := skill.ValidateName(name); err != nil {
74+
return &ValidationError{Message: err.Error()}
75+
}
76+
s.Name = name
77+
}
78+
79+
if s.Name == "" {
80+
return &ValidationError{Message: "imported skill has no name; use --name to specify one"}
81+
}
82+
83+
if err := skill.ValidateName(s.Name); err != nil {
84+
return &ValidationError{Message: fmt.Sprintf("imported skill name is invalid: %s", err)}
85+
}
86+
87+
reg, err := ctx.NewRegistry()
88+
if err != nil {
89+
return err
90+
}
91+
92+
// Overlap detection
93+
discovered, _, err := reg.ListAll()
94+
if err != nil {
95+
return fmt.Errorf("checking for overlapping skills: %w", err)
96+
}
97+
98+
if len(discovered) > 0 {
99+
var existing []skill.Skill
100+
var scopes []skill.Scope
101+
for _, d := range discovered {
102+
existing = append(existing, d.Skill)
103+
scopes = append(scopes, d.Scope)
104+
}
105+
106+
matches := overlap.Check(s.Name, s.Description, existing, scopes)
107+
if len(matches) > 0 {
108+
blocked := overlap.ShouldBlock(matches) && !force
109+
110+
if blocked {
111+
maxScore := overlap.MaxScore(matches)
112+
text := formatOverlapBlock(s.Name, matches)
113+
overlapResult := output.OverlapCheckResult{
114+
Blocked: true,
115+
MaxScore: maxScore,
116+
}
117+
for _, m := range matches {
118+
overlapResult.Matches = append(overlapResult.Matches, output.OverlapResult{
119+
Name: m.Name,
120+
Score: m.Score,
121+
Scope: string(m.Scope),
122+
})
123+
}
124+
ctx.Printer.PrintResult(overlapResult, text)
125+
return &ValidationError{Message: fmt.Sprintf("skill %q blocked due to near-duplicate (score %.2f); use --force to override", s.Name, maxScore)}
126+
}
127+
128+
// Warn but proceed
129+
text := formatOverlapWarn(s.Name, matches)
130+
ctx.Printer.Print("%s", text)
131+
}
132+
}
133+
134+
// Import into registry
135+
path, err := reg.Import(s, fetched.Files, scopeVal, force)
136+
if err != nil {
137+
return err
138+
}
139+
140+
result := output.SkillImportResult{
141+
Name: s.Name,
142+
Scope: scope,
143+
Path: path,
144+
Source: rawURL,
145+
}
146+
text := fmt.Sprintf("Imported skill %q from %s into %s scope at %s\n", s.Name, formatSource(src), scope, path)
147+
ctx.Printer.PrintResult(result, text)
148+
return nil
149+
},
150+
}
151+
152+
cmd.Flags().StringVar(&scope, "scope", "user", "skill scope (user or project)")
153+
cmd.Flags().StringVar(&name, "name", "", "override the skill name from the manifest")
154+
cmd.Flags().BoolVar(&force, "force", false, "overwrite if skill already exists and bypass overlap block")
155+
156+
return cmd
157+
}
158+
159+
func formatSource(src *skill.ImportSource) string {
160+
switch src.Type {
161+
case skill.SourceGitHubRepo:
162+
return fmt.Sprintf("github.com/%s/%s", src.Owner, src.Repo)
163+
case skill.SourceGitHubGist:
164+
return fmt.Sprintf("gist %s", src.GistID)
165+
default:
166+
return src.RawURL
167+
}
168+
}

0 commit comments

Comments
 (0)