5 Commits

Author SHA1 Message Date
fdf1ba1b17 fix(milestone): use nested /projects/{project}/milestones routes + datetime due
Two contract bugs broke `hf milestone *` against the backend:

- The backend mounts milestones at prefix /projects/{project_id}/milestones
  (nested), but the CLI used flat /milestones, /milestones/<code>, etc. →
  every milestone create/get/update/delete/progress/list 404'd. Switch to
  the nested routes: list/create take --project; get/update/delete/progress
  derive the project from the milestone code (PFIXTU:00001 → PFIXTU) via a
  new milestoneProject() helper. list now requires --project.
- due_date is a REQUIRED datetime on the backend, but --due <yyyy-mm-dd>
  was sent date-only → 422 datetime_parsing. Anchor a bare date to
  start-of-day (toMilestoneDateTime), same as the worklog logged_date fix.

Verified on sim: milestone create/list/get/progress all succeed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:56:58 +01:00
6dcf43ce41 feat(help): add knowledge-base leaf help (per-command usage)
The knowledge-base command family was registered in the help surface
(group + subcommands) but had NO leaf entries in internal/help/leaf.go, so
`hf knowledge-base add-fact --help` (and every other kb subcommand) printed
no usage. Weak agents then guessed flags (e.g. `add-fact --kb --topic-id
--title` instead of `--topic <id> --fact <text>`).

Add leaf help for all 18 kb subcommands (list/get/tree/topics/create/update/
delete/link/unlink/add-topic/update-topic/delete-topic/add-category/
update-category/delete-category/add-fact/update-fact/delete-fact), with the
authoritative usage strings copied from internal/commands/knowledge_base.go.
Now `hf knowledge-base <sub> --help` renders proper usage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:52:40 +01:00
729365ca46 Merge fix/security-audit: CLI credential hardening 2026-06-01 09:23:52 +01:00
ef8d4dbdad Merge feat/knowledge-base: KnowledgeBase CLI commands 2026-06-01 09:23:52 +01:00
4df6e1bd5f feat(knowledge-base): wrap KnowledgeBase API in the CLI
Add `hf knowledge-base` group: list/get/tree/topics, create/update/delete,
link/unlink to projects, and add/update/delete for topics, categories and
facts. Mirrors the project command style (flag parsing, JSON/table output,
token resolution). Registered in the dispatcher and the help surface, gated
on the knowledge-base.* permissions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:03:22 +01:00
5 changed files with 887 additions and 83 deletions

View File

@@ -216,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
@@ -731,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

