diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 64b807c..98a51d2 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "strings" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/help" @@ -305,6 +306,7 @@ func handleUserCommand(subCmd string, args []string) { commands.RunUserGet(filtered[0], tokenFlag) case "create": username, password, email, fullName, discordUserID := "", "", "", "", "" + agentID, clawID := "", "" for i := 0; i < len(filtered); i++ { switch filtered[i] { case "--user": @@ -332,14 +334,54 @@ func handleUserCommand(subCmd string, args []string) { i++ discordUserID = filtered[i] } + case "--agent-id": + if i+1 < len(filtered) { + i++ + agentID = filtered[i] + } + case "--claw-identifier": + if i+1 < len(filtered) { + i++ + clawID = filtered[i] + } default: output.Errorf("unknown flag: %s", filtered[i]) } } if username == "" { - output.Error("usage: hf user create --user ") + output.Error("usage: hf user create --user [--agent-id --claw-identifier ]") } - commands.RunUserCreate(username, password, email, fullName, discordUserID, accMgrTokenFlag) + commands.RunUserCreate(username, password, email, fullName, discordUserID, agentID, clawID, accMgrTokenFlag) + case "bind-agent": + // `hf user bind-agent [--agent-id ] [--claw-identifier ]` + // Backfills the agents row for an existing user. Pcexec env + // (AGENT_ID + `openclaw config get plugins.harbor-forge.identifier`) + // covers the flags when run from a pcexec session. + username, agentID, clawID := "", "", "" + for i := 0; i < len(filtered); i++ { + switch filtered[i] { + case "--agent-id": + if i+1 < len(filtered) { + i++ + agentID = filtered[i] + } + case "--claw-identifier": + if i+1 < len(filtered) { + i++ + clawID = filtered[i] + } + default: + if username == "" && !strings.HasPrefix(filtered[i], "--") { + username = filtered[i] + } else { + output.Errorf("unknown flag: %s", filtered[i]) + } + } + } + if username == "" { + output.Error("usage: hf user bind-agent [--agent-id ] [--claw-identifier ]") + } + commands.RunUserBindAgent(username, agentID, clawID, accMgrTokenFlag) case "update": if len(filtered) < 1 { output.Error("usage: hf user update [--email ...] [--full-name ...] [--pass ...] [--active ...]") diff --git a/internal/commands/user.go b/internal/commands/user.go index af2555c..a41ef00 100644 --- a/internal/commands/user.go +++ b/internal/commands/user.go @@ -141,6 +141,60 @@ type userCreatePayload struct { 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) { @@ -174,7 +228,12 @@ func maybeResolveDiscordUserID(explicit string, requireEnv bool) (string, bool, } // RunUserCreate implements `hf user create`. -func RunUserCreate(username, password, email, fullName, discordUserID, accMgrTokenFlag string) { +// +// 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() { @@ -224,6 +283,12 @@ func RunUserCreate(username, password, email, fullName, discordUserID, accMgrTok 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) @@ -447,3 +512,82 @@ func RunUserResetAPIKey(username, tokenFlag, accMgrTokenFlag string) { "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", + ) +} diff --git a/internal/help/surface.go b/internal/help/surface.go index 5e88b0e..4fffdd0 100644 --- a/internal/help/surface.go +++ b/internal/help/surface.go @@ -48,6 +48,7 @@ func CommandSurface() []Group { {Name: "deactivate", Description: "Deactivate a user", Permitted: has(perms, "user.manage")}, {Name: "delete", Description: "Delete a user", Permitted: has(perms, "user.manage")}, {Name: "reset-apikey", Description: "Reset a user's API key", Permitted: true}, + {Name: "bind-agent", Description: "Backfill the agents row for an existing user (acc-mgr token; use --agent-id / --claw-identifier or AGENT_ID env)", Permitted: true}, }, }, {