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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 19 additions & 9 deletions internal/cli/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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
}
4 changes: 2 additions & 2 deletions internal/cli/skill_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/skill_install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions internal/cli/skill_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}} {
Expand Down Expand Up @@ -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
Expand All @@ -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 })
Expand Down
Loading