// kvs-shell/main.go package main import ( "bufio" "bytes" "encoding/json" "fmt" "io" "net/http" "os" "strconv" "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) < 1 { fmt.Println(red("Usage: put ")) fmt.Println("Or: put (then paste JSON on next line)") return } var jsonStr string if len(args) == 1 { // Multiline mode - read from next input fmt.Println(yellow("Enter JSON (Ctrl+D when done):")) scanner := bufio.NewScanner(os.Stdin) var lines []string for scanner.Scan() { lines = append(lines, scanner.Text()) } jsonStr = strings.Join(lines, "\n") } else { // Single line mode jsonStr = strings.Join(args[1:], " ") } // Parse JSON data var data json.RawMessage if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { printJSONError(jsonStr, 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) } } // handleMeta is the dispatcher for "meta" sub-commands func (c *KVSClient) handleMeta(args []string) { if len(args) == 0 { fmt.Println(red("Usage: meta [options]")) return } subCmd := args[0] switch subCmd { case "get": c.handleMetaGet(args[1:]) case "set": c.handleMetaSet(args[1:]) default: fmt.Println(red("Unknown meta command:"), subCmd) fmt.Println("Available commands: get, set") } } // handleMetaGet fetches and displays metadata for a key func (c *KVSClient) handleMetaGet(args []string) { if len(args) < 1 { fmt.Println(red("Usage: meta get ")) return } key := args[0] respBody, status, err := c.doRequest("GET", "/kv/"+key+"/metadata", nil) if err != nil { fmt.Println(red("Error:"), err) return } if status == http.StatusNotFound { fmt.Println(yellow("No metadata found for key:"), key) return } if status != http.StatusOK { fmt.Printf(red("Error getting metadata (status %d):\n"), status) fmt.Println(string(respBody)) return } // Struct to parse the metadata response type MetaResponse struct { OwnerUUID string `json:"owner_uuid"` GroupUUID string `json:"group_uuid"` Permissions int `json:"permissions"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } var meta MetaResponse if err := json.Unmarshal(respBody, &meta); err != nil { fmt.Println(red("Error parsing metadata response:"), err) return } fmt.Println(cyan("Metadata for key:"), key) fmt.Printf(" Owner UUID: %s\n", meta.OwnerUUID) fmt.Printf(" Group UUID: %s\n", meta.GroupUUID) fmt.Printf(" Permissions: %d\n", meta.Permissions) fmt.Printf(" Created At: %s\n", time.Unix(meta.CreatedAt, 0).Format(time.RFC3339)) fmt.Printf(" Updated At: %s\n", time.Unix(meta.UpdatedAt, 0).Format(time.RFC3339)) } // handleMetaSet updates metadata for a key using flags func (c *KVSClient) handleMetaSet(args []string) { if len(args) < 3 { fmt.Println(red("Usage: meta set --owner | --group | --permissions ")) return } key := args[0] // Use a map to build the JSON payload for partial updates payload := make(map[string]interface{}) // Parse command-line flags for i := 1; i < len(args); i++ { switch args[i] { case "--owner": if i+1 < len(args) { payload["owner_uuid"] = args[i+1] i++ // Skip the value } case "--group": if i+1 < len(args) { payload["group_uuid"] = args[i+1] i++ // Skip the value } case "--permissions": if i+1 < len(args) { perms, err := strconv.Atoi(args[i+1]) if err != nil { fmt.Println(red("Invalid permissions value:"), args[i+1]) return } payload["permissions"] = perms i++ // Skip the value } } } if len(payload) == 0 { fmt.Println(red("No valid metadata fields provided to set.")) return } respBody, status, err := c.doRequest("PUT", "/kv/"+key+"/metadata", payload) if err != nil { fmt.Println(red("Error:"), err) return } if status != http.StatusOK { fmt.Printf(red("Error setting metadata (status %d):\n"), status) fmt.Println(string(respBody)) return } fmt.Println(green("Metadata updated successfully for key:"), key) // The response body contains the full updated metadata, so we can print it for confirmation var pretty bytes.Buffer json.Indent(&pretty, respBody, "", " ") fmt.Println(pretty.String()) } 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)) } func (c *KVSClient) handleUserCreate(args []string) { if len(args) < 1 { fmt.Println(red("Usage: user create ")) return } // The request body requires a JSON object like {"nickname": "..."} requestPayload := map[string]string{ "nickname": args[0], } respBody, status, err := c.doRequest("POST", "/api/users", requestPayload) if err != nil { fmt.Println(red("Error:"), err) return } // The backend returns 200 OK on success if status != http.StatusOK { fmt.Printf(red("Error creating user (status %d):\n"), status) fmt.Println(string(respBody)) return } // Parse the successful response to get the new user's UUID var response struct { UUID string `json:"uuid"` } if err := json.Unmarshal(respBody, &response); err != nil { fmt.Println(red("Error parsing successful response:"), err) return } fmt.Println(green("User created successfully")) fmt.Println(cyan("UUID:"), response.UUID) } // 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 Resource Metadata: meta get - Get metadata (owner, group) for a key meta set [flags] - Set metadata for a key Flags: --owner , --group , --permissions 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 "meta": client.handleMeta(args) case "members": client.handleMembers(args) case "health": client.handleHealth(args) case "user": if len(args) > 0 { switch args[0] { case "get": client.handleUserGet(args[1:]) case "create": client.handleUserCreate(args[1:]) default: fmt.Println(red("Unknown user command:"), args[0]) fmt.Println("Type 'help' for available commands") } } else { // This was the original behavior, which notes that listing users is not implemented. 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("meta", readline.PcItem("get"), readline.PcItem("set", readline.PcItem("--owner"), readline.PcItem("--group"), readline.PcItem("--permissions"), ), ), 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 rune = 0 // Use a rune to store the active quote character (' or ") for _, ch := range line { switch { // If we see a quote character case ch == '\'' || ch == '"': if quoteChar == 0 { // Not currently inside a quoted string, so start one quoteChar = ch } else if quoteChar == ch { // Inside a quoted string and found the matching quote, so end it quoteChar = 0 } else { // Inside a quoted string but found the *other* type of quote, treat it as a literal character current.WriteRune(ch) } // If we see a space or tab and are NOT inside a quoted string case (ch == ' ' || ch == '\t') && quoteChar == 0: if current.Len() > 0 { parts = append(parts, current.String()) current.Reset() } // Any other character default: current.WriteRune(ch) } } // Add the last part if it exists if current.Len() > 0 { parts = append(parts, current.String()) } return parts }