diff --git a/README.md b/README.md index cfc32e2a..1546ab56 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Stacks naturally form a tree structureβ€”a single branch can have multiple child - πŸ”’ **Branch protection** β€” `lock` or `freeze` branches to prevent accidental modifications - πŸ” **Branch inspection** β€” Easily see parent/child relationships with `children` and `parent` commands - βš™οΈ **Advanced configuration** β€” Customize branch naming patterns and submit behavior -- πŸ€– **AI assistant integration** β€” Generate integration files for Cursor and Claude Code +- πŸ€– **AI assistant integration** β€” Generate integration files for Cursor, Claude Code, and Codex - πŸ™ **GitHub Integration** β€” Install CI checks to prevent merging locked PRs - βš“ **Git Hooks** β€” Automatically validate branch state before committing with `precommit` - πŸ“‚ **Worktrees** β€” Work on multiple stacks in parallel with dedicated directories and post-creation hooks @@ -189,13 +189,13 @@ Stackit includes specialized commands designed for Claude Code, providing intell | `stack-fix` | Diagnose and fix common stack issues | Resolving compilation errors or structural problems | | `stack-describe` | Generate or update stack description from changes | Documenting your stack for PRs | -### Setting Up Claude Integration +### Setting Up AI Agent Integration ```bash stackit agent install ``` -This creates the necessary integration files for Claude Code to use these specialized commands. The commands are designed to: +This creates integration files for Claude Code, Codex, and Cursor. The Claude commands are designed to: - **Understand Context**: Each command analyzes your current stack state and git status - **Provide Validation**: Commands include quality checks and error handling @@ -275,7 +275,7 @@ stack-submit --stack # Creates/updates all PRs in the stack ### Integrations | Command | Description | |:---|:---| -| `stackit agent install` | Setup integration files for Cursor and Claude Code | +| `stackit agent install` | Setup integration files for Cursor, Claude Code, and Codex | | `stackit github install` | Install GitHub Action CI checks for branch locking | | `stackit precommit install` | Install git pre-commit hook for branch state validation | | `stackit precommit uninstall` | Remove the git pre-commit hook | diff --git a/doc/src/integrations/claude.md b/doc/src/integrations/claude.md index 2571ce7c..5d1a2ce8 100644 --- a/doc/src/integrations/claude.md +++ b/doc/src/integrations/claude.md @@ -25,7 +25,7 @@ Install the Claude integration files: stackit agent install ``` -This creates the necessary integration files for Claude Code to use specialized stacking commands. +This creates the necessary integration files for Claude Code, plus Codex/Cursor agent formats. ## Available commands diff --git a/doc/src/integrations/index.md b/doc/src/integrations/index.md index 845dd016..ab01c2c3 100644 --- a/doc/src/integrations/index.md +++ b/doc/src/integrations/index.md @@ -12,13 +12,13 @@ Stackit integrates with your development tools to provide a seamless stacking wo
-- :material-robot:{ .lg .middle } **Claude Code** +- :material-robot:{ .lg .middle } **AI Agents** --- - AI-assisted stacking with intelligent commands for creating, syncing, and managing stacks. + AI-assisted stacking with intelligent commands and skill files for Claude Code, Codex, and Cursor. - [Setup Claude β†’](claude.md) + [Setup agents β†’](claude.md) - :material-github:{ .lg .middle } **GitHub** @@ -51,7 +51,7 @@ Stackit integrates with your development tools to provide a seamless stacking wo Install all recommended integrations: ```bash -# Claude Code integration +# Agent integration (Claude Code + Codex + Cursor) stackit agent install # GitHub Actions @@ -69,7 +69,7 @@ eval "$(stackit shell zsh)" # or bash/fish | Integration | Purpose | Setup Command | |:------------|:--------|:--------------| -| Claude Code | AI-assisted stacking commands | `stackit agent install` | +| AI Agents (Claude/Codex/Cursor) | AI-assisted stacking commands | `stackit agent install` | | GitHub | CI checks for PRs | `stackit github install` | | Pre-commit hook | Block commits to locked branches | `stackit precommit install` | | Pre-push hook | Block pushes to locked branches | `stackit prepush install` | diff --git a/internal/cli/init.go b/internal/cli/init.go index 211a645d..19d00e76 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -139,7 +139,7 @@ func (h *cliInitHandler) offerIntegrations(splog output.Output) { if agentsInstalled { splog.Info("βœ“ AI agent files already installed") } else { - installAgents, err := tui.PromptConfirm("Install AI agent files? (Claude Code / Cursor integration)", false) + installAgents, err := tui.PromptConfirm("Install AI agent files? (Claude Code / Codex integration)", false) if err == nil && installAgents { if err := integrations.InstallAgents(h.runner, false, false, h.version, h.writer); err != nil { splog.Warn("Failed to install agent files: %v", err) diff --git a/internal/cli/integrations/agents.go b/internal/cli/integrations/agents.go index 98a5c346..f9471a3e 100644 --- a/internal/cli/integrations/agents.go +++ b/internal/cli/integrations/agents.go @@ -20,10 +20,10 @@ import ( func NewAgentsCmd(version string) *cobra.Command { cmd := &cobra.Command{ Use: "agent", - Short: "Manage agent integration files for Cursor and Claude Code", + Short: "Manage agent integration files for Claude Code and Codex", Long: `Manage agent integration files that help AI assistants use stackit effectively. -This command generates configuration files that enable AI agents (like Cursor and Claude Code) +This command generates configuration files that enable AI agents (like Claude Code and Codex) to understand how to use stackit commands for managing stacked branches.`, SilenceUsage: true, } @@ -37,20 +37,19 @@ to understand how to use stackit commands for managing stacked branches.`, func newAgentInstallCmd(version string) *cobra.Command { var local bool var force bool + var formats []string cmd := &cobra.Command{ Use: "install", Short: "Install agent integration files", Long: `Install agent integration files for AI assistants. -By default, files are installed globally in ~/.claude/ and work across all repositories. -Use --local to install files in the current repository instead. +By default, this command installs files in your home directory and prompts +you to select one or more skill folder formats. -This will create: - - .cursor/rules/stackit.md (for Cursor) - - .claude/skills/stackit/ (Claude Code skill) - - .claude/skills/stackit/subagents/ (Haiku subagent templates) - - .claude/commands/ (Claude Code slash commands) +This will create one or both: + - ~/.claude/skills/stackit/ (Claude Code skill format) + - ~/.codex/skills/stackit/ (Codex skill format) These files contain instructions for AI agents on how to use stackit commands to manage stacked branches, create commits, submit PRs, and more. @@ -64,12 +63,14 @@ block to your project's CLAUDE.md or AGENTS.md file.`, if cwd != "" { runner = git.NewRunnerWithPath(cwd, nil) } - return runAgentInstall(runner, local, force, version, cmd.OutOrStdout()) + return runAgentInstall(runner, local, force, formats, version, cmd.OutOrStdout()) }, } - cmd.Flags().BoolVar(&local, "local", false, "Install files in current repository instead of globally") + cmd.Flags().BoolVar(&local, "local", false, "Deprecated: ignored (agent skills are always installed globally)") cmd.Flags().BoolVar(&force, "force", false, "Force overwrite existing files") + cmd.Flags().StringSliceVar(&formats, "format", nil, "Skill format(s) to install (claude,codex). Repeat flag or use comma-separated values") + _ = cmd.Flags().MarkDeprecated("local", "ignored: agent skills are always installed globally") return cmd } @@ -83,100 +84,385 @@ type fileGroup struct { replaceVer bool // whether to replace {{VERSION}} placeholder } -func runAgentInstall(runner git.Runner, local, force bool, version string, out io.Writer) error { +type agentSkillFormat string + +const ( + agentSkillFormatClaude agentSkillFormat = "claude" + agentSkillFormatCodex agentSkillFormat = "codex" +) + +type agentInstallTarget struct { + format agentSkillFormat + skillDir string + displayPath string + includeAgentsMeta bool + includeClaudeCommand bool +} + +type existingSkillInstallation struct { + target agentInstallTarget + path string + version string +} + +var ( + skillRootFiles = []string{"SKILL.md", "reference.md"} + + skillCommandFiles = []string{"navigation.md", "branch.md", "stack.md", "recovery.md"} + + skillWorkflowFiles = []string{"absorb-conflict.md", "conflict-resolution.md", "fix-absorb.md", "stack-fold.md"} + + skillScriptFiles = []string{"analyze_stack.sh"} + + subagentFiles = []string{"commit-message.md", "review-triage.md"} + + claudeCommandFiles = []string{ + "stack-absorb.md", "stack-create.md", "stack-describe.md", "stack-extract.md", + "stack-fix.md", "stack-fold.md", "stack-plan.md", "stack-restack.md", + "stack-review.md", "stack-split.md", "stack-status.md", "stack-submit.md", + "stack-sync.md", "stack-verify.md", + } + + promptSelect = tui.PromptSelect + promptMultiSelectWithDefault = tui.PromptMultiSelectWithDefaults +) + +func runAgentInstall(runner git.Runner, local, force bool, formats []string, version string, out io.Writer) error { + _ = local // Deprecated flag retained for compatibility. + repoRoot, _ := runner.DiscoverRepoRoot() - baseDir, err := resolveBaseDir(local, repoRoot) + baseDir, err := resolveInstallBaseDir() + if err != nil { + return err + } + + targets, err := selectInstallTargets(baseDir, formats) if err != nil { + if errors.Is(err, stackiterrors.ErrCanceled) { + return nil + } return err } - if !force { - if err := checkExistingInstallation(baseDir, version, out); err != nil { + if err := confirmOverwriteIfNeeded(baseDir, targets, force, version, out); err != nil { + if errors.Is(err, stackiterrors.ErrCanceled) { + return nil + } + return err + } + + for _, target := range targets { + groups := buildAgentFileGroups(target) + for _, g := range groups { + if err := installFileGroup(baseDir, g, version); err != nil { + return err + } + } + } + + // Install workflow block to CLAUDE.md or AGENTS.md if in a git repo + var workflowBlockInstalled bool + var workflowBlockPath string + if repoRoot != "" { + workflowBlockInstalled, workflowBlockPath, err = promptAndInstallWorkflowBlock(repoRoot, force) + if err != nil { return err } } - // Define all file groups to install - skillDir := filepath.Join(".claude", "skills", "stackit") + printSuccessMessage(out, targets, workflowBlockInstalled, workflowBlockPath, len(claudeCommandFiles)) + return nil +} + +func selectInstallTargets(baseDir string, formats []string) ([]agentInstallTarget, error) { + if len(formats) > 0 { + parsed, err := parseAgentSkillFormats(formats) + if err != nil { + return nil, err + } + return targetsForFormats(parsed), nil + } + + hasClaudeDir := dirExists(filepath.Join(baseDir, ".claude")) + hasCodexDir := dirExists(filepath.Join(baseDir, ".codex")) + preSelected := []bool{ + hasClaudeDir, + hasCodexDir, + } + if !hasClaudeDir && !hasCodexDir { + preSelected = []bool{true, false} + } + + selected, err := promptMultiSelectWithDefault( + "Which skill format(s) would you like to install?", + []string{ + "Claude Code - Claude Code CLI skill format (~/.claude/skills/stackit)", + "Codex - Codex skill format (~/.codex/skills/stackit)", + }, + preSelected, + ) + if errors.Is(err, tui.ErrInteractiveDisabled) { + fallback := make([]agentSkillFormat, 0, 2) + if hasClaudeDir { + fallback = append(fallback, agentSkillFormatClaude) + } + if hasCodexDir { + fallback = append(fallback, agentSkillFormatCodex) + } + if len(fallback) > 0 { + return targetsForFormats(fallback), nil + } + return nil, fmt.Errorf("format selection requires interactive mode; use --format=claude or --format=codex") + } + if err != nil { + return nil, err + } + + selectedFormats, err := parseSelectedFormatLabels(selected) + if err != nil { + return nil, err + } + if len(selectedFormats) == 0 { + return nil, stackiterrors.ErrCanceled + } + return targetsForFormats(selectedFormats), nil +} + +func confirmOverwriteIfNeeded(baseDir string, targets []agentInstallTarget, force bool, version string, out io.Writer) error { + if force { + return nil + } + + existing := detectExistingInstallations(baseDir, targets) + if len(existing) == 0 { + return nil + } + + confirmed, err := promptOverwriteExistingInstallations(existing, version) + if errors.Is(err, tui.ErrInteractiveDisabled) { + var hasConflict bool + for _, target := range targets { + if err := checkExistingInstallation(baseDir, target.format, version, out); err != nil { + hasConflict = true + } + } + if hasConflict { + return fmt.Errorf("existing installation found") + } + return nil + } + if errors.Is(err, stackiterrors.ErrCanceled) { + return err + } + if err != nil { + return err + } + if !confirmed { + return stackiterrors.ErrCanceled + } + + return nil +} + +func detectExistingInstallations(baseDir string, targets []agentInstallTarget) []existingSkillInstallation { + existing := make([]existingSkillInstallation, 0, len(targets)) + for _, target := range targets { + path, existingVersion, found := firstExistingInstallation(baseDir, target.format) + if !found { + continue + } + existing = append(existing, existingSkillInstallation{ + target: target, + path: path, + version: existingVersion, + }) + } + return existing +} + +func firstExistingInstallation(baseDir string, format agentSkillFormat) (path, version string, found bool) { + for _, skillPath := range installedSkillManifestPathsForFormat(baseDir, format) { + content, err := os.ReadFile(skillPath) + if err != nil { + continue + } + return skillPath, extractVersion(string(content)), true + } + return "", "", false +} + +func promptOverwriteExistingInstallations(existing []existingSkillInstallation, version string) (bool, error) { + if len(existing) == 0 { + return true, nil + } + + var b strings.Builder + b.WriteString("Existing skill installations detected:\n") + for _, installation := range existing { + _, _ = fmt.Fprintf(&b, "- %s", installation.target.displayPath) + if installation.version != "" { + _, _ = fmt.Fprintf(&b, " (version %s)", installation.version) + } + b.WriteString("\n") + } + if version != "" { + _, _ = fmt.Fprintf(&b, "\nThese files will be overwritten with version %s.\n", version) + } else { + b.WriteString("\nThese files will be overwritten.\n") + } + + return tui.PromptConfirm(b.String()+"Continue?", false) +} + +func parseSelectedFormatLabels(selected []string) ([]agentSkillFormat, error) { + formats := make([]agentSkillFormat, 0, len(selected)) + for _, label := range selected { + switch { + case strings.HasPrefix(label, "Claude Code -"): + formats = append(formats, agentSkillFormatClaude) + case strings.HasPrefix(label, "Codex -"): + formats = append(formats, agentSkillFormatCodex) + default: + return nil, fmt.Errorf("unsupported selected format label %q", label) + } + } + return dedupeFormats(formats), nil +} + +func parseAgentSkillFormats(rawValues []string) ([]agentSkillFormat, error) { + formats := make([]agentSkillFormat, 0, len(rawValues)) + for _, raw := range rawValues { + parts := strings.Split(raw, ",") + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + parsed, err := parseAgentSkillFormat(trimmed) + if err != nil { + return nil, err + } + formats = append(formats, parsed) + } + } + if len(formats) == 0 { + return nil, fmt.Errorf("at least one format must be provided") + } + return dedupeFormats(formats), nil +} + +func dedupeFormats(formats []agentSkillFormat) []agentSkillFormat { + seen := map[agentSkillFormat]bool{} + result := make([]agentSkillFormat, 0, len(formats)) + for _, format := range formats { + if seen[format] { + continue + } + seen[format] = true + result = append(result, format) + } + return result +} + +func targetsForFormats(formats []agentSkillFormat) []agentInstallTarget { + targets := make([]agentInstallTarget, 0, len(formats)) + for _, format := range formats { + targets = append(targets, installTargetForFormat(format)) + } + return targets +} + +func parseAgentSkillFormat(raw string) (agentSkillFormat, error) { + switch strings.TrimSpace(strings.ToLower(raw)) { + case string(agentSkillFormatClaude), "claude-code": + return agentSkillFormatClaude, nil + case string(agentSkillFormatCodex): + return agentSkillFormatCodex, nil + default: + return "", fmt.Errorf("unsupported format %q (expected claude or codex)", raw) + } +} + +func installTargetForFormat(format agentSkillFormat) agentInstallTarget { + switch format { + case agentSkillFormatCodex: + return agentInstallTarget{ + format: agentSkillFormatCodex, + skillDir: filepath.Join(".codex", "skills", "stackit"), + displayPath: "~/.codex/skills/stackit", + includeAgentsMeta: true, + } + default: + return agentInstallTarget{ + format: agentSkillFormatClaude, + skillDir: filepath.Join(".claude", "skills", "stackit"), + displayPath: "~/.claude/skills/stackit", + includeClaudeCommand: true, + } + } +} + +func buildAgentFileGroups(target agentInstallTarget) []fileGroup { + groups := buildSkillFileGroups(target.skillDir, target.includeAgentsMeta) + if target.includeClaudeCommand { + groups = append(groups, fileGroup{ + templateDir: "agents/templates/commands", + destDir: filepath.Join(".claude", "commands"), + files: claudeCommandFiles, + }) + } + return groups +} + +func resolveInstallBaseDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to resolve home directory: %w", err) + } + return homeDir, nil +} + +func buildSkillFileGroups(skillDir string, includeAgentsMetadata bool) []fileGroup { groups := []fileGroup{ { templateDir: "agents/templates/skill", destDir: skillDir, - files: []string{"SKILL.md", "reference.md"}, + files: skillRootFiles, replaceVer: true, }, { templateDir: "agents/templates/skill/commands", destDir: filepath.Join(skillDir, "commands"), - files: []string{"navigation.md", "branch.md", "stack.md", "recovery.md"}, + files: skillCommandFiles, }, { templateDir: "agents/templates/skill/workflows", destDir: filepath.Join(skillDir, "workflows"), - files: []string{"absorb-conflict.md", "conflict-resolution.md", "fix-absorb.md", "stack-fold.md"}, + files: skillWorkflowFiles, }, { templateDir: "agents/templates/skill/scripts", destDir: filepath.Join(skillDir, "scripts"), - files: []string{"analyze_stack.sh"}, + files: skillScriptFiles, executable: true, }, { templateDir: "agents/templates/subagents", destDir: filepath.Join(skillDir, "subagents"), - files: []string{"commit-message.md", "review-triage.md"}, - }, - { - templateDir: "agents/templates/commands", - destDir: filepath.Join(".claude", "commands"), - files: []string{ - "stack-absorb.md", "stack-create.md", "stack-describe.md", "stack-extract.md", - "stack-fix.md", "stack-fold.md", "stack-plan.md", "stack-restack.md", - "stack-review.md", "stack-split.md", "stack-status.md", "stack-submit.md", - "stack-sync.md", "stack-verify.md", - }, - }, - { - templateDir: "agents/templates/cursor", - destDir: filepath.Join(".cursor", "rules"), - files: []string{"stackit.md"}, + files: subagentFiles, }, } - // Install all file groups - for _, g := range groups { - if err := installFileGroup(baseDir, g, version); err != nil { - return err - } + if includeAgentsMetadata { + groups = append(groups, fileGroup{ + templateDir: "agents/templates/skill/agents", + destDir: filepath.Join(skillDir, "agents"), + files: []string{"openai.yaml"}, + }) } - // Install workflow block to CLAUDE.md or AGENTS.md if in a git repo - var workflowBlockInstalled bool - var workflowBlockPath string - if repoRoot != "" { - workflowBlockInstalled, workflowBlockPath, err = promptAndInstallWorkflowBlock(repoRoot, force) - if err != nil { - return err - } - } - - printSuccessMessage(out, local, workflowBlockInstalled, workflowBlockPath, len(groups[5].files)) - return nil -} - -func resolveBaseDir(local bool, repoRoot string) (string, error) { - if local { - if repoRoot == "" { - return "", fmt.Errorf("not a git repository: cannot use --local outside a git repository") - } - return repoRoot, nil - } - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("failed to get home directory: %w", err) - } - return homeDir, nil + return groups } func installFileGroup(baseDir string, g fileGroup, version string) error { @@ -211,23 +497,22 @@ func installFileGroup(baseDir string, g fileGroup, version string) error { return nil } -func printSuccessMessage(out io.Writer, local, workflowBlockInstalled bool, workflowBlockPath string, commandCount int) { - displayPath := "~" - installType := "globally" - if local { - displayPath = "." - installType = "locally" +func printSuccessMessage(out io.Writer, targets []agentInstallTarget, workflowBlockInstalled bool, workflowBlockPath string, commandCount int) { + _, _ = fmt.Fprintln(out, "βœ“ Installed agent files") + + installedClaudeCommands := false + for _, target := range targets { + _, _ = fmt.Fprintf(out, "βœ“ Created %s\n", target.displayPath) + if target.includeClaudeCommand { + installedClaudeCommands = true + } } - _, _ = fmt.Fprintf(out, "βœ“ Installed agent files %s\n\n", installType) - _, _ = fmt.Fprintln(out, "Claude Code integration:") - _, _ = fmt.Fprintf(out, "βœ“ Created %s/.claude/skills/stackit/ (skill + reference + commands + workflows + scripts + subagents)\n", displayPath) - _, _ = fmt.Fprintln(out) - _, _ = fmt.Fprintln(out, "Slash commands:") - _, _ = fmt.Fprintf(out, "βœ“ Created %s/.claude/commands/stack-*.md (%d commands)\n", displayPath, commandCount) - _, _ = fmt.Fprintln(out) - _, _ = fmt.Fprintln(out, "Cursor integration:") - _, _ = fmt.Fprintf(out, "βœ“ Created %s/.cursor/rules/stackit.md\n", displayPath) + if installedClaudeCommands { + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintln(out, "Slash commands:") + _, _ = fmt.Fprintf(out, "βœ“ Created ~/.claude/commands/stack-*.md (%d commands)\n", commandCount) + } if workflowBlockInstalled { _, _ = fmt.Fprintln(out) @@ -239,29 +524,68 @@ func printSuccessMessage(out io.Writer, local, workflowBlockInstalled bool, work _, _ = fmt.Fprintln(out, "Available commands: /stack-absorb, /stack-create, /stack-describe, /stack-extract,") _, _ = fmt.Fprintln(out, "/stack-fix, /stack-fold, /stack-plan, /stack-restack, /stack-review, /stack-split,") _, _ = fmt.Fprintln(out, "/stack-status, /stack-submit, /stack-sync, /stack-verify") +} - if !local { - _, _ = fmt.Fprintln(out) - _, _ = fmt.Fprintln(out, "Tip: Use 'stackit agent install --local' to install in a specific repository") +func checkExistingInstallation(baseDir string, format agentSkillFormat, version string, out io.Writer) error { + skillPath, existingVersion, found := firstExistingInstallation(baseDir, format) + if !found { + return nil + } + + // Same version already installed β€” nothing to do. + if version != "" && existingVersion == version { + return nil + } + + _, _ = fmt.Fprintf(out, "Found existing installation at %s", skillPath) + if existingVersion != "" { + _, _ = fmt.Fprintf(out, " (version %s)", existingVersion) } + _, _ = fmt.Fprintln(out) + if version != "" && existingVersion != "" { + _, _ = fmt.Fprintf(out, "New version available: %s\n", version) + } + _, _ = fmt.Fprintln(out) + _, _ = fmt.Fprintln(out, "Run with --force to overwrite") + return fmt.Errorf("existing installation found") } -func checkExistingInstallation(baseDir, version string, out io.Writer) error { - // Check if SKILL.md exists and has version info - skillPath := filepath.Join(baseDir, ".claude", "skills", "stackit", "SKILL.md") - if content, err := os.ReadFile(skillPath); err == nil { - // File exists, check version - existingVersion := extractVersion(string(content)) - if existingVersion != "" && existingVersion != version { - _, _ = fmt.Fprintf(out, "Found existing installation (version %s)\n", existingVersion) - _, _ = fmt.Fprintf(out, "New version available: %s\n", version) - _, _ = fmt.Fprintln(out) - _, _ = fmt.Fprintln(out, "Run with --force to update") - return fmt.Errorf("existing installation found") +func installedSkillManifestPaths(baseDir string) []string { + paths := installedSkillManifestPathsForFormat(baseDir, agentSkillFormatClaude) + paths = append(paths, installedSkillManifestPathsForFormat(baseDir, agentSkillFormatCodex)...) + return paths +} + +func installedSkillManifestPathsForFormat(baseDir string, format agentSkillFormat) []string { + switch format { + case agentSkillFormatCodex: + return []string{ + filepath.Join(baseDir, ".codex", "skills", "stackit", "SKILL.md"), + filepath.Join(baseDir, ".codex", "skills", "stackit", "skill.md"), // legacy path compatibility + } + default: + return []string{ + filepath.Join(baseDir, ".claude", "skills", "stackit", "SKILL.md"), + filepath.Join(baseDir, ".claude", "skills", "stackit", "skill.md"), // legacy path compatibility } } +} - return nil +func dirExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} + +func isAnySkillInstalled(baseDir string) bool { + for _, skillPath := range installedSkillManifestPaths(baseDir) { + if _, err := os.Stat(skillPath); err == nil { + return true + } + } + return false } func extractVersion(content string) string { @@ -351,12 +675,12 @@ func promptAndInstallWorkflowBlock(repoRoot string, force bool) (bool, string, e switch { case claude.exists && agents.exists: // Both exist - prompt user to choose - selected, err := tui.PromptSelect( + selected, err := promptSelect( "Both CLAUDE.md and AGENTS.md exist. Which file should receive the stacking workflow block?", []tui.SelectOption{ + {Label: "Skip (don't add workflow block)", Value: "skip"}, {Label: "CLAUDE.md", Value: "CLAUDE.md"}, {Label: "AGENTS.md", Value: "AGENTS.md"}, - {Label: "Skip (don't add workflow block)", Value: "skip"}, }, 0, ) @@ -376,7 +700,7 @@ func promptAndInstallWorkflowBlock(repoRoot string, force bool) (bool, string, e // Only CLAUDE.md exists - confirm confirmed, err := tui.PromptConfirm( fmt.Sprintf("Add stacking workflow block to %s?", claude.name), - true, + false, ) if errors.Is(err, stackiterrors.ErrCanceled) || errors.Is(err, tui.ErrInteractiveDisabled) { return false, "", nil @@ -393,7 +717,7 @@ func promptAndInstallWorkflowBlock(repoRoot string, force bool) (bool, string, e // Only AGENTS.md exists - confirm confirmed, err := tui.PromptConfirm( fmt.Sprintf("Add stacking workflow block to %s?", agents.name), - true, + false, ) if errors.Is(err, stackiterrors.ErrCanceled) || errors.Is(err, tui.ErrInteractiveDisabled) { return false, "", nil @@ -410,7 +734,7 @@ func promptAndInstallWorkflowBlock(repoRoot string, force bool) (bool, string, e // Neither exists - ask if they want to create CLAUDE.md confirmed, err := tui.PromptConfirm( "No CLAUDE.md or AGENTS.md found. Create CLAUDE.md with stacking workflow block?", - true, + false, ) if errors.Is(err, stackiterrors.ErrCanceled) || errors.Is(err, tui.ErrInteractiveDisabled) { return false, "", nil diff --git a/internal/cli/integrations/agents/templates/skill/agents/openai.yaml b/internal/cli/integrations/agents/templates/skill/agents/openai.yaml new file mode 100644 index 00000000..4260f006 --- /dev/null +++ b/internal/cli/integrations/agents/templates/skill/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Stackit" + short_description: "Manage stacked Git branches" diff --git a/internal/cli/integrations/agents_templates.go b/internal/cli/integrations/agents_templates.go index fdf0b03e..0444d40f 100644 --- a/internal/cli/integrations/agents_templates.go +++ b/internal/cli/integrations/agents_templates.go @@ -7,5 +7,5 @@ import "embed" // Kept for backward compatibility but should not be referenced. var TemplateVersion = "dev" -//go:embed agents/templates/skill/*.md agents/templates/skill/commands/*.md agents/templates/skill/workflows/*.md agents/templates/skill/scripts/*.sh agents/templates/commands/*.md agents/templates/cursor/*.md agents/templates/subagents/*.md agents/templates/agents-block.md +//go:embed agents/templates/skill/*.md agents/templates/skill/agents/*.yaml agents/templates/skill/commands/*.md agents/templates/skill/workflows/*.md agents/templates/skill/scripts/*.sh agents/templates/commands/*.md agents/templates/subagents/*.md agents/templates/agents-block.md var agentTemplates embed.FS diff --git a/internal/cli/integrations/agents_test.go b/internal/cli/integrations/agents_test.go index 026f3819..5345de77 100644 --- a/internal/cli/integrations/agents_test.go +++ b/internal/cli/integrations/agents_test.go @@ -1,11 +1,16 @@ package integrations import ( + "bytes" + "io" "os" "path/filepath" "testing" "github.com/stretchr/testify/require" + + stackiterrors "stackit.dev/stackit/internal/errors" + "stackit.dev/stackit/internal/tui" ) func TestReplaceWorkflowBlock(t *testing.T) { @@ -286,3 +291,488 @@ func TestInstallWorkflowBlock(t *testing.T) { require.Contains(t, string(content), "# Header\n\n") }) } + +// Not parallel: subtests mutate tui.PromptConfirm. +func TestPromptAndInstallWorkflowBlockConfirmDefaultsToSkip(t *testing.T) { + t.Run("only CLAUDE.md exists uses false default", func(t *testing.T) { + tmpDir := t.TempDir() + err := os.WriteFile(filepath.Join(tmpDir, "CLAUDE.md"), []byte("# Project"), 0600) + require.NoError(t, err) + + originalPromptConfirm := tui.PromptConfirm + t.Cleanup(func() { + tui.PromptConfirm = originalPromptConfirm + }) + + var called bool + tui.PromptConfirm = func(_ string, defaultValue bool) (bool, error) { + called = true + require.False(t, defaultValue) + return false, nil + } + + installed, path, err := promptAndInstallWorkflowBlock(tmpDir, false) + require.NoError(t, err) + require.False(t, installed) + require.Empty(t, path) + require.True(t, called) + }) + + t.Run("no agent files exist uses false default", func(t *testing.T) { + tmpDir := t.TempDir() + + originalPromptConfirm := tui.PromptConfirm + t.Cleanup(func() { + tui.PromptConfirm = originalPromptConfirm + }) + + var called bool + tui.PromptConfirm = func(_ string, defaultValue bool) (bool, error) { + called = true + require.False(t, defaultValue) + return false, nil + } + + installed, path, err := promptAndInstallWorkflowBlock(tmpDir, false) + require.NoError(t, err) + require.False(t, installed) + require.Empty(t, path) + require.True(t, called) + }) +} + +// Not parallel: mutates promptSelect. +func TestPromptAndInstallWorkflowBlockBothFilesDefaultsToSkip(t *testing.T) { + tmpDir := t.TempDir() + err := os.WriteFile(filepath.Join(tmpDir, "CLAUDE.md"), []byte("# Claude"), 0600) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(tmpDir, "AGENTS.md"), []byte("# Agents"), 0600) + require.NoError(t, err) + + originalPromptSelect := promptSelect + t.Cleanup(func() { + promptSelect = originalPromptSelect + }) + + var called bool + promptSelect = func(_ string, options []tui.SelectOption, defaultIndex int) (string, error) { + called = true + require.Equal(t, 3, len(options)) + require.Equal(t, "skip", options[0].Value) + require.Equal(t, "CLAUDE.md", options[1].Value) + require.Equal(t, "AGENTS.md", options[2].Value) + require.Equal(t, 0, defaultIndex) + return "skip", nil + } + + installed, path, err := promptAndInstallWorkflowBlock(tmpDir, false) + require.NoError(t, err) + require.False(t, installed) + require.Empty(t, path) + require.True(t, called) +} + +// Not parallel: mutates promptMultiSelectWithDefault. +func TestSelectInstallTargetsPromptsWithoutSkipOption(t *testing.T) { + tmpDir := t.TempDir() + + originalPromptMultiSelect := promptMultiSelectWithDefault + t.Cleanup(func() { + promptMultiSelectWithDefault = originalPromptMultiSelect + }) + + var called bool + promptMultiSelectWithDefault = func(_ string, options []string, preSelected []bool) ([]string, error) { + called = true + require.Equal(t, []string{ + "Claude Code - Claude Code CLI skill format (~/.claude/skills/stackit)", + "Codex - Codex skill format (~/.codex/skills/stackit)", + }, options) + require.Equal(t, []bool{true, false}, preSelected) + return []string{options[0]}, nil + } + + targets, err := selectInstallTargets(tmpDir, nil) + require.NoError(t, err) + require.True(t, called) + require.Len(t, targets, 1) + require.Equal(t, "~/.claude/skills/stackit", targets[0].displayPath) +} + +func TestBuildAgentFileGroups(t *testing.T) { + t.Parallel() + + t.Run("includes codex metadata group", func(t *testing.T) { + t.Parallel() + target := installTargetForFormat(agentSkillFormatCodex) + groups := buildAgentFileGroups(target) + + var found bool + for _, g := range groups { + if g.destDir == filepath.Join(".codex", "skills", "stackit", "agents") { + require.Equal(t, "agents/templates/skill/agents", g.templateDir) + require.Equal(t, []string{"openai.yaml"}, g.files) + found = true + break + } + } + require.True(t, found) + }) + + t.Run("includes claude slash commands group", func(t *testing.T) { + t.Parallel() + target := installTargetForFormat(agentSkillFormatClaude) + groups := buildAgentFileGroups(target) + + var found bool + for _, g := range groups { + if g.destDir == filepath.Join(".claude", "commands") { + require.Equal(t, "agents/templates/commands", g.templateDir) + require.Equal(t, len(claudeCommandFiles), len(g.files)) + found = true + break + } + } + require.True(t, found) + }) + + t.Run("codex does not include claude slash commands group", func(t *testing.T) { + t.Parallel() + target := installTargetForFormat(agentSkillFormatCodex) + groups := buildAgentFileGroups(target) + + for _, g := range groups { + require.NotEqual(t, filepath.Join(".claude", "commands"), g.destDir) + } + }) +} + +func TestCheckExistingInstallation(t *testing.T) { + t.Parallel() + + t.Run("returns error for claude version mismatch", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + skillPath := filepath.Join(tmpDir, ".claude", "skills", "stackit", "SKILL.md") + err := os.MkdirAll(filepath.Dir(skillPath), 0750) + require.NoError(t, err) + err = os.WriteFile(skillPath, []byte(testSkillContent("1.0.0")), 0600) + require.NoError(t, err) + + var out bytes.Buffer + err = checkExistingInstallation(tmpDir, agentSkillFormatClaude, "2.0.0", &out) + require.Error(t, err) + require.Contains(t, out.String(), "existing installation") + require.Contains(t, out.String(), "1.0.0") + }) + + t.Run("returns error for codex version mismatch", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + skillPath := filepath.Join(tmpDir, ".codex", "skills", "stackit", "SKILL.md") + err := os.MkdirAll(filepath.Dir(skillPath), 0750) + require.NoError(t, err) + err = os.WriteFile(skillPath, []byte(testSkillContent("1.0.0")), 0600) + require.NoError(t, err) + + var out bytes.Buffer + err = checkExistingInstallation(tmpDir, agentSkillFormatCodex, "2.0.0", &out) + require.Error(t, err) + require.Contains(t, out.String(), "existing installation") + require.Contains(t, out.String(), "1.0.0") + }) + + t.Run("does not error when versions match", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + skillPath := filepath.Join(tmpDir, ".codex", "skills", "stackit", "SKILL.md") + err := os.MkdirAll(filepath.Dir(skillPath), 0750) + require.NoError(t, err) + err = os.WriteFile(skillPath, []byte(testSkillContent("2.0.0")), 0600) + require.NoError(t, err) + + var out bytes.Buffer + err = checkExistingInstallation(tmpDir, agentSkillFormatCodex, "2.0.0", &out) + require.NoError(t, err) + require.Empty(t, out.String()) + }) +} + +// Not parallel: subtests mutate tui.PromptConfirm. +func TestConfirmOverwriteIfNeeded(t *testing.T) { + t.Run("continues when user confirms overwrite", func(t *testing.T) { + tmpDir := t.TempDir() + skillPath := filepath.Join(tmpDir, ".claude", "skills", "stackit", "SKILL.md") + err := os.MkdirAll(filepath.Dir(skillPath), 0750) + require.NoError(t, err) + err = os.WriteFile(skillPath, []byte(testSkillContent("1.0.0")), 0600) + require.NoError(t, err) + + originalPromptConfirm := tui.PromptConfirm + t.Cleanup(func() { + tui.PromptConfirm = originalPromptConfirm + }) + + called := false + tui.PromptConfirm = func(prompt string, defaultValue bool) (bool, error) { + called = true + require.Contains(t, prompt, "Existing skill installations detected") + require.Contains(t, prompt, "~/.claude/skills/stackit") + return true, nil + } + + err = confirmOverwriteIfNeeded( + tmpDir, + []agentInstallTarget{installTargetForFormat(agentSkillFormatClaude)}, + false, + "2.0.0", + io.Discard, + ) + require.NoError(t, err) + require.True(t, called) + }) + + t.Run("aborts when user declines overwrite", func(t *testing.T) { + tmpDir := t.TempDir() + skillPath := filepath.Join(tmpDir, ".codex", "skills", "stackit", "SKILL.md") + err := os.MkdirAll(filepath.Dir(skillPath), 0750) + require.NoError(t, err) + err = os.WriteFile(skillPath, []byte(testSkillContent("1.0.0")), 0600) + require.NoError(t, err) + + originalPromptConfirm := tui.PromptConfirm + t.Cleanup(func() { + tui.PromptConfirm = originalPromptConfirm + }) + tui.PromptConfirm = func(_ string, _ bool) (bool, error) { + return false, nil + } + + err = confirmOverwriteIfNeeded( + tmpDir, + []agentInstallTarget{installTargetForFormat(agentSkillFormatCodex)}, + false, + "2.0.0", + io.Discard, + ) + require.ErrorIs(t, err, stackiterrors.ErrCanceled) + }) + + t.Run("requires force in non-interactive mode", func(t *testing.T) { + tmpDir := t.TempDir() + skillPath := filepath.Join(tmpDir, ".codex", "skills", "stackit", "SKILL.md") + err := os.MkdirAll(filepath.Dir(skillPath), 0750) + require.NoError(t, err) + err = os.WriteFile(skillPath, []byte(testSkillContent("1.0.0")), 0600) + require.NoError(t, err) + + originalPromptConfirm := tui.PromptConfirm + t.Cleanup(func() { + tui.PromptConfirm = originalPromptConfirm + }) + tui.PromptConfirm = func(_ string, _ bool) (bool, error) { + return false, tui.ErrInteractiveDisabled + } + + var out bytes.Buffer + err = confirmOverwriteIfNeeded( + tmpDir, + []agentInstallTarget{installTargetForFormat(agentSkillFormatCodex)}, + false, + "2.0.0", + &out, + ) + require.Error(t, err) + require.Contains(t, out.String(), "Run with --force to overwrite") + }) + + t.Run("reports all conflicts in non-interactive mode", func(t *testing.T) { + tmpDir := t.TempDir() + + // Install both claude and codex skills + for _, dir := range []string{".claude", ".codex"} { + skillPath := filepath.Join(tmpDir, dir, "skills", "stackit", "SKILL.md") + err := os.MkdirAll(filepath.Dir(skillPath), 0750) + require.NoError(t, err) + err = os.WriteFile(skillPath, []byte(testSkillContent("1.0.0")), 0600) + require.NoError(t, err) + } + + originalPromptConfirm := tui.PromptConfirm + t.Cleanup(func() { + tui.PromptConfirm = originalPromptConfirm + }) + tui.PromptConfirm = func(_ string, _ bool) (bool, error) { + return false, tui.ErrInteractiveDisabled + } + + var out bytes.Buffer + err := confirmOverwriteIfNeeded( + tmpDir, + []agentInstallTarget{ + installTargetForFormat(agentSkillFormatClaude), + installTargetForFormat(agentSkillFormatCodex), + }, + false, + "2.0.0", + &out, + ) + require.Error(t, err) + require.Contains(t, out.String(), ".claude") + require.Contains(t, out.String(), ".codex") + }) +} + +func TestIsAnySkillInstalled(t *testing.T) { + t.Parallel() + + t.Run("detects codex skill", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + skillPath := filepath.Join(tmpDir, ".codex", "skills", "stackit", "SKILL.md") + err := os.MkdirAll(filepath.Dir(skillPath), 0750) + require.NoError(t, err) + err = os.WriteFile(skillPath, []byte("skill"), 0600) + require.NoError(t, err) + + require.True(t, isAnySkillInstalled(tmpDir)) + }) + + t.Run("detects legacy lowercase claude skill", func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + skillPath := filepath.Join(tmpDir, ".claude", "skills", "stackit", "skill.md") + err := os.MkdirAll(filepath.Dir(skillPath), 0750) + require.NoError(t, err) + err = os.WriteFile(skillPath, []byte("skill"), 0600) + require.NoError(t, err) + + require.True(t, isAnySkillInstalled(tmpDir)) + }) + + t.Run("returns false when no skill files exist", func(t *testing.T) { + t.Parallel() + require.False(t, isAnySkillInstalled(t.TempDir())) + }) +} + +func TestPrintSuccessMessageIncludesCodex(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + printSuccessMessage(&out, []agentInstallTarget{installTargetForFormat(agentSkillFormatCodex)}, false, "", len(claudeCommandFiles)) + + require.Contains(t, out.String(), "Installed agent files") + require.Contains(t, out.String(), "~/.codex/skills/stackit") + require.NotContains(t, out.String(), "Slash commands:") +} + +func TestPrintSuccessMessageIncludesClaudeCommands(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + printSuccessMessage(&out, []agentInstallTarget{installTargetForFormat(agentSkillFormatClaude)}, false, "", len(claudeCommandFiles)) + + require.Contains(t, out.String(), "~/.claude/skills/stackit") + require.Contains(t, out.String(), "Slash commands:") +} + +func TestPrintSuccessMessageIncludesMultipleTargets(t *testing.T) { + t.Parallel() + + var out bytes.Buffer + printSuccessMessage( + &out, + []agentInstallTarget{ + installTargetForFormat(agentSkillFormatClaude), + installTargetForFormat(agentSkillFormatCodex), + }, + false, + "", + len(claudeCommandFiles), + ) + + require.Contains(t, out.String(), "~/.claude/skills/stackit") + require.Contains(t, out.String(), "~/.codex/skills/stackit") + require.Contains(t, out.String(), "Slash commands:") +} + +func TestParseAgentSkillFormat(t *testing.T) { + t.Parallel() + + parsed, err := parseAgentSkillFormat("claude") + require.NoError(t, err) + require.Equal(t, agentSkillFormatClaude, parsed) + + parsed, err = parseAgentSkillFormat("codex") + require.NoError(t, err) + require.Equal(t, agentSkillFormatCodex, parsed) + + _, err = parseAgentSkillFormat("invalid") + require.Error(t, err) +} + +func TestParseAgentSkillFormats(t *testing.T) { + t.Parallel() + + formats, err := parseAgentSkillFormats([]string{"claude", "codex"}) + require.NoError(t, err) + require.Equal(t, []agentSkillFormat{agentSkillFormatClaude, agentSkillFormatCodex}, formats) + + formats, err = parseAgentSkillFormats([]string{"claude,codex", "claude"}) + require.NoError(t, err) + require.Equal(t, []agentSkillFormat{agentSkillFormatClaude, agentSkillFormatCodex}, formats) + + _, err = parseAgentSkillFormats([]string{"bad-format"}) + require.Error(t, err) +} + +func TestParseSelectedFormatLabels(t *testing.T) { + t.Parallel() + + formats, err := parseSelectedFormatLabels([]string{ + "Claude Code - Claude Code CLI skill format (~/.claude/skills/stackit)", + "Codex - Codex skill format (~/.codex/skills/stackit)", + }) + require.NoError(t, err) + require.Equal(t, []agentSkillFormat{agentSkillFormatClaude, agentSkillFormatCodex}, formats) + + _, err = parseSelectedFormatLabels([]string{"Skip (don't install agent skills)"}) + require.Error(t, err) +} + +func TestTargetsForFormats(t *testing.T) { + t.Parallel() + + targets := targetsForFormats([]agentSkillFormat{agentSkillFormatClaude, agentSkillFormatCodex}) + require.Len(t, targets, 2) + require.Equal(t, "~/.claude/skills/stackit", targets[0].displayPath) + require.Equal(t, "~/.codex/skills/stackit", targets[1].displayPath) +} + +func TestInstalledSkillManifestPathsForFormat(t *testing.T) { + t.Parallel() + + base := "/tmp/test" + paths := installedSkillManifestPathsForFormat(base, agentSkillFormatClaude) + require.Len(t, paths, 2) + require.Contains(t, paths[0], filepath.Join(".claude", "skills", "stackit")) + + paths = installedSkillManifestPathsForFormat(base, agentSkillFormatCodex) + require.Len(t, paths, 2) + require.Contains(t, paths[0], filepath.Join(".codex", "skills", "stackit")) +} + +// Not parallel: uses t.Setenv. +func TestResolveInstallBaseDirUsesHomeDirectory(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + baseDir, err := resolveInstallBaseDir() + require.NoError(t, err) + require.Equal(t, homeDir, baseDir) +} + +func testSkillContent(version string) string { + return "---\nversion: " + version + "\n---\n" +} diff --git a/internal/cli/integrations/install.go b/internal/cli/integrations/install.go index a614f3e3..302592c6 100644 --- a/internal/cli/integrations/install.go +++ b/internal/cli/integrations/install.go @@ -44,8 +44,31 @@ func InstallPrepush(runner git.Runner, out io.Writer) error { // InstallAgents installs AI agent integration files. // This is a convenience wrapper for use during init. +// It auto-detects formats from existing directories to avoid a nested prompt. func InstallAgents(runner git.Runner, local, force bool, version string, out io.Writer) error { - return runAgentInstall(runner, local, force, version, out) + formats := autoDetectFormats() + return runAgentInstall(runner, local, force, formats, version, out) +} + +// autoDetectFormats returns format flags based on which agent directories exist, +// defaulting to claude if none are found. +func autoDetectFormats() []string { + homeDir, err := os.UserHomeDir() + if err != nil { + return []string{"claude"} + } + + var formats []string + if dirExists(filepath.Join(homeDir, ".claude")) { + formats = append(formats, "claude") + } + if dirExists(filepath.Join(homeDir, ".codex")) { + formats = append(formats, "codex") + } + if len(formats) == 0 { + formats = []string{"claude"} + } + return formats } // stackitWorkflowMarker is a string that identifies a stackit-generated GitHub workflow. @@ -108,24 +131,18 @@ func IsPrepushInstalled(runner git.Runner) bool { } // IsAgentsInstalled checks if agent integration files are already installed. -// Checks both global (~/.claude/) and local (.claude/) installations. +// Checks both global (~/.claude/, ~/.codex/) and local (.claude/, .codex/) installations. func IsAgentsInstalled(runner git.Runner) bool { // Check global installation homeDir, err := os.UserHomeDir() - if err == nil { - skillPath := filepath.Join(homeDir, ".claude", "skills", "stackit", "skill.md") - if _, err := os.Stat(skillPath); err == nil { - return true - } + if err == nil && isAnySkillInstalled(homeDir) { + return true } // Check local installation repoRoot, err := runner.DiscoverRepoRoot() - if err == nil { - skillPath := filepath.Join(repoRoot, ".claude", "skills", "stackit", "skill.md") - if _, err := os.Stat(skillPath); err == nil { - return true - } + if err == nil && isAnySkillInstalled(repoRoot) { + return true } return false diff --git a/internal/cli/integrations/install_test.go b/internal/cli/integrations/install_test.go new file mode 100644 index 00000000..62d941be --- /dev/null +++ b/internal/cli/integrations/install_test.go @@ -0,0 +1,79 @@ +package integrations + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "stackit.dev/stackit/internal/git" +) + +// Not parallel: subtests use t.Setenv. +func TestIsAgentsInstalled(t *testing.T) { + t.Run("detects global codex installation", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + skillPath := filepath.Join(homeDir, ".codex", "skills", "stackit", "SKILL.md") + err := os.MkdirAll(filepath.Dir(skillPath), 0750) + require.NoError(t, err) + err = os.WriteFile(skillPath, []byte("skill"), 0600) + require.NoError(t, err) + + runner := git.NewRunnerWithPath(t.TempDir(), nil) + require.True(t, IsAgentsInstalled(runner)) + }) + + t.Run("detects local claude installation", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + repoRoot := t.TempDir() + cmd := exec.Command("git", "init", repoRoot) + out, err := cmd.CombinedOutput() + require.NoError(t, err, string(out)) + + skillPath := filepath.Join(repoRoot, ".claude", "skills", "stackit", "SKILL.md") + err = os.MkdirAll(filepath.Dir(skillPath), 0750) + require.NoError(t, err) + err = os.WriteFile(skillPath, []byte("skill"), 0600) + require.NoError(t, err) + + runner := git.NewRunnerWithPath(repoRoot, nil) + require.True(t, IsAgentsInstalled(runner)) + }) + + t.Run("returns false when no installation exists", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + runner := git.NewRunnerWithPath(t.TempDir(), nil) + require.False(t, IsAgentsInstalled(runner)) + }) +} + +// Not parallel: subtests use t.Setenv. +func TestAutoDetectFormats(t *testing.T) { + t.Run("defaults to claude when no dirs exist", func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + require.Equal(t, []string{"claude"}, autoDetectFormats()) + }) + + t.Run("detects both dirs", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + require.NoError(t, os.MkdirAll(filepath.Join(homeDir, ".claude"), 0750)) + require.NoError(t, os.MkdirAll(filepath.Join(homeDir, ".codex"), 0750)) + require.Equal(t, []string{"claude", "codex"}, autoDetectFormats()) + }) + + t.Run("detects only codex", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + require.NoError(t, os.MkdirAll(filepath.Join(homeDir, ".codex"), 0750)) + require.Equal(t, []string{"codex"}, autoDetectFormats()) + }) +}