From 4fffef545eb2de7ea37cf679a9100079493b2f0c Mon Sep 17 00:00:00 2001 From: "Customer.io Open Source Bot" Date: Thu, 23 Jul 2026 10:15:21 +0100 Subject: [PATCH] Add Customer.io CLI source CioCliPublicExport-RevId: aed4b276dce2d1a641b610d11d76f462d2617a64 --- cmd/skills_install.go | 105 +++++++++++++++++++++++++++++++++---- cmd/skills_install_test.go | 62 ++++++++++++++++++++++ internal/tui/model.go | 22 ++++---- 3 files changed, 170 insertions(+), 19 deletions(-) diff --git a/cmd/skills_install.go b/cmd/skills_install.go index 281551e..62375a9 100644 --- a/cmd/skills_install.go +++ b/cmd/skills_install.go @@ -32,6 +32,8 @@ var bootstrapSkillBody string type installTarget struct { // name is the value accepted by --target. name string + // label is the display name shown in the human-readable summary. + label string // subdir is the path under the install base where skill folders live. subdir string } @@ -43,8 +45,17 @@ type installTarget struct { // - codex — Codex, Cursor, Windsurf, and other agents that support the open // agent skills convention read .agents/skills//SKILL.md. var installTargets = []installTarget{ - {name: "claude", subdir: filepath.Join(".claude", "skills")}, - {name: "codex", subdir: filepath.Join(".agents", "skills")}, + {name: "claude", label: "Claude Code", subdir: filepath.Join(".claude", "skills")}, + {name: "codex", label: "Codex", subdir: filepath.Join(".agents", "skills")}, +} + +// installedFile is one target's entry in the install result. A nil Files means +// the SKILL.md was already there and left untouched (no --force). +type installedFile struct { + Skill string `json:"skill"` + Target string `json:"target"` + Dir string `json:"dir"` + Files []string `json:"files"` } var skillsInstallCmd = &cobra.Command{ @@ -108,13 +119,6 @@ func runSkillsInstall(cmd *cobra.Command, args []string) error { dryRun := GetDryRun(cmd) force, _ := cmd.Flags().GetBool("force") - type installedFile struct { - Skill string `json:"skill"` - Target string `json:"target"` - Dir string `json:"dir"` - Files []string `json:"files"` - } - installed := make([]installedFile, 0, len(selected)*len(targets)) for _, s := range selected { // The skill path and file names come from the server; never let them @@ -143,6 +147,13 @@ func runSkillsInstall(cmd *cobra.Command, args []string) error { } } + // This CLI speaks JSON for agents and pipes, but a person who ran install at + // a terminal gets a readable summary instead. + if installWantsHumanOutput(cmd) { + printInstallSummary(cmd.OutOrStdout(), dryRun, installed) + return nil + } + action := "installed" if dryRun { action = "would install" @@ -157,6 +168,82 @@ func runSkillsInstall(cmd *cobra.Command, args []string) error { }) } +// installWantsHumanOutput is true only at an interactive terminal with no +// machine-output flag — --jq and --raw-output both mean the caller wants JSON. +func installWantsHumanOutput(cmd *cobra.Command) bool { + if GetJQFlag(cmd) != "" || GetRawFlag(cmd) { + return false + } + return writerIsTerminal(cmd.OutOrStdout()) +} + +// printInstallSummary renders the install result for a human, in place of the +// JSON that agents get. +func printInstallSummary(w io.Writer, dryRun bool, installed []installedFile) { + wrote := false + width := 0 + for _, f := range installed { + if len(f.Files) > 0 { + wrote = true + } + if l := len(targetLabel(f.Target)); l > width { + width = l + } + } + + switch { + case dryRun: + fmt.Fprintln(w, "Would install the Customer.io CLI skill:") + case wrote: + fmt.Fprintln(w, "Installed the Customer.io CLI skill:") + default: + fmt.Fprintln(w, "The Customer.io CLI skill is already installed:") + } + fmt.Fprintln(w) + + for _, f := range installed { + path := tildeAbbrev(filepath.Join(f.Dir, "SKILL.md")) + note := "" + if !dryRun && len(f.Files) == 0 { + note = " (already present)" + } + fmt.Fprintf(w, " %-*s%s%s\n", width+2, targetLabel(f.Target), path, note) + } + fmt.Fprintln(w) + + switch { + case dryRun: + fmt.Fprintln(w, "Run without --dry-run to write these files.") + case !wrote: + fmt.Fprintln(w, "Re-run with --force to overwrite.") + default: + fmt.Fprintln(w, "Your agent picks it up on its next run. Try: cio prime") + } +} + +func targetLabel(name string) string { + for _, t := range installTargets { + if t.name == name { + return t.label + } + } + return name +} + +func tildeAbbrev(path string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return path + } + if path == home { + return "~" + } + if rest, ok := strings.CutPrefix(path, home+string(filepath.Separator)); ok { + return "~" + string(filepath.Separator) + rest + } + return path +} + // writeSkill writes the bootstrap SKILL.md (a thin pointer to `cio prime`) // into dir, carrying the skill's server-tuned name and description as // frontmatter. It returns the names of the files written. When dryRun is set diff --git a/cmd/skills_install_test.go b/cmd/skills_install_test.go index 2cec6a8..d211aae 100644 --- a/cmd/skills_install_test.go +++ b/cmd/skills_install_test.go @@ -230,6 +230,68 @@ func TestSkillsInstallNoForceSkipsExisting(t *testing.T) { } } +func TestPrintInstallSummary(t *testing.T) { + claude := installedFile{Skill: "cli", Target: "claude", Dir: "/home/u/.claude/skills/cli", Files: []string{"SKILL.md"}} + codex := installedFile{Skill: "cli", Target: "codex", Dir: "/home/u/.agents/skills/cli", Files: []string{"SKILL.md"}} + claudeSkipped := installedFile{Skill: "cli", Target: "claude", Dir: "/home/u/.claude/skills/cli", Files: nil} + codexSkipped := installedFile{Skill: "cli", Target: "codex", Dir: "/home/u/.agents/skills/cli", Files: nil} + + cases := []struct { + name string + dryRun bool + installed []installedFile + wantHead string + wantFoot string + wantNote bool // expect an "(already present)" marker + }{ + { + name: "fresh install", + installed: []installedFile{claude, codex}, + wantHead: "Installed the Customer.io CLI skill:", + wantFoot: "cio prime", + }, + { + name: "dry run", + dryRun: true, + installed: []installedFile{claude, codex}, + wantHead: "Would install the Customer.io CLI skill:", + wantFoot: "Run without --dry-run", + }, + { + name: "all already present", + installed: []installedFile{claudeSkipped, codexSkipped}, + wantHead: "The Customer.io CLI skill is already installed:", + wantFoot: "Re-run with --force", + wantNote: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var b bytes.Buffer + printInstallSummary(&b, tc.dryRun, tc.installed) + out := b.String() + t.Logf("\n%s", out) + + if !strings.Contains(out, tc.wantHead) { + t.Errorf("missing header %q in:\n%s", tc.wantHead, out) + } + if !strings.Contains(out, tc.wantFoot) { + t.Errorf("missing footer %q in:\n%s", tc.wantFoot, out) + } + if !strings.Contains(out, "Claude Code") || !strings.Contains(out, "Codex") { + t.Errorf("expected friendly target labels, got:\n%s", out) + } + if strings.Contains(out, "{") || strings.Contains(out, "\"status\"") { + t.Errorf("human summary must not contain JSON, got:\n%s", out) + } + if got := strings.Contains(out, "(already present)"); got != tc.wantNote { + t.Errorf("already-present note = %v, want %v in:\n%s", got, tc.wantNote, out) + } + }) + } +} + func TestSafeRelPath(t *testing.T) { for _, name := range []string{"SKILL.md", "recipes/liquid.md", "a/b/c.md"} { if _, err := safeRelPath(name); err != nil { diff --git a/internal/tui/model.go b/internal/tui/model.go index c1c17dd..9a09177 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -7,16 +7,18 @@ import ( "github.com/charmbracelet/lipgloss" ) -const logoText = `█████▄▄ ▄▄▄▄▄▄▄ -████████▄ ████████▄ - ▀█████████▄ ██████████▄ - ▀█████████▄ ████████████▄ - ▀████████▄ █████████████ - ▄████████▀ █████████████ - ▄█████████▀ ████████████▀ - ▄█████████▀ ██████████▀ -████████▀ ████████▀ -█████▀▀ ▀▀▀▀▀▀▀` +const logoText = `██████▄ ▟█████▙▖ +████████▄ ████████▄ +██████████▄ ██████████▄ +▜█████████▛▘▀███████████ + ▀█████▛▘ ▀▜████████ + ▝▜▛▘ ▝▜██████ + ▗▟▙▖ ▗▟██████ + ▄█████▙▖ ▄▟████████ +▟█████████▙▖▄███████████ +██████████▀ ██████████▀ +████████▀ ████████▀ +██████▀ ▜█████▛▘` type command struct { name string