From 46d928782b93a6ee763bdea0ed018b9a1315248f Mon Sep 17 00:00:00 2001 From: hanghang zhang Date: Fri, 22 May 2026 20:01:37 +0100 Subject: [PATCH] feat(cli): hf user create --agent-id/--claw-identifier + hf user bind-agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements NEXT_WAVE_DEV_DIRECTION.md §7.3 (was 4 lines of spec, never shipped). Backend's POST /users has accepted agent_id+claw_identifier since BE-CAL-003 but the cli never sent them, so historically every agent user (zhi/lyn/mirror/sherlock/orion/nav on prod today) was created with only the user row — agents table left empty, and all downstream calendar/heartbeat/schedule-type flows that go through _require_agent() returned 404. ## hf user create — new flags --agent-id --claw-identifier Both required together (matches backend invariant). Either can come from pcexec env: AGENT_ID env for agent-id, `openclaw config get plugins.harbor-forge.identifier` for claw-identifier. Partial pair is treated as "neither" so plain user creation (no binding intended) still works without a 400. ## hf user bind-agent — NEW subcommand Backfills agents row for an existing user. PATCH /users/{username}/bind-agent. Same accept --agent-id/--claw-identifier flags + pcexec env fallback. requireBoth=true here — fail loudly if the pair can't be resolved since the whole command is the binding. ## Wiring - userCreatePayload gains AgentID + ClawIdentifier omitempty fields - new userBindAgentPayload struct (both required) - resolveAgentBinding helper shared by both commands - main.go user create case parses --agent-id/--claw-identifier; new user bind-agent case parses positional username + the same flags - surface.go lists bind-agent so `hf user` and `hf --help` show it Build: clean. Smoke-tested both subcommand usage strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/hf/main.go | 46 +++++++++++- internal/commands/user.go | 146 +++++++++++++++++++++++++++++++++++++- internal/help/surface.go | 1 + 3 files changed, 190 insertions(+), 3 deletions(-) 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}, }, }, {