Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 130 additions & 36 deletions pkg/cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type agentSetupCmd struct {
skillsCheck func(ctx context.Context, destDir string) (*agentskills.DirStatus, error)
skillsLocalDir func() (string, error)
skillsGlobalDir func() (string, error)
skillsDirsExist func(localDir, globalDir string) bool

// callingAgent returns the AI agent invoking this command (e.g.
// "claude_code"), or "" for a human shell. Injectable for tests.
Expand All @@ -72,7 +73,7 @@ type agentSetupCmd struct {
type agentSetupJSON struct {
Status string `json:"status"`
Clients []agentsetup.Status `json:"clients"`
Skills skillsScopesJSON `json:"skills"`
Skills *skillsScopesJSON `json:"skills,omitempty"`
Actions []agentsetup.Plan `json:"actions,omitempty"`
Errors []string `json:"errors,omitempty"`
}
Expand Down Expand Up @@ -111,6 +112,7 @@ func newAgentSetupCmd() *agentSetupCmd {
},
skillsLocalDir: func() (string, error) { return skillsDirUnder(os.Getwd) },
skillsGlobalDir: func() (string, error) { return skillsDirUnder(os.UserHomeDir) },
skillsDirsExist: skillsDirsExist,
callingAgent: func() string { return useragent.DetectAIAgent(os.Getenv) },
isInteractive: isInteractiveTerminal,
}
Expand Down Expand Up @@ -163,35 +165,36 @@ func (asc *agentSetupCmd) runSetup(cmd *cobra.Command, _ []string) error {

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
}

if asc.jsonOutput {
return asc.writeJSON(out, providers, statuses, skills)
}

if asc.statusOnly {
if asc.jsonOutput || asc.statusOnly {
view, err := asc.skillsStatusView(ctx, detected)
if err != nil {
return err
}
if asc.jsonOutput {
return asc.writeJSON(out, providers, statuses, view)
}
if len(detected) > 0 {
printStatusTable(out, detected)
fmt.Fprintln(out)
} else {
printNothingDetected(out)
fmt.Fprintln(out)
}
printSkillsStatusTable(out, skills)
if view.show {
printSkillsStatusTable(out, view.scopes, view.allowInstall)
}
return nil
}

var skills skillsScopes
if asc.needsInteractiveSkills() {
var err error
skills, err = asc.loadSkillsScopes(ctx)
if err != nil {
return err
}
}

sel, scope, err := asc.resolveSelection(cmd, out, detected, skills)
if err != nil {
return err
Expand All @@ -203,6 +206,61 @@ func (asc *agentSetupCmd) runSetup(cmd *cobra.Command, _ []string) error {
return asc.install(ctx, out, providers, *sel, scope)
}

func (asc *agentSetupCmd) loadSkillsScopes(ctx context.Context) (skillsScopes, error) {
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
})
return skills, runErr
}

type skillsStatusView struct {
scopes skillsScopes
show bool
allowInstall bool
}

func (asc *agentSetupCmd) skillsStatusView(ctx context.Context, detected []agentsetup.Status) (skillsStatusView, error) {
if len(detected) == 0 {
scopes, err := asc.loadSkillsScopes(ctx)
return skillsStatusView{scopes: scopes, show: true, allowInstall: true}, err
}
localDir, err := asc.skillsLocalDir()
if err != nil {
return skillsStatusView{}, fmt.Errorf("resolving local skills directory: %w", err)
}
globalDir, err := asc.skillsGlobalDir()
if err != nil {
return skillsStatusView{}, fmt.Errorf("resolving global skills directory: %w", err)
}
if !asc.skillsDirsExist(localDir, globalDir) {
return skillsStatusView{}, nil
}
scopes, err := asc.loadSkillsScopes(ctx)
if err != nil {
return skillsStatusView{}, err
}
if !skillsScopesHasInstalled(scopes) {
return skillsStatusView{}, nil
}
return skillsStatusView{scopes: scopes, show: true}, nil
}

func (asc *agentSetupCmd) needsInteractiveSkills() bool {
if asc.yes {
return false
}
if asc.callingAgent() != "" {
return false
}
return asc.isInteractive()
}

// resolveSelection decides which agents and/or skills to install. It uses the
// interactive TUI when attached to a terminal (and --yes was not passed),
// otherwise it derives the selection from flags.
Expand Down Expand Up @@ -462,17 +520,51 @@ func (asc *agentSetupCmd) checkSkillsScopes(ctx context.Context) (skillsScopes,
}

local, err := asc.skillsCheck(ctx, localDir)
if err != nil {
if local == nil {
return skillsScopes{}, err
}
global, err := asc.skillsCheck(ctx, globalDir)
if err != nil {
if global == nil {
return skillsScopes{}, err
}

return skillsScopes{Local: *local, Global: *global}, nil
}

func skillsScopeCheckFailed(d agentskills.DirStatus) bool {
return d.Status == agentskills.StatusError
}

func skillsScopeHasInstalled(d agentskills.DirStatus) bool {
switch d.Status {
case agentskills.StatusCurrent, agentskills.StatusOutOfDate:
return true
case agentskills.StatusError:
return d.InstalledCount > 0
default:
return false
}
}

func skillsScopesHasInstalled(scopes skillsScopes) bool {
return skillsScopeHasInstalled(scopes.Local) || skillsScopeHasInstalled(scopes.Global)
}

func skillsDirsExist(localDir, globalDir string) bool {
for _, dir := range []string{localDir, globalDir} {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
return true
}
}
}
return false
}

