feat(cli): hf user create --agent-id/--claw-identifier + hf user bind-agent

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 <id>
  --claw-identifier <id>

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 <username> — 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) <noreply@anthropic.com>
This commit is contained in:
hanghang zhang
2026-05-22 20:01:37 +01:00
parent e99b12ef08
commit 46d928782b
3 changed files with 190 additions and 3 deletions

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"strings"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/help" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/help"
@@ -305,6 +306,7 @@ func handleUserCommand(subCmd string, args []string) {
commands.RunUserGet(filtered[0], tokenFlag) commands.RunUserGet(filtered[0], tokenFlag)
case "create": case "create":
username, password, email, fullName, discordUserID := "", "", "", "", "" username, password, email, fullName, discordUserID := "", "", "", "", ""
agentID, clawID := "", ""
for i := 0; i < len(filtered); i++ { for i := 0; i < len(filtered); i++ {
switch filtered[i] { switch filtered[i] {
case "--user": case "--user":
@@ -332,14 +334,54 @@ func handleUserCommand(subCmd string, args []string) {
i++ i++
discordUserID = filtered[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: default:
output.Errorf("unknown flag: %s", filtered[i]) output.Errorf("unknown flag: %s", filtered[i])
} }
} }
if username == "" { if username == "" {
output.Error("usage: hf user create --user <username>") output.Error("usage: hf user create --user <username> [--agent-id <id> --claw-identifier <id>]")
} }
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 <username> [--agent-id <id>] [--claw-identifier <id>]`
// 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 <username> [--agent-id <id>] [--claw-identifier <id>]")
}
commands.RunUserBindAgent(username, agentID, clawID, accMgrTokenFlag)
case "update": case "update":
if len(filtered) < 1 { if len(filtered) < 1 {
output.Error("usage: hf user update <username> [--email ...] [--full-name ...] [--pass ...] [--active ...]") output.Error("usage: hf user update <username> [--email ...] [--full-name ...] [--pass ...] [--active ...]")

View File

@@ -141,6 +141,60 @@ type userCreatePayload struct {
FullName *string `json:"full_name,omitempty"` FullName *string `json:"full_name,omitempty"`
Password *string `json:"password,omitempty"` Password *string `json:"password,omitempty"`
DiscordUserID *string `json:"discord_user_id,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) { 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`. // 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 // Resolve account-manager token
var accMgrToken string var accMgrToken string
if mode.IsPaddedCell() { if mode.IsPaddedCell() {
@@ -224,6 +283,12 @@ func RunUserCreate(username, password, email, fullName, discordUserID, accMgrTok
payload.FullName = &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) body, err := json.Marshal(payload)
if err != nil { if err != nil {
output.Errorf("cannot marshal payload: %v", err) output.Errorf("cannot marshal payload: %v", err)
@@ -447,3 +512,82 @@ func RunUserResetAPIKey(username, tokenFlag, accMgrTokenFlag string) {
"message", r.Message, "message", r.Message,
) )
} }
// RunUserBindAgent implements `hf user bind-agent <username>`.
//
// 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 <username> [--agent-id <id>] [--claw-identifier <id>]")
}
// 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 <token> required or execute with pcexec")
}
accMgrToken = tok
} else {
if accMgrTokenFlag == "" {
output.Error("--acc-mgr-token <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",
)
}

View File

@@ -48,6 +48,7 @@ func CommandSurface() []Group {
{Name: "deactivate", Description: "Deactivate a user", Permitted: has(perms, "user.manage")}, {Name: "deactivate", Description: "Deactivate a user", Permitted: has(perms, "user.manage")},
{Name: "delete", Description: "Delete 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: "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},
}, },
}, },
{ {