Skip to content
Open
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
53 changes: 52 additions & 1 deletion cmd/skillshare/extras_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"fmt"
"os"
"strings"
"time"

"skillshare/internal/config"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 <name> [options]

Expand All @@ -225,7 +275,7 @@ Arguments:
name Name for the extra (e.g., rules, commands, prompts)

Options:
--target <path> Target directory (repeatable)
--target <path> Target directory (repeatable); commands also supports claude/cursor/codex
--source <path> Custom source directory (overrides extras_source and default; global mode only)
--mode <mode> Sync mode: merge (default), copy, symlink
--flatten Flatten files from subdirectories into target root
Expand All @@ -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
Expand Down
133 changes: 133 additions & 0 deletions cmd/skillshare/extras_init_command_presets_test.go
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 11 additions & 4 deletions cmd/skillshare/extras_init_tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 "~/.<tool>/commands"
}
placeholders := []string{
"~/.claude/rules",
"~/.cursor/rules",
Expand Down
1 change: 1 addition & 0 deletions skills/skillshare/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion website/docs/reference/commands/extras.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ skillshare extras init <name> --target <path> [--target <path2>] [--mode <mode>]

| Flag | Description |
|------|-------------|
| `--target <path>` | Target directory path (repeatable) |
| `--target <path>` | Target directory path (repeatable). For `commands`, `claude`, `cursor`, and `codex` are also supported as target presets. |
| `--mode <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 <path>` | Custom source directory for this extra (overrides `extras_source` and default; **global mode only**) |
Expand All @@ -52,6 +52,9 @@ skillshare extras init <name> --target <path> [--target <path2>] [--mode <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

Expand All @@ -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.
Expand Down
Loading