@@ -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
2829func 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
4855func 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
201357func 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
539695func 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 {
0 commit comments