Skip to content

Commit 34e12a5

Browse files
committed
A more streamlined, automated install process and ability to set per-folder default users
1 parent 658088e commit 34e12a5

14 files changed

Lines changed: 888 additions & 71 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ Thumbs.db
1515
.vscode/
1616
*.swp
1717
*.swo
18+
.five9-cli/

README.md

Lines changed: 26 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,20 @@ five9 campaigns list --output csv > campaigns.csv
8686
- **HTTP Basic auth** over SOAP — `five9 login` prompts for username and password
8787
- **OS keyring storage** — credentials stored securely in macOS Keychain / Linux keyring / Windows Credential Manager
8888
- **Multi-user** — log in with multiple Five9 accounts and switch between them
89+
- **Per-folder defaults** — associate a user with a working directory for multi-tenant workflows
8990
- **Environment variables** — set `FIVE9_USERNAME` and `FIVE9_PASSWORD` to skip the keyring
9091

9192
```bash
92-
five9 login # Login (prompts for credentials)
93-
five9 logout # Remove stored credentials
94-
five9 auth status # Show current user
95-
five9 auth list # List all authenticated users
96-
five9 auth switch <username> # Switch default user
93+
five9 login # Login (prompts for credentials)
94+
five9 logout # Remove stored credentials
95+
five9 auth status # Show current user and folder default
96+
five9 auth list # List all authenticated users
97+
five9 auth switch <username> # Switch default user
98+
five9 auth set-folder-default <username> # Set default user for current folder
99+
five9 auth clear-folder-default # Remove folder default
97100
```
98101

99-
Credential resolution order: `--username`/`--password` flags > `FIVE9_USERNAME`/`FIVE9_PASSWORD` env vars > OS keyring.
102+
Credential resolution order: `--username`/`--password` flags > `FIVE9_USERNAME`/`FIVE9_PASSWORD` env vars > `--user` flag > `FIVE9_USER` env var > folder default (`.five9-cli/config.json`) > global default > OS keyring.
100103

101104
## Output Formats
102105

@@ -125,63 +128,50 @@ Control output with `--output`:
125128

