Skip to content

Commit 47f2c84

Browse files
committed
Add the agent skill + multi-agent install
Ship skills/emailable/SKILL.md as the source of truth for teaching agents how to drive the CLI. The binary embeds it via skills.FS (go:embed). `emailable skill install` writes the canonical copy to ~/.agents/skills/emailable and symlinks every detected global agent (Claude / OpenCode / Codex) back to it. --target / --all / --dir cover specific cases; bare `emailable skill` on a TTY launches a picker. Post-login onboarding asks Yes/No to install for all known agents. .claude-plugin/plugin.json plus a symlink make the repo installable as a Claude Code plugin — the plugin manifest's skills folder is symlinked into the root skills/ tree rather than duplicated. Also fixes a .gitignore bug: bare `emailable` was unanchored and matched every directory named "emailable" in the tree, silently hiding skills/emailable/SKILL.md from git.
1 parent 0bba4aa commit 47f2c84

12 files changed

Lines changed: 1343 additions & 2 deletions

File tree

‎.claude-plugin/plugin.json‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "emailable",
3+
"version": "0.1.0",
4+
"description": "Emailable integration for Claude Code. Verify single addresses, run batch verification jobs, and check account credits via the Emailable CLI.",
5+
"author": {
6+
"name": "Emailable",
7+
"email": "support@emailable.com"
8+
},
9+
"homepage": "https://github.com/emailable/emailable-cli",
10+
"repository": "https://github.com/emailable/emailable-cli",
11+
"license": "MIT"
12+
}

‎.claude-plugin/skills/emailable‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../skills/emailable

‎.gitignore‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Binaries
22
/bin/
33
/dist/
4-
emailable
4+
/emailable
55

66
# Generated man pages (built at release time by goreleaser)
77
/man/

‎cmd/root.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,10 @@ func newRootCmd(v string) *cobra.Command {
221221
versionSub := newVersionCmd()
222222
versionSub.GroupID = groupExtras
223223

224-
root.AddCommand(verify, batch, account, login, logout, status, versionSub, newManCmd())
224+
skillSub := newSkillCmd()
225+
skillSub.GroupID = groupExtras
226+
227+
root.AddCommand(verify, batch, account, login, logout, status, versionSub, skillSub, newManCmd())
225228

226229
// Hide cobra's auto-generated `completion` command from --help. It's
227230
// still callable, just doesn't clutter the curated command list.

‎cmd/skill.go‎

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
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

Comments
 (0)