From 7605ed7121d3325e3ded26ed0c71d42c1ec318c2 Mon Sep 17 00:00:00 2001 From: TiR <70480807+TIR44@users.noreply.github.com> Date: Sun, 31 May 2026 16:18:53 +0300 Subject: [PATCH] Added command target presets for extras init --- cmd/skillshare/extras_init.go | 53 ++++++- .../extras_init_command_presets_test.go | 133 ++++++++++++++++++ cmd/skillshare/extras_init_tui.go | 15 +- skills/skillshare/SKILL.md | 1 + website/docs/reference/commands/extras.md | 19 ++- 5 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 cmd/skillshare/extras_init_command_presets_test.go diff --git a/cmd/skillshare/extras_init.go b/cmd/skillshare/extras_init.go index 57a53dbf..e607147f 100644 --- a/cmd/skillshare/extras_init.go +++ b/cmd/skillshare/extras_init.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "strings" "time" "skillshare/internal/config" @@ -119,6 +120,10 @@ func extrasInitGlobal(name string, targets []string, syncMode string, sourceOver if err != nil { return err } + targets, err = resolveExtraInitTargetPresets(name, targets, modeGlobal) + if err != nil { + return err + } if force { cfg.Extras = removeExtraByName(cfg.Extras, name) @@ -164,6 +169,10 @@ func extrasInitProject(cwd, name string, targets []string, syncMode string, flat if err != nil { return err } + targets, err = resolveExtraInitTargetPresets(name, targets, modeProject) + if err != nil { + return err + } if force { projCfg.Extras = removeExtraByName(projCfg.Extras, name) @@ -216,6 +225,47 @@ func removeExtraByName(extras []config.ExtraConfig, name string) []config.ExtraC return result } +func resolveExtraInitTargetPresets(name string, targets []string, mode runMode) ([]string, error) { + if name != "commands" { + return targets, nil + } + resolved := make([]string, 0, len(targets)) + for _, target := range targets { + path, ok, err := resolveCommandTargetPreset(target, mode) + if err != nil { + return nil, err + } + if ok { + resolved = append(resolved, path) + continue + } + resolved = append(resolved, target) + } + return resolved, nil +} + +func resolveCommandTargetPreset(target string, mode runMode) (string, bool, error) { + switch strings.ToLower(strings.TrimSpace(target)) { + case "claude", "claude-code": + if mode == modeProject { + return ".claude/commands", true, nil + } + return "~/.claude/commands", true, nil + case "cursor": + if mode == modeProject { + return ".cursor/commands", true, nil + } + return "~/.cursor/commands", true, nil + case "codex": + if mode == modeProject { + return "", true, fmt.Errorf("codex command preset is global-only; use --global or pass --target ~/.codex/prompts explicitly") + } + return "~/.codex/prompts", true, nil + default: + return "", false, nil + } +} + func printExtrasInitHelp() { fmt.Println(`Usage: skillshare extras init [options] @@ -225,7 +275,7 @@ Arguments: name Name for the extra (e.g., rules, commands, prompts) Options: - --target Target directory (repeatable) + --target Target directory (repeatable); commands also supports claude/cursor/codex --source Custom source directory (overrides extras_source and default; global mode only) --mode Sync mode: merge (default), copy, symlink --flatten Flatten files from subdirectories into target root @@ -237,6 +287,7 @@ Options: Examples: skillshare extras init rules --target ~/.claude/rules --target ~/.cursor/rules + skillshare extras init commands --target claude --target cursor --target codex skillshare extras init commands --target ~/.claude/commands --mode copy skillshare extras init rules --source ~/company-shared/rules --target ~/.claude/rules skillshare extras init rules --target ~/.claude/rules --force diff --git a/cmd/skillshare/extras_init_command_presets_test.go b/cmd/skillshare/extras_init_command_presets_test.go new file mode 100644 index 00000000..9cf93303 --- /dev/null +++ b/cmd/skillshare/extras_init_command_presets_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "skillshare/internal/config" +) + +func TestResolveExtraInitTargetPresets_CommandsGlobal(t *testing.T) { + got, err := resolveExtraInitTargetPresets("commands", []string{"claude", "cursor", "codex", "~/.custom/commands"}, modeGlobal) + if err != nil { + t.Fatalf("resolve presets: %v", err) + } + want := []string{"~/.claude/commands", "~/.cursor/commands", "~/.codex/prompts", "~/.custom/commands"} + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("targets = %#v, want %#v", got, want) + } +} + +func TestResolveExtraInitTargetPresets_CommandsProject(t *testing.T) { + got, err := resolveExtraInitTargetPresets("commands", []string{"claude-code", "cursor"}, modeProject) + if err != nil { + t.Fatalf("resolve presets: %v", err) + } + want := []string{".claude/commands", ".cursor/commands"} + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("targets = %#v, want %#v", got, want) + } +} + +func TestResolveExtraInitTargetPresets_ProjectCodexRejected(t *testing.T) { + _, err := resolveExtraInitTargetPresets("commands", []string{"codex"}, modeProject) + if err == nil { + t.Fatal("expected project codex preset to fail") + } + if !strings.Contains(err.Error(), "global-only") { + t.Fatalf("expected global-only error, got %v", err) + } +} + +func TestResolveExtraInitTargetPresets_NonCommandsAreRawPaths(t *testing.T) { + got, err := resolveExtraInitTargetPresets("rules", []string{"claude", "cursor"}, modeGlobal) + if err != nil { + t.Fatalf("resolve presets: %v", err) + } + want := []string{"claude", "cursor"} + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("targets = %#v, want %#v", got, want) + } +} + +func TestCmdExtrasInit_CommandsGlobalPresets(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + t.Setenv("HOME", home) + t.Setenv("SKILLSHARE_CONFIG", filepath.Join(root, "config.yaml")) + + cfg := &config.Config{ + Source: filepath.Join(root, "skills"), + Targets: map[string]config.TargetConfig{}, + } + if err := cfg.Save(); err != nil { + t.Fatalf("save config: %v", err) + } + + if err := cmdExtrasInit([]string{"--global", "commands", "--target", "claude", "--target", "cursor", "--target", "codex"}); err != nil { + t.Fatalf("extras init: %v", err) + } + + loaded, err := config.Load() + if err != nil { + t.Fatalf("load config: %v", err) + } + if len(loaded.Extras) != 1 { + t.Fatalf("expected one extra, got %d", len(loaded.Extras)) + } + got := extraTargetPaths(loaded.Extras[0]) + want := []string{ + filepath.Join(home, ".claude", "commands"), + filepath.Join(home, ".cursor", "commands"), + filepath.Join(home, ".codex", "prompts"), + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("targets = %#v, want %#v", got, want) + } +} + +func TestCmdExtrasInit_CommandsProjectPresets(t *testing.T) { + root := t.TempDir() + prevCWD, err := os.Getwd() + if err != nil { + t.Fatalf("get cwd: %v", err) + } + if err := os.Chdir(root); err != nil { + t.Fatalf("chdir project root: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(prevCWD) + }) + + projCfg := &config.ProjectConfig{Targets: []config.ProjectTargetEntry{{Name: "claude"}, {Name: "cursor"}}} + if err := projCfg.Save(root); err != nil { + t.Fatalf("save project config: %v", err) + } + + if err := cmdExtrasInit([]string{"--project", "commands", "--target", "claude", "--target", "cursor"}); err != nil { + t.Fatalf("extras init: %v", err) + } + + loaded, err := config.LoadProject(root) + if err != nil { + t.Fatalf("load project config: %v", err) + } + if len(loaded.Extras) != 1 { + t.Fatalf("expected one extra, got %d", len(loaded.Extras)) + } + got := extraTargetPaths(loaded.Extras[0]) + want := []string{".claude/commands", ".cursor/commands"} + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("targets = %#v, want %#v", got, want) + } +} + +func extraTargetPaths(extra config.ExtraConfig) []string { + paths := make([]string, 0, len(extra.Targets)) + for _, target := range extra.Targets { + paths = append(paths, target.Path) + } + return paths +} diff --git a/cmd/skillshare/extras_init_tui.go b/cmd/skillshare/extras_init_tui.go index 2fb30935..f78fb0d2 100644 --- a/cmd/skillshare/extras_init_tui.go +++ b/cmd/skillshare/extras_init_tui.go @@ -101,7 +101,7 @@ func (m extrasInitTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.targets = m.targets[:len(m.targets)-1] // remove the pending target m.phase = extrasPhaseTargetInput m.textInput.SetValue("") - m.textInput.Placeholder = targetPlaceholder(len(m.targets)) + m.textInput.Placeholder = targetPlaceholder(m.name, len(m.targets)) return m, nil case extrasPhaseFlattenToggle: m.phase = extrasPhaseModeSelect @@ -144,7 +144,7 @@ func (m extrasInitTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sourceValue = strings.TrimSpace(m.sourceInput.Value()) m.phase = extrasPhaseTargetInput m.textInput.SetValue("") - m.textInput.Placeholder = targetPlaceholder(0) + m.textInput.Placeholder = targetPlaceholder(m.name, 0) return m, nil } @@ -201,7 +201,7 @@ func (m extrasInitTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "y", "Y": m.phase = extrasPhaseTargetInput m.textInput.SetValue("") - m.textInput.Placeholder = targetPlaceholder(len(m.targets)) + m.textInput.Placeholder = targetPlaceholder(m.name, len(m.targets)) return m, nil case "n", "N", "enter": m.phase = extrasPhaseConfirm @@ -365,7 +365,14 @@ func (m extrasInitTUIModel) View() string { } // targetPlaceholder returns a contextual placeholder for the target input. -func targetPlaceholder(n int) string { +func targetPlaceholder(name string, n int) string { + if name == "commands" { + placeholders := []string{"claude", "cursor", "codex"} + if n < len(placeholders) { + return placeholders[n] + } + return "~/./commands" + } placeholders := []string{ "~/.claude/rules", "~/.cursor/rules", diff --git a/skills/skillshare/SKILL.md b/skills/skillshare/SKILL.md index 61b8836c..bc7f5739 100644 --- a/skills/skillshare/SKILL.md +++ b/skills/skillshare/SKILL.md @@ -48,6 +48,7 @@ skillshare sync # Always sync after install ### Extras (Rules, Commands, Prompts) ```bash skillshare extras init rules --target ~/.claude/rules --target ~/.cursor/rules +skillshare extras init commands --target claude --target cursor --target codex skillshare extras init commands --target ~/.claude/commands --mode copy skillshare extras init rules --target ~/.claude/rules --source ~/shared/rules # custom source (global only) skillshare extras init rules --target ~/.cursor/rules --force # overwrite existing diff --git a/website/docs/reference/commands/extras.md b/website/docs/reference/commands/extras.md index 861b6589..e462f089 100644 --- a/website/docs/reference/commands/extras.md +++ b/website/docs/reference/commands/extras.md @@ -33,7 +33,7 @@ skillshare extras init --target [--target ] [--mode ] | Flag | Description | |------|-------------| -| `--target ` | Target directory path (repeatable) | +| `--target ` | Target directory path (repeatable). For `commands`, `claude`, `cursor`, and `codex` are also supported as target presets. | | `--mode ` | Sync mode: `merge` (default), `copy`, or `symlink` | | `--flatten` | Sync files from subdirectories directly into the target root (cannot be used with `symlink` mode) | | `--source ` | Custom source directory for this extra (overrides `extras_source` and default; **global mode only**) | @@ -52,6 +52,9 @@ skillshare extras init --target [--target ] [--mode ] # Sync rules to Claude and Cursor skillshare extras init rules --target ~/.claude/rules --target ~/.cursor/rules +# Sync markdown commands/prompts to common agent targets +skillshare extras init commands --target claude --target cursor --target codex + # Use a custom source directory skillshare extras init rules --target ~/.claude/rules --source ~/company-shared/rules @@ -65,6 +68,20 @@ skillshare extras init prompts --target .claude/prompts --mode copy -p skillshare extras init agents --target ~/.claude/agents --flatten ``` +#### Command target presets + +For `extras init commands`, common tool names expand to the command or prompt directory each tool already reads: + +| Preset | Global target | Project target | +|--------|---------------|----------------| +| `claude` / `claude-code` | `~/.claude/commands` | `.claude/commands` | +| `cursor` | `~/.cursor/commands` | `.cursor/commands` | +| `codex` | `~/.codex/prompts` | Not project-scoped | + +Cursor and Claude command files are invoked as `/command-name`. Codex custom prompt files are invoked as `/prompts:command-name`; Codex still supports these prompt files, but skills are preferred for reusable instructions that should be shared through a repo or discovered implicitly. + +In project mode, the `codex` preset is rejected because Codex prompt files live in the Codex home directory rather than inside a project. Use global mode or pass `--target ~/.codex/prompts` explicitly if that is what you want. + ### `extras list` List all configured extras and their sync status. Launches an interactive TUI by default.