func skillsScopeNeedsUpdate(d agentskills.DirStatus) bool {
return d.Status == agentskills.StatusOutOfDate
}
Expand All @@ -481,11 +573,10 @@ func skillsScopeNeedsInstall(d agentskills.DirStatus) bool {
return d.Status == agentskills.StatusNotInstalled
}

func (asc *agentSetupCmd) writeJSON(w io.Writer, providers map[string]agentsetup.Provider, statuses []agentsetup.Status, skills skillsScopes) error {
func (asc *agentSetupCmd) writeJSON(w io.Writer, providers map[string]agentsetup.Provider, statuses []agentsetup.Status, view skillsStatusView) error {
result := agentSetupJSON{
Status: aggregateStatus(statuses),
Clients: statuses,
Skills: skillsScopesJSON(skills),
}
for _, status := range statuses {
if plan := providers[status.Client].Plan(status, asc.force); plan.Action != agentsetup.ActionNone {
Expand All @@ -495,16 +586,19 @@ 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"},
})
if view.show {
result.Skills = &skillsScopesJSON{Local: view.scopes.Local, Global: view.scopes.Global}
if skillsScopeNeedsUpdate(view.scopes.Local) || skillsScopeNeedsUpdate(view.scopes.Global) {
result.Actions = append(result.Actions, agentsetup.Plan{
Action: "update_skills",
Command: []string{"stripe", "agent", "setup"},
})
} else if view.allowInstall && (skillsScopeNeedsInstall(view.scopes.Local) || skillsScopeNeedsInstall(view.scopes.Global)) {
result.Actions = append(result.Actions, agentsetup.Plan{
Action: "install_skills",
Command: []string{"stripe", "agent", "setup"},
})
}
}

enc := json.NewEncoder(w)
Expand Down Expand Up @@ -641,7 +735,7 @@ func printStatusTable(w io.Writer, statuses []agentsetup.Status) {
}

// printSkillsStatusTable renders the local and global skills install state.
func printSkillsStatusTable(w io.Writer, skills skillsScopes) {
func printSkillsStatusTable(w io.Writer, skills skillsScopes, allowInstall bool) {
color := ansi.Color(w)

fmt.Fprintln(w, color.Bold("Stripe skills:").String())
Expand Down Expand Up @@ -670,7 +764,7 @@ func printSkillsStatusTable(w io.Writer, skills skillsScopes) {
switch {
case needsUpdate:
fmt.Fprintf(w, "\nRun %s to update your Stripe skills.\n", color.Bold("stripe agent setup").String())
case needsInstall:
case needsInstall && allowInstall:
fmt.Fprintf(w, "\nRun %s to install your Stripe skills.\n", color.Bold("stripe agent setup").String())
}
}
Expand Down
16 changes: 12 additions & 4 deletions pkg/cmd/agent_setup_tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,22 +281,30 @@ func scopeStatusHint(status agentskills.DirStatus) string {
return "(out of date)"
case agentskills.StatusNotInstalled:
return "(not installed)"
case agentskills.StatusError:
return "(unavailable — could not check)"
default:
return ""
}
}

func preferredSkillsScope(skills skillsScopes) string {
if skillsScopeNeedsUpdate(skills.Local) {
if skillsScopeNeedsUpdate(skills.Local) && !skillsScopeCheckFailed(skills.Local) {
return skillsScopeLocal
}
if skillsScopeNeedsUpdate(skills.Global) {
if skillsScopeNeedsUpdate(skills.Global) && !skillsScopeCheckFailed(skills.Global) {
return skillsScopeGlobal
}
if skillsScopeNeedsInstall(skills.Local) {
if skillsScopeNeedsInstall(skills.Local) && !skillsScopeCheckFailed(skills.Local) {
return skillsScopeLocal
}
if skillsScopeNeedsInstall(skills.Global) {
if skillsScopeNeedsInstall(skills.Global) && !skillsScopeCheckFailed(skills.Global) {
return skillsScopeGlobal
}
if !skillsScopeCheckFailed(skills.Local) {
return skillsScopeLocal
}
if !skillsScopeCheckFailed(skills.Global) {
return skillsScopeGlobal
}
return skillsScopeLocal
Expand Down
29 changes: 29 additions & 0 deletions pkg/cmd/agent_setup_tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,32 @@ func TestScopeModel_PrefersOutOfDateLocalScope(t *testing.T) {
require.Contains(t, m.labels[0], "(out of date)")
require.Contains(t, m.labels[1], "(up to date)")
}

func TestScopeStatusHintError(t *testing.T) {
require.Equal(t, "(unavailable — could not check)", scopeStatusHint(agentskills.DirStatus{
Status: agentskills.StatusError,
Error: "fetching skills index: request failed",
}))
}

func TestScopeModel_PrefersScopeWhenOtherScopeCheckFailed(t *testing.T) {
skills := skillsScopes{
Local: agentskills.DirStatus{Status: agentskills.StatusNotInstalled},
Global: agentskills.DirStatus{Status: agentskills.StatusError, Error: "fetching skills index: request failed"},
}

m := newScopeModel(skills)

require.Equal(t, skillsScopeLocal, m.options[m.cursor])
require.Contains(t, m.labels[0], "(not installed)")
require.Contains(t, m.labels[1], "(unavailable — could not check)")
}

func TestPreferredSkillsScopeSkipsCheckFailedScope(t *testing.T) {
skills := skillsScopes{
Local: agentskills.DirStatus{Status: agentskills.StatusError, Error: "fetching skills index: request failed"},
Global: agentskills.DirStatus{Status: agentskills.StatusNotInstalled},
}

require.Equal(t, skillsScopeGlobal, preferredSkillsScope(skills))
}
Loading
Loading