diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 5175af5..281d8cb 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -216,6 +216,9 @@ func handleGroup(group help.Group, args []string) { case "project": handleProjectCommand(sub.Name, remaining) return + case "knowledge-base": + handleKnowledgeBaseCommand(sub.Name, remaining) + return case "milestone": handleMilestoneCommand(sub.Name, remaining) 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 ") + commands.RunKnowledgeBaseGet(filtered[0], tokenFlag) + case "create": + commands.RunKnowledgeBaseCreate(filtered, tokenFlag) + case "update": + needArg("usage: hf knowledge-base update [--title ...] [--desc ...]") + commands.RunKnowledgeBaseUpdate(filtered[0], filtered[1:], tokenFlag) + case "delete": + needArg("usage: hf knowledge-base delete ") + commands.RunKnowledgeBaseDelete(filtered[0], tokenFlag) + case "tree": + needArg("usage: hf knowledge-base tree ") + commands.RunKnowledgeBaseTree(filtered[0], tokenFlag) + case "link": + needArg("usage: hf knowledge-base link --project ") + commands.RunKnowledgeBaseLink(filtered[0], filtered[1:], tokenFlag) + case "unlink": + needArg("usage: hf knowledge-base unlink --project ") + commands.RunKnowledgeBaseUnlink(filtered[0], filtered[1:], tokenFlag) + case "topics": + needArg("usage: hf knowledge-base topics ") + commands.RunKnowledgeBaseTopics(filtered[0], tokenFlag) + case "add-topic": + needArg("usage: hf knowledge-base add-topic --topic [--desc ...]") + commands.RunKnowledgeBaseAddTopic(filtered[0], filtered[1:], tokenFlag) + case "update-topic": + needArg("usage: hf knowledge-base update-topic [--topic ...] [--desc ...]") + commands.RunKnowledgeBaseUpdateTopic(filtered[0], filtered[1:], tokenFlag) + case "delete-topic": + needArg("usage: hf knowledge-base delete-topic ") + commands.RunKnowledgeBaseDeleteTopic(filtered[0], tokenFlag) + case "add-category": + commands.RunKnowledgeBaseAddCategory(filtered, tokenFlag) + case "update-category": + needArg("usage: hf knowledge-base update-category [--name ...] [--parent ...] [--desc ...]") + commands.RunKnowledgeBaseUpdateCategory(filtered[0], filtered[1:], tokenFlag) + case "delete-category": + needArg("usage: hf knowledge-base delete-category ") + commands.RunKnowledgeBaseDeleteCategory(filtered[0], tokenFlag) + case "add-fact": + commands.RunKnowledgeBaseAddFact(filtered, tokenFlag) + case "update-fact": + needArg("usage: hf knowledge-base update-fact [--fact ...] [--category ...]") + commands.RunKnowledgeBaseUpdateFact(filtered[0], filtered[1:], tokenFlag) + case "delete-fact": + needArg("usage: hf knowledge-base delete-fact ") + commands.RunKnowledgeBaseDeleteFact(filtered[0], tokenFlag) + default: + output.Errorf("hf knowledge-base %s is not implemented yet", subCmd) + } +} + func handleMeetingCommand(subCmd string, args []string) { tokenFlag := "" var filtered []string diff --git a/internal/commands/knowledge_base.go b/internal/commands/knowledge_base.go new file mode 100644 index 0000000..418b884 --- /dev/null +++ b/internal/commands/knowledge_base.go @@ -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 ]`. +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 `. +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 [--desc ]`. +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 [--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 +} diff --git a/internal/help/surface.go b/internal/help/surface.go index a2ce8c2..f49c299 100644 --- a/internal/help/surface.go +++ b/internal/help/surface.go @@ -84,6 +84,30 @@ func CommandSurface() []Group { {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", Description: "Manage milestones",