From e4fb373ff83cbb54d0b311b211f815d9dc05eeb8 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 14 Oct 2026 10:52:03 +0800 Subject: [PATCH] feat(skill): add codex target subcommand for skill install This change introduces subcommand-style targets for the `skill` command, allowing users to pick an installation destination via `jevwise skill codex`, `claude`, or `agent` rather than relying solely on target flags. **New Codex target support:** * Added a new `codex` target that installs under `.agents/skills`, matching the official Codex project skill location, distinct from the legacy singular `.agent/skills` used by the generic `agent` target. * Registered `codex`, `claude`, and `agent` as subcommands that share the parent's args and run logic, with target flags moved to persistent flags so they apply across subcommands. * Extended the install validation to accept `.agents` alongside `.agent` and `.claude`. **Backward compatibility and guards:** * Kept legacy `--agent`/`--claude` flags working but hidden from help, and rejected combining them with target subcommands. * Made online mode reject target subcommands in addition to the existing target and force flags. **Docs and tests:** * Updated usage docs to document the new subcommands and the separate `.agents` vs `.agent` directories. * Added tests covering the new targets, force combinations, and invalid flag/subcommand pairings. --- docs/usage.md | 10 ++++++---- internal/cli/skill.go | 28 +++++++++++++++++++--------- internal/cli/skill_install.go | 4 ++-- internal/cli/skill_install_test.go | 2 +- internal/cli/skill_test.go | 7 +++++-- 5 files changed, 33 insertions(+), 18 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 8bdd4ec..d990b81 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -28,14 +28,16 @@ printf '%s' 'Which task should I tackle first?' | jevwise decide --stdin \ ```sh jevwise skill # Default: .agent/skills/typesafe-ai/SKILL.md -jevwise skill --agent # Explicit default target -jevwise skill --claude # .claude/skills/typesafe-ai/SKILL.md -jevwise skill --force # Replace an existing regular skill file +jevwise skill agent # Explicit default target +jevwise skill codex # .agents/skills/typesafe-ai/SKILL.md +jevwise skill claude # .claude/skills/typesafe-ai/SKILL.md +jevwise skill codex --force # Replace an existing regular Codex skill file jevwise skill --online # Print the upstream GitHub URL only ``` - Downloads the latest [upstream skill](https://github.com/typesafe-ai/skills/blob/main/skills/typesafe-ai/SKILL.md) unchanged, relative to the current project directory. -- Local installation is the default; `--local` selects it explicitly. `--online` and `--local`, or `--agent` and `--claude`, cannot be combined. Online mode rejects installation flags and performs no download or write. +- Local installation is the default; `--local` selects it explicitly. `--online` and `--local` cannot be combined. Online mode rejects target subcommands and installation flags and performs no download or write. Legacy `--agent`/`--claude` still work but are hidden from help; do not combine them with target subcommands. +- Codex uses the [official project skill location](https://learn.chatgpt.com/docs/build-skills#where-codex-loads-local-skills), `.agents/skills`. The generic `agent` target retains the legacy singular `.agent/skills`; these are separate directories. - Hidden `--jev` selects the only supported skill. No decision credentials/configuration are read or sent. `--timeout` sets a `10s` default deadline for fetching and checks before file publication; filesystem calls are not forcibly interrupted. - Downloads require HTTP 200 and valid UTF-8 Markdown/front matter, are limited to 1 MiB, and never follow redirects. Invalid downloads leave existing skills untouched. - Existing files require `--force`. Static symlinks and nonregular destinations are refused; staging files are cleaned and complete bytes published without truncating the old inode. diff --git a/internal/cli/skill.go b/internal/cli/skill.go index d760496..2cc9785 100644 --- a/internal/cli/skill.go +++ b/internal/cli/skill.go @@ -105,11 +105,14 @@ func newSkill(fetch skillFetch, install skillInstall) *cobra.Command { if flag := cmd.Flags().Lookup("provider"); flag != nil && flag.Changed && flag.Value.String() != "jev" { return errors.New("skill supports only the Jev provider") } + if cmd.Name() != "skill" && (cmd.Flags().Changed("agent") || cmd.Flags().Changed("claude")) { + return errors.New("do not combine skill target subcommands with target flags") + } if cmd.Flags().Changed("agent") && !agent || cmd.Flags().Changed("claude") && !claude || agent && claude { return errors.New("select at most one enabled skill target") } if online { - if cmd.Flags().Changed("agent") || cmd.Flags().Changed("claude") || cmd.Flags().Changed("force") { + if cmd.Name() != "skill" || cmd.Flags().Changed("agent") || cmd.Flags().Changed("claude") || cmd.Flags().Changed("force") { return errors.New("online skill mode does not accept target or force flags") } if _, err := fmt.Fprintln(cmd.OutOrStdout(), skillURL); err != nil { @@ -145,8 +148,10 @@ func newSkill(fetch skillFetch, install skillInstall) *cobra.Command { return err } target := ".agent" - if claude { + if claude || cmd.Name() == "claude" { target = ".claude" + } else if cmd.Name() == "codex" { + target = ".agents" } if err := install(ctx, data, target, force); err != nil { if errors.Is(err, errSkillExists) { @@ -156,12 +161,17 @@ func newSkill(fetch skillFetch, install skillInstall) *cobra.Command { } return ctx.Err() }} - cmd.Flags().BoolVar(&online, "online", false, "Print the upstream GitHub URL without downloading") - cmd.Flags().BoolVar(&local, "local", false, "Fetch and install the skill (default)") - cmd.Flags().BoolVar(&agent, "agent", false, "Install under .agent/skills (default)") - cmd.Flags().BoolVar(&claude, "claude", false, "Install under .claude/skills") - cmd.Flags().BoolVar(&force, "force", false, "Replace an existing regular skill file") - cmd.Flags().BoolVar(&jev, "jev", true, "Use the Jev skill") - _ = cmd.Flags().MarkHidden("jev") + cmd.PersistentFlags().BoolVar(&online, "online", false, "Print the upstream GitHub URL without downloading") + cmd.PersistentFlags().BoolVar(&local, "local", false, "Fetch and install the skill (default)") + cmd.PersistentFlags().BoolVar(&agent, "agent", false, "Install under .agent/skills (default)") + cmd.PersistentFlags().BoolVar(&claude, "claude", false, "Install under .claude/skills") + cmd.PersistentFlags().BoolVar(&force, "force", false, "Replace an existing regular skill file") + cmd.PersistentFlags().BoolVar(&jev, "jev", true, "Use the Jev skill") + for _, name := range []string{"jev", "agent", "claude"} { + _ = cmd.PersistentFlags().MarkHidden(name) + } + for _, target := range []string{"codex", "claude", "agent"} { + cmd.AddCommand(&cobra.Command{Use: target, Short: "Install Jev's skill for " + target, Args: cmd.Args, RunE: cmd.RunE}) + } return cmd } diff --git a/internal/cli/skill_install.go b/internal/cli/skill_install.go index 306d45d..c93a312 100644 --- a/internal/cli/skill_install.go +++ b/internal/cli/skill_install.go @@ -43,8 +43,8 @@ func installSkillAt(ctx context.Context, project string, data []byte, target str if err := validateSkill(data); err != nil { return err } - if target != ".agent" && target != ".claude" { - return errors.New("skill target must be .agent or .claude") + if target != ".agent" && target != ".claude" && target != ".agents" { + return errors.New("skill target must be .agent, .agents or .claude") } switch runtime.GOOS { case "js", "wasip1", "plan9": diff --git a/internal/cli/skill_install_test.go b/internal/cli/skill_install_test.go index f9a9c7a..522f037 100644 --- a/internal/cli/skill_install_test.go +++ b/internal/cli/skill_install_test.go @@ -22,7 +22,7 @@ func skillInstalled(t *testing.T, project, target string, want []byte) { } func TestSkillInstallTargetsAndForce(t *testing.T) { - for _, target := range []string{".agent", ".claude"} { + for _, target := range []string{".agent", ".agents", ".claude"} { project := t.TempDir() if err := installSkillAt(context.Background(), project, validSkill, target, false, skillInstallHooks{}); err != nil { t.Fatal(err) diff --git a/internal/cli/skill_test.go b/internal/cli/skill_test.go index 8f0d3ea..3b71120 100644 --- a/internal/cli/skill_test.go +++ b/internal/cli/skill_test.go @@ -134,7 +134,7 @@ func TestSkillModesHelpAndLocalCallbacks(t *testing.T) { t.Fatal("online mode used IO/config", err) } out, _, err = execute(skillRoot(nil, nil), "skill", "--help") - if err != nil || strings.Contains(out, "--jev") || !strings.Contains(out, "--online") { + if err != nil || strings.Contains(out, "--jev") || strings.Contains(out, "--claude") || strings.Contains(out, "--agent") || !strings.Contains(out, "codex") || !strings.Contains(out, "--online") { t.Fatal("hidden Jev/help invalid", err) } for _, args := range [][]string{{"skill", "private-content"}, {"skill", "--online", "--local"}, {"skill", "--online=false"}, {"skill", "--local=false"}, {"skill", "--jev=false"}, {"skill", "--online", "--provider", "claude"}, {"skill", "--timeout", "0s"}} { @@ -220,6 +220,8 @@ func TestSkillTargetFlagsAndRegistration(t *testing.T) { force bool }{ {nil, ".agent", false}, {[]string{"--agent"}, ".agent", false}, {[]string{"--claude"}, ".claude", false}, + {[]string{"codex"}, ".agents", false}, {[]string{"claude"}, ".claude", false}, {[]string{"agent"}, ".agent", false}, + {[]string{"codex", "--force"}, ".agents", true}, {[]string{"--force", "claude"}, ".claude", true}, {[]string{"--claude", "--force"}, ".claude", true}, {[]string{"--force=false"}, ".agent", false}, } { called := false @@ -235,7 +237,8 @@ func TestSkillTargetFlagsAndRegistration(t *testing.T) { } } for _, args := range [][]string{ - {"--agent=false"}, {"--claude=false"}, {"--agent", "--claude"}, + {"codex", "--agent"}, {"claude", "--claude"}, {"agent", "--online"}, {"codex", "--online"}, + {"codex", "private-content"}, {"--agent=false"}, {"--claude=false"}, {"--agent", "--claude"}, {"--online", "--agent"}, {"--online", "--claude"}, {"--online", "--force"}, {"--online", "--force=false"}, } { cmd := skillRoot(func(context.Context) ([]byte, error) { t.Fatal("invalid target fetched"); return nil, nil }, func(context.Context, []byte, string, bool) error { t.Fatal("invalid target installed"); return nil })