From 05d7e9d955c55083c3c4db7128209c5b7827a851 Mon Sep 17 00:00:00 2001 From: Gus Nguyen Date: Wed, 8 Jul 2026 14:57:25 -0700 Subject: [PATCH 1/7] Add skills freshness checking to agentskills package. Compare installed skill files against the remote index by SHA256 hash, with parallel remote fetches capped for docs.stripe.com requests. Co-authored-by: Cursor Committed-By-Agent: cursor --- go.mod | 2 +- pkg/agentskills/agentskills.go | 222 +++++++++++++++++++++++++++- pkg/agentskills/agentskills_test.go | 109 ++++++++++++++ 3 files changed, 331 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index da8a29f4..1ad9bd9b 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,7 @@ require ( github.com/sahilm/fuzzy v0.1.2 github.com/yuin/goldmark v1.8.2 github.com/zalando/go-keyring v0.2.8 + golang.org/x/sync v0.20.0 ) require ( @@ -96,7 +97,6 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/sync v0.20.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/pkg/agentskills/agentskills.go b/pkg/agentskills/agentskills.go index a0fa8bb1..b301be8c 100644 --- a/pkg/agentskills/agentskills.go +++ b/pkg/agentskills/agentskills.go @@ -5,7 +5,9 @@ package agentskills import ( + "bytes" "context" + "crypto/sha256" "encoding/json" "fmt" "io" @@ -14,6 +16,8 @@ import ( "path/filepath" "strings" "time" + + "golang.org/x/sync/errgroup" ) // IndexURL is the canonical Stripe skills index. It is a var (not a const) so @@ -21,7 +25,11 @@ import ( // from it, so overriding it redirects file fetches too. var IndexURL = "https://docs.stripe.com/.well-known/skills/index.json" -const requestTimeout = 10 * time.Second +const ( + requestTimeout = 10 * time.Second + skillCheckConcurrency = 8 + remoteFetchConcurrency = 8 +) // Index is the top-level response from IndexURL. type Index struct { @@ -36,6 +44,32 @@ type Skill struct { Files []string `json:"files"` } +const ( + StatusNotInstalled = "not_installed" + StatusCurrent = "current" + StatusOutOfDate = "out_of_date" + StatusPartial = "partial" + StatusError = "error" +) + +// SkillCheck is the per-skill result of comparing a local install to the index. +type SkillCheck struct { + Name string `json:"name"` + Status string `json:"status"` + MissingFiles []string `json:"missing_files,omitempty"` + ChangedFiles []string `json:"changed_files,omitempty"` +} + +// DirStatus summarizes installed skills under a single destination directory. +type DirStatus struct { + Dir string `json:"dir"` + Status string `json:"status"` + Skills []SkillCheck `json:"skills,omitempty"` + InstalledCount int `json:"installed_count"` + OutOfDateCount int `json:"out_of_date_count"` + Error string `json:"error,omitempty"` +} + func clientOrDefault(httpClient *http.Client) *http.Client { if httpClient != nil { return httpClient @@ -64,6 +98,192 @@ func FetchIndex(ctx context.Context, httpClient *http.Client) (*Index, error) { return &index, nil } +// Check compares the skills installed under destDir against the remote index. +// It fetches remote content for each indexed file and compares SHA256 hashes +// with the local copies. +func Check(ctx context.Context, httpClient *http.Client, destDir string) (*DirStatus, error) { + client := clientOrDefault(httpClient) + + index, err := FetchIndex(ctx, client) + if err != nil { + return &DirStatus{Dir: destDir, Status: StatusError, Error: err.Error()}, err + } + + result := &DirStatus{ + Dir: destDir, + Status: StatusNotInstalled, + } + + base := filesBaseURL() + remote := newLimitedGetter(client, remoteFetchConcurrency) + type skillJob struct { + skill Skill + } + jobs := make([]skillJob, 0, len(index.Skills)) + for _, skill := range index.Skills { + if skill.Name == "" { + continue + } + jobs = append(jobs, skillJob{skill: skill}) + } + + checks := make([]SkillCheck, len(jobs)) + g, ctx := errgroup.WithContext(ctx) + g.SetLimit(skillCheckConcurrency) + for i, job := range jobs { + i, job := i, job + g.Go(func() error { + checks[i] = checkSkill(ctx, remote, destDir, base, job.skill) + return nil + }) + } + if err := g.Wait(); err != nil { + return &DirStatus{Dir: destDir, Status: StatusError, Error: err.Error()}, err + } + + for _, check := range checks { + if check.Status == StatusCurrent || check.Status == StatusOutOfDate { + result.InstalledCount++ + } + if check.Status == StatusOutOfDate { + result.OutOfDateCount++ + } + result.Skills = append(result.Skills, check) + } + + result.Status = aggregateDirStatus(result) + return result, nil +} + +type limitedGetter struct { + client *http.Client + sem chan struct{} +} + +func newLimitedGetter(client *http.Client, concurrency int) *limitedGetter { + return &limitedGetter{ + client: client, + sem: make(chan struct{}, concurrency), + } +} + +func (g *limitedGetter) get(ctx context.Context, rawURL string) ([]byte, error) { + select { + case g.sem <- struct{}{}: + defer func() { <-g.sem }() + case <-ctx.Done(): + return nil, ctx.Err() + } + return get(ctx, g.client, rawURL) +} + +func checkSkill(ctx context.Context, remote *limitedGetter, destDir, base string, skill Skill) SkillCheck { + check := SkillCheck{Name: skill.Name} + + skillDir := filepath.Join(destDir, skill.Name) + if info, err := os.Stat(skillDir); err != nil || !info.IsDir() { + check.Status = StatusNotInstalled + return check + } + + type fileJob struct { + file string + } + jobs := make([]fileJob, 0, len(skill.Files)) + for _, file := range skill.Files { + if file == "" { + continue + } + target := filepath.Join(destDir, skill.Name, filepath.FromSlash(file)) + if !isUnderDir(target, destDir) { + continue + } + jobs = append(jobs, fileJob{file: file}) + } + + type fileOutcome struct { + file string + missing bool + changed bool + hasLocal bool + } + outcomes := make([]fileOutcome, len(jobs)) + + g, ctx := errgroup.WithContext(ctx) + for i, job := range jobs { + i, job := i, job + g.Go(func() error { + target := filepath.Join(destDir, skill.Name, filepath.FromSlash(job.file)) + local, err := os.ReadFile(target) + if err != nil { + outcomes[i] = fileOutcome{file: job.file, missing: true} + return nil + } + + outcome := fileOutcome{file: job.file, hasLocal: true} + remoteContent, err := remote.get(ctx, base+skill.Name+"/"+job.file) + if err != nil { + outcome.changed = true + outcomes[i] = outcome + return nil + } + if !bytes.Equal(hashBytes(local), hashBytes(remoteContent)) { + outcome.changed = true + } + outcomes[i] = outcome + return nil + }) + } + _ = g.Wait() + + hasFiles := false + for _, outcome := range outcomes { + if outcome.missing { + check.MissingFiles = append(check.MissingFiles, outcome.file) + continue + } + if outcome.hasLocal { + hasFiles = true + } + if outcome.changed { + check.ChangedFiles = append(check.ChangedFiles, outcome.file) + } + } + + switch { + case !hasFiles && len(check.MissingFiles) > 0: + check.Status = StatusNotInstalled + case len(check.MissingFiles) > 0 || len(check.ChangedFiles) > 0: + check.Status = StatusOutOfDate + default: + check.Status = StatusCurrent + } + return check +} + +func aggregateDirStatus(result *DirStatus) string { + if result.InstalledCount == 0 { + return StatusNotInstalled + } + + total := len(result.Skills) + if result.OutOfDateCount > 0 { + if result.InstalledCount < total { + return StatusPartial + } + return StatusOutOfDate + } + if result.InstalledCount < total { + return StatusPartial + } + return StatusCurrent +} + +func hashBytes(b []byte) []byte { + sum := sha256.Sum256(b) + return sum[:] +} + // Install fetches every file of every skill in the index and writes it under // destDir preserving the skill/relative-path layout (destDir//). // Installation is best-effort: a file that fails to fetch or write is skipped. diff --git a/pkg/agentskills/agentskills_test.go b/pkg/agentskills/agentskills_test.go index 79dad8dc..a173b1d6 100644 --- a/pkg/agentskills/agentskills_test.go +++ b/pkg/agentskills/agentskills_test.go @@ -141,3 +141,112 @@ func TestInstall_IndexError(t *testing.T) { require.Nil(t, installed) require.Contains(t, err.Error(), "fetching skills index") } + +func TestCheck_NotInstalled(t *testing.T) { + index := Index{Skills: []Skill{ + {Name: "stripe-best-practices", Files: []string{"SKILL.md"}}, + }} + server := startSkillsServer(t, index, map[string]string{ + "stripe-best-practices/SKILL.md": "# best practices", + }) + + status, err := Check(context.Background(), server.Client(), t.TempDir()) + + require.NoError(t, err) + require.Equal(t, StatusNotInstalled, status.Status) + require.Equal(t, 0, status.InstalledCount) + require.Equal(t, 0, status.OutOfDateCount) + require.Len(t, status.Skills, 1) + require.Equal(t, StatusNotInstalled, status.Skills[0].Status) +} + +func TestCheck_Current(t *testing.T) { + content := "# best practices" + index := Index{Skills: []Skill{ + {Name: "stripe-best-practices", Files: []string{"SKILL.md"}}, + {Name: "upgrade-stripe", Files: []string{"SKILL.md"}}, + }} + server := startSkillsServer(t, index, map[string]string{ + "stripe-best-practices/SKILL.md": content, + "upgrade-stripe/SKILL.md": "# upgrade", + }) + + dest := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dest, "stripe-best-practices"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dest, "stripe-best-practices", "SKILL.md"), []byte(content), 0600)) + require.NoError(t, os.MkdirAll(filepath.Join(dest, "upgrade-stripe"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dest, "upgrade-stripe", "SKILL.md"), []byte("# upgrade"), 0600)) + + status, err := Check(context.Background(), server.Client(), dest) + + require.NoError(t, err) + require.Equal(t, StatusCurrent, status.Status) + require.Equal(t, 2, status.InstalledCount) + require.Equal(t, 0, status.OutOfDateCount) +} + +func TestCheck_OutOfDate(t *testing.T) { + index := Index{Skills: []Skill{ + {Name: "stripe-best-practices", Files: []string{"SKILL.md"}}, + }} + server := startSkillsServer(t, index, map[string]string{ + "stripe-best-practices/SKILL.md": "# updated", + }) + + dest := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dest, "stripe-best-practices"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dest, "stripe-best-practices", "SKILL.md"), []byte("# stale"), 0600)) + + status, err := Check(context.Background(), server.Client(), dest) + + require.NoError(t, err) + require.Equal(t, StatusOutOfDate, status.Status) + require.Equal(t, 1, status.InstalledCount) + require.Equal(t, 1, status.OutOfDateCount) + require.Equal(t, []string{"SKILL.md"}, status.Skills[0].ChangedFiles) +} + +func TestCheck_Partial(t *testing.T) { + index := Index{Skills: []Skill{ + {Name: "stripe-best-practices", Files: []string{"SKILL.md"}}, + {Name: "upgrade-stripe", Files: []string{"SKILL.md"}}, + }} + server := startSkillsServer(t, index, map[string]string{ + "stripe-best-practices/SKILL.md": "# best practices", + "upgrade-stripe/SKILL.md": "# upgrade", + }) + + dest := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dest, "stripe-best-practices"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dest, "stripe-best-practices", "SKILL.md"), []byte("# best practices"), 0600)) + + status, err := Check(context.Background(), server.Client(), dest) + + require.NoError(t, err) + require.Equal(t, StatusPartial, status.Status) + require.Equal(t, 1, status.InstalledCount) + require.Equal(t, 0, status.OutOfDateCount) + require.Equal(t, StatusCurrent, status.Skills[0].Status) + require.Equal(t, StatusNotInstalled, status.Skills[1].Status) +} + +func TestCheck_MissingFileWithinSkill(t *testing.T) { + index := Index{Skills: []Skill{ + {Name: "stripe-best-practices", Files: []string{"SKILL.md", "references/billing.md"}}, + }} + server := startSkillsServer(t, index, map[string]string{ + "stripe-best-practices/SKILL.md": "# best practices", + "stripe-best-practices/references/billing.md": "# billing", + }) + + dest := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dest, "stripe-best-practices"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dest, "stripe-best-practices", "SKILL.md"), []byte("# best practices"), 0600)) + + status, err := Check(context.Background(), server.Client(), dest) + + require.NoError(t, err) + require.Equal(t, StatusOutOfDate, status.Status) + require.Equal(t, 1, status.OutOfDateCount) + require.Equal(t, []string{"references/billing.md"}, status.Skills[0].MissingFiles) +} From 6501e2ec175025d8ac303c6ef2484441c9e19bf9 Mon Sep 17 00:00:00 2001 From: Gus Nguyen Date: Wed, 8 Jul 2026 15:56:37 -0700 Subject: [PATCH 2/7] Drop the unused partial skills status. Collapse partial into out_of_date at the source so no downstream code reasons about a state the CLI never surfaces. Co-authored-by: Cursor Committed-By-Agent: cursor --- pkg/agentskills/agentskills.go | 9 +-------- pkg/agentskills/agentskills_test.go | 4 ++-- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/pkg/agentskills/agentskills.go b/pkg/agentskills/agentskills.go index b301be8c..344fe9ac 100644 --- a/pkg/agentskills/agentskills.go +++ b/pkg/agentskills/agentskills.go @@ -48,7 +48,6 @@ const ( StatusNotInstalled = "not_installed" StatusCurrent = "current" StatusOutOfDate = "out_of_date" - StatusPartial = "partial" StatusError = "error" ) @@ -267,15 +266,9 @@ func aggregateDirStatus(result *DirStatus) string { } total := len(result.Skills) - if result.OutOfDateCount > 0 { - if result.InstalledCount < total { - return StatusPartial - } + if result.OutOfDateCount > 0 || result.InstalledCount < total { return StatusOutOfDate } - if result.InstalledCount < total { - return StatusPartial - } return StatusCurrent } diff --git a/pkg/agentskills/agentskills_test.go b/pkg/agentskills/agentskills_test.go index a173b1d6..0a024c95 100644 --- a/pkg/agentskills/agentskills_test.go +++ b/pkg/agentskills/agentskills_test.go @@ -206,7 +206,7 @@ func TestCheck_OutOfDate(t *testing.T) { require.Equal(t, []string{"SKILL.md"}, status.Skills[0].ChangedFiles) } -func TestCheck_Partial(t *testing.T) { +func TestCheck_SomeSkillsMissing(t *testing.T) { index := Index{Skills: []Skill{ {Name: "stripe-best-practices", Files: []string{"SKILL.md"}}, {Name: "upgrade-stripe", Files: []string{"SKILL.md"}}, @@ -223,7 +223,7 @@ func TestCheck_Partial(t *testing.T) { status, err := Check(context.Background(), server.Client(), dest) require.NoError(t, err) - require.Equal(t, StatusPartial, status.Status) + require.Equal(t, StatusOutOfDate, status.Status) require.Equal(t, 1, status.InstalledCount) require.Equal(t, 0, status.OutOfDateCount) require.Equal(t, StatusCurrent, status.Skills[0].Status) From 4c874fbbd9201ffe931a5247971077950ee2f362 Mon Sep 17 00:00:00 2001 From: Gus Nguyen Date: Thu, 9 Jul 2026 11:16:10 -0700 Subject: [PATCH 3/7] Document why a failed remote fetch marks a skill out of date. Answers review feedback: on a per-file fetch failure we intentionally bias toward update (idempotent, non-destructive) rather than reporting the skill as current, which could hide real drift. Co-authored-by: Cursor Committed-By-Agent: cursor --- pkg/agentskills/agentskills.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/agentskills/agentskills.go b/pkg/agentskills/agentskills.go index 344fe9ac..cffdb659 100644 --- a/pkg/agentskills/agentskills.go +++ b/pkg/agentskills/agentskills.go @@ -222,6 +222,12 @@ func checkSkill(ctx context.Context, remote *limitedGetter, destDir, base string outcome := fileOutcome{file: job.file, hasLocal: true} remoteContent, err := remote.get(ctx, base+skill.Name+"/"+job.file) if err != nil { + // We have a local copy but couldn't fetch the remote to + // compare. Bias toward "out of date" rather than "current": + // updating is idempotent and never discards local work, so a + // false positive just re-syncs, whereas a false "current" could + // hide real drift. (A fully unreachable index is already caught + // earlier and reported as StatusError.) outcome.changed = true outcomes[i] = outcome return nil From b427700d3be63f0667cddc054545d40def76c9ce Mon Sep 17 00:00:00 2001 From: Gus Nguyen Date: Thu, 9 Jul 2026 11:17:56 -0700 Subject: [PATCH 4/7] Document why g.Wait's error is ignored. Each file-check goroutine records its result in outcomes and always returns nil, so the errgroup error is intentionally discarded. Co-authored-by: Cursor Committed-By-Agent: cursor --- pkg/agentskills/agentskills.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/agentskills/agentskills.go b/pkg/agentskills/agentskills.go index cffdb659..52e03967 100644 --- a/pkg/agentskills/agentskills.go +++ b/pkg/agentskills/agentskills.go @@ -239,6 +239,9 @@ func checkSkill(ctx context.Context, remote *limitedGetter, destDir, base string return nil }) } + // Each goroutine records its result in outcomes[i] and always returns nil, + // so g.Wait never reports an error; we intentionally ignore its return and + // derive the skill status from the collected outcomes below. _ = g.Wait() hasFiles := false From d8f30b9a96bf92ff929f6a999ce3328ffd009675 Mon Sep 17 00:00:00 2001 From: Gus Nguyen Date: Wed, 8 Jul 2026 14:57:33 -0700 Subject: [PATCH 5/7] Surface skills status in agent setup flows. Show local and global skills state in --status, --json, and the interactive TUI, and support updating already-installed skills. Co-authored-by: Cursor Committed-By-Agent: cursor --- pkg/cmd/agent.go | 241 ++++++++++++++++++++++++++++---- pkg/cmd/agent_setup_tui.go | 104 ++++++++++++-- pkg/cmd/agent_setup_tui_test.go | 83 +++++++++-- pkg/cmd/agent_test.go | 162 ++++++++++++++++++--- 4 files changed, 514 insertions(+), 76 deletions(-) diff --git a/pkg/cmd/agent.go b/pkg/cmd/agent.go index 251e6bd1..77215f1b 100644 --- a/pkg/cmd/agent.go +++ b/pkg/cmd/agent.go @@ -57,6 +57,7 @@ type agentSetupCmd struct { // Skills installation is injectable so tests can avoid the network and // point installs at temp directories. skillsInstall func(ctx context.Context, destDir string) ([]string, error) + skillsCheck func(ctx context.Context, destDir string) (*agentskills.DirStatus, error) skillsLocalDir func() (string, error) skillsGlobalDir func() (string, error) @@ -71,10 +72,22 @@ type agentSetupCmd struct { type agentSetupJSON struct { Status string `json:"status"` Clients []agentsetup.Status `json:"clients"` + Skills skillsScopesJSON `json:"skills"` Actions []agentsetup.Plan `json:"actions,omitempty"` Errors []string `json:"errors,omitempty"` } +type skillsScopesJSON struct { + Local agentskills.DirStatus `json:"local"` + Global agentskills.DirStatus `json:"global"` +} + +// skillsScopes holds the check result for local and global skill directories. +type skillsScopes struct { + Local agentskills.DirStatus + Global agentskills.DirStatus +} + func newAgentCmd() *agentCmd { ac := &agentCmd{} ac.cmd = &cobra.Command{ @@ -93,6 +106,9 @@ func newAgentSetupCmd() *agentSetupCmd { skillsInstall: func(ctx context.Context, destDir string) ([]string, error) { return agentskills.Install(ctx, nil, destDir) }, + skillsCheck: func(ctx context.Context, destDir string) (*agentskills.DirStatus, error) { + return agentskills.Check(ctx, nil, destDir) + }, skillsLocalDir: func() (string, error) { return skillsDirUnder(os.Getwd) }, skillsGlobalDir: func() (string, error) { return skillsDirUnder(os.UserHomeDir) }, callingAgent: func() string { return useragent.DetectAIAgent(os.Getenv) }, @@ -110,7 +126,7 @@ func newAgentSetupCmd() *agentSetupCmd { } asc.cmd.Flags().BoolVar(&asc.statusOnly, "status", false, "Check installed agent tooling without making changes") asc.cmd.Flags().BoolVarP(&asc.yes, "yes", "y", false, "Set up every detected client without prompting") - asc.cmd.Flags().BoolVar(&asc.force, "force", false, "Reinstall even when agent tooling is already installed") + asc.cmd.Flags().BoolVar(&asc.force, "force", false, "Reinstall even when agent tooling or skills are already up to date") asc.cmd.Flags().StringVar(&asc.client, "client", "", fmt.Sprintf("Limit setup to a single client; supported values: %s", agentsetup.SupportedProviderIDs(asc.providers))) asc.cmd.Flags().BoolVar(&asc.jsonOutput, "json", false, "Write machine-readable status output") @@ -145,24 +161,38 @@ func (asc *agentSetupCmd) runSetup(cmd *cobra.Command, _ []string) error { } } - if asc.jsonOutput { - return asc.writeJSON(out, providers, statuses) + detected := detectedStatuses(statuses) + + var skills skillsScopes + runErr := spinner.New(). + WithLabel("Checking skills..."). + WithOutput(os.Stderr). + Run(func() error { + var e error + skills, e = asc.checkSkillsScopes(ctx) + return e + }) + if runErr != nil { + return runErr } - detected := detectedStatuses(statuses) + if asc.jsonOutput { + return asc.writeJSON(out, providers, statuses, skills) + } if asc.statusOnly { - if len(detected) == 0 { + if len(detected) > 0 { + printStatusTable(out, detected) + fmt.Fprintln(out) + } else { printNothingDetected(out) - return nil + fmt.Fprintln(out) } - // Only show clients that are actually installed on this machine; there's - // no value in listing clients the user doesn't have. - printStatusTable(out, detected) + printSkillsStatusTable(out, skills) return nil } - sel, scope, err := asc.resolveSelection(cmd, out, detected) + sel, scope, err := asc.resolveSelection(cmd, out, detected, skills) if err != nil { return err } @@ -177,7 +207,7 @@ func (asc *agentSetupCmd) runSetup(cmd *cobra.Command, _ []string) error { // interactive TUI when attached to a terminal (and --yes was not passed), // otherwise it derives the selection from flags. // A nil Selection means there is nothing to do and a message has been printed. -func (asc *agentSetupCmd) resolveSelection(cmd *cobra.Command, out io.Writer, detected []agentsetup.Status) (*Selection, string, error) { +func (asc *agentSetupCmd) resolveSelection(cmd *cobra.Command, out io.Writer, detected []agentsetup.Status, skills skillsScopes) (*Selection, string, error) { // Detect the calling agent up front. agent := asc.callingAgent() @@ -204,7 +234,7 @@ func (asc *agentSetupCmd) resolveSelection(cmd *cobra.Command, out io.Writer, de if asc.isInteractive() && agent == "" { printNothingDetectedInteractive(out) fmt.Fprintln(out) - chosen, ok, err := RunSkillsScopeTUI() + chosen, ok, err := RunSkillsScopeTUI(skills) if err != nil { return nil, "", err } @@ -228,7 +258,7 @@ func (asc *agentSetupCmd) resolveSelection(cmd *cobra.Command, out io.Writer, de return &Selection{Agents: detected}, "", nil } - sel, err := RunSelectionTUI(detected) + sel, err := RunSelectionTUI(detected, skills) if err != nil { return nil, "", err } @@ -248,7 +278,7 @@ func (asc *agentSetupCmd) resolveSelection(cmd *cobra.Command, out io.Writer, de // If skills were selected in the TUI, ask where to install them. if sel.InstallSkills { fmt.Fprintln(out) - chosen, ok, err := RunSkillsScopeTUI() + chosen, ok, err := RunSkillsScopeTUI(skills) if err != nil { return nil, "", err } @@ -286,7 +316,7 @@ func (asc *agentSetupCmd) install(ctx context.Context, out io.Writer, providers cross := color.Red("✗").String() warn := color.Yellow("⚠").String() - var successCount, skipCount, errCount int + var installedCount, updatedCount, skipCount, errCount int for _, status := range sel.Agents { provider := providers[status.Client] plan := provider.Plan(status, asc.force) @@ -319,18 +349,21 @@ func (asc *agentSetupCmd) install(ctx context.Context, out io.Writer, providers } fmt.Fprintf(out, " %s done\n", check) sendAgentEvent(ctx, "Agent Setup: Plugin Install", status.Client+":success") - successCount++ + installedCount++ } if sel.InstallSkills { - if asc.installSkills(ctx, out, scope, check, cross) { - successCount++ - } else { + ok, installed, updated, skipped := asc.installSkills(ctx, out, scope, check, cross) + if !ok { errCount++ + } else if !skipped { + installedCount += installed + updatedCount += updated } + // Skills already up to date are a successful no-op; they don't count as skipped. } - fmt.Fprintf(out, "\n%d installed, %d skipped, %d errors\n", successCount, skipCount, errCount) + fmt.Fprintf(out, "\n%d installed, %d updated, %d skipped, %d errors\n", installedCount, updatedCount, skipCount, errCount) if errCount > 0 { return fmt.Errorf("%d item(s) failed to set up", errCount) } @@ -338,8 +371,9 @@ func (asc *agentSetupCmd) install(ctx context.Context, out io.Writer, providers } // installSkills fetches and writes Stripe skills to the local or global -// .agents/skills directory. It returns true on success. -func (asc *agentSetupCmd) installSkills(ctx context.Context, out io.Writer, scope, check, cross string) bool { +// .agents/skills directory. It returns (ok, installed, updated, skipped): +// skipped is true when skills were already current and --force was not set. +func (asc *agentSetupCmd) installSkills(ctx context.Context, out io.Writer, scope, check, cross string) (bool, int, int, bool) { fmt.Fprintf(out, "\n Stripe skills (%s)\n", scope) dirFn := asc.skillsLocalDir @@ -350,33 +384,119 @@ func (asc *agentSetupCmd) installSkills(ctx context.Context, out io.Writer, scop if err != nil { fmt.Fprintf(out, " %s error: resolving skills directory: %v\n", cross, err) sendAgentEvent(ctx, "Agent Setup: Skills Install", scope+":error") - return false + return false, 0, 0, false } - var installed []string + var priorStatus *agentskills.DirStatus + checkErr := spinner.New(). + WithLabel("Checking skills..."). + WithOutput(os.Stderr). + Run(func() error { + var e error + priorStatus, e = asc.skillsCheck(ctx, dir) + return e + }) + if checkErr != nil { + fmt.Fprintf(out, " %s error: %v\n", cross, checkErr) + sendAgentEvent(ctx, "Agent Setup: Skills Install", scope+":error") + return false, 0, 0, false + } + + if priorStatus.Status == agentskills.StatusCurrent && !asc.force { + fmt.Fprintf(out, " %s already up to date\n", check) + sendAgentEvent(ctx, "Agent Setup: Skills Install", scope+":skip") + return true, 0, 0, true + } + + var skillNames []string runErr := spinner.New(). WithLabel("Installing skills..."). WithOutput(os.Stderr). Run(func() error { var e error - installed, e = asc.skillsInstall(ctx, dir) + skillNames, e = asc.skillsInstall(ctx, dir) return e }) if runErr != nil { fmt.Fprintf(out, " %s error: %v\n", cross, runErr) sendAgentEvent(ctx, "Agent Setup: Skills Install", scope+":error") - return false + return false, 0, 0, false + } + + priorByName := make(map[string]agentskills.SkillCheck, len(priorStatus.Skills)) + for _, skill := range priorStatus.Skills { + priorByName[skill.Name] = skill + } + + var installed, updated int + for _, name := range skillNames { + prior, ok := priorByName[name] + if !ok || prior.Status == agentskills.StatusNotInstalled { + installed++ + continue + } + updated++ + } + + switch { + case installed > 0 && updated > 0: + fmt.Fprintf(out, " %s installed %d and updated %d skill(s) to %s: %s\n", check, installed, updated, dir, strings.Join(skillNames, ", ")) + case updated > 0: + fmt.Fprintf(out, " %s updated %d skill(s) to %s: %s\n", check, updated, dir, strings.Join(skillNames, ", ")) + default: + fmt.Fprintf(out, " %s installed %d skill(s) to %s: %s\n", check, installed, dir, strings.Join(skillNames, ", ")) } sendAgentEvent(ctx, "Agent Setup: Skills Install", scope+":success") - fmt.Fprintf(out, " %s installed %d skill(s) to %s: %s\n", check, len(installed), dir, strings.Join(installed, ", ")) - return true + return true, installed, updated, false +} + +func (asc *agentSetupCmd) checkSkillsScopes(ctx context.Context) (skillsScopes, error) { + localDir, err := asc.skillsLocalDir() + if err != nil { + return skillsScopes{}, fmt.Errorf("resolving local skills directory: %w", err) + } + globalDir, err := asc.skillsGlobalDir() + if err != nil { + return skillsScopes{}, fmt.Errorf("resolving global skills directory: %w", err) + } + + local, err := asc.skillsCheck(ctx, localDir) + if err != nil { + return skillsScopes{}, err + } + global, err := asc.skillsCheck(ctx, globalDir) + if err != nil { + return skillsScopes{}, err + } + + return skillsScopes{Local: *local, Global: *global}, nil +} + +func skillsNeedsAction(s skillsScopes) bool { + return skillsScopeNeedsAction(s.Local) || skillsScopeNeedsAction(s.Global) +} + +func skillsScopeNeedsUpdate(d agentskills.DirStatus) bool { + return d.Status == agentskills.StatusOutOfDate || d.Status == agentskills.StatusPartial +} + +func skillsScopeNeedsInstall(d agentskills.DirStatus) bool { + return d.Status == agentskills.StatusNotInstalled +} + +func skillsScopeNeedsAction(d agentskills.DirStatus) bool { + return skillsScopeNeedsUpdate(d) || skillsScopeNeedsInstall(d) } -func (asc *agentSetupCmd) writeJSON(w io.Writer, providers map[string]agentsetup.Provider, statuses []agentsetup.Status) error { +func (asc *agentSetupCmd) writeJSON(w io.Writer, providers map[string]agentsetup.Provider, statuses []agentsetup.Status, skills skillsScopes) error { result := agentSetupJSON{ Status: aggregateStatus(statuses), Clients: statuses, + Skills: skillsScopesJSON{ + Local: skills.Local, + Global: skills.Global, + }, } for _, status := range statuses { if plan := providers[status.Client].Plan(status, asc.force); plan.Action != agentsetup.ActionNone { @@ -386,6 +506,17 @@ func (asc *agentSetupCmd) writeJSON(w io.Writer, providers map[string]agentsetup result.Errors = append(result.Errors, status.Error) } } + if skillsScopeNeedsUpdate(skills.Local) || skillsScopeNeedsUpdate(skills.Global) { + result.Actions = append(result.Actions, agentsetup.Plan{ + Action: "update_skills", + Command: []string{"stripe", "agent", "setup"}, + }) + } else if skillsScopeNeedsInstall(skills.Local) || skillsScopeNeedsInstall(skills.Global) { + result.Actions = append(result.Actions, agentsetup.Plan{ + Action: "install_skills", + Command: []string{"stripe", "agent", "setup"}, + }) + } enc := json.NewEncoder(w) enc.SetIndent("", " ") @@ -520,6 +651,58 @@ func printStatusTable(w io.Writer, statuses []agentsetup.Status) { } } +// printSkillsStatusTable renders the local and global skills install state. +func printSkillsStatusTable(w io.Writer, skills skillsScopes) { + color := ansi.Color(w) + + fmt.Fprintln(w, color.Bold("Stripe skills:").String()) + fmt.Fprintln(w) + + scopeWidth := len("global") + needsInstall := false + needsUpdate := false + for _, entry := range []struct { + name string + dir agentskills.DirStatus + }{ + {"local", skills.Local}, + {"global", skills.Global}, + } { + icon, state := skillsScopeStatusLine(w, entry.dir) + if skillsScopeNeedsInstall(entry.dir) { + needsInstall = true + } + if skillsScopeNeedsUpdate(entry.dir) { + needsUpdate = true + } + fmt.Fprintf(w, " %s %-*s %s %s\n", icon, scopeWidth, entry.name, state, color.Faint(entry.dir.Dir).String()) + } + + switch { + case needsUpdate: + fmt.Fprintf(w, "\nRun %s to update your Stripe skills.\n", color.Bold("stripe agent setup").String()) + case needsInstall: + fmt.Fprintf(w, "\nRun %s to install your Stripe skills.\n", color.Bold("stripe agent setup").String()) + } +} + +func skillsScopeStatusLine(w io.Writer, d agentskills.DirStatus) (icon, state string) { + color := ansi.Color(w) + total := len(d.Skills) + switch d.Status { + case agentskills.StatusError: + return color.Red("✗").String(), color.Red("error: " + d.Error).String() + case agentskills.StatusCurrent: + return color.Green("✔").String(), fmt.Sprintf("%d skills up to date", total) + case agentskills.StatusOutOfDate: + return color.Yellow("⚠").String(), fmt.Sprintf("%d of %d skills out of date", d.OutOfDateCount, total) + case agentskills.StatusPartial: + return color.Yellow("⚠").String(), fmt.Sprintf("%d of %d skills installed, some out of date", d.InstalledCount, total) + default: + return color.Yellow("•").String(), "not installed" + } +} + // pluginDetail summarizes an installed plugin, e.g. "stripe@cursor-public 0.1.0, user". func pluginDetail(p agentsetup.PluginStatus) string { detail := p.ID diff --git a/pkg/cmd/agent_setup_tui.go b/pkg/cmd/agent_setup_tui.go index 734e2be2..558f18c0 100644 --- a/pkg/cmd/agent_setup_tui.go +++ b/pkg/cmd/agent_setup_tui.go @@ -3,11 +3,13 @@ package cmd import ( "fmt" "os" + "path/filepath" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/stripe/stripe-cli/pkg/agentsetup" + "github.com/stripe/stripe-cli/pkg/agentskills" ) const ( @@ -45,7 +47,7 @@ type Selection struct { InstallSkills bool } -func newSelectModel(statuses []agentsetup.Status) selectModel { +func newSelectModel(statuses []agentsetup.Status, skills skillsScopes) selectModel { rows := make([]selectRow, 0, len(statuses)+1) for _, s := range statuses { row := selectRow{ @@ -66,16 +68,33 @@ func newSelectModel(statuses []agentsetup.Status) selectModel { row.selected = !row.disabled rows = append(rows, row) } + + skillsLabel, skillsDetail, skillsSelected := skillsRowPresentation(statuses, skills) rows = append(rows, selectRow{ kind: rowSkills, - label: "Install Stripe skills", - detail: "", - selected: len(statuses) == 0, // pre-selected only when it's the sole option + label: skillsLabel, + detail: skillsDetail, + selected: skillsSelected, }) return selectModel{rows: rows} } +func skillsRowPresentation(statuses []agentsetup.Status, skills skillsScopes) (label, detail string, selected bool) { + label = "Install Stripe skills" + + hasOutOfDate := skillsScopeNeedsUpdate(skills.Local) || skillsScopeNeedsUpdate(skills.Global) + if hasOutOfDate { + return label, "Detected outdated Stripe skills", true + } + + if skills.Local.Status == agentskills.StatusCurrent && skills.Global.Status == agentskills.StatusCurrent { + return "Stripe skills", "up to date", false + } + + return label, "", len(statuses) == 0 +} + func (m selectModel) Init() tea.Cmd { return nil } func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -191,8 +210,8 @@ func (m selectModel) View() tea.View { // RunSelectionTUI shows the checklist and returns the user's selection. It // returns nil (not an error) when the user quits without confirming. -func RunSelectionTUI(statuses []agentsetup.Status) (*Selection, error) { - m := newSelectModel(statuses) +func RunSelectionTUI(statuses []agentsetup.Status, skills skillsScopes) (*Selection, error) { + m := newSelectModel(statuses, skills) final, err := tea.NewProgram(m).Run() if err != nil { return nil, fmt.Errorf("agent selection: %w", err) @@ -210,24 +229,79 @@ func RunSelectionTUI(statuses []agentsetup.Status) (*Selection, error) { type scopeModel struct { options []string labels []string + title string cursor int done bool quit bool } -func newScopeModel() scopeModel { - localLabel := "This project (current directory only)" +func newScopeModel(skills skillsScopes) scopeModel { + localPath := ".agents/skills" if cwd, err := os.Getwd(); err == nil { - localLabel = fmt.Sprintf("This project %s/.agents/skills", cwd) + localPath = filepath.Join(cwd, ".agents", "skills") } - globalLabel := "Global (available everywhere)" + globalPath := ".agents/skills" if home, err := os.UserHomeDir(); err == nil { - globalLabel = fmt.Sprintf("Global %s/.agents/skills", home) + globalPath = filepath.Join(home, ".agents", "skills") + } + + title := "Install Stripe skills where?" + if skillsScopeNeedsUpdate(skills.Local) || skillsScopeNeedsUpdate(skills.Global) { + title = "Update Stripe skills where?" } + + cursor := 0 + if preferred := preferredSkillsScope(skills); preferred == skillsScopeGlobal { + cursor = 1 + } + return scopeModel{ options: []string{skillsScopeLocal, skillsScopeGlobal}, - labels: []string{localLabel, globalLabel}, + labels: []string{ + scopeOptionLabel("This project", localPath, skills.Local), + scopeOptionLabel("Global", globalPath, skills.Global), + }, + title: title, + cursor: cursor, + } +} + +func scopeOptionLabel(prefix, path string, status agentskills.DirStatus) string { + if hint := scopeStatusHint(status); hint != "" { + return fmt.Sprintf("%-14s %s %s", prefix, path, hint) + } + return fmt.Sprintf("%-14s %s", prefix, path) +} + +func scopeStatusHint(status agentskills.DirStatus) string { + switch status.Status { + case agentskills.StatusCurrent: + return "(up to date)" + case agentskills.StatusOutOfDate: + return "(out of date)" + case agentskills.StatusPartial: + return "(partially installed)" + case agentskills.StatusNotInstalled: + return "(not installed)" + default: + return "" + } +} + +func preferredSkillsScope(skills skillsScopes) string { + if skillsScopeNeedsUpdate(skills.Local) { + return skillsScopeLocal + } + if skillsScopeNeedsUpdate(skills.Global) { + return skillsScopeGlobal + } + if skillsScopeNeedsInstall(skills.Local) { + return skillsScopeLocal + } + if skillsScopeNeedsInstall(skills.Global) { + return skillsScopeGlobal } + return skillsScopeLocal } func (m scopeModel) Init() tea.Cmd { return nil } @@ -257,7 +331,7 @@ func (m scopeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m scopeModel) View() tea.View { - body := "Install Stripe skills where?\n\n" + body := m.title + "\n\n" for i := range m.options { marker := "( )" if i == m.cursor { @@ -275,8 +349,8 @@ func (m scopeModel) View() tea.View { // RunSkillsScopeTUI prompts for the skills install scope, returning "local" or // "global". ok is false when the user cancels. -func RunSkillsScopeTUI() (scope string, ok bool, err error) { - final, runErr := tea.NewProgram(newScopeModel()).Run() +func RunSkillsScopeTUI(skills skillsScopes) (scope string, ok bool, err error) { + final, runErr := tea.NewProgram(newScopeModel(skills)).Run() if runErr != nil { return "", false, fmt.Errorf("skills scope selection: %w", runErr) } diff --git a/pkg/cmd/agent_setup_tui_test.go b/pkg/cmd/agent_setup_tui_test.go index 28e3770f..cb205d88 100644 --- a/pkg/cmd/agent_setup_tui_test.go +++ b/pkg/cmd/agent_setup_tui_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/stripe/stripe-cli/pkg/agentsetup" + "github.com/stripe/stripe-cli/pkg/agentskills" ) func testStatuses() []agentsetup.Status { @@ -17,13 +18,20 @@ func testStatuses() []agentsetup.Status { } } +func testSkillsScopes() skillsScopes { + return skillsScopes{ + Local: agentskills.DirStatus{Status: agentskills.StatusNotInstalled}, + Global: agentskills.DirStatus{Status: agentskills.StatusNotInstalled}, + } +} + func pressSelect(m selectModel, code rune) selectModel { next, _ := m.Update(tea.KeyPressMsg{Code: code}) return next.(selectModel) } func TestSelectModel_AgentsSelectedSkillsRowNot(t *testing.T) { - m := newSelectModel(testStatuses()) + m := newSelectModel(testStatuses(), testSkillsScopes()) // 3 agent rows + 1 skills row. require.Len(t, m.rows, 4) @@ -36,7 +44,7 @@ func TestSelectModel_AgentsSelectedSkillsRowNot(t *testing.T) { } func TestSelectModel_NoAgentsPreselectsSkills(t *testing.T) { - m := newSelectModel(nil) + m := newSelectModel(nil, testSkillsScopes()) require.Len(t, m.rows, 1) require.Equal(t, rowSkills, m.rows[0].kind) @@ -44,7 +52,7 @@ func TestSelectModel_NoAgentsPreselectsSkills(t *testing.T) { } func TestSelectModel_SelectionSeparatesAgentsAndSkills(t *testing.T) { - m := newSelectModel(testStatuses()) + m := newSelectModel(testStatuses(), testSkillsScopes()) // Toggle the skills row (index 3) on. m.cursor = 3 @@ -61,7 +69,7 @@ func TestSelectModel_SelectionSeparatesAgentsAndSkills(t *testing.T) { } func TestSelectModel_SelectAllTogglesEveryRow(t *testing.T) { - m := newSelectModel(testStatuses()) + m := newSelectModel(testStatuses(), testSkillsScopes()) // Skills row starts unselected, so 'a' selects all. m = pressSelect(m, 'a') @@ -76,7 +84,7 @@ func TestSelectModel_SelectAllTogglesEveryRow(t *testing.T) { } func TestSelectModel_CursorClampedAcrossAllRows(t *testing.T) { - m := newSelectModel(testStatuses()) + m := newSelectModel(testStatuses(), testSkillsScopes()) m = pressSelect(m, tea.KeyUp) require.Equal(t, 0, m.cursor) @@ -88,14 +96,14 @@ func TestSelectModel_CursorClampedAcrossAllRows(t *testing.T) { } func TestSelectModel_EnterConfirmsQuitCancels(t *testing.T) { - m := newSelectModel(testStatuses()) + m := newSelectModel(testStatuses(), testSkillsScopes()) next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) m = next.(selectModel) require.True(t, m.done) require.False(t, m.quit) require.NotNil(t, cmd) - m2 := newSelectModel(testStatuses()) + m2 := newSelectModel(testStatuses(), testSkillsScopes()) next2, cmd2 := m2.Update(tea.KeyPressMsg{Code: 'q'}) m2 = next2.(selectModel) require.True(t, m2.quit) @@ -104,7 +112,7 @@ func TestSelectModel_EnterConfirmsQuitCancels(t *testing.T) { } func TestScopeModel_DefaultsToLocalAndConfirms(t *testing.T) { - m := newScopeModel() + m := newScopeModel(testSkillsScopes()) require.Equal(t, skillsScopeLocal, m.options[m.cursor]) next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) @@ -118,9 +126,66 @@ func TestScopeModel_DefaultsToLocalAndConfirms(t *testing.T) { } func TestScopeModel_QuitCancels(t *testing.T) { - m := newScopeModel() + m := newScopeModel(testSkillsScopes()) next, _ := m.Update(tea.KeyPressMsg{Code: 'q'}) m = next.(scopeModel) require.True(t, m.quit) require.False(t, m.done) } + +func TestSelectModel_OutOfDatePreselectsUpdateRow(t *testing.T) { + skills := skillsScopes{ + Local: agentskills.DirStatus{ + Status: agentskills.StatusOutOfDate, + OutOfDateCount: 2, + }, + Global: agentskills.DirStatus{Status: agentskills.StatusCurrent}, + } + + m := newSelectModel(testStatuses(), skills) + + require.Equal(t, "Install Stripe skills", m.rows[len(m.rows)-1].label) + require.Equal(t, "Detected outdated Stripe skills", m.rows[len(m.rows)-1].detail) + require.True(t, m.rows[len(m.rows)-1].selected) +} + +func TestSelectModel_CurrentSkillsNotPreselected(t *testing.T) { + skills := skillsScopes{ + Local: agentskills.DirStatus{Status: agentskills.StatusCurrent}, + Global: agentskills.DirStatus{Status: agentskills.StatusCurrent}, + } + + m := newSelectModel(testStatuses(), skills) + + require.Equal(t, "Stripe skills", m.rows[len(m.rows)-1].label) + require.Equal(t, "up to date", m.rows[len(m.rows)-1].detail) + require.False(t, m.rows[len(m.rows)-1].selected) +} + +func TestScopeModel_PrefersScopeNeedingInstall(t *testing.T) { + skills := skillsScopes{ + Local: agentskills.DirStatus{Status: agentskills.StatusCurrent, InstalledCount: 4}, + Global: agentskills.DirStatus{Status: agentskills.StatusNotInstalled}, + } + + m := newScopeModel(skills) + + require.Equal(t, "Install Stripe skills where?", m.title) + require.Equal(t, skillsScopeGlobal, m.options[m.cursor]) + require.Contains(t, m.labels[0], "(up to date)") + require.Contains(t, m.labels[1], "(not installed)") +} + +func TestScopeModel_PrefersOutOfDateLocalScope(t *testing.T) { + skills := skillsScopes{ + Local: agentskills.DirStatus{Status: agentskills.StatusOutOfDate, InstalledCount: 1}, + Global: agentskills.DirStatus{Status: agentskills.StatusCurrent, InstalledCount: 4}, + } + + m := newScopeModel(skills) + + require.Equal(t, "Update Stripe skills where?", m.title) + require.Equal(t, skillsScopeLocal, m.options[m.cursor]) + require.Contains(t, m.labels[0], "(out of date)") + require.Contains(t, m.labels[1], "(up to date)") +} diff --git a/pkg/cmd/agent_test.go b/pkg/cmd/agent_test.go index ceb28089..f29e8e09 100644 --- a/pkg/cmd/agent_test.go +++ b/pkg/cmd/agent_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/stripe/stripe-cli/pkg/agentsetup" + "github.com/stripe/stripe-cli/pkg/agentskills" ) func TestAgentSetupStatusDoesNotInstall(t *testing.T) { @@ -64,13 +65,16 @@ func TestAgentSetupJSONReportsActionWithoutInstalling(t *testing.T) { require.Len(t, result.Clients, 1) require.True(t, result.Clients[0].Detected) require.False(t, result.Clients[0].Plugin.Installed) - require.Len(t, result.Actions, 1) + require.Len(t, result.Actions, 2) require.Equal(t, agentsetup.ActionInstall, result.Actions[0].Action) require.Equal(t, []string{"claude", "plugin", "install", agentsetup.TargetClaudePlugin}, result.Actions[0].Command) + require.Equal(t, "install_skills", result.Actions[1].Action) + require.Equal(t, agentskills.StatusNotInstalled, result.Skills.Local.Status) + require.Equal(t, agentskills.StatusNotInstalled, result.Skills.Global.Status) } func TestAgentSetupJSONShowsUpgradeHintWhenPluginCommandFails(t *testing.T) { - setup := newAgentSetupCmd() + setup := testAgentSetupCmd() claude := agentsetup.NewClaudeProvider(agentsetup.Scanner{ LookPath: func(string) (string, error) { return "/usr/local/bin/claude", nil }, }, nil) @@ -108,7 +112,7 @@ func TestAgentSetupForceYesInvokesInstallerWhenInstalled(t *testing.T) { require.Contains(t, output, "Setting up Stripe agent tooling") require.Contains(t, output, "Claude Code") require.Contains(t, output, "done") - require.Contains(t, output, "1 installed, 0 skipped, 0 errors") + require.Contains(t, output, "1 installed, 0 updated, 0 skipped, 0 errors") } func TestAgentSetupRetriesAfterMarketplaceUpdate(t *testing.T) { @@ -135,7 +139,7 @@ func TestAgentSetupRetriesAfterMarketplaceUpdate(t *testing.T) { }, calls) require.Contains(t, output, "Updating Claude plugin marketplace and retrying") require.Contains(t, output, "done") - require.Contains(t, output, "1 installed, 0 skipped, 0 errors") + require.Contains(t, output, "1 installed, 0 updated, 0 skipped, 0 errors") } func TestAgentSetupNoClaudeDoesNotFail(t *testing.T) { @@ -163,7 +167,7 @@ func TestAgentSetupInstallsAllDetectedClients(t *testing.T) { claude := agentsetup.NewClaudeProvider(claudeMissingPluginScanner(t), record) codex := codexMissingProvider(record) - setup := newAgentSetupCmd() + setup := testAgentSetupCmd() setup.providers = map[string]agentsetup.Provider{claude.ID(): claude, codex.ID(): codex} setup.callingAgent = func() string { return "" } // no agent -> install all detected setup.cmd.SetContext(context.Background()) @@ -175,7 +179,7 @@ func TestAgentSetupInstallsAllDetectedClients(t *testing.T) { require.ElementsMatch(t, []string{"claude", "codex"}, installed) require.Contains(t, output, "Claude Code") require.Contains(t, output, "Codex CLI") - require.Contains(t, output, "2 installed, 0 skipped, 0 errors") + require.Contains(t, output, "2 installed, 0 updated, 0 skipped, 0 errors") } func TestAgentSetupClientFlagLimitsToOne(t *testing.T) { @@ -188,7 +192,7 @@ func TestAgentSetupClientFlagLimitsToOne(t *testing.T) { claude := agentsetup.NewClaudeProvider(claudeMissingPluginScanner(t), record) codex := codexMissingProvider(record) - setup := newAgentSetupCmd() + setup := testAgentSetupCmd() setup.providers = map[string]agentsetup.Provider{claude.ID(): claude, codex.ID(): codex} setup.callingAgent = func() string { return "" } setup.cmd.SetContext(context.Background()) @@ -197,7 +201,7 @@ func TestAgentSetupClientFlagLimitsToOne(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{"codex"}, installed) - require.Contains(t, output, "1 installed, 0 skipped, 0 errors") + require.Contains(t, output, "1 installed, 0 updated, 0 skipped, 0 errors") } func TestAgentSetupAutoInstallsForCallingAgent(t *testing.T) { @@ -210,7 +214,7 @@ func TestAgentSetupAutoInstallsForCallingAgent(t *testing.T) { claude := agentsetup.NewClaudeProvider(claudeMissingPluginScanner(t), record) codex := codexMissingProvider(record) - setup := newAgentSetupCmd() + setup := testAgentSetupCmd() setup.providers = map[string]agentsetup.Provider{claude.ID(): claude, codex.ID(): codex} // Simulate being invoked by Codex CLI — only its plugin should install, // even though Claude is also detected, and with no --client flag. @@ -222,7 +226,7 @@ func TestAgentSetupAutoInstallsForCallingAgent(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{"codex"}, installed) // Claude NOT installed require.Contains(t, output, "Detected Codex CLI — setting up its Stripe plugin.") - require.Contains(t, output, "1 installed, 0 skipped, 0 errors") + require.Contains(t, output, "1 installed, 0 updated, 0 skipped, 0 errors") } // codexMissingProvider returns a Codex provider that detects the binary, starts @@ -256,7 +260,7 @@ func TestAgentSetupUnsupportedAgentInstallsSkillsToLocal(t *testing.T) { return nil }) - setup := newAgentSetupCmd() + setup := testAgentSetupCmd() setup.providers = map[string]agentsetup.Provider{claude.ID(): claude} setup.callingAgent = func() string { return "gemini_cli" } localDir := filepath.Join(t.TempDir(), ".agents", "skills") @@ -272,7 +276,7 @@ func TestAgentSetupUnsupportedAgentInstallsSkillsToLocal(t *testing.T) { require.NoError(t, err) require.Equal(t, localDir, gotDir) require.Contains(t, output, "Stripe skills (local)") - require.Contains(t, output, "1 installed, 0 skipped, 0 errors") + require.Contains(t, output, "1 installed, 0 updated, 0 skipped, 0 errors") } func TestAgentSetupCursorIsSkippedNotInstalled(t *testing.T) { @@ -281,7 +285,7 @@ func TestAgentSetupCursorIsSkippedNotInstalled(t *testing.T) { LookPath: func(string) (string, error) { return "/usr/local/bin/cursor", nil }, }, nil) - setup := newAgentSetupCmd() + setup := testAgentSetupCmd() setup.providers = map[string]agentsetup.Provider{cursor.ID(): cursor} setup.callingAgent = func() string { return "" } setup.cmd.SetContext(context.Background()) @@ -291,7 +295,7 @@ func TestAgentSetupCursorIsSkippedNotInstalled(t *testing.T) { require.NoError(t, err) require.Contains(t, output, "manual step") require.Contains(t, output, "/add-plugin stripe") - require.Contains(t, output, "0 installed, 1 skipped, 0 errors") + require.Contains(t, output, "0 installed, 0 updated, 1 skipped, 0 errors") } func TestAgentSetupStatusHidesUndetectedClients(t *testing.T) { @@ -301,7 +305,7 @@ func TestAgentSetupStatusHidesUndetectedClients(t *testing.T) { LookPath: func(string) (string, error) { return "", errors.New("not found") }, }, nil) - setup := newAgentSetupCmd() + setup := testAgentSetupCmd() setup.providers = map[string]agentsetup.Provider{claude.ID(): claude, cursor.ID(): cursor} setup.callingAgent = func() string { return "" } setup.cmd.SetContext(context.Background()) @@ -316,7 +320,7 @@ func TestAgentSetupStatusHidesUndetectedClients(t *testing.T) { func TestAgentSetupNoClientsNonInteractiveShowsHint(t *testing.T) { // No clients detected, non-interactive: show info message. - setup := newAgentSetupCmd() + setup := testAgentSetupCmd() setup.providers = map[string]agentsetup.Provider{} // nothing detected setup.callingAgent = func() string { return "" } setup.isInteractive = func() bool { return false } @@ -344,7 +348,7 @@ func TestAgentSetupAgentScopingWinsOverYes(t *testing.T) { }) codex := codexMissingProvider(record) - setup := newAgentSetupCmd() + setup := testAgentSetupCmd() setup.providers = map[string]agentsetup.Provider{claude.ID(): claude, codex.ID(): codex} setup.callingAgent = func() string { return "codex_cli" } setup.cmd.SetContext(context.Background()) @@ -354,7 +358,7 @@ func TestAgentSetupAgentScopingWinsOverYes(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{"codex"}, installed) // only codex, despite --yes and Claude detected require.Contains(t, output, "Detected Codex CLI — setting up its Stripe plugin.") - require.Contains(t, output, "1 installed, 0 skipped, 0 errors") + require.Contains(t, output, "1 installed, 0 updated, 0 skipped, 0 errors") } func TestAgentSetupYesInstallsAllWhenNoAgent(t *testing.T) { @@ -367,7 +371,7 @@ func TestAgentSetupYesInstallsAllWhenNoAgent(t *testing.T) { claude := agentsetup.NewClaudeProvider(claudeMissingPluginScanner(t), record) codex := codexMissingProvider(record) - setup := newAgentSetupCmd() + setup := testAgentSetupCmd() setup.providers = map[string]agentsetup.Provider{claude.ID(): claude, codex.ID(): codex} setup.callingAgent = func() string { return "" } setup.cmd.SetContext(context.Background()) @@ -388,7 +392,7 @@ func TestAgentSetupAgentNeverUsesInteractivePicker(t *testing.T) { return nil }) - setup := newAgentSetupCmd() + setup := testAgentSetupCmd() setup.providers = map[string]agentsetup.Provider{claude.ID(): claude} setup.callingAgent = func() string { return "gemini_cli" } setup.isInteractive = func() bool { return true } // simulate a PTY @@ -404,33 +408,145 @@ func TestAgentSetupAgentNeverUsesInteractivePicker(t *testing.T) { require.NoError(t, err) require.True(t, skillsCalled) require.Contains(t, output, "installing Stripe skills instead") - require.Contains(t, output, "1 installed, 0 skipped, 0 errors") + require.Contains(t, output, "1 installed, 0 updated, 0 skipped, 0 errors") +} + +func TestAgentSetupStatusShowsSkillsForBothScopes(t *testing.T) { + setup := newTestAgentSetupCmd(t, claudeMissingPluginScanner(t), nil) + + output, err := executeCommand(setup.cmd, "--status") + + require.NoError(t, err) + require.Contains(t, output, "Stripe skills:") + require.Contains(t, output, "local") + require.Contains(t, output, "global") + require.Contains(t, output, "not installed") + require.Contains(t, output, "Run stripe agent setup to install your Stripe skills.") +} + +func TestAgentSetupStatusWithNoClientsShowsSkills(t *testing.T) { + setup := testAgentSetupCmd() + setup.providers = map[string]agentsetup.Provider{} + setup.callingAgent = func() string { return "" } + setup.cmd.SetContext(context.Background()) + + output, err := executeCommand(setup.cmd, "--status") + + require.NoError(t, err) + require.Contains(t, output, "No supported AI coding clients detected on this machine.") + require.Contains(t, output, "Stripe skills:") +} + +func TestAgentSetupStatusShowsCTAWhenOutOfDate(t *testing.T) { + setup := newTestAgentSetupCmd(t, claudeMissingPluginScanner(t), nil) + setup.skillsCheck = mockSkillsCheckOutOfDate + + output, err := executeCommand(setup.cmd, "--status") + + require.NoError(t, err) + require.Contains(t, output, "out of date") + require.Contains(t, output, "Run stripe agent setup to update your Stripe skills.") +} + +func TestAgentSetupSkillsSkipWhenCurrent(t *testing.T) { + var installCalled bool + setup := testAgentSetupCmd() + setup.providers = map[string]agentsetup.Provider{} + setup.callingAgent = func() string { return "gemini_cli" } + setup.skillsCheck = mockSkillsCheckCurrent + setup.skillsInstall = func(context.Context, string) ([]string, error) { + installCalled = true + return nil, nil + } + setup.skillsLocalDir = func() (string, error) { return filepath.Join(t.TempDir(), ".agents", "skills"), nil } + setup.cmd.SetContext(context.Background()) + + output, err := executeCommand(setup.cmd) + + require.NoError(t, err) + require.False(t, installCalled) + require.Contains(t, output, "already up to date") + require.Contains(t, output, "0 installed, 0 updated, 0 skipped, 0 errors") +} + +func TestAgentSetupSkillsUpdateWhenOutOfDate(t *testing.T) { + var installCalled bool + setup := testAgentSetupCmd() + setup.providers = map[string]agentsetup.Provider{} + setup.callingAgent = func() string { return "gemini_cli" } + setup.skillsCheck = mockSkillsCheckOutOfDate + setup.skillsInstall = func(_ context.Context, _ string) ([]string, error) { + installCalled = true + return []string{"stripe-best-practices"}, nil + } + setup.skillsLocalDir = func() (string, error) { return filepath.Join(t.TempDir(), ".agents", "skills"), nil } + setup.cmd.SetContext(context.Background()) + + output, err := executeCommand(setup.cmd) + + require.NoError(t, err) + require.True(t, installCalled) + require.Contains(t, output, "updated 1 skill(s)") + require.Contains(t, output, "0 installed, 1 updated, 0 skipped, 0 errors") } func newTestAgentSetupCmd(t *testing.T, scanner agentsetup.Scanner, runInstall agentsetup.RunCommandFunc) *agentSetupCmd { t.Helper() - setup := newAgentSetupCmd() + setup := testAgentSetupCmd() claude := agentsetup.NewClaudeProvider(scanner, runInstall) claude.RunOutput = claudeListEmpty setup.providers = map[string]agentsetup.Provider{claude.ID(): claude} setup.callingAgent = func() string { return "" } + setup.skillsCheck = mockSkillsCheckNotInstalled setup.cmd.SetContext(context.Background()) return setup } func newTestAgentSetupCmdInstalled(t *testing.T, runInstall agentsetup.RunCommandFunc) *agentSetupCmd { t.Helper() - setup := newAgentSetupCmd() + setup := testAgentSetupCmd() claude := agentsetup.NewClaudeProvider(agentsetup.Scanner{ LookPath: func(string) (string, error) { return "/usr/local/bin/claude", nil }, }, runInstall) claude.RunOutput = claudeListInstalled setup.providers = map[string]agentsetup.Provider{claude.ID(): claude} setup.callingAgent = func() string { return "" } + setup.skillsCheck = mockSkillsCheckNotInstalled setup.cmd.SetContext(context.Background()) return setup } +func mockSkillsCheckNotInstalled(_ context.Context, destDir string) (*agentskills.DirStatus, error) { + return &agentskills.DirStatus{Dir: destDir, Status: agentskills.StatusNotInstalled}, nil +} + +func mockSkillsCheckCurrent(_ context.Context, destDir string) (*agentskills.DirStatus, error) { + return &agentskills.DirStatus{ + Dir: destDir, + Status: agentskills.StatusCurrent, + InstalledCount: 4, + Skills: []agentskills.SkillCheck{{Name: "stripe-best-practices", Status: agentskills.StatusCurrent}}, + }, nil +} + +func mockSkillsCheckOutOfDate(_ context.Context, destDir string) (*agentskills.DirStatus, error) { + return &agentskills.DirStatus{ + Dir: destDir, + Status: agentskills.StatusOutOfDate, + InstalledCount: 1, + OutOfDateCount: 1, + Skills: []agentskills.SkillCheck{{Name: "stripe-best-practices", Status: agentskills.StatusOutOfDate, ChangedFiles: []string{"SKILL.md"}}}, + }, nil +} + +func testAgentSetupCmd() *agentSetupCmd { + setup := newAgentSetupCmd() + setup.skillsCheck = mockSkillsCheckNotInstalled + setup.skillsLocalDir = func() (string, error) { return "/tmp/project/.agents/skills", nil } + setup.skillsGlobalDir = func() (string, error) { return "/tmp/home/.agents/skills", nil } + return setup +} + func claudeMissingPluginScanner(t *testing.T) agentsetup.Scanner { t.Helper() return agentsetup.Scanner{ From baf7ff2532c760cc757826a21f17664e7cd5732a Mon Sep 17 00:00:00 2001 From: Gus Nguyen Date: Wed, 8 Jul 2026 15:57:47 -0700 Subject: [PATCH 6/7] Drop partial skills handling and unused needs-action helpers. Follows the removal of the partial status: simplify skillsScopeNeedsUpdate and the status renderers, and delete skillsNeedsAction/skillsScopeNeedsAction which had no call sites. Co-authored-by: Cursor Committed-By-Agent: cursor --- pkg/cmd/agent.go | 12 +----------- pkg/cmd/agent_setup_tui.go | 2 -- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/pkg/cmd/agent.go b/pkg/cmd/agent.go index 77215f1b..ad20f216 100644 --- a/pkg/cmd/agent.go +++ b/pkg/cmd/agent.go @@ -473,22 +473,14 @@ func (asc *agentSetupCmd) checkSkillsScopes(ctx context.Context) (skillsScopes, return skillsScopes{Local: *local, Global: *global}, nil } -func skillsNeedsAction(s skillsScopes) bool { - return skillsScopeNeedsAction(s.Local) || skillsScopeNeedsAction(s.Global) -} - func skillsScopeNeedsUpdate(d agentskills.DirStatus) bool { - return d.Status == agentskills.StatusOutOfDate || d.Status == agentskills.StatusPartial + return d.Status == agentskills.StatusOutOfDate } func skillsScopeNeedsInstall(d agentskills.DirStatus) bool { return d.Status == agentskills.StatusNotInstalled } -func skillsScopeNeedsAction(d agentskills.DirStatus) bool { - return skillsScopeNeedsUpdate(d) || skillsScopeNeedsInstall(d) -} - func (asc *agentSetupCmd) writeJSON(w io.Writer, providers map[string]agentsetup.Provider, statuses []agentsetup.Status, skills skillsScopes) error { result := agentSetupJSON{ Status: aggregateStatus(statuses), @@ -696,8 +688,6 @@ func skillsScopeStatusLine(w io.Writer, d agentskills.DirStatus) (icon, state st return color.Green("✔").String(), fmt.Sprintf("%d skills up to date", total) case agentskills.StatusOutOfDate: return color.Yellow("⚠").String(), fmt.Sprintf("%d of %d skills out of date", d.OutOfDateCount, total) - case agentskills.StatusPartial: - return color.Yellow("⚠").String(), fmt.Sprintf("%d of %d skills installed, some out of date", d.InstalledCount, total) default: return color.Yellow("•").String(), "not installed" } diff --git a/pkg/cmd/agent_setup_tui.go b/pkg/cmd/agent_setup_tui.go index 558f18c0..9da6cafb 100644 --- a/pkg/cmd/agent_setup_tui.go +++ b/pkg/cmd/agent_setup_tui.go @@ -279,8 +279,6 @@ func scopeStatusHint(status agentskills.DirStatus) string { return "(up to date)" case agentskills.StatusOutOfDate: return "(out of date)" - case agentskills.StatusPartial: - return "(partially installed)" case agentskills.StatusNotInstalled: return "(not installed)" default: From 4e2215b593636e4cd8f1efca072c9b2e4668aa2d Mon Sep 17 00:00:00 2001 From: Gus Nguyen Date: Thu, 9 Jul 2026 10:36:53 -0700 Subject: [PATCH 7/7] fix lint: convert skillsScopes to skillsScopesJSON directly (S1016) Co-authored-by: Cursor Committed-By-Agent: cursor --- pkg/cmd/agent.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/cmd/agent.go b/pkg/cmd/agent.go index ad20f216..c2454a46 100644 --- a/pkg/cmd/agent.go +++ b/pkg/cmd/agent.go @@ -485,10 +485,7 @@ func (asc *agentSetupCmd) writeJSON(w io.Writer, providers map[string]agentsetup result := agentSetupJSON{ Status: aggregateStatus(statuses), Clients: statuses, - Skills: skillsScopesJSON{ - Local: skills.Local, - Global: skills.Global, - }, + Skills: skillsScopesJSON(skills), } for _, status := range statuses { if plan := providers[status.Client].Plan(status, asc.force); plan.Action != agentsetup.ActionNone {