Compare commits
13 Commits
8dd58bad43
...
729365ca46
| Author | SHA1 | Date | |
|---|---|---|---|
| 729365ca46 | |||
| ef8d4dbdad | |||
| 4125a4c102 | |||
| 4df6e1bd5f | |||
| c0ab087436 | |||
| 3edabb72ba | |||
| 1c9e90b033 | |||
| 2176383729 | |||
| a42ba6f880 | |||
| b0f4aa286b | |||
|
|
46d928782b | ||
| e99b12ef08 | |||
|
|
6ace6f2594 |
144
cmd/hf/main.go
144
cmd/hf/main.go
@@ -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"
|
||||||
@@ -54,6 +55,24 @@ func main() {
|
|||||||
discordID = filtered[1]
|
discordID = filtered[1]
|
||||||
}
|
}
|
||||||
commands.RunUserUpdateDiscordID(filtered[0], discordID, tokenFlag)
|
commands.RunUserUpdateDiscordID(filtered[0], discordID, tokenFlag)
|
||||||
|
case "agent":
|
||||||
|
// `hf agent <sub>` — currently only `status` is implemented (wraps
|
||||||
|
// `POST /calendar/agent/status`, identifies caller via
|
||||||
|
// AGENT_ID/CLAW_IDENTIFIER env, no token needed).
|
||||||
|
if len(args) < 2 {
|
||||||
|
output.Error("usage: hf agent status --set <idle|busy|on_call|exhausted|offline>")
|
||||||
|
}
|
||||||
|
switch args[1] {
|
||||||
|
case "status":
|
||||||
|
commands.RunAgentStatus(args[2:])
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown agent subcommand: %s", args[1])
|
||||||
|
}
|
||||||
|
case "assign-schedule-type":
|
||||||
|
// Leaf command (no subcommands) — args are <agent-id> <type-name>.
|
||||||
|
// Must dispatch at top level because handleGroup treats args[0] as
|
||||||
|
// a subcommand name and would error "unknown ... subcommand: <agent-id>".
|
||||||
|
handleAssignScheduleType(args[1:])
|
||||||
default:
|
default:
|
||||||
if group, ok := findGroup(args[0]); ok {
|
if group, ok := findGroup(args[0]); ok {
|
||||||
handleGroup(group, args[1:])
|
handleGroup(group, args[1:])
|
||||||
@@ -197,6 +216,9 @@ func handleGroup(group help.Group, args []string) {
|
|||||||
case "project":
|
case "project":
|
||||||
handleProjectCommand(sub.Name, remaining)
|
handleProjectCommand(sub.Name, remaining)
|
||||||
return
|
return
|
||||||
|
case "knowledge-base":
|
||||||
|
handleKnowledgeBaseCommand(sub.Name, remaining)
|
||||||
|
return
|
||||||
case "milestone":
|
case "milestone":
|
||||||
handleMilestoneCommand(sub.Name, remaining)
|
handleMilestoneCommand(sub.Name, remaining)
|
||||||
return
|
return
|
||||||
@@ -292,6 +314,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":
|
||||||
@@ -319,14 +342,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 ...]")
|
||||||
@@ -671,6 +734,83 @@ func handleProjectCommand(subCmd string, args []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleKnowledgeBaseCommand(subCmd string, args []string) {
|
||||||
|
tokenFlag := ""
|
||||||
|
var filtered []string
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--token":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
i++
|
||||||
|
tokenFlag = args[i]
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
filtered = append(filtered, args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
needArg := func(usage string) {
|
||||||
|
if len(filtered) < 1 {
|
||||||
|
output.Error(usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch subCmd {
|
||||||
|
case "list":
|
||||||
|
commands.RunKnowledgeBaseList(filtered, tokenFlag)
|
||||||
|
case "get":
|
||||||
|
needArg("usage: hf knowledge-base get <kb-code>")
|
||||||
|
commands.RunKnowledgeBaseGet(filtered[0], tokenFlag)
|
||||||
|
case "create":
|
||||||
|
commands.RunKnowledgeBaseCreate(filtered, tokenFlag)
|
||||||
|
case "update":
|
||||||
|
needArg("usage: hf knowledge-base update <kb-code> [--title ...] [--desc ...]")
|
||||||
|
commands.RunKnowledgeBaseUpdate(filtered[0], filtered[1:], tokenFlag)
|
||||||
|
case "delete":
|
||||||
|
needArg("usage: hf knowledge-base delete <kb-code>")
|
||||||
|
commands.RunKnowledgeBaseDelete(filtered[0], tokenFlag)
|
||||||
|
case "tree":
|
||||||
|
needArg("usage: hf knowledge-base tree <kb-code>")
|
||||||
|
commands.RunKnowledgeBaseTree(filtered[0], tokenFlag)
|
||||||
|
case "link":
|
||||||
|
needArg("usage: hf knowledge-base link <kb-code> --project <project-code>")
|
||||||
|
commands.RunKnowledgeBaseLink(filtered[0], filtered[1:], tokenFlag)
|
||||||
|
case "unlink":
|
||||||
|
needArg("usage: hf knowledge-base unlink <kb-code> --project <project-code>")
|
||||||
|
commands.RunKnowledgeBaseUnlink(filtered[0], filtered[1:], tokenFlag)
|
||||||
|
case "topics":
|
||||||
|
needArg("usage: hf knowledge-base topics <kb-code>")
|
||||||
|
commands.RunKnowledgeBaseTopics(filtered[0], tokenFlag)
|
||||||
|
case "add-topic":
|
||||||
|
needArg("usage: hf knowledge-base add-topic <kb-code> --topic <name> [--desc ...]")
|
||||||
|
commands.RunKnowledgeBaseAddTopic(filtered[0], filtered[1:], tokenFlag)
|
||||||
|
case "update-topic":
|
||||||
|
needArg("usage: hf knowledge-base update-topic <topic-id> [--topic ...] [--desc ...]")
|
||||||
|
commands.RunKnowledgeBaseUpdateTopic(filtered[0], filtered[1:], tokenFlag)
|
||||||
|
case "delete-topic":
|
||||||
|
needArg("usage: hf knowledge-base delete-topic <topic-id>")
|
||||||
|
commands.RunKnowledgeBaseDeleteTopic(filtered[0], tokenFlag)
|
||||||
|
case "add-category":
|
||||||
|
commands.RunKnowledgeBaseAddCategory(filtered, tokenFlag)
|
||||||
|
case "update-category":
|
||||||
|
needArg("usage: hf knowledge-base update-category <category-id> [--name ...] [--parent ...] [--desc ...]")
|
||||||
|
commands.RunKnowledgeBaseUpdateCategory(filtered[0], filtered[1:], tokenFlag)
|
||||||
|
case "delete-category":
|
||||||
|
needArg("usage: hf knowledge-base delete-category <category-id>")
|
||||||
|
commands.RunKnowledgeBaseDeleteCategory(filtered[0], tokenFlag)
|
||||||
|
case "add-fact":
|
||||||
|
commands.RunKnowledgeBaseAddFact(filtered, tokenFlag)
|
||||||
|
case "update-fact":
|
||||||
|
needArg("usage: hf knowledge-base update-fact <fact-id> [--fact ...] [--category ...]")
|
||||||
|
commands.RunKnowledgeBaseUpdateFact(filtered[0], filtered[1:], tokenFlag)
|
||||||
|
case "delete-fact":
|
||||||
|
needArg("usage: hf knowledge-base delete-fact <fact-id>")
|
||||||
|
commands.RunKnowledgeBaseDeleteFact(filtered[0], tokenFlag)
|
||||||
|
default:
|
||||||
|
output.Errorf("hf knowledge-base %s is not implemented yet", subCmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleMeetingCommand(subCmd string, args []string) {
|
func handleMeetingCommand(subCmd string, args []string) {
|
||||||
tokenFlag := ""
|
tokenFlag := ""
|
||||||
var filtered []string
|
var filtered []string
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
neturl "net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -19,14 +21,34 @@ type Client struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Client with the given base URL and optional auth token.
|
// New creates a Client with the given base URL and optional auth token.
|
||||||
|
//
|
||||||
|
// The token is sent as Authorization: Bearer when it looks like a JWT
|
||||||
|
// (eyJ-prefixed, three dot-separated segments). Anything else is treated as
|
||||||
|
// an API key and sent via X-API-Key. This lets call sites that historically
|
||||||
|
// passed an api-key as a "token" (e.g. the value returned by passmgr.GetToken
|
||||||
|
// in padded-cell mode) authenticate correctly without per-callsite churn.
|
||||||
func New(baseURL, token string) *Client {
|
func New(baseURL, token string) *Client {
|
||||||
return &Client{
|
c := &Client{
|
||||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
Token: token,
|
|
||||||
HTTPClient: &http.Client{
|
HTTPClient: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if isLikelyJWT(token) {
|
||||||
|
c.Token = token
|
||||||
|
} else if token != "" {
|
||||||
|
c.APIKey = token
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLikelyJWT returns true for tokens that look like a JSON Web Token:
|
||||||
|
// "eyJ"-prefixed (base64-encoded JSON header opening with `{"`) and exactly
|
||||||
|
// two dots separating header.payload.signature. API keys minted by the HF
|
||||||
|
// backend are hex (`/users/{id}/apikey` returns a 64-hex-char `key`); fabric
|
||||||
|
// keys use a `fak_` prefix. None of those match this shape.
|
||||||
|
func isLikelyJWT(token string) bool {
|
||||||
|
return strings.HasPrefix(token, "eyJ") && strings.Count(token, ".") == 2
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWithAPIKey creates a Client that authenticates using X-API-Key.
|
// NewWithAPIKey creates a Client that authenticates using X-API-Key.
|
||||||
@@ -50,8 +72,39 @@ func (e *RequestError) Error() string {
|
|||||||
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body)
|
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// guardPlaintextCreds refuses to transmit a token / API key over a plaintext
|
||||||
|
// http:// connection to a non-loopback host (prevents credential interception).
|
||||||
|
func (c *Client) guardPlaintextCreds() error {
|
||||||
|
if c.Token == "" && c.APIKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u, err := neturl.Parse(c.BaseURL)
|
||||||
|
if err != nil || u.Scheme != "http" {
|
||||||
|
return nil // parse errors and https:// are fine
|
||||||
|
}
|
||||||
|
if isLoopbackHost(u.Hostname()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("refusing to send credentials over plaintext http:// to non-loopback host %q — use an https:// base URL (hf config set-url ...)", u.Hostname())
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLoopbackHost reports whether h is a loopback address or localhost name.
|
||||||
|
func isLoopbackHost(h string) bool {
|
||||||
|
h = strings.ToLower(h)
|
||||||
|
if h == "localhost" || strings.HasSuffix(h, ".localhost") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(h); ip != nil {
|
||||||
|
return ip.IsLoopback()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Do executes an HTTP request and returns the response body bytes.
|
// Do executes an HTTP request and returns the response body bytes.
|
||||||
func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) {
|
func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) {
|
||||||
|
if err := c.guardPlaintextCreds(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
url := c.BaseURL + path
|
url := c.BaseURL + path
|
||||||
req, err := http.NewRequest(method, url, body)
|
req, err := http.NewRequest(method, url, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
104
internal/client/client_test.go
Normal file
104
internal/client/client_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsLikelyJWT(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want bool
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
// Real JWT minted by an HS256 signer (header `{"alg":"HS256","typ":"JWT"}`).
|
||||||
|
{"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature", true, "valid JWT shape"},
|
||||||
|
// HF backend api-keys are 64-char hex from /users/{id}/apikey.
|
||||||
|
{"f654c3ff0bbc09e6a22294dfbbbff371a4550366849f59de68ddf064742831a0", false, "hex api-key"},
|
||||||
|
// Fabric api-keys carry a fak_ prefix.
|
||||||
|
{"fak_30791357ca11ac2ff963999bf265f6a5f240593eb01c06fc", false, "fabric api-key"},
|
||||||
|
// eyJ prefix without three segments isn't a JWT.
|
||||||
|
{"eyJabc", false, "prefix only"},
|
||||||
|
{"eyJabc.def", false, "two segments"},
|
||||||
|
// Empty / nonsense.
|
||||||
|
{"", false, "empty"},
|
||||||
|
{"....", false, "dots only"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := isLikelyJWT(c.in); got != c.want {
|
||||||
|
t.Errorf("isLikelyJWT(%q) = %v, want %v (%s)", c.in, got, c.want, c.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAutoSelectsAuthHeader(t *testing.T) {
|
||||||
|
// Capture which auth header reaches the server for each token shape.
|
||||||
|
var lastReq *http.Request
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastReq = r
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = io.WriteString(w, "{}")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// api-key path: should go via X-API-Key, NOT Authorization.
|
||||||
|
apiKey := "f654c3ff0bbc09e6a22294dfbbbff371a4550366849f59de68ddf064742831a0"
|
||||||
|
if _, err := New(srv.URL, apiKey).Get("/anything"); err != nil {
|
||||||
|
t.Fatalf("api-key call failed: %v", err)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("X-API-Key"); got != apiKey {
|
||||||
|
t.Errorf("api-key not sent as X-API-Key (got %q)", got)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("Authorization"); got != "" {
|
||||||
|
t.Errorf("api-key leaked into Authorization header (got %q)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT path: should go via Authorization: Bearer, NOT X-API-Key.
|
||||||
|
jwt := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature"
|
||||||
|
if _, err := New(srv.URL, jwt).Get("/anything"); err != nil {
|
||||||
|
t.Fatalf("jwt call failed: %v", err)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") || !strings.HasSuffix(got, jwt) {
|
||||||
|
t.Errorf("jwt not sent as Bearer (got %q)", got)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("X-API-Key"); got != "" {
|
||||||
|
t.Errorf("jwt leaked into X-API-Key header (got %q)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty token: neither header set.
|
||||||
|
if _, err := New(srv.URL, "").Get("/anything"); err != nil {
|
||||||
|
t.Fatalf("empty-token call failed: %v", err)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("Authorization"); got != "" {
|
||||||
|
t.Errorf("empty token set Authorization (got %q)", got)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("X-API-Key"); got != "" {
|
||||||
|
t.Errorf("empty token set X-API-Key (got %q)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWithAPIKeyAlwaysUsesAPIKeyHeader(t *testing.T) {
|
||||||
|
// Even if someone passes a JWT-shaped string via NewWithAPIKey, it must
|
||||||
|
// still go via X-API-Key — the explicit constructor wins.
|
||||||
|
var lastReq *http.Request
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lastReq = r
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = io.WriteString(w, "{}")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
jwtShape := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature"
|
||||||
|
if _, err := NewWithAPIKey(srv.URL, jwtShape).Get("/anything"); err != nil {
|
||||||
|
t.Fatalf("call failed: %v", err)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("X-API-Key"); got != jwtShape {
|
||||||
|
t.Errorf("NewWithAPIKey didn't use X-API-Key (got %q)", got)
|
||||||
|
}
|
||||||
|
if got := lastReq.Header.Get("Authorization"); got != "" {
|
||||||
|
t.Errorf("NewWithAPIKey set Authorization (got %q)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
140
internal/commands/agent.go
Normal file
140
internal/commands/agent.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
// Package commands — agent runtime-status command (`hf agent status`).
|
||||||
|
//
|
||||||
|
// Wraps the plugin-facing `POST /calendar/agent/status` endpoint so agents
|
||||||
|
// driven from `pcexec` (which sets AGENT_ID/CLAW_IDENTIFIER env) can report
|
||||||
|
// their status from a workflow without writing curl in the middle of a
|
||||||
|
// `flow.md` procedure.
|
||||||
|
//
|
||||||
|
// The endpoint itself is unauthenticated at the HTTP layer — it identifies
|
||||||
|
// the agent purely from X-Agent-ID + X-Claw-Identifier headers — so this
|
||||||
|
// command does NOT call `ResolveToken`. Calling it from outside a pcexec
|
||||||
|
// session will fail because AGENT_ID/CLAW_IDENTIFIER won't be set.
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/config"
|
||||||
|
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunAgentStatus implements `hf agent status --set <status>`.
|
||||||
|
//
|
||||||
|
// Supported statuses (mirrors backend `AgentStatus` enum):
|
||||||
|
// idle | busy | on_call | exhausted | offline
|
||||||
|
//
|
||||||
|
// For `exhausted`, an optional `--reason <rate_limit|billing>` and
|
||||||
|
// `--recovery-at <ISO-8601>` can be provided.
|
||||||
|
func RunAgentStatus(args []string) {
|
||||||
|
target := ""
|
||||||
|
reason := ""
|
||||||
|
recoveryAt := ""
|
||||||
|
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--set":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("usage: hf agent status --set <idle|busy|on_call|exhausted|offline> [--reason <rate_limit|billing>] [--recovery-at <iso>]")
|
||||||
|
}
|
||||||
|
target = args[i+1]
|
||||||
|
i++
|
||||||
|
case "--reason":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--reason requires a value")
|
||||||
|
}
|
||||||
|
reason = args[i+1]
|
||||||
|
i++
|
||||||
|
case "--recovery-at":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--recovery-at requires an ISO-8601 timestamp")
|
||||||
|
}
|
||||||
|
recoveryAt = args[i+1]
|
||||||
|
i++
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown flag: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if target == "" {
|
||||||
|
output.Error("--set <status> is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
agentID := strings.TrimSpace(os.Getenv("AGENT_ID"))
|
||||||
|
clawID := strings.TrimSpace(os.Getenv("CLAW_IDENTIFIER"))
|
||||||
|
if clawID == "" {
|
||||||
|
// Match the plugin convention: hostname fallback when CLAW_IDENTIFIER
|
||||||
|
// is unset. Most pcexec callers won't have it set in env.
|
||||||
|
if h, err := os.Hostname(); err == nil {
|
||||||
|
clawID = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if agentID == "" {
|
||||||
|
output.Error("AGENT_ID env is not set — run via pcexec or export AGENT_ID first")
|
||||||
|
}
|
||||||
|
if clawID == "" {
|
||||||
|
output.Error("CLAW_IDENTIFIER env not set and hostname() failed — set CLAW_IDENTIFIER explicitly")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("config error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"agent_id": agentID,
|
||||||
|
"claw_identifier": clawID,
|
||||||
|
"status": target,
|
||||||
|
}
|
||||||
|
if reason != "" {
|
||||||
|
body["exhaust_reason"] = reason
|
||||||
|
}
|
||||||
|
if recoveryAt != "" {
|
||||||
|
body["recovery_at"] = recoveryAt
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("cannot marshal payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimRight(cfg.BaseURL, "/") + "/calendar/agent/status"
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("cannot build request: %v", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Agent-ID", agentID)
|
||||||
|
req.Header.Set("X-Claw-Identifier", clawID)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, _ = buf.ReadFrom(resp.Body)
|
||||||
|
output.Errorf("backend returned %d: %s", resp.StatusCode, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if output.JSONMode {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
_, _ = buf.ReadFrom(resp.Body)
|
||||||
|
fmt.Println(buf.String())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
output.PrintKeyValue(
|
||||||
|
"agent_id", agentID,
|
||||||
|
"claw_identifier", clawID,
|
||||||
|
"status", target,
|
||||||
|
"ok", "true",
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/mode"
|
"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/output"
|
||||||
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr"
|
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/passmgr"
|
||||||
@@ -20,12 +23,17 @@ func ResolveToken(tokenFlag string) string {
|
|||||||
}
|
}
|
||||||
return tok
|
return tok
|
||||||
}
|
}
|
||||||
// manual mode
|
// manual mode — prefer the explicit flag, else fall back to the HF_TOKEN
|
||||||
if tokenFlag == "" {
|
// env var so the token need not appear in argv (visible via `ps`/history).
|
||||||
output.Error("--token <token> required or execute this with pcexec")
|
if tokenFlag != "" {
|
||||||
}
|
|
||||||
return tokenFlag
|
return tokenFlag
|
||||||
}
|
}
|
||||||
|
if env := strings.TrimSpace(os.Getenv("HF_TOKEN")); env != "" {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
output.Error("--token <token> or HF_TOKEN env required, or execute this with pcexec")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// RejectTokenInPaddedCell checks if --token was passed in padded-cell mode
|
// RejectTokenInPaddedCell checks if --token was passed in padded-cell mode
|
||||||
// and terminates with the standard error message.
|
// and terminates with the standard error message.
|
||||||
|
|||||||
658
internal/commands/knowledge_base.go
Normal file
658
internal/commands/knowledge_base.go
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"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/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
// kbResponse matches the backend KnowledgeBaseResponse schema.
|
||||||
|
type kbResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
KnowledgeBaseCode string `json:"knowledge_base_code"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
CreatedBy int `json:"created_by"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
LastUpdatedAt string `json:"last_updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type kbFactNode struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Fact string `json:"fact"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type kbCategoryNode struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Categories []kbCategoryNode `json:"categories"`
|
||||||
|
Facts []kbFactNode `json:"facts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type kbTopicNode struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Categories []kbCategoryNode `json:"categories"`
|
||||||
|
Facts []kbFactNode `json:"facts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type kbTree struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
KnowledgeBaseCode string `json:"knowledge_base_code"`
|
||||||
|
Topics []kbTopicNode `json:"topics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func kbClient(tokenFlag string) *client.Client {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("config error: %v", err)
|
||||||
|
}
|
||||||
|
return client.New(cfg.BaseURL, ResolveToken(tokenFlag))
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitJSONOr(data []byte, fallback func()) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
fallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Knowledge base CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// RunKnowledgeBaseList implements `hf knowledge-base list [--project <project-code>]`.
|
||||||
|
func RunKnowledgeBaseList(args []string, tokenFlag string) {
|
||||||
|
query := ""
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--project":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--project requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
query = appendQuery(query, "project", args[i])
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown flag: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
path := "/knowledge-bases"
|
||||||
|
if query != "" {
|
||||||
|
path += "?" + query
|
||||||
|
}
|
||||||
|
data, err := c.Get(path)
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to list knowledge bases: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
emitJSONOr(data, func() {
|
||||||
|
var kbs []kbResponse
|
||||||
|
if err := json.Unmarshal(data, &kbs); err != nil {
|
||||||
|
output.Errorf("cannot parse knowledge base list: %v", err)
|
||||||
|
}
|
||||||
|
headers := []string{"CODE", "TITLE", "DESCRIPTION"}
|
||||||
|
var rows [][]string
|
||||||
|
for _, k := range kbs {
|
||||||
|
desc := ""
|
||||||
|
if k.Description != nil {
|
||||||
|
desc = *k.Description
|
||||||
|
if len(desc) > 50 {
|
||||||
|
desc = desc[:47] + "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{k.KnowledgeBaseCode, k.Title, desc})
|
||||||
|
}
|
||||||
|
output.PrintTable(headers, rows)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunKnowledgeBaseGet implements `hf knowledge-base get <kb-code>`.
|
||||||
|
func RunKnowledgeBaseGet(code, tokenFlag string) {
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
data, err := c.Get("/knowledge-bases/" + code)
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to get knowledge base: %v", err)
|
||||||
|
}
|
||||||
|
emitJSONOr(data, func() {
|
||||||
|
var k kbResponse
|
||||||
|
if err := json.Unmarshal(data, &k); err != nil {
|
||||||
|
output.Errorf("cannot parse knowledge base: %v", err)
|
||||||
|
}
|
||||||
|
desc := ""
|
||||||
|
if k.Description != nil {
|
||||||
|
desc = *k.Description
|
||||||
|
}
|
||||||
|
output.PrintKeyValue(
|
||||||
|
"code", k.KnowledgeBaseCode,
|
||||||
|
"title", k.Title,
|
||||||
|
"description", desc,
|
||||||
|
"created", k.CreatedAt,
|
||||||
|
"updated", k.LastUpdatedAt,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunKnowledgeBaseCreate implements `hf knowledge-base create --title <t> [--desc <d>]`.
|
||||||
|
func RunKnowledgeBaseCreate(args []string, tokenFlag string) {
|
||||||
|
title, desc := "", ""
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--title":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--title requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
title = args[i]
|
||||||
|
case "--desc":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--desc requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
desc = args[i]
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown flag: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
output.Error("usage: hf knowledge-base create --title <title> [--desc <description>]")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{"title": title}
|
||||||
|
if desc != "" {
|
||||||
|
payload["description"] = desc
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
data, err := c.Post("/knowledge-bases", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to create knowledge base: %v", err)
|
||||||
|
}
|
||||||
|
emitJSONOr(data, func() {
|
||||||
|
var k kbResponse
|
||||||
|
if err := json.Unmarshal(data, &k); err != nil {
|
||||||
|
fmt.Printf("knowledge base created: %s\n", title)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("knowledge base created: %s (code: %s)\n", k.Title, k.KnowledgeBaseCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunKnowledgeBaseUpdate implements `hf knowledge-base update <kb-code> [--title ...] [--desc ...]`.
|
||||||
|
func RunKnowledgeBaseUpdate(code string, args []string, tokenFlag string) {
|
||||||
|
payload := make(map[string]interface{})
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--title":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--title requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
payload["title"] = args[i]
|
||||||
|
case "--desc":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--desc requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
payload["description"] = args[i]
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown flag: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(payload) == 0 {
|
||||||
|
output.Error("nothing to update — provide --title and/or --desc")
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
_, err := c.Patch("/knowledge-bases/"+code, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to update knowledge base: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("knowledge base updated: %s\n", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunKnowledgeBaseDelete implements `hf knowledge-base delete <kb-code>`.
|
||||||
|
func RunKnowledgeBaseDelete(code, tokenFlag string) {
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
_, err := c.Delete("/knowledge-bases/" + code)
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to delete knowledge base: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("knowledge base deleted: %s\n", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunKnowledgeBaseTree implements `hf knowledge-base tree <kb-code>`.
|
||||||
|
func RunKnowledgeBaseTree(code, tokenFlag string) {
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
data, err := c.Get("/knowledge-bases/" + code + "/tree")
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to get knowledge base tree: %v", err)
|
||||||
|
}
|
||||||
|
emitJSONOr(data, func() {
|
||||||
|
var tree kbTree
|
||||||
|
if err := json.Unmarshal(data, &tree); err != nil {
|
||||||
|
output.Errorf("cannot parse tree: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s (%s)\n", tree.Title, tree.KnowledgeBaseCode)
|
||||||
|
for _, t := range tree.Topics {
|
||||||
|
fmt.Printf(" # %s [topic:%d]\n", t.Topic, t.ID)
|
||||||
|
for _, f := range t.Facts {
|
||||||
|
fmt.Printf(" - %s [fact:%d]\n", f.Fact, f.ID)
|
||||||
|
}
|
||||||
|
for _, cat := range t.Categories {
|
||||||
|
printKBCategory(cat, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func printKBCategory(cat kbCategoryNode, depth int) {
|
||||||
|
indent := strings.Repeat(" ", depth)
|
||||||
|
fmt.Printf("%s> %s [category:%d]\n", indent, cat.Name, cat.ID)
|
||||||
|
for _, f := range cat.Facts {
|
||||||
|
fmt.Printf("%s - %s [fact:%d]\n", indent, f.Fact, f.ID)
|
||||||
|
}
|
||||||
|
for _, child := range cat.Categories {
|
||||||
|
printKBCategory(child, depth+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Project links
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// RunKnowledgeBaseLink implements `hf knowledge-base link <kb-code> --project <project-code>`.
|
||||||
|
func RunKnowledgeBaseLink(code string, args []string, tokenFlag string) {
|
||||||
|
project := ""
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--project":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--project requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
project = args[i]
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown flag: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if project == "" {
|
||||||
|
output.Error("usage: hf knowledge-base link <kb-code> --project <project-code>")
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{"knowledge_base": code})
|
||||||
|
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
_, err := c.Post("/projects/"+project+"/knowledge-bases", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to link knowledge base: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("knowledge base %s linked to project %s\n", code, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunKnowledgeBaseUnlink implements `hf knowledge-base unlink <kb-code> --project <project-code>`.
|
||||||
|
func RunKnowledgeBaseUnlink(code string, args []string, tokenFlag string) {
|
||||||
|
project := ""
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--project":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--project requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
project = args[i]
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown flag: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if project == "" {
|
||||||
|
output.Error("usage: hf knowledge-base unlink <kb-code> --project <project-code>")
|
||||||
|
}
|
||||||
|
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
_, err := c.Delete("/projects/" + project + "/knowledge-bases/" + code)
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to unlink knowledge base: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("knowledge base %s unlinked from project %s\n", code, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Topics
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// RunKnowledgeBaseTopics implements `hf knowledge-base topics <kb-code>`.
|
||||||
|
func RunKnowledgeBaseTopics(code, tokenFlag string) {
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
data, err := c.Get("/knowledge-bases/" + code + "/topics")
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to list topics: %v", err)
|
||||||
|
}
|
||||||
|
emitJSONOr(data, func() {
|
||||||
|
var topics []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &topics); err != nil {
|
||||||
|
output.Errorf("cannot parse topics: %v", err)
|
||||||
|
}
|
||||||
|
headers := []string{"ID", "TOPIC", "DESCRIPTION"}
|
||||||
|
var rows [][]string
|
||||||
|
for _, t := range topics {
|
||||||
|
desc := ""
|
||||||
|
if t.Description != nil {
|
||||||
|
desc = *t.Description
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{fmt.Sprintf("%d", t.ID), t.Topic, desc})
|
||||||
|
}
|
||||||
|
output.PrintTable(headers, rows)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunKnowledgeBaseAddTopic implements `hf knowledge-base add-topic <kb-code> --topic <name> [--desc ...]`.
|
||||||
|
func RunKnowledgeBaseAddTopic(code string, args []string, tokenFlag string) {
|
||||||
|
topic, desc := "", ""
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--topic":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--topic requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
topic = args[i]
|
||||||
|
case "--desc":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--desc requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
desc = args[i]
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown flag: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if topic == "" {
|
||||||
|
output.Error("usage: hf knowledge-base add-topic <kb-code> --topic <name> [--desc <description>]")
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{"topic": topic}
|
||||||
|
if desc != "" {
|
||||||
|
payload["description"] = desc
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
data, err := c.Post("/knowledge-bases/"+code+"/topics", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to add topic: %v", err)
|
||||||
|
}
|
||||||
|
emitJSONOr(data, func() { fmt.Printf("topic added: %s\n", topic) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunKnowledgeBaseUpdateTopic implements `hf knowledge-base update-topic <topic-id> [--topic ...] [--desc ...]`.
|
||||||
|
func RunKnowledgeBaseUpdateTopic(topicID string, args []string, tokenFlag string) {
|
||||||
|
payload := make(map[string]interface{})
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--topic":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--topic requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
payload["topic"] = args[i]
|
||||||
|
case "--desc":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--desc requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
payload["description"] = args[i]
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown flag: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(payload) == 0 {
|
||||||
|
output.Error("nothing to update — provide --topic and/or --desc")
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
_, err := c.Patch("/knowledge-topics/"+topicID, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to update topic: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("topic updated: %s\n", topicID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunKnowledgeBaseDeleteTopic implements `hf knowledge-base delete-topic <topic-id>`.
|
||||||
|
func RunKnowledgeBaseDeleteTopic(topicID, tokenFlag string) {
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
_, err := c.Delete("/knowledge-topics/" + topicID)
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to delete topic: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("topic deleted: %s\n", topicID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Categories
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// RunKnowledgeBaseAddCategory implements
|
||||||
|
// `hf knowledge-base add-category --topic <id> --name <n> [--parent <id>] [--desc ...]`.
|
||||||
|
func RunKnowledgeBaseAddCategory(args []string, tokenFlag string) {
|
||||||
|
name, desc := "", ""
|
||||||
|
var topicID, parentID *int
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--topic":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--topic requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
topicID = parseIntFlag("--topic", args[i])
|
||||||
|
case "--parent":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--parent requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
parentID = parseIntFlag("--parent", args[i])
|
||||||
|
case "--name":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--name requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
name = args[i]
|
||||||
|
case "--desc":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--desc requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
desc = args[i]
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown flag: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if topicID == nil || name == "" {
|
||||||
|
output.Error("usage: hf knowledge-base add-category --topic <topic-id> --name <name> [--parent <category-id>] [--desc <description>]")
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{"topic_id": *topicID, "name": name}
|
||||||
|
if parentID != nil {
|
||||||
|
payload["parent"] = *parentID
|
||||||
|
}
|
||||||
|
if desc != "" {
|
||||||
|
payload["description"] = desc
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
data, err := c.Post("/knowledge-categories", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to add category: %v", err)
|
||||||
|
}
|
||||||
|
emitJSONOr(data, func() { fmt.Printf("category added: %s\n", name) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunKnowledgeBaseUpdateCategory implements
|
||||||
|
// `hf knowledge-base update-category <category-id> [--name ...] [--parent ...] [--desc ...]`.
|
||||||
|
func RunKnowledgeBaseUpdateCategory(categoryID string, args []string, tokenFlag string) {
|
||||||
|
payload := make(map[string]interface{})
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--name":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--name requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
payload["name"] = args[i]
|
||||||
|
case "--parent":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--parent requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
payload["parent"] = *parseIntFlag("--parent", args[i])
|
||||||
|
case "--desc":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--desc requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
payload["description"] = args[i]
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown flag: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(payload) == 0 {
|
||||||
|
output.Error("nothing to update — provide --name, --parent and/or --desc")
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
_, err := c.Patch("/knowledge-categories/"+categoryID, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to update category: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("category updated: %s\n", categoryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunKnowledgeBaseDeleteCategory implements `hf knowledge-base delete-category <category-id>`.
|
||||||
|
func RunKnowledgeBaseDeleteCategory(categoryID, tokenFlag string) {
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
_, err := c.Delete("/knowledge-categories/" + categoryID)
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to delete category: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("category deleted: %s\n", categoryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Facts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// RunKnowledgeBaseAddFact implements
|
||||||
|
// `hf knowledge-base add-fact --topic <id> [--category <id>] --fact <text>`.
|
||||||
|
func RunKnowledgeBaseAddFact(args []string, tokenFlag string) {
|
||||||
|
factText := ""
|
||||||
|
var topicID, categoryID *int
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--topic":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--topic requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
topicID = parseIntFlag("--topic", args[i])
|
||||||
|
case "--category":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--category requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
categoryID = parseIntFlag("--category", args[i])
|
||||||
|
case "--fact":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--fact requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
factText = args[i]
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown flag: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if topicID == nil || factText == "" {
|
||||||
|
output.Error("usage: hf knowledge-base add-fact --topic <topic-id> [--category <category-id>] --fact <text>")
|
||||||
|
}
|
||||||
|
payload := map[string]interface{}{"topic_id": *topicID, "fact": factText}
|
||||||
|
if categoryID != nil {
|
||||||
|
payload["category_id"] = *categoryID
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
data, err := c.Post("/knowledge-facts", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to add fact: %v", err)
|
||||||
|
}
|
||||||
|
emitJSONOr(data, func() { fmt.Println("fact added") })
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunKnowledgeBaseUpdateFact implements
|
||||||
|
// `hf knowledge-base update-fact <fact-id> [--fact ...] [--category ...]`.
|
||||||
|
func RunKnowledgeBaseUpdateFact(factID string, args []string, tokenFlag string) {
|
||||||
|
payload := make(map[string]interface{})
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
switch args[i] {
|
||||||
|
case "--fact":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--fact requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
payload["fact"] = args[i]
|
||||||
|
case "--category":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
output.Error("--category requires a value")
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
payload["category_id"] = *parseIntFlag("--category", args[i])
|
||||||
|
default:
|
||||||
|
output.Errorf("unknown flag: %s", args[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(payload) == 0 {
|
||||||
|
output.Error("nothing to update — provide --fact and/or --category")
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
_, err := c.Patch("/knowledge-facts/"+factID, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to update fact: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("fact updated: %s\n", factID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunKnowledgeBaseDeleteFact implements `hf knowledge-base delete-fact <fact-id>`.
|
||||||
|
func RunKnowledgeBaseDeleteFact(factID, tokenFlag string) {
|
||||||
|
c := kbClient(tokenFlag)
|
||||||
|
_, err := c.Delete("/knowledge-facts/" + factID)
|
||||||
|
if err != nil {
|
||||||
|
output.Errorf("failed to delete fact: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("fact deleted: %s\n", factID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntFlag(flag, value string) *int {
|
||||||
|
var n int
|
||||||
|
if _, err := fmt.Sscanf(value, "%d", &n); err != nil {
|
||||||
|
output.Errorf("%s requires an integer value, got %q", flag, value)
|
||||||
|
}
|
||||||
|
return &n
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ func CommandSurface() []Group {
|
|||||||
{Name: "version", Description: "Show CLI version", Permitted: true},
|
{Name: "version", Description: "Show CLI version", Permitted: true},
|
||||||
{Name: "health", Description: "Check API health", Permitted: true},
|
{Name: "health", Description: "Check API health", Permitted: true},
|
||||||
{Name: "config", Description: "View and manage CLI configuration", Permitted: true},
|
{Name: "config", Description: "View and manage CLI configuration", Permitted: true},
|
||||||
|
{
|
||||||
|
Name: "agent",
|
||||||
|
Description: "Runtime status reporting for the calling agent (uses AGENT_ID/CLAW_IDENTIFIER env)",
|
||||||
|
SubCommands: []Command{
|
||||||
|
{Name: "status", Description: "Report runtime status: hf agent status --set <idle|busy|on_call|exhausted|offline>", Permitted: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "user",
|
Name: "user",
|
||||||
Description: "Manage users",
|
Description: "Manage users",
|
||||||
@@ -41,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},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,7 +76,7 @@ func CommandSurface() []Group {
|
|||||||
SubCommands: []Command{
|
SubCommands: []Command{
|
||||||
{Name: "list", Description: "List projects", Permitted: has(perms, "project.read")},
|
{Name: "list", Description: "List projects", Permitted: has(perms, "project.read")},
|
||||||
{Name: "get", Description: "Show a project by code", Permitted: has(perms, "project.read")},
|
{Name: "get", Description: "Show a project by code", Permitted: has(perms, "project.read")},
|
||||||
{Name: "create", Description: "Create a project", Permitted: has(perms, "project.write")},
|
{Name: "create", Description: "Create a project", Permitted: has(perms, "project.create")},
|
||||||
{Name: "update", Description: "Update a project", Permitted: has(perms, "project.write")},
|
{Name: "update", Description: "Update a project", Permitted: has(perms, "project.write")},
|
||||||
{Name: "delete", Description: "Delete a project", Permitted: has(perms, "project.delete")},
|
{Name: "delete", Description: "Delete a project", Permitted: has(perms, "project.delete")},
|
||||||
{Name: "members", Description: "List project members", Permitted: has(perms, "project.read")},
|
{Name: "members", Description: "List project members", Permitted: has(perms, "project.read")},
|
||||||
@@ -76,6 +84,30 @@ func CommandSurface() []Group {
|
|||||||
{Name: "remove-member", Description: "Remove a project member", Permitted: has(perms, "project.manage_members")},
|
{Name: "remove-member", Description: "Remove a project member", Permitted: has(perms, "project.manage_members")},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "knowledge-base",
|
||||||
|
Description: "Manage knowledge bases (topics, categories, facts) and project links",
|
||||||
|
SubCommands: []Command{
|
||||||
|
{Name: "list", Description: "List knowledge bases", Permitted: has(perms, "knowledge-base.read")},
|
||||||
|
{Name: "get", Description: "Show a knowledge base by code", Permitted: has(perms, "knowledge-base.read")},
|
||||||
|
{Name: "tree", Description: "Show the full topic/category/fact tree", Permitted: has(perms, "knowledge-base.read")},
|
||||||
|
{Name: "topics", Description: "List topics in a knowledge base", Permitted: has(perms, "knowledge-base.read")},
|
||||||
|
{Name: "create", Description: "Create a knowledge base", Permitted: has(perms, "knowledge-base.create")},
|
||||||
|
{Name: "update", Description: "Update a knowledge base", Permitted: has(perms, "knowledge-base.update")},
|
||||||
|
{Name: "delete", Description: "Delete a knowledge base", Permitted: has(perms, "knowledge-base.delete")},
|
||||||
|
{Name: "link", Description: "Link a knowledge base to a project", Permitted: has(perms, "knowledge-base.update")},
|
||||||
|
{Name: "unlink", Description: "Unlink a knowledge base from a project", Permitted: has(perms, "knowledge-base.update")},
|
||||||
|
{Name: "add-topic", Description: "Add a topic", Permitted: has(perms, "knowledge-base.update")},
|
||||||
|
{Name: "update-topic", Description: "Update a topic", Permitted: has(perms, "knowledge-base.update")},
|
||||||
|
{Name: "delete-topic", Description: "Delete a topic", Permitted: has(perms, "knowledge-base.update")},
|
||||||
|
{Name: "add-category", Description: "Add a category", Permitted: has(perms, "knowledge-base.update")},
|
||||||
|
{Name: "update-category", Description: "Update a category", Permitted: has(perms, "knowledge-base.update")},
|
||||||
|
{Name: "delete-category", Description: "Delete a category (and its descendants)", Permitted: has(perms, "knowledge-base.update")},
|
||||||
|
{Name: "add-fact", Description: "Add a fact", Permitted: has(perms, "knowledge-base.update")},
|
||||||
|
{Name: "update-fact", Description: "Update a fact", Permitted: has(perms, "knowledge-base.update")},
|
||||||
|
{Name: "delete-fact", Description: "Delete a fact", Permitted: has(perms, "knowledge-base.update")},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "milestone",
|
Name: "milestone",
|
||||||
Description: "Manage milestones",
|
Description: "Manage milestones",
|
||||||
@@ -216,7 +248,12 @@ func loadPermissionState(token string) permissionState {
|
|||||||
return permissionState{Known: false, Permissions: map[string]struct{}{}}
|
return permissionState{Known: false, Permissions: map[string]struct{}{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
c := client.New(cfg.BaseURL, token)
|
// passmgr.GetToken() returns an api-key in padded-cell mode (provisioned
|
||||||
|
// by scripts/provision-hf-accounts.sh via `hf user reset-apikey`), so go
|
||||||
|
// through the X-API-Key path explicitly. client.New also auto-detects this
|
||||||
|
// nowadays, but the explicit call keeps the introspection path independent
|
||||||
|
// of that heuristic.
|
||||||
|
c := client.NewWithAPIKey(cfg.BaseURL, token)
|
||||||
data, err := c.Get("/auth/me/permissions")
|
data, err := c.Get("/auth/me/permissions")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return permissionState{Known: false, Permissions: map[string]struct{}{}}
|
return permissionState{Known: false, Permissions: map[string]struct{}{}}
|
||||||
|
|||||||
Reference in New Issue
Block a user