Skip to content
This repository was archived by the owner on Dec 17, 2025. It is now read-only.

Commit 893e9fb

Browse files
committed
New init process that suggests skills
- Init process now defaults to personal and sleuth as the sharing option - Ability to add skills in init - After a skill is added, it prompts to then install - We now show recommended skills during init - New support for github urls of a skill in a public repo - New ability to change the lock file config of an existing artifact without updating
1 parent 9b0b7d2 commit 893e9fb

File tree

12 files changed

+955
-121
lines changed

12 files changed

+955
-121
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ require (
1313
github.com/schollz/progressbar/v3 v3.18.0
1414
github.com/spf13/cobra v1.10.1
1515
gopkg.in/natefinch/lumberjack.v2 v2.2.1
16+
gopkg.in/yaml.v3 v3.0.1
1617
)
1718

1819
require (
@@ -40,5 +41,4 @@ require (
4041
golang.org/x/sys v0.39.0 // indirect
4142
golang.org/x/term v0.37.0 // indirect
4243
golang.org/x/time v0.12.0 // indirect
43-
gopkg.in/yaml.v3 v3.0.1 // indirect
4444
)

internal/commands/add.go

Lines changed: 170 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/sleuth-io/skills/internal/buildinfo"
1919
"github.com/sleuth-io/skills/internal/config"
2020
"github.com/sleuth-io/skills/internal/constants"
21+
"github.com/sleuth-io/skills/internal/github"
2122
"github.com/sleuth-io/skills/internal/lockfile"
2223
"github.com/sleuth-io/skills/internal/metadata"
2324
"github.com/sleuth-io/skills/internal/repository"
@@ -27,10 +28,16 @@ import (
2728
// NewAddCommand creates the add command
2829
func NewAddCommand() *cobra.Command {
2930
cmd := &cobra.Command{
30-
Use: "add [zip-file-directory-or-url]",
31-
Short: "Add a zip file, directory, or URL artifact to the repository",
32-
Long: `Take a local zip file, directory, or URL to a zip file, detect metadata from its
33-
contents, prompt for confirmation/edits, install it to the repository, and update the lock file.`,
31+
Use: "add [source-or-artifact-name]",
32+
Short: "Add an artifact or configure an existing one",
33+
Long: `Add an artifact from a local zip file, directory, URL, or GitHub path.
34+
If the argument is an existing artifact name, configure its installation scope instead.
35+
36+
Examples:
37+
skills add ./my-skill # Add from local directory
38+
skills add https://... # Add from URL
39+
skills add https://github.com/owner/repo/tree/main/path # Add from GitHub
40+
skills add my-skill # Configure scope for existing artifact`,
3441
Args: cobra.MaximumNArgs(1),
3542
RunE: func(cmd *cobra.Command, args []string) error {
3643
var zipFile string
@@ -46,13 +53,31 @@ contents, prompt for confirmation/edits, install it to the repository, and updat
4653

4754
// runAdd executes the add command
4855
func runAdd(cmd *cobra.Command, zipFile string) error {
56+
return runAddWithOptions(cmd, zipFile, true)
57+
}
58+
59+
// runAddSkipInstall executes the add command without prompting to install
60+
func runAddSkipInstall(cmd *cobra.Command, zipFile string) error {
61+
return runAddWithOptions(cmd, zipFile, false)
62+
}
63+
64+
// runAddWithOptions executes the add command with configurable options
65+
func runAddWithOptions(cmd *cobra.Command, input string, promptInstall bool) error {
4966
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
5067
defer cancel()
5168

5269
out := newOutputHelper(cmd)
5370

71+
// Check if input is an existing artifact name (not a file, directory, or URL)
72+
if input != "" && !isURL(input) && !github.IsTreeURL(input) {
73+
if _, err := os.Stat(input); os.IsNotExist(err) {
74+
// Not a file/directory - check if it's an existing artifact
75+
return configureExistingArtifact(ctx, cmd, out, input, promptInstall)
76+
}
77+
}
78+
5479
// Get and validate zip file
55-
zipFile, zipData, err := loadZipFile(out, zipFile)
80+
zipFile, zipData, err := loadZipFile(out, input)
5681
if err != nil {
5782
return err
5883
}
@@ -76,12 +101,105 @@ func runAdd(cmd *cobra.Command, zipFile string) error {
76101
}
77102

78103
// Handle identical content case
104+
var addErr error
79105
if contentsIdentical {
80-
return handleIdenticalArtifact(ctx, out, repo, name, version, artifactType)
106+
addErr = handleIdenticalArtifact(ctx, out, repo, name, version, artifactType)
107+
} else {
108+
// Add new or updated artifact
109+
addErr = addNewArtifact(ctx, out, repo, name, artifactType, version, zipFile, zipData, metadataExists)
110+
}
111+
112+
if addErr != nil {
113+
return addErr
114+
}
115+
116+
// Prompt to run install (if enabled)
117+
if promptInstall {
118+
promptRunInstall(cmd, ctx, out)
81119
}
82120

83-
// Add new or updated artifact
84-
return addNewArtifact(ctx, out, repo, name, artifactType, version, zipFile, zipData, metadataExists)
121+
return nil
122+
}
123+
124+
// configureExistingArtifact handles configuring scope for an artifact that already exists in the repository
125+
func configureExistingArtifact(ctx context.Context, cmd *cobra.Command, out *outputHelper, artifactName string, promptInstall bool) error {
126+
// Create repository instance
127+
repo, err := createRepository()
128+
if err != nil {
129+
return err
130+
}
131+
132+
// Load lock file to find the artifact
133+
lockFileContent, _, _, err := repo.GetLockFile(ctx, "")
134+
if err != nil {
135+
return fmt.Errorf("failed to fetch lock file: %w", err)
136+
}
137+
138+
lockFile, err := lockfile.Parse(lockFileContent)
139+
if err != nil {
140+
return fmt.Errorf("failed to parse lock file: %w", err)
141+
}
142+
143+
// Find the artifact
144+
var foundArtifact *lockfile.Artifact
145+
for i := range lockFile.Artifacts {
146+
if lockFile.Artifacts[i].Name == artifactName {
147+
foundArtifact = &lockFile.Artifacts[i]
148+
break
149+
}
150+
}
151+
152+
if foundArtifact == nil {
153+
return fmt.Errorf("artifact '%s' not found in repository", artifactName)
154+
}
155+
156+
out.printf("Configuring scope for %s@%s\n", foundArtifact.Name, foundArtifact.Version)
157+
158+
// Prompt for repository configurations
159+
repositories, err := promptForRepositories(out, foundArtifact.Name, foundArtifact.Version)
160+
if err != nil {
161+
return fmt.Errorf("failed to configure repositories: %w", err)
162+
}
163+
164+
// If nil, user chose not to install
165+
if repositories == nil {
166+
return nil
167+
}
168+
169+
// Update artifact with new repositories
170+
foundArtifact.Repositories = repositories
171+
172+
// Update lock file
173+
if err := updateLockFile(ctx, out, repo, foundArtifact); err != nil {
174+
return fmt.Errorf("failed to update lock file: %w", err)
175+
}
176+
177+
// Prompt to run install (if enabled)
178+
if promptInstall {
179+
promptRunInstall(cmd, ctx, out)
180+
}
181+
182+
return nil
183+
}
184+
185+
// promptRunInstall asks if the user wants to run install after adding an artifact
186+
func promptRunInstall(cmd *cobra.Command, ctx context.Context, out *outputHelper) {
187+
out.println()
188+
response, err := out.prompt("Run install now to activate the artifact? (Y/n): ")
189+
if err != nil {
190+
return
191+
}
192+
193+
response = strings.ToLower(strings.TrimSpace(response))
194+
if response == "n" || response == "no" {
195+
out.println("Run 'skills install' when ready to activate.")
196+
return
197+
}
198+
199+
out.println()
200+
if err := runInstall(cmd, nil, false, ""); err != nil {
201+
out.printfErr("Install failed: %v\n", err)
202+
}
85203
}
86204

87205
// isURL checks if the input looks like a URL
@@ -104,7 +222,21 @@ func loadZipFile(out *outputHelper, zipFile string) (string, []byte, error) {
104222
return "", nil, fmt.Errorf("zip file, directory path, or URL is required")
105223
}
106224

107-
// Check if it's a URL
225+
// Check if it's a GitHub tree URL (directory)
226+
if github.IsTreeURL(zipFile) {
227+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
228+
defer cancel()
229+
230+
out.println()
231+
out.println("Downloading from GitHub directory...")
232+
zipData, err := downloadFromGitHub(ctx, out, zipFile)
233+
if err != nil {
234+
return "", nil, err
235+
}
236+
return zipFile, zipData, nil
237+
}
238+
239+
// Check if it's a regular URL (zip file)
108240
if isURL(zipFile) {
109241
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
110242
defer cancel()
@@ -197,6 +329,30 @@ func downloadZipFromURL(ctx context.Context, out *outputHelper, zipURL string) (
197329
return data, nil
198330
}
199331

332+
// downloadFromGitHub downloads files from a GitHub directory URL and returns them as a zip.
333+
func downloadFromGitHub(ctx context.Context, out *outputHelper, gitHubURL string) ([]byte, error) {
334+
treeURL := github.ParseTreeURL(gitHubURL)
335+
if treeURL == nil {
336+
return nil, fmt.Errorf("invalid GitHub directory URL: %s", gitHubURL)
337+
}
338+
339+
out.printf("Repository: %s/%s\n", treeURL.Owner, treeURL.Repo)
340+
out.printf("Branch/Tag: %s\n", treeURL.Ref)
341+
if treeURL.Path != "" {
342+
out.printf("Path: %s\n", treeURL.Path)
343+
}
344+
out.println()
345+
346+
fetcher := github.NewFetcher()
347+
zipData, err := fetcher.FetchDirectory(ctx, treeURL)
348+
if err != nil {
349+
return nil, fmt.Errorf("failed to download from GitHub: %w", err)
350+
}
351+
352+
out.printf("Downloaded %d bytes\n", len(zipData))
353+
return zipData, nil
354+
}
355+
200356
// detectArtifactInfo extracts or detects artifact name and type, then confirms with user
201357
func detectArtifactInfo(out *outputHelper, zipFile string, zipData []byte) (name string, artifactType artifact.Type, metadataExists bool, err error) {
202358
// Extract or detect name and type
@@ -537,6 +693,11 @@ func updateMetadataInZip(meta *metadata.Metadata, zipData []byte, metadataExists
537693

538694
// guessArtifactName extracts a reasonable artifact name from the zip file path or URL
539695
func guessArtifactName(zipPath string) string {
696+
// Handle GitHub tree URLs specially
697+
if treeURL := github.ParseTreeURL(zipPath); treeURL != nil {
698+
return treeURL.SkillName()
699+
}
700+
540701
// Handle URLs - extract path component
541702
if isURL(zipPath) {
542703
if parsed, err := url.Parse(zipPath); err == nil {

internal/commands/hooks.go

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,22 @@
11
package commands
22

33
import (
4-
"github.com/sleuth-io/skills/internal/claude"
5-
)
6-
7-
// outputAdapter adapts outputHelper to claude.Output interface
8-
type outputAdapter struct {
9-
out *outputHelper
10-
}
4+
"context"
115

12-
func (a *outputAdapter) Println(msg string) {
13-
a.out.println(msg)
14-
}
6+
"github.com/sleuth-io/skills/internal/clients"
7+
"github.com/sleuth-io/skills/internal/logger"
8+
)
159

16-
func (a *outputAdapter) PrintfErr(format string, args ...interface{}) {
17-
a.out.printfErr(format, args...)
18-
}
10+
// installAllClientHooks detects installed clients and installs hooks for each.
11+
func installAllClientHooks(ctx context.Context, out *outputHelper) {
12+
log := logger.Get()
13+
registry := clients.NewRegistry()
14+
installedClients := registry.DetectInstalled()
1915

20-
// installClaudeCodeHooks installs all Claude Code hooks (usage tracking and auto-update)
21-
func installClaudeCodeHooks(claudeDir string, out *outputHelper) error {
22-
// Note: claudeDir parameter is ignored, we use claude.InstallHooks which gets it internally
23-
adapter := &outputAdapter{out: out}
24-
return claude.InstallHooks(adapter)
16+
for _, client := range installedClients {
17+
if err := client.InstallHooks(ctx); err != nil {
18+
out.printfErr("Warning: failed to install hooks for %s: %v\n", client.DisplayName(), err)
19+
log.Error("failed to install client hooks", "client", client.ID(), "error", err)
20+
}
21+
}
2522
}

0 commit comments

Comments
 (0)