11package install
22
33import (
4+ "encoding/base64"
45 "errors"
56 "fmt"
67 "io"
@@ -57,6 +58,7 @@ type InstallOptions struct {
5758 Force bool
5859 FromLocal bool // treat SkillSource as a local directory path
5960 AllowHiddenDirs bool // include skills in dot-prefixed directories
61+ Upstream bool // install from upstream when re-published skill detected
6062
6163 repo ghrepo.Interface // set when SkillSource is a GitHub repository
6264 localPath string // set when FromLocal is true
@@ -193,6 +195,10 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru
193195 return err
194196 }
195197
198+ if err := cmdutil .MutuallyExclusive ("--from-local and --upstream cannot be used together" , opts .FromLocal , opts .Upstream ); err != nil {
199+ return err
200+ }
201+
196202 if opts .Pin != "" && opts .SkillName != "" && strings .Contains (opts .SkillName , "@" ) {
197203 return cmdutil .FlagErrorf ("cannot use --pin with an inline @version in the skill name" )
198204 }
@@ -212,6 +218,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru
212218 cmd .Flags ().BoolVarP (& opts .Force , "force" , "f" , false , "Overwrite existing skills without prompting" )
213219 cmd .Flags ().BoolVar (& opts .FromLocal , "from-local" , false , "Treat the argument as a local directory path instead of a repository" )
214220 cmd .Flags ().BoolVar (& opts .AllowHiddenDirs , "allow-hidden-dirs" , false , "Include skills in hidden directories (e.g. .claude/skills/, .agents/skills/)" )
221+ cmd .Flags ().BoolVar (& opts .Upstream , "upstream" , false , "Install from the upstream source when a re-published skill is detected" )
215222 cmdutil .DisableAuthCheckFlag (cmd .Flags ().Lookup ("from-local" ))
216223
217224 return cmd
@@ -248,13 +255,17 @@ func installRun(opts *InstallOptions) error {
248255 // Kick off the visibility fetch in parallel with the install work so
249256 // the extra API roundtrip doesn't add latency on the critical path.
250257 // The result is consumed when the telemetry event is emitted below.
258+ // Capture repo fields now to avoid a data race if opts.repo is
259+ // swapped during an upstream redirect.
251260 type visResult struct {
252261 vis discovery.RepoVisibility
253262 err error
254263 }
255264 visCh := make (chan visResult , 1 )
265+ visOwner := opts .repo .RepoOwner ()
266+ visRepo := opts .repo .RepoName ()
256267 go func () {
257- vis , err := discovery .FetchRepoVisibility (apiClient , hostname , opts . repo . RepoOwner (), opts . repo . RepoName () )
268+ vis , err := discovery .FetchRepoVisibility (apiClient , hostname , visOwner , visRepo )
258269 visCh <- visResult {vis : vis , err : err }
259270 }()
260271
@@ -293,6 +304,43 @@ func installRun(opts *InstallOptions) error {
293304 }
294305 }
295306
307+ // Track upstream provenance detection result for telemetry.
308+ upstreamSource := "none"
309+
310+ // Check if the selected skill was re-published from an upstream source.
311+ // The re-publisher's SKILL.md will have github-repo metadata pointing
312+ // to the original source repo. If detected, offer to install directly
313+ // from upstream instead.
314+ if len (selectedSkills ) == 1 && selectedSkills [0 ].BlobSHA != "" {
315+ upstreamRepo , detected , err := checkUpstreamProvenance (opts , apiClient , hostname , selectedSkills [0 ], resolved .SHA )
316+ if err != nil {
317+ return err
318+ }
319+ if upstreamRepo != nil {
320+ redirectDims := map [string ]string {}
321+ select {
322+ case r := <- visCh :
323+ if r .err == nil && r .vis == discovery .RepoVisibilityPublic {
324+ redirectDims ["from_owner" ] = visOwner
325+ redirectDims ["from_repo" ] = visRepo
326+ }
327+ case <- time .After (visibilityWaitTimeout ):
328+ }
329+ opts .Telemetry .Record (ghtelemetry.Event {
330+ Type : "skill_upstream_redirect" ,
331+ Dimensions : redirectDims ,
332+ })
333+ opts .repo = upstreamRepo
334+ opts .SkillSource = ghrepo .FullName (upstreamRepo )
335+ opts .version = ""
336+ opts .Pin = ""
337+ return installRun (opts )
338+ }
339+ if detected {
340+ upstreamSource = "republisher"
341+ }
342+ }
343+
296344 printPreInstallDisclaimer (opts .IO .ErrOut , cs )
297345
298346 selectedHosts , err := resolveHosts (opts , canPrompt )
@@ -355,6 +403,7 @@ func installRun(opts *InstallOptions) error {
355403 dims := map [string ]string {
356404 "agent_hosts" : mapAgentHostsToIDs (selectedHosts ),
357405 "skill_host_type" : ghinstance .CategorizeHost (opts .repo .RepoHost ()),
406+ "upstream_source" : upstreamSource ,
358407 }
359408 select {
360409 case r := <- visCh :
@@ -1169,3 +1218,85 @@ func filterHiddenDirSkills(opts *InstallOptions, allSkills []discovery.Skill) ([
11691218
11701219 return standard , nil
11711220}
1221+
1222+ // checkUpstreamProvenance fetches the skill's SKILL.md via the contents API
1223+ // to check if it contains github-repo metadata pointing to a different
1224+ // repository, indicating the skill was re-published from an upstream source.
1225+ // In interactive mode, the user is asked whether to install from the
1226+ // re-publisher or redirect to the upstream. Non-interactive mode always
1227+ // installs from the re-publisher.
1228+ // Returns (repo to redirect to, whether upstream was detected, error).
1229+ func checkUpstreamProvenance (opts * InstallOptions , client * api.Client , hostname string , skill discovery.Skill , commitSHA string ) (ghrepo.Interface , bool , error ) {
1230+ apiPath := fmt .Sprintf ("repos/%s/%s/contents/%s?ref=%s" ,
1231+ opts .repo .RepoOwner (), opts .repo .RepoName (),
1232+ skill .Path + "/SKILL.md" , commitSHA )
1233+ var fileResp struct {
1234+ Content string `json:"content"`
1235+ Encoding string `json:"encoding"`
1236+ }
1237+ if err := client .REST (hostname , "GET" , apiPath , nil , & fileResp ); err != nil {
1238+ return nil , false , nil //nolint:nilerr // best-effort check; failing to fetch is not fatal
1239+ }
1240+ if fileResp .Encoding != "base64" {
1241+ return nil , false , nil
1242+ }
1243+ decoded , decodeErr := io .ReadAll (base64 .NewDecoder (base64 .StdEncoding , strings .NewReader (fileResp .Content )))
1244+ if decodeErr != nil {
1245+ return nil , false , nil //nolint:nilerr // best-effort; decode failure is not fatal
1246+ }
1247+ content := string (decoded )
1248+
1249+ result , parseErr := frontmatter .Parse (content )
1250+ if parseErr != nil || result .Metadata .Meta == nil {
1251+ //nolint:nilerr // unparseable frontmatter means no upstream to detect
1252+ return nil , false , nil
1253+ }
1254+
1255+ existingRepo , _ := result .Metadata .Meta ["github-repo" ].(string )
1256+ if existingRepo == "" {
1257+ return nil , false , nil
1258+ }
1259+
1260+ currentRepoURL := source .BuildRepoURL (hostname , opts .repo .RepoOwner (), opts .repo .RepoName ())
1261+ if existingRepo == currentRepoURL {
1262+ return nil , false , nil
1263+ }
1264+
1265+ upstreamRepo , parseErr := source .ParseRepoURL (existingRepo )
1266+ if parseErr != nil {
1267+ //nolint:nilerr // invalid repo URL means we can't redirect; install normally
1268+ return nil , false , nil
1269+ }
1270+
1271+ cs := opts .IO .ColorScheme ()
1272+ upstreamLabel := ghrepo .FullName (upstreamRepo )
1273+ repoSource := ghrepo .FullName (opts .repo )
1274+
1275+ fmt .Fprintf (opts .IO .ErrOut , "%s This skill was originally published in %s\n " , cs .WarningIcon (), upstreamLabel )
1276+
1277+ if opts .Upstream {
1278+ fmt .Fprintf (opts .IO .ErrOut , "Redirecting install to %s...\n " , upstreamLabel )
1279+ return upstreamRepo , true , nil
1280+ }
1281+
1282+ if ! opts .IO .CanPrompt () {
1283+ fmt .Fprintf (opts .IO .ErrOut , " Installing from %s (use --upstream or interactive mode to choose upstream)\n " , repoSource )
1284+ return nil , true , nil
1285+ }
1286+
1287+ choices := []string {
1288+ fmt .Sprintf ("%s (re-publisher, recommended)" , repoSource ),
1289+ fmt .Sprintf ("%s (upstream)" , upstreamLabel ),
1290+ }
1291+ idx , err := opts .Prompter .Select ("Install from:" , "" , choices )
1292+ if err != nil {
1293+ return nil , true , err
1294+ }
1295+
1296+ if idx == 1 {
1297+ fmt .Fprintf (opts .IO .ErrOut , "Redirecting install to %s...\n " , upstreamLabel )
1298+ return upstreamRepo , true , nil
1299+ }
1300+
1301+ return nil , true , nil
1302+ }
0 commit comments