View 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
}

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" "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/config"
@@ -32,11 +33,30 @@ type milestoneProgressResponse struct {
Progress float64 `json:"progress"` Progress float64 `json:"progress"`
} }
// milestoneProject extracts the project code from a milestone code
// (e.g. "PFIXTU:00001" -> "PFIXTU"); milestones are nested under their
// project in the API (/projects/{project}/milestones/{code}).
func milestoneProject(code string) string {
if i := strings.IndexByte(code, ':'); i >= 0 {
return code[:i]
}
return code
}
// toMilestoneDateTime anchors a bare YYYY-MM-DD due date to a datetime, since
// the backend's due_date field requires a full datetime.
func toMilestoneDateTime(d string) string {
if len(d) == 10 {
return d + "T00:00:00Z"
}
return d
}
// RunMilestoneList implements `hf milestone list --project <project-code>`. // RunMilestoneList implements `hf milestone list --project <project-code>`.
func RunMilestoneList(args []string, tokenFlag string) { func RunMilestoneList(args []string, tokenFlag string) {
token := ResolveToken(tokenFlag) token := ResolveToken(tokenFlag)
query := "" query, project := "", ""
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
switch args[i] { switch args[i] {
case "--project": case "--project":
@@ -44,7 +64,7 @@ func RunMilestoneList(args []string, tokenFlag string) {
output.Error("--project requires a value") output.Error("--project requires a value")
} }
i++ i++
query = appendQuery(query, "project_code", args[i]) project = args[i]
case "--status": case "--status":
if i+1 >= len(args) { if i+1 >= len(args) {
output.Error("--status requires a value") output.Error("--status requires a value")
@@ -66,8 +86,11 @@ func RunMilestoneList(args []string, tokenFlag string) {
if err != nil { if err != nil {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
if project == "" {
output.Error("--project is required (milestones are listed per project)")
}
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
path := "/milestones" path := "/projects/" + project + "/milestones"
if query != "" { if query != "" {
path += "?" + query path += "?" + query
} }
@@ -110,7 +133,7 @@ func RunMilestoneGet(milestoneCode, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
data, err := c.Get("/milestones/" + milestoneCode) data, err := c.Get("/projects/" + milestoneProject(milestoneCode) + "/milestones/" + milestoneCode)
if err != nil { if err != nil {
output.Errorf("failed to get milestone: %v", err) output.Errorf("failed to get milestone: %v", err)
} }
@@ -196,7 +219,7 @@ func RunMilestoneCreate(args []string, tokenFlag string) {
payload["description"] = desc payload["description"] = desc
} }
if due != "" { if due != "" {
payload["due_date"] = due payload["due_date"] = toMilestoneDateTime(due)
} }
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
@@ -209,7 +232,7 @@ func RunMilestoneCreate(args []string, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
data, err := c.Post("/milestones", bytes.NewReader(body)) data, err := c.Post("/projects/"+project+"/milestones", bytes.NewReader(body))
if err != nil { if err != nil {
output.Errorf("failed to create milestone: %v", err) output.Errorf("failed to create milestone: %v", err)
} }
@@ -261,7 +284,7 @@ func RunMilestoneUpdate(milestoneCode string, args []string, tokenFlag string) {
output.Error("--due requires a value") output.Error("--due requires a value")
} }
i++ i++
payload["due_date"] = args[i] payload["due_date"] = toMilestoneDateTime(args[i])
default: default:
output.Errorf("unknown flag: %s", args[i]) output.Errorf("unknown flag: %s", args[i])
} }
@@ -281,7 +304,7 @@ func RunMilestoneUpdate(milestoneCode string, args []string, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
_, err = c.Patch("/milestones/"+milestoneCode, bytes.NewReader(body)) _, err = c.Patch("/projects/"+milestoneProject(milestoneCode)+"/milestones/"+milestoneCode, bytes.NewReader(body))
if err != nil { if err != nil {
output.Errorf("failed to update milestone: %v", err) output.Errorf("failed to update milestone: %v", err)
} }
@@ -297,7 +320,7 @@ func RunMilestoneDelete(milestoneCode, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
_, err = c.Delete("/milestones/" + milestoneCode) _, err = c.Delete("/projects/" + milestoneProject(milestoneCode) + "/milestones/" + milestoneCode)
if err != nil { if err != nil {
output.Errorf("failed to delete milestone: %v", err) output.Errorf("failed to delete milestone: %v", err)
} }
@@ -312,7 +335,7 @@ func RunMilestoneProgress(milestoneCode, tokenFlag string) {
output.Errorf("config error: %v", err) output.Errorf("config error: %v", err)
} }
c := client.New(cfg.BaseURL, token) c := client.New(cfg.BaseURL, token)
data, err := c.Get("/milestones/" + milestoneCode + "/progress") data, err := c.Get("/projects/" + milestoneProject(milestoneCode) + "/milestones/" + milestoneCode + "/progress")
if err != nil { if err != nil {
output.Errorf("failed to get milestone progress: %v", err) output.Errorf("failed to get milestone progress: %v", err)
} }

View File

@@ -193,6 +193,25 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) {
"monitor/api-key": {Summary: "Manage monitor API keys", Usage: []string{"hf monitor api-key generate <identifier>", "hf monitor api-key revoke <identifier>"}, Flags: authFlagHelp()}, "monitor/api-key": {Summary: "Manage monitor API keys", Usage: []string{"hf monitor api-key generate <identifier>", "hf monitor api-key revoke <identifier>"}, Flags: authFlagHelp()},
"monitor/api-key/generate": {Summary: "Generate a monitor API key", Usage: []string{"hf monitor api-key generate <identifier>"}, Flags: authFlagHelp()}, "monitor/api-key/generate": {Summary: "Generate a monitor API key", Usage: []string{"hf monitor api-key generate <identifier>"}, Flags: authFlagHelp()},
"monitor/api-key/revoke": {Summary: "Revoke a monitor API key", Usage: []string{"hf monitor api-key revoke <identifier>"}, Flags: authFlagHelp()}, "monitor/api-key/revoke": {Summary: "Revoke a monitor API key", Usage: []string{"hf monitor api-key revoke <identifier>"}, Flags: authFlagHelp()},
"knowledge-base/list": {Summary: "List knowledge bases", Usage: []string{"hf knowledge-base list [--project <project-code>]"}, Flags: authFlagHelp()},
"knowledge-base/get": {Summary: "Show a knowledge base by code", Usage: []string{"hf knowledge-base get <kb-code>"}, Flags: authFlagHelp()},
"knowledge-base/tree": {Summary: "Show the full topic/category/fact tree", Usage: []string{"hf knowledge-base tree <kb-code>"}, Flags: authFlagHelp()},
"knowledge-base/topics": {Summary: "List topics in a knowledge base", Usage: []string{"hf knowledge-base topics <kb-code>"}, Flags: authFlagHelp()},
"knowledge-base/create": {Summary: "Create a knowledge base", Usage: []string{"hf knowledge-base create --title <title> [--desc <description>]"}, Flags: authFlagHelp()},
"knowledge-base/update": {Summary: "Update a knowledge base", Usage: []string{"hf knowledge-base update <kb-code> [--title <title>] [--desc <description>]"}, Flags: authFlagHelp()},
"knowledge-base/delete": {Summary: "Delete a knowledge base", Usage: []string{"hf knowledge-base delete <kb-code>"}, Flags: authFlagHelp()},
"knowledge-base/link": {Summary: "Link a knowledge base to a project", Usage: []string{"hf knowledge-base link <kb-code> --project <project-code>"}, Flags: authFlagHelp()},
"knowledge-base/unlink": {Summary: "Unlink a knowledge base from a project", Usage: []string{"hf knowledge-base unlink <kb-code> --project <project-code>"}, Flags: authFlagHelp()},
"knowledge-base/add-topic": {Summary: "Add a topic to a knowledge base", Usage: []string{"hf knowledge-base add-topic <kb-code> --topic <name> [--desc <description>]"}, Flags: authFlagHelp()},
"knowledge-base/update-topic": {Summary: "Update a topic", Usage: []string{"hf knowledge-base update-topic <topic-id> [--topic <name>] [--desc <description>]"}, Flags: authFlagHelp()},
"knowledge-base/delete-topic": {Summary: "Delete a topic", Usage: []string{"hf knowledge-base delete-topic <topic-id>"}, Flags: authFlagHelp()},
"knowledge-base/add-category": {Summary: "Add a category under a topic", Usage: []string{"hf knowledge-base add-category --topic <topic-id> --name <name> [--parent <category-id>] [--desc <description>]"}, Flags: authFlagHelp()},
"knowledge-base/update-category": {Summary: "Update a category", Usage: []string{"hf knowledge-base update-category <category-id> [--name <name>] [--parent <category-id>] [--desc <description>]"}, Flags: authFlagHelp()},
"knowledge-base/delete-category": {Summary: "Delete a category", Usage: []string{"hf knowledge-base delete-category <category-id>"}, Flags: authFlagHelp()},
"knowledge-base/add-fact": {Summary: "Add a fact under a topic (optionally a category)", Usage: []string{"hf knowledge-base add-fact --topic <topic-id> [--category <category-id>] --fact <text>"}, Flags: authFlagHelp()},
"knowledge-base/update-fact": {Summary: "Update a fact", Usage: []string{"hf knowledge-base update-fact <fact-id> [--fact <text>] [--category <category-id>]"}, Flags: authFlagHelp()},
"knowledge-base/delete-fact": {Summary: "Delete a fact", Usage: []string{"hf knowledge-base delete-fact <fact-id>"}, Flags: authFlagHelp()},
} }
if group == "" { if group == "" {

View File

@@ -84,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",