126129
The Five9 CLI includes a [skill](https://agentskills.io) that enables AI coding agents to query and manage your Five9 environment. It works with any agent that supports the skills standard — [Claude Code](https://claude.com/claude-code), [OpenAI Codex](https://openai.com/codex/), [Cursor](https://cursor.com), and others.
127130

128-
### Setup
131+
### Automatic Setup
129132

130-
1. Install the Five9 CLI (see [Install](#install))
131-
2. Login: `five9 login`
132-
3. Download the skill into the correct folder for your coding agent:
133+
The installer and `five9 post-install` command will offer to install the skill for detected agents (Claude Code, Claude Cowork, OpenAI Codex, Cursor) via an interactive menu. Skills are also kept up to date when you run `five9 update`.
134+
135+
### Manual Setup
136+
137+
If you prefer to install manually, download the skill into the correct folder for your coding agent:
133138

134139
#### Claude Code
135140

136-
**macOS / Linux:**
137141
```bash
138142
mkdir -p ~/.claude/skills/five9-cli
139143
curl -fsSL https://raw.githubusercontent.com/Cloverhound/five9-cli/main/skill/SKILL.md \
140144
-o ~/.claude/skills/five9-cli/SKILL.md
141145
```
142146

143-
**Windows (PowerShell):**
144-
```powershell
145-
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.claude\skills\five9-cli"
146-
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Cloverhound/five9-cli/main/skill/SKILL.md" `
147-
-OutFile "$env:USERPROFILE\.claude\skills\five9-cli\SKILL.md"
148-
```
149-
150147
#### OpenAI Codex
151148

152-
**macOS / Linux:**
153149
```bash
154-
mkdir -p ~/.agents/skills/five9-cli
150+
mkdir -p ~/.codex/skills/five9-cli
155151
curl -fsSL https://raw.githubusercontent.com/Cloverhound/five9-cli/main/skill/SKILL.md \
156-
-o ~/.agents/skills/five9-cli/SKILL.md
157-
```
158-
159-
**Windows (PowerShell):**
160-
```powershell
161-
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.agents\skills\five9-cli"
162-
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Cloverhound/five9-cli/main/skill/SKILL.md" `
163-
-OutFile "$env:USERPROFILE\.agents\skills\five9-cli\SKILL.md"
152+
-o ~/.codex/skills/five9-cli/SKILL.md
164153
```
165154

166155
#### Cursor
167156

168-
**macOS / Linux:**
169157
```bash
170158
mkdir -p ~/.cursor/skills/five9-cli
171159
curl -fsSL https://raw.githubusercontent.com/Cloverhound/five9-cli/main/skill/SKILL.md \
172160
-o ~/.cursor/skills/five9-cli/SKILL.md
173161
```
174162

175-
**Windows (PowerShell):**
176-
```powershell
177-
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.cursor\skills\five9-cli"
178-
Invoke-WebRequest -Uri "https://raw.githubusercontent.com/Cloverhound/five9-cli/main/skill/SKILL.md" `
179-
-OutFile "$env:USERPROFILE\.cursor\skills\five9-cli\SKILL.md"
163+
#### Claude Cowork
164+
165+
Cowork requires uploading a ZIP file through the Claude Desktop app. Run `five9 post-install` to generate the ZIP, or create it manually:
166+
167+
```bash
168+
mkdir -p /tmp/five9-cli && cp ~/.claude/skills/five9-cli/SKILL.md /tmp/five9-cli/
169+
cd /tmp && zip -r ~/Downloads/five9-cli-skill.zip five9-cli/
180170
```
181171

182-
> These commands install the skill globally (user-level). You can also install per-project by placing the `five9-cli/SKILL.md` folder inside your project's `.claude/skills/`, `.agents/skills/`, or `.cursor/skills/` directory instead.
172+
Then upload at: Claude Desktop → Cowork tab → Customize → Skills → + → Upload a skill.
183173

184-
4. If the `five9` binary is not in your `$PATH`, ask your coding agent to update the binary path in the skill file.
174+
> These commands install the skill globally (user-level). You can also install per-project by placing the `five9-cli/SKILL.md` folder inside your project's `.claude/skills/`, `.codex/skills/`, or `.cursor/skills/` directory instead.
185175
186176
### Example Prompts
187177

cmd/auth.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package cmd
22

33
import (
44
"fmt"
5+
"os"
6+
"path/filepath"
57

68
"github.com/Cloverhound/five9-cli/internal/appconfig"
79
"github.com/Cloverhound/five9-cli/internal/auth"
10+
"github.com/Cloverhound/five9-cli/internal/localconfig"
811
"github.com/spf13/cobra"
912
)
1013

@@ -34,11 +37,145 @@ var authStatusCmd = &cobra.Command{
3437
}
3538

3639
fmt.Printf("Logged in as: %s\n", cfg.DefaultUser)
40+
41+
// Show folder default if set
42+
if cwd, err := os.Getwd(); err == nil {
43+
if lcfg, err := localconfig.Load(cwd); err == nil && lcfg != nil && lcfg.User != "" {
44+
fmt.Printf("Folder default: %s (for this directory)\n", lcfg.User)
45+
}
46+
}
47+
48+
return nil
49+
},
50+
}
51+
52+
var authListCmd = &cobra.Command{
53+
Use: "list",
54+
Short: "List all authenticated users",
55+
RunE: func(cmd *cobra.Command, args []string) error {
56+
cfg, err := appconfig.Load()
57+
if err != nil {
58+
return fmt.Errorf("loading config: %w", err)
59+
}
60+
61+
if len(cfg.KnownUsers) == 0 {
62+
fmt.Println("No authenticated users. Run: five9 login")
63+
return nil
64+
}
65+
66+
for _, username := range cfg.KnownUsers {
67+
status := "ok"
68+
if _, err := auth.LoadCredentials(username); err != nil {
69+
status = "no credentials"
70+
}
71+
72+
marker := ""
73+
if username == cfg.DefaultUser {
74+
marker = " (default)"
75+
}
76+
77+
fmt.Printf(" %s [%s]%s\n", username, status, marker)
78+
}
79+
80+
return nil
81+
},
82+
}
83+
84+
var authSwitchCmd = &cobra.Command{
85+
Use: "switch <username>",
86+
Short: "Switch the default user",
87+
Args: cobra.ExactArgs(1),
88+
RunE: func(cmd *cobra.Command, args []string) error {
89+
username := args[0]
90+
91+
cfg, err := appconfig.Load()
92+
if err != nil {
93+
return fmt.Errorf("loading config: %w", err)
94+
}
95+
96+
// Check user is known
97+
found := false
98+
for _, u := range cfg.KnownUsers {
99+
if u == username {
100+
found = true
101+
break
102+
}
103+
}
104+
if !found {
105+
return fmt.Errorf("user %s not found — run: five9 login", username)
106+
}
107+
108+
cfg.SetDefaultUser(username)
109+
if err := cfg.Save(); err != nil {
110+
return fmt.Errorf("saving config: %w", err)
111+
}
112+
113+
fmt.Printf("Default user set to %s\n", username)
114+
return nil
115+
},
116+
}
117+
118+
var authSetFolderDefaultCmd = &cobra.Command{
119+
Use: "set-folder-default <username>",
120+
Short: "Set the default user for the current folder",
121+
Long: "Associates a Five9 user with the current working directory. When running commands from this folder, this user's credentials will be used automatically.",
122+
Args: cobra.ExactArgs(1),
123+
RunE: func(cmd *cobra.Command, args []string) error {
124+
username := args[0]
125+
126+
// Verify credentials exist in keyring
127+
if _, err := auth.LoadCredentials(username); err != nil {
128+
return fmt.Errorf("no credentials found for %s — run 'five9 login' first", username)
129+
}
130+
131+
cwd, err := os.Getwd()
132+
if err != nil {
133+
return fmt.Errorf("getting current directory: %w", err)
134+
}
135+
136+
if err := localconfig.Save(cwd, username); err != nil {
137+
return fmt.Errorf("saving folder default: %w", err)
138+
}
139+
140+
fmt.Printf("Set folder default to %s for %s\n", username, cwd)
141+
return nil
142+
},
143+
}
144+
145+
var authClearFolderDefaultCmd = &cobra.Command{
146+
Use: "clear-folder-default",
147+
Short: "Remove the folder default user for the current directory",
148+
RunE: func(cmd *cobra.Command, args []string) error {
149+
cwd, err := os.Getwd()
150+
if err != nil {
151+
return fmt.Errorf("getting current directory: %w", err)
152+
}
153+
154+
cfg, err := localconfig.Load(cwd)
155+
if err != nil || cfg == nil {
156+
fmt.Println("No folder default set for this directory.")
157+
return nil
158+
}
159+
160+
// Remove the config file and directory if empty
161+
configPath := filepath.Join(cwd, ".five9-cli", "config.json")
162+
if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
163+
return fmt.Errorf("removing folder config: %w", err)
164+
}
165+
166+
configDir := filepath.Join(cwd, ".five9-cli")
167+
os.Remove(configDir) // ignore error — dir may not be empty
168+
169+
fmt.Printf("Cleared folder default (was %s)\n", cfg.User)
37170
return nil
38171
},
39172
}
40173

41174
func init() {
42175
authCmd.AddCommand(authStatusCmd)
176+
authCmd.AddCommand(authListCmd)
177+
authCmd.AddCommand(authSwitchCmd)
178+
authCmd.AddCommand(authSetFolderDefaultCmd)
179+
authCmd.AddCommand(authClearFolderDefaultCmd)
43180
rootCmd.AddCommand(authCmd)
44181
}

cmd/login.go

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
package cmd
22

33
import (
4+
"bufio"
45
"fmt"
56
"os"
7+
"path/filepath"
8+
"strings"
69

710
"github.com/Cloverhound/five9-cli/internal/appconfig"
811
"github.com/Cloverhound/five9-cli/internal/auth"
912
"github.com/Cloverhound/five9-cli/internal/config"
13+
"github.com/Cloverhound/five9-cli/internal/localconfig"
1014
"github.com/Cloverhound/five9-cli/internal/soap"
15+
"github.com/charmbracelet/huh"
1116
"github.com/spf13/cobra"
1217
"golang.org/x/term"
1318
)
@@ -19,8 +24,9 @@ var loginCmd = &cobra.Command{
1924
RunE: func(cmd *cobra.Command, args []string) error {
2025
// Prompt for username
2126
fmt.Print("Five9 Username: ")
22-
var username string
23-
fmt.Scanln(&username)
27+
reader := bufio.NewReader(os.Stdin)
28+
username, _ := reader.ReadString('\n')
29+
username = strings.TrimSpace(username)
2430
if username == "" {
2531
return fmt.Errorf("username is required")
2632
}
@@ -55,21 +61,69 @@ var loginCmd = &cobra.Command{
5561
return fmt.Errorf("saving credentials: %w", err)
5662
}
5763

58-
// Update config
64+
// Update global config
5965
cfg, err := appconfig.Load()
6066
if err != nil {
6167
return fmt.Errorf("loading config: %w", err)
6268
}
6369
cfg.SetDefaultUser(username)
70+
cfg.AddUser(username)
6471
if err := cfg.Save(); err != nil {
6572
return fmt.Errorf("saving config: %w", err)
6673
}
6774

68-
fmt.Printf("Logged in as %s\n", username)
75+
fmt.Printf("Logged in as %s\n\n", username)
76+
77+
// Offer to associate this user with the current folder
78+
cwd, err := os.Getwd()
79+
if err == nil {
80+
if err := promptFolderAssociation(username, cwd); err != nil {
81+
return err
82+
}
83+
}
84+
6985
return nil
7086
},
7187
}
7288

89+
func promptFolderAssociation(username, dir string) error {
90+
folderName := filepath.Base(dir)
91+
92+
var choice string
93+
form := huh.NewForm(
94+
huh.NewGroup(
95+
huh.NewSelect[string]().
96+
Title("Associate this user with the current folder?").
97+
Description(
98+
fmt.Sprintf(
99+
"This saves \"%s\" as the default user for %s/.\n"+
100+
"Useful when different folders connect to different Five9 tenants,\n"+
101+
"so the right credentials are used automatically.",
102+
username, folderName,
103+
),
104+
).
105+
Options(
106+
huh.NewOption(fmt.Sprintf("Yes — use \"%s\" whenever I'm in %s/", username, folderName), "yes"),
107+
huh.NewOption("No — don't set folder default", "no"),
108+
).
109+
Value(&choice),
110+
),
111+
)
112+
113+
if err := form.Run(); err != nil {
114+
return nil // user cancelled, not an error
115+
}
116+
117+
if choice == "yes" {
118+
if err := localconfig.Save(dir, username); err != nil {
119+
return fmt.Errorf("saving local config: %w", err)
120+
}
121+
fmt.Printf("Saved to %s/.five9-cli/config.json\n", folderName)
122+
}
123+
124+
return nil
125+
}
126+
73127
func init() {
74128
rootCmd.AddCommand(loginCmd)
75129
}

cmd/logout.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,9 @@ var logoutCmd = &cobra.Command{
3434
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not remove credentials for %s: %v\n", username, err)
3535
}
3636

37-
if cfg.DefaultUser == username {
38-
cfg.DefaultUser = ""
39-
if err := cfg.Save(); err != nil {
40-
return fmt.Errorf("saving config: %w", err)
41-
}
37+
cfg.RemoveUser(username)
38+
if err := cfg.Save(); err != nil {
39+
return fmt.Errorf("saving config: %w", err)
4240
}
4341

4442
fmt.Printf("Logged out %s\n", username)

0 commit comments

Comments
 (0)