diff --git a/CLAUDE.md b/CLAUDE.md index 5a26812..0e05e28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,14 +32,15 @@ make clean ## Architecture -AgentX is a CLI tool for managing MCP servers, skills, and plugins across multiple AI coding agents (Claude Code, Cursor, Gemini CLI, OpenCode). +AgentX is a CLI tool for managing MCP servers, skills, and plugins across multiple AI coding agents (Claude Code, Codex, Cursor, Gemini CLI, OpenCode). ### Core Layers **CLI Layer** (`cmd/`): Cobra-based commands. `root.go` launches the TUI when run without arguments, or delegates to subcommands (`install`, `check`, `list`, `remove`, `skills`, `plugins`). -**Agent Abstraction** (`internal/agent/`): The `Agent` interface defines operations for all supported agents. Each agent (Claude, Cursor, Gemini, OpenCode) implements this interface with its own config file location and format: +**Agent Abstraction** (`internal/agent/`): The `Agent` interface defines operations for all supported agents. Each agent (Claude, Codex, Cursor, Gemini, OpenCode) implements this interface with its own config file location and format: - Claude Code: `~/.claude.json` +- Codex: `~/.codex/config.toml` - Cursor: `~/.cursor/mcp.json` - Gemini CLI: `~/.gemini/settings.json` - OpenCode: `~/.opencode/config.json` diff --git a/README.md b/README.md index 00cb604..2d70fc6 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ AgentX stands for Agent eXtension: A unified CLI tool for managing **MCP (Model AgentX simplifies the installation, management, and monitoring of MCP servers and skills across popular AI coding tools: - **Claude Code** +- **Codex** - **Cursor** - **Gemini CLI** - **OpenCode** @@ -23,9 +24,9 @@ It provides both a command-line interface and an interactive terminal UI (TUI) f - **Playwright** - Browser automation capabilities - **Context7** - Library documentation access -### Claude Code Skills Management +### Claude Code & Codex Skills Management - Install skills from local paths or Git repositories -- Support for skill directories (with `SKILL.md`) and command files (`.md`) +- Support for skill directories (with `SKILL.md`); Claude Code also supports command files (`.md`) - Install from GitHub URLs with tree fragments - Personal and project scope management - Skills health checking and validation @@ -105,24 +106,34 @@ The tool responds to: `agentx`, `agents`, or `ax` | Agent | Config Path | |-------|-------------| | Claude Code | `~/.claude.json` | +| Codex | `~/.codex/config.toml` | | Cursor | `~/.cursor/mcp.json` | | Gemini CLI | `~/.gemini/settings.json` | | OpenCode | `~/.opencode/config.json` | ### Skills Storage +Claude Code: + | Scope | Skills Directory | Commands Directory | |-------|------------------|-------------------| | Personal | `~/.claude/skills/` | `~/.claude/commands/` | | Project | `.claude/skills/` | `.claude/commands/` | +Codex: + +| Scope | Skills Directory | +|-------|------------------| +| Personal | `$CODEX_HOME/skills/` (default `~/.codex/skills/`) | +| Project | `.codex/skills/` | + ## Project Structure ``` agentx/ ├── cmd/ # CLI commands ├── internal/ -│ ├── agent/ # Agent implementations (Claude, Cursor, Gemini, OpenCode) +│ ├── agent/ # Agent implementations (Claude, Codex, Cursor, Gemini, OpenCode) │ ├── config/ # Configuration management │ ├── skills/ # Skills management │ ├── mcp/ # MCP-specific logic diff --git a/cmd/install.go b/cmd/install.go index d7d3199..fcfd60f 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -3,8 +3,8 @@ package cmd import ( "fmt" - "github.com/spf13/cobra" "github.com/agentsdance/agentx/internal/agent" + "github.com/spf13/cobra" ) var agentFlag string @@ -54,5 +54,5 @@ var installCmd = &cobra.Command{ } func init() { - installCmd.Flags().StringVarP(&agentFlag, "agent", "a", "", "Target agent (claude, gemini, opencode)") + installCmd.Flags().StringVarP(&agentFlag, "agent", "a", "", "Target agent (claude, codex, cursor, gemini, opencode)") } diff --git a/cmd/root.go b/cmd/root.go index 9dc1f31..55cff2a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,9 +4,9 @@ import ( "fmt" "os" - "github.com/spf13/cobra" "github.com/agentsdance/agentx/internal/version" "github.com/agentsdance/agentx/ui" + "github.com/spf13/cobra" ) var rootCmd = &cobra.Command{ @@ -14,7 +14,7 @@ var rootCmd = &cobra.Command{ Aliases: []string{"agents", "ax"}, Short: "Unified MCP Servers & Agent Skills Manager for AI coding agents", Long: `agentx is a CLI tool for managing MCP servers and skills across AI coding agents -(Claude Code, Cursor, Gemini cli, opencode). +(Claude Code, Codex, Cursor, Gemini cli, opencode). Run without arguments to launch the TUI interface. diff --git a/cmd/skills.go b/cmd/skills.go index f5ea9a6..9765ef7 100644 --- a/cmd/skills.go +++ b/cmd/skills.go @@ -3,22 +3,31 @@ package cmd import ( "fmt" "os" + "strings" "text/tabwriter" - "github.com/spf13/cobra" "github.com/agentsdance/agentx/internal/skills" + "github.com/spf13/cobra" ) var skillsScope string +var skillsAgent string var skillsCmd = &cobra.Command{ Use: "skills", - Short: "Manage Claude Code skills", - Long: `Manage Claude Code skills and slash commands. + Short: "Manage Claude Code and Codex skills", + Long: `Manage Claude Code and Codex skills and slash commands. + +Use --agent to switch between agents (default: Claude Code). Codex does not +support command files. Skills are stored in: Personal: ~/.claude/skills/ and ~/.claude/commands/ - Project: .claude/skills/ and .claude/commands/`, + Project: .claude/skills/ and .claude/commands/ + +Codex skills are stored in: + Personal: $CODEX_HOME/skills/ (default ~/.codex/skills/) + Project: .codex/skills/`, } var skillsListCmd = &cobra.Command{ @@ -26,10 +35,13 @@ var skillsListCmd = &cobra.Command{ Short: "List installed skills", Long: `List all installed skills and commands.`, Run: func(cmd *cobra.Command, args []string) { - mgr := skills.NewSkillManager() + mgr, err := resolveSkillsManager() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } var skillList []skills.Skill - var err error if skillsScope != "" { scope := skills.SkillScope(skillsScope) @@ -90,7 +102,11 @@ The source can be: scope = skills.ScopeProject } - mgr := skills.NewSkillManager() + mgr, err := resolveSkillsManager() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } skill, err := mgr.Install(source, scope) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -117,7 +133,11 @@ var skillsRemoveCmd = &cobra.Command{ scope = skills.ScopeProject } - mgr := skills.NewSkillManager() + mgr, err := resolveSkillsManager() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } if err := mgr.Remove(name, scope); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -132,7 +152,11 @@ var skillsCheckCmd = &cobra.Command{ Short: "Check skills installation status", Long: `Verify that all installed skills are valid and properly configured.`, Run: func(cmd *cobra.Command, args []string) { - mgr := skills.NewSkillManager() + mgr, err := resolveSkillsManager() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } statuses, err := mgr.Check() if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -186,9 +210,30 @@ var skillsCheckCmd = &cobra.Command{ func init() { skillsCmd.PersistentFlags().StringVarP(&skillsScope, "scope", "s", "", "Scope for the operation (personal, project)") + skillsCmd.PersistentFlags().StringVarP(&skillsAgent, "agent", "a", "claude", + "Target agent for skills (claude, codex)") skillsCmd.AddCommand(skillsListCmd) skillsCmd.AddCommand(skillsInstallCmd) skillsCmd.AddCommand(skillsRemoveCmd) skillsCmd.AddCommand(skillsCheckCmd) } + +func resolveSkillsManager() (*skills.DefaultSkillManager, error) { + switch normalizeSkillsAgent(skillsAgent) { + case "", "claude", "claudecode": + return skills.NewSkillManager(), nil + case "codex": + return skills.NewCodexSkillManager(), nil + default: + return nil, fmt.Errorf("unknown agent: %s (use 'claude' or 'codex')", skillsAgent) + } +} + +func normalizeSkillsAgent(name string) string { + normalized := strings.ToLower(strings.TrimSpace(name)) + normalized = strings.ReplaceAll(normalized, "-", "") + normalized = strings.ReplaceAll(normalized, "_", "") + normalized = strings.ReplaceAll(normalized, " ", "") + return normalized +} diff --git a/go.mod b/go.mod index a4635aa..6532d4c 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.1 require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/pelletier/go-toml/v2 v2.2.4 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index eae863d..8a06478 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index a37e7fa..5db359b 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -42,6 +42,7 @@ type Agent interface { func GetAllAgents() []Agent { return []Agent{ NewClaudeAgent(), + NewCodexAgent(), NewCursorAgent(), NewGeminiAgent(), NewOpenCodeAgent(), @@ -64,6 +65,8 @@ func matchAgentName(agentName, input string) bool { switch input { case "claude", "claudecode", "claude-code", "claude_code": return agentName == "Claude Code" + case "codex", "codexcli", "codex-cli", "codex_cli": + return agentName == "Codex" case "cursor": return agentName == "Cursor" case "gemini", "geminicli", "gemini-cli", "gemini_cli": diff --git a/internal/agent/codex.go b/internal/agent/codex.go new file mode 100644 index 0000000..51264f4 --- /dev/null +++ b/internal/agent/codex.go @@ -0,0 +1,190 @@ +package agent + +import ( + "os" + "path/filepath" + + "github.com/agentsdance/agentx/internal/config" + "github.com/agentsdance/agentx/internal/skills" +) + +const codexMCPKey = "mcp_servers" + +var codexPlaywrightMCPConfig = map[string]interface{}{ + "command": "npx", + "args": []string{"@playwright/mcp@latest"}, +} + +var codexContext7MCPConfig = map[string]interface{}{ + "command": "npx", + "args": []string{"-y", "@upstash/context7-mcp"}, +} + +// CodexAgent represents Codex CLI agent +type CodexAgent struct { + configPath string +} + +// NewCodexAgent creates a new Codex agent +func NewCodexAgent() *CodexAgent { + home, _ := os.UserHomeDir() + codexHome := os.Getenv("CODEX_HOME") + if codexHome == "" { + codexHome = filepath.Join(home, ".codex") + } + + return &CodexAgent{ + configPath: filepath.Join(codexHome, "config.toml"), + } +} + +func (a *CodexAgent) Name() string { + return "Codex" +} + +func (a *CodexAgent) ConfigPath() string { + return a.configPath +} + +func (a *CodexAgent) Exists() bool { + _, err := os.Stat(a.configPath) + return err == nil +} + +func (a *CodexAgent) HasPlaywright() (bool, error) { + cfg, err := config.ReadTOMLConfig(a.configPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return hasCodexMCP(cfg, "playwright"), nil +} + +func (a *CodexAgent) InstallPlaywright() error { + cfg, err := config.ReadTOMLConfig(a.configPath) + if err != nil { + if os.IsNotExist(err) { + cfg = make(map[string]interface{}) + } else { + return err + } + } + addCodexMCP(cfg, "playwright", codexPlaywrightMCPConfig) + return config.WriteTOMLConfig(a.configPath, cfg) +} + +func (a *CodexAgent) RemovePlaywright() error { + cfg, err := config.ReadTOMLConfig(a.configPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + removeCodexMCP(cfg, "playwright") + return config.WriteTOMLConfig(a.configPath, cfg) +} + +func (a *CodexAgent) HasContext7() (bool, error) { + cfg, err := config.ReadTOMLConfig(a.configPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + return hasCodexMCP(cfg, "context7"), nil +} + +func (a *CodexAgent) InstallContext7() error { + cfg, err := config.ReadTOMLConfig(a.configPath) + if err != nil { + if os.IsNotExist(err) { + cfg = make(map[string]interface{}) + } else { + return err + } + } + addCodexMCP(cfg, "context7", codexContext7MCPConfig) + return config.WriteTOMLConfig(a.configPath, cfg) +} + +func (a *CodexAgent) RemoveContext7() error { + cfg, err := config.ReadTOMLConfig(a.configPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + removeCodexMCP(cfg, "context7") + return config.WriteTOMLConfig(a.configPath, cfg) +} + +func (a *CodexAgent) SupportsSkills() bool { + return true +} + +func (a *CodexAgent) HasSkill(skillName string) (bool, error) { + mgr := skills.NewCodexSkillManager() + skill, err := mgr.Get(skillName) + if err != nil { + return false, nil // Not found is not an error + } + return skill != nil, nil +} + +func (a *CodexAgent) InstallSkill(skillName, source string) error { + mgr := skills.NewCodexSkillManager() + _, err := mgr.Install(source, skills.ScopePersonal) + return err +} + +func (a *CodexAgent) RemoveSkill(skillName string) error { + mgr := skills.NewCodexSkillManager() + return mgr.Remove(skillName, skills.ScopePersonal) +} + +func (a *CodexAgent) SupportsPlugins() bool { + return false +} + +func (a *CodexAgent) HasPlugin(pluginName string) (bool, error) { + return false, nil +} + +func (a *CodexAgent) InstallPlugin(pluginName, source string) error { + return nil +} + +func (a *CodexAgent) RemovePlugin(pluginName string) error { + return nil +} + +func hasCodexMCP(cfg map[string]interface{}, name string) bool { + mcpServers, ok := cfg[codexMCPKey].(map[string]interface{}) + if !ok { + return false + } + _, exists := mcpServers[name] + return exists +} + +func addCodexMCP(cfg map[string]interface{}, name string, mcpConfig map[string]interface{}) { + mcpServers, ok := cfg[codexMCPKey].(map[string]interface{}) + if !ok { + mcpServers = make(map[string]interface{}) + cfg[codexMCPKey] = mcpServers + } + mcpServers[name] = mcpConfig +} + +func removeCodexMCP(cfg map[string]interface{}, name string) { + mcpServers, ok := cfg[codexMCPKey].(map[string]interface{}) + if !ok { + return + } + delete(mcpServers, name) +} diff --git a/internal/config/toml.go b/internal/config/toml.go new file mode 100644 index 0000000..c8baec9 --- /dev/null +++ b/internal/config/toml.go @@ -0,0 +1,49 @@ +package config + +import ( + "bytes" + "os" + "path/filepath" + + "github.com/pelletier/go-toml/v2" +) + +// ReadTOMLConfig reads a TOML config file. +func ReadTOMLConfig(path string) (map[string]interface{}, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + if len(bytes.TrimSpace(data)) == 0 { + return map[string]interface{}{}, nil + } + + var cfg map[string]interface{} + if err := toml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + if cfg == nil { + cfg = map[string]interface{}{} + } + return cfg, nil +} + +// WriteTOMLConfig writes a TOML config file with pretty formatting. +func WriteTOMLConfig(path string, cfg map[string]interface{}) error { + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + if cfg == nil { + cfg = map[string]interface{}{} + } + + data, err := toml.Marshal(cfg) + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} diff --git a/internal/skills/manager.go b/internal/skills/manager.go index e7679f8..3d52a21 100644 --- a/internal/skills/manager.go +++ b/internal/skills/manager.go @@ -9,11 +9,42 @@ import ( ) // DefaultSkillManager implements SkillManager -type DefaultSkillManager struct{} +type DefaultSkillManager struct { + getCommandsDir func(scope SkillScope) (string, error) + getSkillsDir func(scope SkillScope) (string, error) + supportsCommands bool +} // NewSkillManager creates a new skill manager func NewSkillManager() *DefaultSkillManager { - return &DefaultSkillManager{} + return &DefaultSkillManager{ + getCommandsDir: GetCommandsDir, + getSkillsDir: GetSkillsDir, + supportsCommands: true, + } +} + +// NewCodexSkillManager creates a new skill manager for Codex +func NewCodexSkillManager() *DefaultSkillManager { + return &DefaultSkillManager{ + getCommandsDir: GetCodexCommandsDir, + getSkillsDir: GetCodexSkillsDir, + supportsCommands: false, + } +} + +func (m *DefaultSkillManager) commandsDir(scope SkillScope) (string, error) { + if m.getCommandsDir == nil { + return "", fmt.Errorf("commands directory resolver not configured") + } + return m.getCommandsDir(scope) +} + +func (m *DefaultSkillManager) skillsDir(scope SkillScope) (string, error) { + if m.getSkillsDir == nil { + return "", fmt.Errorf("skills directory resolver not configured") + } + return m.getSkillsDir(scope) } // List returns all installed skills from both personal and project scopes @@ -36,25 +67,27 @@ func (m *DefaultSkillManager) List() ([]Skill, error) { func (m *DefaultSkillManager) ListByScope(scope SkillScope) ([]Skill, error) { var skills []Skill - // List commands (single .md files) - commandsDir, err := GetCommandsDir(scope) - if err != nil { - return nil, err - } + if m.supportsCommands { + // List commands (single .md files) + commandsDir, err := m.commandsDir(scope) + if err != nil { + return nil, err + } - if entries, err := os.ReadDir(commandsDir); err == nil { - for _, entry := range entries { - if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") { - skill, err := m.parseCommandFile(filepath.Join(commandsDir, entry.Name()), scope) - if err == nil { - skills = append(skills, *skill) + if entries, err := os.ReadDir(commandsDir); err == nil { + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".md") { + skill, err := m.parseCommandFile(filepath.Join(commandsDir, entry.Name()), scope) + if err == nil { + skills = append(skills, *skill) + } } } } } // List skills (directories with SKILL.md) - skillsDir, err := GetSkillsDir(scope) + skillsDir, err := m.skillsDir(scope) if err != nil { return nil, err } @@ -101,15 +134,17 @@ func (m *DefaultSkillManager) Install(source string, scope SkillScope) (*Skill, // Remove removes a skill by name func (m *DefaultSkillManager) Remove(name string, scope SkillScope) error { - // Try removing from commands - commandsDir, _ := GetCommandsDir(scope) - commandPath := filepath.Join(commandsDir, name+".md") - if _, err := os.Stat(commandPath); err == nil { - return os.Remove(commandPath) + if m.supportsCommands { + // Try removing from commands + commandsDir, _ := m.commandsDir(scope) + commandPath := filepath.Join(commandsDir, name+".md") + if _, err := os.Stat(commandPath); err == nil { + return os.Remove(commandPath) + } } // Try removing from skills - skillsDir, _ := GetSkillsDir(scope) + skillsDir, _ := m.skillsDir(scope) skillPath := filepath.Join(skillsDir, name) if _, err := os.Stat(skillPath); err == nil { return os.RemoveAll(skillPath) @@ -252,6 +287,9 @@ func (m *DefaultSkillManager) installFromLocal(sourcePath string, scope SkillSco // It's a command file if strings.HasSuffix(sourcePath, ".md") { + if !m.supportsCommands { + return nil, fmt.Errorf("command files are not supported for this agent") + } return m.installCommandFile(sourcePath, scope) } @@ -297,6 +335,9 @@ func (m *DefaultSkillManager) installFromGit(repoURL, fragment, skillPath string } // Check if it's a command file if isCommandFile(targetPath + ".md") { + if !m.supportsCommands { + return nil, fmt.Errorf("command files are not supported for this agent") + } skill, err := m.installCommandFile(targetPath+".md", scope) if err != nil { return nil, err @@ -310,20 +351,23 @@ func (m *DefaultSkillManager) installFromGit(repoURL, fragment, skillPath string // Find the skill in the repository using fragment foundPath, err := FindSkillInRepo(tmpDir, lookupName) if err != nil { - // Maybe it's a command file - cmdPath, cmdErr := FindCommandInRepo(tmpDir, lookupName) - if cmdErr != nil { - return nil, err // Return original error - } - skill, err := m.installCommandFile(cmdPath, scope) - if err != nil { - return nil, err - } - skill.Source = repoURL - if fragment != "" { - skill.Source = repoURL + "#" + fragment + if m.supportsCommands { + // Maybe it's a command file + cmdPath, cmdErr := FindCommandInRepo(tmpDir, lookupName) + if cmdErr != nil { + return nil, err // Return original error + } + skill, err := m.installCommandFile(cmdPath, scope) + if err != nil { + return nil, err + } + skill.Source = repoURL + if fragment != "" { + skill.Source = repoURL + "#" + fragment + } + return skill, nil } - return skill, nil + return nil, err } skill, err := m.installSkillDir(foundPath, scope) @@ -346,7 +390,7 @@ func (m *DefaultSkillManager) installSkillDir(sourcePath string, scope SkillScop } // Get target directory - skillsDir, err := GetSkillsDir(scope) + skillsDir, err := m.skillsDir(scope) if err != nil { return nil, err } @@ -374,6 +418,9 @@ func (m *DefaultSkillManager) installSkillDir(sourcePath string, scope SkillScop // installCommandFile installs a command .md file func (m *DefaultSkillManager) installCommandFile(sourcePath string, scope SkillScope) (*Skill, error) { + if !m.supportsCommands { + return nil, fmt.Errorf("command files are not supported for this agent") + } // Parse the command first to get its name skill, err := m.parseCommandFile(sourcePath, scope) if err != nil { @@ -381,7 +428,7 @@ func (m *DefaultSkillManager) installCommandFile(sourcePath string, scope SkillS } // Get target directory - commandsDir, err := GetCommandsDir(scope) + commandsDir, err := m.commandsDir(scope) if err != nil { return nil, err } diff --git a/internal/skills/paths.go b/internal/skills/paths.go index 23e829a..78e642c 100644 --- a/internal/skills/paths.go +++ b/internal/skills/paths.go @@ -24,6 +24,29 @@ func GetClaudeBasePaths() (personal, project string, err error) { return personal, project, nil } +// GetCodexBasePaths returns the base paths for Codex configuration +func GetCodexBasePaths() (personal, project string, err error) { + codexHome := os.Getenv("CODEX_HOME") + if codexHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", "", err + } + codexHome = filepath.Join(home, ".codex") + } + + personal = codexHome + + // Project path is relative to current directory + cwd, err := os.Getwd() + if err != nil { + return personal, "", err + } + project = filepath.Join(cwd, ".codex") + + return personal, project, nil +} + // GetCommandsDir returns the commands directory for a scope func GetCommandsDir(scope SkillScope) (string, error) { personal, project, err := GetClaudeBasePaths() @@ -38,6 +61,20 @@ func GetCommandsDir(scope SkillScope) (string, error) { return filepath.Join(base, "commands"), nil } +// GetCodexCommandsDir returns the Codex commands directory for a scope +func GetCodexCommandsDir(scope SkillScope) (string, error) { + personal, project, err := GetCodexBasePaths() + if err != nil { + return "", err + } + + base := personal + if scope == ScopeProject { + base = project + } + return filepath.Join(base, "commands"), nil +} + // GetSkillsDir returns the skills directory for a scope func GetSkillsDir(scope SkillScope) (string, error) { personal, project, err := GetClaudeBasePaths() @@ -52,6 +89,20 @@ func GetSkillsDir(scope SkillScope) (string, error) { return filepath.Join(base, "skills"), nil } +// GetCodexSkillsDir returns the Codex skills directory for a scope +func GetCodexSkillsDir(scope SkillScope) (string, error) { + personal, project, err := GetCodexBasePaths() + if err != nil { + return "", err + } + + base := personal + if scope == ScopeProject { + base = project + } + return filepath.Join(base, "skills"), nil +} + // EnsureDir creates a directory if it doesn't exist func EnsureDir(path string) error { return os.MkdirAll(path, 0755) diff --git a/internal/skills/skill.go b/internal/skills/skill.go index 311fe2c..3ca2213 100644 --- a/internal/skills/skill.go +++ b/internal/skills/skill.go @@ -20,7 +20,7 @@ const ( ScopeProject SkillScope = "project" ) -// Skill represents a Claude Code skill or command +// Skill represents a Claude Code or Codex skill or command type Skill struct { Name string `json:"name"` Description string `json:"description"`