package commands import ( "bytes" "encoding/json" "fmt" "os" "os/exec" "strings" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr" ) // userResponse matches the backend UserResponse schema. type userResponse struct { ID int `json:"id"` Username string `json:"username"` Email string `json:"email"` FullName *string `json:"full_name"` IsActive bool `json:"is_active"` IsAdmin bool `json:"is_admin"` RoleID *int `json:"role_id"` RoleName *string `json:"role_name"` DiscordUserID *string `json:"discord_user_id"` CreatedAt string `json:"created_at"` } // RunUserList implements `hf user list`. func RunUserList(tokenFlag string) { token := ResolveToken(tokenFlag) cfg, err := config.Load() if err != nil { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) data, err := c.Get("/users") if err != nil { output.Errorf("failed to list users: %v", err) } if output.JSONMode { var raw json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { output.Errorf("invalid JSON response: %v", err) } output.PrintJSON(raw) return } var users []userResponse if err := json.Unmarshal(data, &users); err != nil { output.Errorf("cannot parse user list: %v", err) } headers := []string{"USERNAME", "EMAIL", "FULL NAME", "ROLE", "ACTIVE", "ADMIN"} var rows [][]string for _, u := range users { fullName := "" if u.FullName != nil { fullName = *u.FullName } roleName := "" if u.RoleName != nil { roleName = *u.RoleName } active := "yes" if !u.IsActive { active = "no" } admin := "" if u.IsAdmin { admin = "yes" } rows = append(rows, []string{u.Username, u.Email, fullName, roleName, active, admin}) } output.PrintTable(headers, rows) } // RunUserGet implements `hf user get `. func RunUserGet(username, tokenFlag string) { token := ResolveToken(tokenFlag) cfg, err := config.Load() if err != nil { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) data, err := c.Get("/users/" + username) if err != nil { output.Errorf("failed to get user: %v", err) } if output.JSONMode { var raw json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { output.Errorf("invalid JSON response: %v", err) } output.PrintJSON(raw) return } var u userResponse if err := json.Unmarshal(data, &u); err != nil { output.Errorf("cannot parse user: %v", err) } fullName := "" if u.FullName != nil { fullName = *u.FullName } roleName := "" if u.RoleName != nil { roleName = *u.RoleName } active := "yes" if !u.IsActive { active = "no" } admin := "" if u.IsAdmin { admin = "yes" } output.PrintKeyValue( "username", u.Username, "email", u.Email, "full-name", fullName, "role", roleName, "active", active, "admin", admin, "created", u.CreatedAt, ) } // userCreatePayload is the JSON body for POST /users. type userCreatePayload struct { Username string `json:"username"` Email string `json:"email"` FullName *string `json:"full_name,omitempty"` Password *string `json:"password,omitempty"` DiscordUserID *string `json:"discord_user_id,omitempty"` // Agent binding — both fields go together or both stay nil. // Backend rejects (400) if only one is set. AgentID *string `json:"agent_id,omitempty"` ClawIdentifier *string `json:"claw_identifier,omitempty"` } // userBindAgentPayload is the JSON body for PATCH /users/{id}/bind-agent. type userBindAgentPayload struct { AgentID string `json:"agent_id"` ClawIdentifier string `json:"claw_identifier"` } // resolveAgentBinding picks the final (agent_id, claw_identifier) pair // to send to the backend. // // Explicit flags win. If either is empty, fall back to the pcexec env // (AGENT_ID) and to `openclaw config get plugins.harbor-forge.identifier` // for the claw — same convention as the openclaw plugin's heartbeat. // // Honours the backend's "both or neither" invariant: if only one side // can be resolved, returns (nil, nil) — caller's intent of creating a // non-agent user is preserved instead of producing a 400. When the // caller actually NEEDS the binding (e.g. `hf user bind-agent` is the // whole point of the command), set `requireBoth=true` to fail loudly // instead. func resolveAgentBinding(explicitAgentID, explicitClawID string, requireBoth bool) (*string, *string) { agentID := strings.TrimSpace(explicitAgentID) clawID := strings.TrimSpace(explicitClawID) if agentID == "" { agentID = strings.TrimSpace(os.Getenv("AGENT_ID")) } if clawID == "" { if v, err := exec.Command("openclaw", "config", "get", "plugins.harbor-forge.identifier").Output(); err == nil { clawID = strings.TrimSpace(string(v)) } } if agentID == "" && clawID == "" { if requireBoth { output.Error("--agent-id and --claw-identifier required (AGENT_ID env and `openclaw config get plugins.harbor-forge.identifier` both empty)") } return nil, nil } if agentID == "" || clawID == "" { if requireBoth { output.Errorf( "could not resolve agent binding pair: agent_id=%q claw_identifier=%q (need both)", agentID, clawID, ) } return nil, nil } return &agentID, &clawID } func maybeResolveDiscordUserID(explicit string, requireEnv bool) (string, bool, error) { if strings.TrimSpace(explicit) != "" { return strings.TrimSpace(explicit), true, nil } agentID := strings.TrimSpace(os.Getenv("AGENT_ID")) agentVerify := strings.TrimSpace(os.Getenv("AGENT_VERIFY")) if agentID == "" || agentVerify == "" { if requireEnv { return "", false, fmt.Errorf("discord id not provided and AGENT_ID/AGENT_VERIFY are missing") } return "", false, nil } cmd := exec.Command("ego-mgr", "get", "discord-id") out, err := cmd.Output() if err != nil { if requireEnv { return "", false, fmt.Errorf("failed to resolve discord id from ego-mgr: %w", err) } return "", false, nil } value := strings.TrimSpace(string(out)) if value == "" { if requireEnv { return "", false, fmt.Errorf("ego-mgr returned empty discord id") } return "", false, nil } return value, true, nil } // RunUserCreate implements `hf user create`. // // Agent binding: if `agentIDFlag` + `clawIDFlag` are both set (or both // resolvable from env), the backend creates the matching agents row in // the same transaction. Partial pair → treated as "neither" so callers // who didn't want a binding still get a normal user. func RunUserCreate(username, password, email, fullName, discordUserID, agentIDFlag, clawIDFlag, accMgrTokenFlag string) { // Resolve account-manager token var accMgrToken string if mode.IsPaddedCell() { if accMgrTokenFlag != "" { output.Error("padded-cell installed, --acc-mgr-token flag disabled, use command directly") } tok, err := passmgr.GetAccountManagerToken() if err != nil { output.Error("--acc-mgr-token required or execute with pcexec") } accMgrToken = tok } else { if accMgrTokenFlag == "" { output.Error("--acc-mgr-token required or execute with pcexec") } accMgrToken = accMgrTokenFlag } // Resolve password if password == "" && mode.IsPaddedCell() { pw, err := passmgr.GeneratePassword("hf", username) if err != nil { output.Error("--pass required or execute with pcexec") } password = pw } if password == "" && !mode.IsPaddedCell() { output.Error("--pass required or execute with pcexec") } // Resolve email (default to username@harborforge.local if not provided) if email == "" { email = username + "@harborforge.local" } payload := userCreatePayload{ Username: username, Email: email, Password: &password, } if resolvedDiscordID, ok, err := maybeResolveDiscordUserID(discordUserID, false); err != nil { output.Errorf("failed to resolve discord user id: %v", err) } else if ok { payload.DiscordUserID = &resolvedDiscordID } if fullName != "" { payload.FullName = &fullName } // Agent binding — resolve from flags + pcexec env fallback. if agentID, clawID := resolveAgentBinding(agentIDFlag, clawIDFlag, false); agentID != nil && clawID != nil { payload.AgentID = agentID payload.ClawIdentifier = clawID } body, err := json.Marshal(payload) if err != nil { output.Errorf("cannot marshal payload: %v", err) } cfg, err := config.Load() if err != nil { output.Errorf("config error: %v", err) } c := client.NewWithAPIKey(cfg.BaseURL, accMgrToken) data, err := c.Post("/users", bytes.NewReader(body)) if err != nil { output.Errorf("failed to create user: %v", err) } if output.JSONMode { var raw json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { output.Errorf("invalid JSON response: %v", err) } output.PrintJSON(raw) return } var u userResponse if err := json.Unmarshal(data, &u); err != nil { output.Errorf("cannot parse response: %v", err) } fmt.Printf("user created: %s\n", u.Username) } // RunUserUpdateDiscordID updates a user's discord_user_id field. func RunUserUpdateDiscordID(username, discordUserID, tokenFlag string) { token := ResolveToken(tokenFlag) resolvedDiscordID, _, err := maybeResolveDiscordUserID(discordUserID, true) if err != nil { output.Errorf("failed to resolve discord user id: %v", err) } body, err := json.Marshal(map[string]interface{}{"discord_user_id": resolvedDiscordID}) if err != nil { output.Errorf("cannot marshal payload: %v", err) } cfg, err := config.Load() if err != nil { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) if _, err := c.Patch("/users/"+username, bytes.NewReader(body)); err != nil { output.Errorf("failed to update discord id: %v", err) } fmt.Printf("discord id updated: %s\n", username) } // RunUserUpdate implements `hf user update `. func RunUserUpdate(username string, args []string, tokenFlag string) { token := ResolveToken(tokenFlag) payload := make(map[string]interface{}) for i := 0; i < len(args); i++ { switch args[i] { case "--email": if i+1 >= len(args) { output.Error("--email requires a value") } i++ payload["email"] = args[i] case "--full-name": if i+1 >= len(args) { output.Error("--full-name requires a value") } i++ payload["full_name"] = args[i] case "--pass": if i+1 >= len(args) { output.Error("--pass requires a value") } i++ payload["password"] = args[i] case "--active": if i+1 >= len(args) { output.Error("--active requires true or false") } i++ payload["is_active"] = strings.ToLower(args[i]) == "true" default: output.Errorf("unknown flag: %s", args[i]) } } if len(payload) == 0 { output.Error("nothing to update — provide at least one flag") } body, err := json.Marshal(payload) if err != nil { output.Errorf("cannot marshal payload: %v", err) } cfg, err := config.Load() if err != nil { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) data, err := c.Patch("/users/"+username, bytes.NewReader(body)) if err != nil { output.Errorf("failed to update user: %v", err) } if output.JSONMode { var raw json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { output.Errorf("invalid JSON response: %v", err) } output.PrintJSON(raw) return } fmt.Printf("user updated: %s\n", username) } // RunUserActivate implements `hf user activate `. func RunUserActivate(username, tokenFlag string) { token := ResolveToken(tokenFlag) cfg, err := config.Load() if err != nil { output.Errorf("config error: %v", err) } body, _ := json.Marshal(map[string]interface{}{"is_active": true}) c := client.New(cfg.BaseURL, token) _, err = c.Patch("/users/"+username, bytes.NewReader(body)) if err != nil { output.Errorf("failed to activate user: %v", err) } fmt.Printf("user activated: %s\n", username) } // RunUserDeactivate implements `hf user deactivate `. func RunUserDeactivate(username, tokenFlag string) { token := ResolveToken(tokenFlag) cfg, err := config.Load() if err != nil { output.Errorf("config error: %v", err) } body, _ := json.Marshal(map[string]interface{}{"is_active": false}) c := client.New(cfg.BaseURL, token) _, err = c.Patch("/users/"+username, bytes.NewReader(body)) if err != nil { output.Errorf("failed to deactivate user: %v", err) } fmt.Printf("user deactivated: %s\n", username) } // RunUserDelete implements `hf user delete `. func RunUserDelete(username, tokenFlag string) { token := ResolveToken(tokenFlag) cfg, err := config.Load() if err != nil { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) _, err = c.Delete("/users/" + username) if err != nil { output.Errorf("failed to delete user: %v", err) } fmt.Printf("user deleted: %s\n", username) } // resetAPIKeyResponse matches the backend reset-apikey response. type resetAPIKeyResponse struct { UserID int `json:"user_id"` Username string `json:"username"` APIKey string `json:"api_key"` Message string `json:"message"` } // RunUserResetAPIKey implements `hf user reset-apikey `. func RunUserResetAPIKey(username, tokenFlag, accMgrTokenFlag string) { cfg, err := config.Load() if err != nil { output.Errorf("config error: %v", err) } // Try acc-mgr-token first (allows provisioning without existing user token) var c *client.Client if accMgrTokenFlag != "" { c = client.NewWithAPIKey(cfg.BaseURL, accMgrTokenFlag) } else if mode.IsPaddedCell() { if tok, err := passmgr.GetAccountManagerToken(); err == nil && tok != "" { c = client.NewWithAPIKey(cfg.BaseURL, tok) } else { token := ResolveToken(tokenFlag) c = client.New(cfg.BaseURL, token) } } else { token := ResolveToken(tokenFlag) c = client.New(cfg.BaseURL, token) } data, err := c.Post("/users/"+username+"/reset-apikey", nil) if err != nil { output.Errorf("failed to reset API key: %v", err) } if output.JSONMode { var raw json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { output.Errorf("invalid JSON response: %v", err) } output.PrintJSON(raw) return } var r resetAPIKeyResponse if err := json.Unmarshal(data, &r); err != nil { fmt.Printf("API key reset for: %s\n", username) return } output.PrintKeyValue( "username", r.Username, "api-key", r.APIKey, "message", r.Message, ) } // RunUserBindAgent implements `hf user bind-agent `. // // Backfills the agents row for an existing user that was created via // the old `hf user create` (which did not accept --agent-id / // --claw-identifier). Hits PATCH /users/{username}/bind-agent. // // Uses an account-manager token like `hf user create` does — same // permission surface (`account.create`). func RunUserBindAgent(username, agentIDFlag, clawIDFlag, accMgrTokenFlag string) { if username == "" { output.Error("usage: hf user bind-agent [--agent-id ] [--claw-identifier ]") } // Resolve account-manager token (same as RunUserCreate). var accMgrToken string if mode.IsPaddedCell() { if accMgrTokenFlag != "" { output.Error("padded-cell installed, --acc-mgr-token flag disabled, use command directly") } tok, err := passmgr.GetAccountManagerToken() if err != nil { output.Error("--acc-mgr-token required or execute with pcexec") } accMgrToken = tok } else { if accMgrTokenFlag == "" { output.Error("--acc-mgr-token required or execute with pcexec") } accMgrToken = accMgrTokenFlag } // Resolve agent binding — REQUIRED for this command (errors out on partial). agentID, clawID := resolveAgentBinding(agentIDFlag, clawIDFlag, true) if agentID == nil || clawID == nil { output.Error("--agent-id and --claw-identifier could not be resolved (bug — requireBoth should have errored already)") } payload := userBindAgentPayload{ AgentID: *agentID, ClawIdentifier: *clawID, } body, err := json.Marshal(payload) if err != nil { output.Errorf("cannot marshal payload: %v", err) } cfg, err := config.Load() if err != nil { output.Errorf("config error: %v", err) } c := client.NewWithAPIKey(cfg.BaseURL, accMgrToken) data, err := c.Patch("/users/"+username+"/bind-agent", bytes.NewReader(body)) if err != nil { output.Errorf("failed to bind agent: %v", err) } if output.JSONMode { var raw json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { output.Errorf("invalid JSON response: %v", err) } output.PrintJSON(raw) return } var u userResponse if err := json.Unmarshal(data, &u); err != nil { fmt.Printf("agent bound to %s (agent_id=%s, claw=%s)\n", username, *agentID, *clawID) return } output.PrintKeyValue( "username", u.Username, "user_id", fmt.Sprint(u.ID), "agent_id", *agentID, "claw_identifier", *clawID, "message", "agent binding written", ) }