// kvs-shell/main.go package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" "github.com/chzyer/readline" "github.com/fatih/color" ) // Types matching your backend type StoredValue struct { UUID string `json:"uuid"` Timestamp int64 `json:"timestamp"` Data json.RawMessage `json:"data"` } type Member struct { ID string `json:"id"` Address string `json:"address"` LastSeen int64 `json:"last_seen"` JoinedTimestamp int64 `json:"joined_timestamp"` } type User struct { UUID string `json:"uuid"` NicknameHash string `json:"nickname_hash"` Groups []string `json:"groups"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } type Group struct { UUID string `json:"uuid"` NameHash string `json:"name_hash"` Members []string `json:"members"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } // Client state type KVSClient struct { baseURL string currentToken string currentUser string httpClient *http.Client profiles map[string]Profile // Stored user profiles activeProfile string } type Profile struct { Name string `json:"name"` Token string `json:"token"` UserUUID string `json:"user_uuid"` BaseURL string `json:"base_url"` } // Colors var ( cyan = color.New(color.FgCyan).SprintFunc() green = color.New(color.FgGreen).SprintFunc() yellow = color.New(color.FgYellow).SprintFunc() red = color.New(color.FgRed).SprintFunc() magenta = color.New(color.FgMagenta).SprintFunc() ) func NewKVSClient(baseURL string) *KVSClient { return &KVSClient{ baseURL: baseURL, httpClient: &http.Client{ Timeout: 30 * time.Second, }, profiles: make(map[string]Profile), } } // HTTP request helper func (c *KVSClient) doRequest(method, path string, body interface{}) ([]byte, int, error) { var reqBody io.Reader if body != nil { jsonData, err := json.Marshal(body) if err != nil { return nil, 0, err } reqBody = bytes.NewBuffer(jsonData) } req, err := http.NewRequest(method, c.baseURL+path, reqBody) if err != nil { return nil, 0, err } req.Header.Set("Content-Type", "application/json") if c.currentToken != "" { req.Header.Set("Authorization", "Bearer "+c.currentToken) } resp, err := c.httpClient.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) return respBody, resp.StatusCode, err } // Command handlers func (c *KVSClient) handleConnect(args []string) { if len(args) < 1 { fmt.Println(red("Usage: connect ")) return } c.baseURL = args[0] fmt.Println(green("Connected to:"), c.baseURL) } func (c *KVSClient) handleAuth(args []string) { if len(args) < 1 { fmt.Println(red("Usage: auth ")) return } c.currentToken = args[0] fmt.Println(green("Authentication token set")) } func (c *KVSClient) handleProfile(args []string) { if len(args) == 0 { // List profiles if len(c.profiles) == 0 { fmt.Println(yellow("No profiles configured")) return } fmt.Println(cyan("Available profiles:")) for name, profile := range c.profiles { marker := " " if name == c.activeProfile { marker = "*" } fmt.Printf("%s %s (user: %s, url: %s)\n", marker, name, profile.UserUUID, profile.BaseURL) } return } subCmd := args[0] switch subCmd { case "add": if len(args) < 4 { fmt.Println(red("Usage: profile add [base-url]")) return } baseURL := c.baseURL if len(args) >= 5 { baseURL = args[4] } c.profiles[args[1]] = Profile{ Name: args[1], Token: args[2], UserUUID: args[3], BaseURL: baseURL, } fmt.Println(green("Profile added:"), args[1]) case "use": if len(args) < 2 { fmt.Println(red("Usage: profile use ")) return } profile, ok := c.profiles[args[1]] if !ok { fmt.Println(red("Profile not found:"), args[1]) return } c.currentToken = profile.Token c.currentUser = profile.UserUUID c.baseURL = profile.BaseURL c.activeProfile = args[1] fmt.Println(green("Switched to profile:"), args[1]) case "remove": if len(args) < 2 { fmt.Println(red("Usage: profile remove ")) return } delete(c.profiles, args[1]) if c.activeProfile == args[1] { c.activeProfile = "" } fmt.Println(green("Profile removed:"), args[1]) } } func (c *KVSClient) handleGet(args []string) { if len(args) < 1 { fmt.Println(red("Usage: get ")) return } respBody, status, err := c.doRequest("GET", "/kv/"+args[0], nil) if err != nil { fmt.Println(red("Error:"), err) return } if status == 404 { fmt.Println(yellow("Key not found")) return } if status != 200 { fmt.Println(red("Error:"), string(respBody)) return } var stored StoredValue if err := json.Unmarshal(respBody, &stored); err != nil { fmt.Println(red("Error parsing response:"), err) return } fmt.Println(cyan("UUID: "), stored.UUID) fmt.Println(cyan("Timestamp:"), time.UnixMilli(stored.Timestamp).Format(time.RFC3339)) fmt.Println(cyan("Data:")) var pretty bytes.Buffer json.Indent(&pretty, stored.Data, "", " ") fmt.Println(pretty.String()) } func (c *KVSClient) handlePut(args []string) { if len(args) < 2 { fmt.Println(red("Usage: put ")) return } // Parse JSON data var data json.RawMessage if err := json.Unmarshal([]byte(args[1]), &data); err != nil { printJSONError(args[1], err) return } respBody, status, err := c.doRequest("PUT", "/kv/"+args[0], data) if err != nil { fmt.Println(red("Error:"), err) return } if status != 200 && status != 201 { fmt.Println(red("Error:"), string(respBody)) return } type PutResponse struct { UUID string `json:"uuid"` Timestamp int64 `json:"timestamp"` } var resp PutResponse if err := json.Unmarshal(respBody, &resp); err == nil { fmt.Println(green("Stored successfully")) fmt.Println(cyan("UUID: "), resp.UUID) fmt.Println(cyan("Timestamp:"), time.UnixMilli(resp.Timestamp).Format(time.RFC3339)) } } func (c *KVSClient) handleDelete(args []string) { if len(args) < 1 { fmt.Println(red("Usage: delete ")) return } _, status, err := c.doRequest("DELETE", "/kv/"+args[0], nil) if err != nil { fmt.Println(red("Error:"), err) return } if status == 404 { fmt.Println(yellow("Key not found")) return } if status == 204 { fmt.Println(green("Deleted successfully")) } else { fmt.Println(red("Unexpected status:"), status) } } func (c *KVSClient) handleMembers(args []string) { respBody, status, err := c.doRequest("GET", "/members/", nil) if err != nil { fmt.Println(red("Error:"), err) return } if status != 200 { fmt.Println(red("Error:"), string(respBody)) return } var members []Member if err := json.Unmarshal(respBody, &members); err != nil { fmt.Println(red("Error parsing response:"), err) return } if len(members) == 0 { fmt.Println(yellow("No cluster members")) return } fmt.Println(cyan("Cluster Members:")) for _, m := range members { lastSeen := time.UnixMilli(m.LastSeen).Format(time.RFC3339) fmt.Printf(" • %s (%s) - Last seen: %s\n", m.ID, m.Address, lastSeen) } } func (c *KVSClient) handleHealth(args []string) { respBody, status, err := c.doRequest("GET", "/health", nil) if err != nil { fmt.Println(red("Error:"), err) return } if status != 200 { fmt.Println(red("Service unhealthy")) return } var health map[string]interface{} if err := json.Unmarshal(respBody, &health); err != nil { fmt.Println(red("Error parsing response:"), err) return } fmt.Println(green("Service Status:")) for k, v := range health { fmt.Printf(" %s: %v\n", cyan(k), v) } } func (c *KVSClient) handleUserList(args []string) { // Note: Backend doesn't have a list-all endpoint, this would need to be added fmt.Println(yellow("User listing not implemented in backend API")) } func (c *KVSClient) handleUserGet(args []string) { if len(args) < 1 { fmt.Println(red("Usage: user get ")) return } respBody, status, err := c.doRequest("GET", "/api/users/"+args[0], nil) if err != nil { fmt.Println(red("Error:"), err) return } if status == 404 { fmt.Println(yellow("User not found")) return } if status != 200 { fmt.Println(red("Error:"), string(respBody)) return } var user User if err := json.Unmarshal(respBody, &user); err != nil { fmt.Println(red("Error parsing response:"), err) return } fmt.Println(cyan("User Details:")) fmt.Printf(" UUID: %s\n", user.UUID) fmt.Printf(" Nickname Hash: %s\n", user.NicknameHash) fmt.Printf(" Groups: %v\n", user.Groups) fmt.Printf(" Created: %s\n", time.Unix(user.CreatedAt, 0).Format(time.RFC3339)) fmt.Printf(" Updated: %s\n", time.Unix(user.UpdatedAt, 0).Format(time.RFC3339)) } // printJSONError displays a user-friendly JSON parsing error with helpful hints func printJSONError(input string, err error) { fmt.Println(red("❌ Invalid JSON:"), err) fmt.Println() // Show what was received if len(input) > 100 { fmt.Println(yellow("Received:"), input[:100]+"...") } else { fmt.Println(yellow("Received:"), input) } fmt.Println() // Provide helpful hints based on common errors errMsg := err.Error() if strings.Contains(errMsg, "looking for beginning of") || strings.Contains(errMsg, "invalid character") { fmt.Println(cyan("💡 Tip:"), "JSON with spaces needs quotes:") fmt.Println(green(" ✓ Correct:"), "put key '{\"hello\":\"world\"}'") fmt.Println(green(" ✓ Correct:"), "put key '{\"a\":1,\"b\":2}'") fmt.Println(green(" ✓ Compact:"), "put key {\"a\":1,\"b\":2} (no spaces)") fmt.Println(red(" ✗ Wrong: "), "put key {\"a\": 1, \"b\": 2} (spaces without quotes)") } else if strings.Contains(errMsg, "unexpected end of JSON") { fmt.Println(cyan("💡 Tip:"), "JSON appears incomplete or cut off") fmt.Println(green(" Check:"), "Are all brackets/braces matched? {}, []") fmt.Println(green(" Check:"), "Did spaces split your JSON into multiple arguments?") } else { fmt.Println(cyan("💡 Tip:"), "Wrap JSON in quotes for complex values:") fmt.Println(green(" Single quotes:"), "put key '{\"your\":\"json\"}'") fmt.Println(green(" Double quotes:"), "put key \"{\\\"your\\\":\\\"json\\\"}\"") } } func (c *KVSClient) handleHelp(args []string) { help := ` KVS Interactive Shell - Available Commands: Connection & Authentication: connect - Connect to KVS server auth - Set authentication token profile add [url] - Add user profile profile use - Switch to user profile profile remove - Remove user profile profile - List all profiles Key-Value Operations: get - Retrieve value for key put - Store JSON value at key delete - Delete key Cluster Management: members - List cluster members health - Check service health User Management: user get - Get user details user create - Create new user (admin only) System: help - Show this help exit, quit - Exit shell clear - Clear screen ` fmt.Println(help) } func main() { client := NewKVSClient("http://localhost:8090") // Setup readline rl, err := readline.NewEx(&readline.Config{ Prompt: cyan("kvs> "), HistoryFile: os.Getenv("HOME") + "/.kvs_history", AutoComplete: completer, InterruptPrompt: "^C", EOFPrompt: "exit", }) if err != nil { panic(err) } defer rl.Close() fmt.Println(magenta("KVS Interactive Shell")) fmt.Println("Type 'help' for available commands") fmt.Println() for { line, err := rl.Readline() if err != nil { break } line = strings.TrimSpace(line) if line == "" { continue } parts := parseCommand(line) if len(parts) == 0 { continue } cmd := parts[0] args := parts[1:] switch cmd { case "exit", "quit": fmt.Println("Goodbye!") return case "clear": print("\033[H\033[2J") case "help": client.handleHelp(args) case "connect": client.handleConnect(args) case "auth": client.handleAuth(args) case "profile": client.handleProfile(args) case "get": client.handleGet(args) case "put": client.handlePut(args) case "delete": client.handleDelete(args) case "members": client.handleMembers(args) case "health": client.handleHealth(args) case "user": if len(args) > 0 && args[0] == "get" { client.handleUserGet(args[1:]) } else { client.handleUserList(args) } default: fmt.Println(red("Unknown command:"), cmd) fmt.Println("Type 'help' for available commands") } } } // Auto-completion var completer = readline.NewPrefixCompleter( readline.PcItem("connect"), readline.PcItem("auth"), readline.PcItem("profile", readline.PcItem("add"), readline.PcItem("use"), readline.PcItem("remove"), ), readline.PcItem("get"), readline.PcItem("put"), readline.PcItem("delete"), readline.PcItem("members"), readline.PcItem("health"), readline.PcItem("user", readline.PcItem("get"), readline.PcItem("create"), ), readline.PcItem("help"), readline.PcItem("exit"), readline.PcItem("quit"), readline.PcItem("clear"), ) // Parse command line respecting quotes func parseCommand(line string) []string { var parts []string var current strings.Builder var quoteChar byte // Track which quote char started this argument (' or ") inQuotes := false for i := 0; i < len(line); i++ { ch := line[i] // Check if this is a quote character at the start of a new argument if (ch == '"' || ch == '\'') && current.Len() == 0 && !inQuotes { // Starting a quoted argument - remember the quote type quoteChar = ch inQuotes = true continue // Don't add the quote itself } // Check if this is the closing quote if inQuotes && ch == quoteChar { // End quoted argument inQuotes = false quoteChar = 0 continue // Don't add the quote itself } // Handle spaces/tabs if (ch == ' ' || ch == '\t') && !inQuotes { if current.Len() > 0 { parts = append(parts, current.String()) current.Reset() } continue } // Add all other characters (including quotes inside unquoted args) current.WriteByte(ch) } if current.Len() > 0 { parts = append(parts, current.String()) } return parts }