|
| 1 | +package cmd |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "io" |
| 6 | + "os" |
| 7 | + "path/filepath" |
| 8 | + "sort" |
| 9 | + "strings" |
| 10 | + |
| 11 | + "github.com/charmbracelet/lipgloss" |
| 12 | + "github.com/emailable/emailable-cli/internal/output" |
| 13 | + "github.com/emailable/emailable-cli/internal/skill" |
| 14 | + "github.com/emailable/emailable-cli/internal/ui" |
| 15 | + "github.com/spf13/cobra" |
| 16 | +) |
| 17 | + |
| 18 | +// newSkillCmd is the `emailable skill` command group. Bare invocation |
| 19 | +// on a TTY launches a picker; otherwise it shows help. |
| 20 | +func newSkillCmd() *cobra.Command { |
| 21 | + cmd := &cobra.Command{ |
| 22 | + Use: "skill", |
| 23 | + Short: "Manage the agent skill for the Emailable CLI", |
| 24 | + Long: "Manage the agent skill the Emailable CLI ships with. Installing " + |
| 25 | + "writes SKILL.md to ~/.agents/skills/emailable and symlinks every " + |
| 26 | + "detected agent (Claude Code, OpenCode, Codex) into that canonical " + |
| 27 | + "copy, so one re-install picks up new releases everywhere.", |
| 28 | + Args: cobra.NoArgs, |
| 29 | + SilenceUsage: true, |
| 30 | + RunE: func(cmd *cobra.Command, _ []string) error { |
| 31 | + if jsonOutput || quietMode || !terminalsInteractive(cmd) { |
| 32 | + return cmd.Help() |
| 33 | + } |
| 34 | + return runSkillWizard(cmd) |
| 35 | + }, |
| 36 | + } |
| 37 | + cmd.AddCommand(newSkillInstallCmd(), newSkillPrintCmd(), newSkillTargetsCmd()) |
| 38 | + return cmd |
| 39 | +} |
| 40 | + |
| 41 | +func runSkillWizard(cmd *cobra.Command) error { |
| 42 | + out := cmd.OutOrStdout() |
| 43 | + stf := output.StylerFor(out) |
| 44 | + title := stf(lipgloss.NewStyle().Bold(true).Foreground(ui.BrandPurple)) |
| 45 | + |
| 46 | + fmt.Fprintln(out) |
| 47 | + fmt.Fprintln(out, " "+title.Render("Emailable Skill Installation")) |
| 48 | + fmt.Fprintln(out) |
| 49 | + |
| 50 | + targets := skill.Targets() |
| 51 | + choices := make([]ui.Choice, 0, len(targets)+1) |
| 52 | + for _, t := range targets { |
| 53 | + choices = append(choices, ui.Choice{ |
| 54 | + Label: fmt.Sprintf("%s (%s)", t.Name, filepath.Join(t.Dir, skill.FileName)), |
| 55 | + }) |
| 56 | + } |
| 57 | + choices = append(choices, ui.Choice{Label: "Other (custom path)"}) |
| 58 | + |
| 59 | + idx, ok, err := ui.Select(os.Stdin, out, "Where would you like to install the Emailable skill?", choices) |
| 60 | + if err != nil { |
| 61 | + return err |
| 62 | + } |
| 63 | + if !ok { |
| 64 | + return nil |
| 65 | + } |
| 66 | + if idx == len(targets) { |
| 67 | + return runSkillWizardCustom(cmd) |
| 68 | + } |
| 69 | + res, err := skill.InstallOne(targets[idx]) |
| 70 | + if err != nil { |
| 71 | + return err |
| 72 | + } |
| 73 | + return renderInstallResult(cmd, res) |
| 74 | +} |
| 75 | + |
| 76 | +func runSkillWizardCustom(cmd *cobra.Command) error { |
| 77 | + out := cmd.OutOrStdout() |
| 78 | + raw, ok, err := ui.PromptWithPlaceholder(os.Stdin, out, "Enter custom path", "/path/to/skills/emailable/SKILL.md", false) |
| 79 | + if err != nil { |
| 80 | + return err |
| 81 | + } |
| 82 | + if !ok || raw == "" { |
| 83 | + return nil |
| 84 | + } |
| 85 | + |
| 86 | + normalized := skill.NormalizeCustomPath(raw) |
| 87 | + abs, err := skill.Expand(normalized) |
| 88 | + if err != nil { |
| 89 | + return err |
| 90 | + } |
| 91 | + if proceed, err := confirmOverwriteIfExists(out, abs); err != nil || !proceed { |
| 92 | + return err |
| 93 | + } |
| 94 | + |
| 95 | + path, err := skill.InstallToFile(normalized) |
| 96 | + if err != nil { |
| 97 | + return err |
| 98 | + } |
| 99 | + return renderInstallResult(cmd, skill.Result{SkillPath: path}) |
| 100 | +} |
| 101 | + |
| 102 | +func confirmOverwriteIfExists(out io.Writer, path string) (bool, error) { |
| 103 | + if _, err := os.Stat(path); os.IsNotExist(err) { |
| 104 | + return true, nil |
| 105 | + } else if err != nil { |
| 106 | + return false, err |
| 107 | + } |
| 108 | + return ui.Confirm(os.Stdin, out, fmt.Sprintf("%s already exists. Overwrite?", path)) |
| 109 | +} |
| 110 | + |
| 111 | +var ( |
| 112 | + skillInstallDir string |
| 113 | + skillInstallTarget string |
| 114 | + skillInstallAll bool |
| 115 | +) |
| 116 | + |
| 117 | +func newSkillInstallCmd() *cobra.Command { |
| 118 | + cmd := &cobra.Command{ |
| 119 | + Use: "install", |
| 120 | + Short: "Install the skill for one or more agents", |
| 121 | + Args: cobra.NoArgs, |
| 122 | + SilenceUsage: true, |
| 123 | + Long: "Install the skill. With no flags, writes the canonical copy at " + |
| 124 | + "~/.agents/skills/emailable and symlinks every detected agent's " + |
| 125 | + "skills directory back to it. Use --target to install for one " + |
| 126 | + "specific agent, --all to symlink every known global agent " + |
| 127 | + "location (whether detected or not), or --dir to write directly " + |
| 128 | + "to a custom directory (no symlinks).\n\n" + |
| 129 | + "Run `emailable skill targets` for the full list of --target IDs.", |
| 130 | + Example: ` # Detect installed agents and link them all into ~/.agents/skills/emailable |
| 131 | + emailable skill install |
| 132 | +
|
| 133 | + # Install for one specific agent |
| 134 | + emailable skill install --target claude-global |
| 135 | + emailable skill install --target opencode-project |
| 136 | +
|
| 137 | + # Symlink every known global agent location (even if not detected) |
| 138 | + emailable skill install --all |
| 139 | +
|
| 140 | + # Install into a custom directory (no symlinks) |
| 141 | + emailable skill install --dir ./vendor/skills/emailable`, |
| 142 | + RunE: runSkillInstallE, |
| 143 | + } |
| 144 | + cmd.Flags().StringVar(&skillInstallDir, "dir", "", "Directory to install SKILL.md into (skips the canonical copy and symlinks)") |
| 145 | + cmd.Flags().StringVar(&skillInstallTarget, "target", "", "Install for a single agent by ID (see `emailable skill targets`)") |
| 146 | + cmd.Flags().BoolVar(&skillInstallAll, "all", false, "Symlink every known global agent location, whether detected or not") |
| 147 | + return cmd |
| 148 | +} |
| 149 | + |
| 150 | +func newSkillPrintCmd() *cobra.Command { |
| 151 | + return &cobra.Command{ |
| 152 | + Use: "print", |
| 153 | + Short: "Print SKILL.md to stdout", |
| 154 | + Args: cobra.NoArgs, |
| 155 | + SilenceUsage: true, |
| 156 | + Example: ` # Pipe to a custom location |
| 157 | + emailable skill print > ~/path/to/SKILL.md`, |
| 158 | + RunE: func(cmd *cobra.Command, _ []string) error { |
| 159 | + _, err := fmt.Fprint(cmd.OutOrStdout(), skill.Content()) |
| 160 | + return err |
| 161 | + }, |
| 162 | + } |
| 163 | +} |
| 164 | + |
| 165 | +func newSkillTargetsCmd() *cobra.Command { |
| 166 | + return &cobra.Command{ |
| 167 | + Use: "targets", |
| 168 | + Short: "List known --target IDs and their paths", |
| 169 | + Args: cobra.NoArgs, |
| 170 | + SilenceUsage: true, |
| 171 | + RunE: func(cmd *cobra.Command, _ []string) error { |
| 172 | + ts := skill.Targets() |
| 173 | + if jsonOutput { |
| 174 | + rows := make([]map[string]any, 0, len(ts)) |
| 175 | + for _, t := range ts { |
| 176 | + row := map[string]any{"id": t.ID, "name": t.Name, "dir": t.Dir, "scope": scopeOf(t)} |
| 177 | + if t.Detect != nil { |
| 178 | + row["detected"] = t.Detect() |
| 179 | + } |
| 180 | + rows = append(rows, row) |
| 181 | + } |
| 182 | + return (&output.JSON{W: cmd.OutOrStdout()}).Print(map[string]any{"targets": rows}) |
| 183 | + } |
| 184 | + printTargetsTable(cmd.OutOrStdout(), ts) |
| 185 | + return nil |
| 186 | + }, |
| 187 | + } |
| 188 | +} |
| 189 | + |
| 190 | +func runSkillInstallE(cmd *cobra.Command, _ []string) error { |
| 191 | + if err := validateInstallFlags(); err != nil { |
| 192 | + return err |
| 193 | + } |
| 194 | + |
| 195 | + if skillInstallDir != "" { |
| 196 | + path, err := skill.InstallToDir(skillInstallDir) |
| 197 | + if err != nil { |
| 198 | + return err |
| 199 | + } |
| 200 | + return renderInstallResult(cmd, skill.Result{SkillPath: path}) |
| 201 | + } |
| 202 | + |
| 203 | + var ( |
| 204 | + res skill.Result |
| 205 | + err error |
| 206 | + ) |
| 207 | + switch { |
| 208 | + case skillInstallTarget != "": |
| 209 | + loc, ok := skill.LookupTarget(skillInstallTarget) |
| 210 | + if !ok { |
| 211 | + return NewInvalidInput(fmt.Sprintf("unknown --target %q; run `emailable skill targets` for the list", skillInstallTarget)) |
| 212 | + } |
| 213 | + res, err = skill.InstallOne(loc) |
| 214 | + case skillInstallAll: |
| 215 | + res, err = skill.InstallAll() |
| 216 | + default: |
| 217 | + res, err = skill.InstallDetected() |
| 218 | + } |
| 219 | + if err != nil { |
| 220 | + return err |
| 221 | + } |
| 222 | + return renderInstallResult(cmd, res) |
| 223 | +} |
| 224 | + |
| 225 | +func validateInstallFlags() error { |
| 226 | + set := 0 |
| 227 | + if skillInstallDir != "" { |
| 228 | + set++ |
| 229 | + } |
| 230 | + if skillInstallTarget != "" { |
| 231 | + set++ |
| 232 | + } |
| 233 | + if skillInstallAll { |
| 234 | + set++ |
| 235 | + } |
| 236 | + if set > 1 { |
| 237 | + return NewInvalidInput("--dir, --target, and --all are mutually exclusive") |
| 238 | + } |
| 239 | + return nil |
| 240 | +} |
| 241 | + |
| 242 | +func renderInstallResult(cmd *cobra.Command, res skill.Result) error { |
| 243 | + if jsonOutput { |
| 244 | + links := make([]map[string]any, 0, len(res.Links)) |
| 245 | + for _, l := range res.Links { |
| 246 | + row := map[string]any{ |
| 247 | + "target": l.Target.ID, |
| 248 | + "name": l.Target.Name, |
| 249 | + "path": l.Path, |
| 250 | + } |
| 251 | + if l.Notice != "" { |
| 252 | + row["notice"] = l.Notice |
| 253 | + } |
| 254 | + links = append(links, row) |
| 255 | + } |
| 256 | + return (&output.JSON{W: cmd.OutOrStdout()}).Print(map[string]any{ |
| 257 | + "skill_path": res.SkillPath, |
| 258 | + "links": links, |
| 259 | + }) |
| 260 | + } |
| 261 | + h := &output.Human{W: cmd.OutOrStdout(), Quiet: quietMode} |
| 262 | + if err := h.Success(fmt.Sprintf("Skill installed to %s", res.SkillPath)); err != nil { |
| 263 | + return err |
| 264 | + } |
| 265 | + if len(res.Links) == 0 { |
| 266 | + return h.Hint("Run `emailable skill targets` to see agent-specific install paths.") |
| 267 | + } |
| 268 | + printLinks(cmd.OutOrStdout(), res.Links) |
| 269 | + return nil |
| 270 | +} |
| 271 | + |
| 272 | +func printLinks(w io.Writer, links []skill.LinkResult) { |
| 273 | + stf := output.StylerFor(w) |
| 274 | + dim := stf(lipgloss.NewStyle().Faint(true)) |
| 275 | + bullet := stf(lipgloss.NewStyle().Foreground(lipgloss.Color("250"))) |
| 276 | + fmt.Fprintln(w) |
| 277 | + fmt.Fprintln(w, dim.Render("Linked into agent locations:")) |
| 278 | + for _, l := range links { |
| 279 | + line := fmt.Sprintf(" %s %s %s %s", bullet.Render("•"), l.Target.Name, dim.Render("→"), l.Path) |
| 280 | + if l.Notice != "" { |
| 281 | + line += " " + dim.Render("("+l.Notice+")") |
| 282 | + } |
| 283 | + fmt.Fprintln(w, line) |
| 284 | + } |
| 285 | +} |
| 286 | + |
| 287 | +func printTargetsTable(w io.Writer, ts []skill.Location) { |
| 288 | + type row struct{ id, scope, dir, detected string } |
| 289 | + rows := make([]row, 0, len(ts)) |
| 290 | + for _, t := range ts { |
| 291 | + r := row{id: t.ID, scope: scopeOf(t), dir: t.Dir} |
| 292 | + if t.Detect != nil { |
| 293 | + if t.Detect() { |
| 294 | + r.detected = "yes" |
| 295 | + } else { |
| 296 | + r.detected = "no" |
| 297 | + } |
| 298 | + } |
| 299 | + rows = append(rows, r) |
| 300 | + } |
| 301 | + sort.SliceStable(rows, func(i, j int) bool { |
| 302 | + if (rows[i].scope == "global") != (rows[j].scope == "global") { |
| 303 | + return rows[i].scope == "global" |
| 304 | + } |
| 305 | + return rows[i].id < rows[j].id |
| 306 | + }) |
| 307 | + pad := 0 |
| 308 | + for _, r := range rows { |
| 309 | + if n := len(r.id); n > pad { |
| 310 | + pad = n |
| 311 | + } |
| 312 | + } |
| 313 | + stf := output.StylerFor(w) |
| 314 | + head := stf(lipgloss.NewStyle().Foreground(lipgloss.Color("241"))) |
| 315 | + dim := stf(lipgloss.NewStyle().Faint(true)) |
| 316 | + fmt.Fprintf(w, "%s %s %s %s\n", |
| 317 | + head.Render(rpad("ID", pad)), |
| 318 | + head.Render(rpad("SCOPE", 7)), |
| 319 | + head.Render(rpad("DETECTED", 8)), |
| 320 | + head.Render("DIR"), |
| 321 | + ) |
| 322 | + for _, r := range rows { |
| 323 | + fmt.Fprintf(w, "%s %s %s %s\n", |
| 324 | + rpad(r.id, pad), |
| 325 | + rpad(r.scope, 7), |
| 326 | + dim.Render(rpad(orDash(r.detected), 8)), |
| 327 | + r.dir, |
| 328 | + ) |
| 329 | + } |
| 330 | +} |
| 331 | + |
| 332 | +func scopeOf(t skill.Location) string { |
| 333 | + if t.Global { |
| 334 | + return "global" |
| 335 | + } |
| 336 | + return "project" |
| 337 | +} |
| 338 | + |
| 339 | +func orDash(s string) string { |
| 340 | + if s == "" { |
| 341 | + return "—" |
| 342 | + } |
| 343 | + return s |
| 344 | +} |
| 345 | + |
| 346 | +func rpad(s string, w int) string { |
| 347 | + if n := len(s); n < w { |
| 348 | + return s + strings.Repeat(" ", w-n) |
| 349 | + } |
| 350 | + return s |
| 351 | +} |
0 commit comments