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())
+ })
+}