Build the knowledge graph pillar with the kg package: - Node: hierarchical notes with parent/children, C2 wiki-style naming, shared tag/category pool with artifacts - Cell: content units (markdown, code, plain) with ordinal ordering - Fact: EAV tuples with transaction timestamps and retraction support - Edge: directed graph links (child, parent, related, artifact_link) Includes schema migration (002_knowledge_graph.sql), protobuf definitions (kg.proto), full gRPC KnowledgeGraphService implementation, CLI commands (node create/get), and comprehensive test coverage. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
276 lines
6.1 KiB
Go
276 lines
6.1 KiB
Go
// Command exo is the CLI client for the exo system.
|
|
// It connects to exod via gRPC for all operations.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
|
|
pb "git.wntrmute.dev/kyle/exo/proto/exo/v1"
|
|
)
|
|
|
|
var version = "dev"
|
|
|
|
func usage() {
|
|
fmt.Fprintf(os.Stderr, `exo — kExocortex CLI client (version %s)
|
|
|
|
Usage:
|
|
exo <command> [arguments]
|
|
|
|
Commands:
|
|
import <file.yaml> [...] Import artifacts from YAML files
|
|
tag add <tag> [...] Create tags
|
|
tag list List all tags
|
|
cat add <category> [...] Create categories
|
|
cat list List all categories
|
|
search tag <tag> Search artifacts by tag
|
|
node create <name> Create a knowledge graph node
|
|
node get <id> Get a node with its cells
|
|
version Print version
|
|
|
|
Environment:
|
|
EXO_ADDR gRPC server address (default: localhost:9090)
|
|
`, version)
|
|
os.Exit(1)
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
usage()
|
|
}
|
|
|
|
switch os.Args[1] {
|
|
case "version":
|
|
fmt.Printf("exo %s\n", version)
|
|
case "import":
|
|
runImport(os.Args[2:])
|
|
case "tag":
|
|
runTag(os.Args[2:])
|
|
case "cat":
|
|
runCat(os.Args[2:])
|
|
case "search":
|
|
runSearch(os.Args[2:])
|
|
case "node":
|
|
runNode(os.Args[2:])
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
|
usage()
|
|
}
|
|
}
|
|
|
|
func dialConn() *grpc.ClientConn {
|
|
addr := os.Getenv("EXO_ADDR")
|
|
if addr == "" {
|
|
addr = "localhost:9090"
|
|
}
|
|
|
|
conn, err := grpc.NewClient(addr,
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
if err != nil {
|
|
log.Fatalf("exo: failed to connect to %s: %v", addr, err)
|
|
}
|
|
return conn
|
|
}
|
|
|
|
func dial() pb.ArtifactServiceClient {
|
|
return pb.NewArtifactServiceClient(dialConn())
|
|
}
|
|
|
|
func dialKG() pb.KnowledgeGraphServiceClient {
|
|
return pb.NewKnowledgeGraphServiceClient(dialConn())
|
|
}
|
|
|
|
func runImport(args []string) {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(os.Stderr, "usage: exo import <file.yaml> [...]")
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := dial()
|
|
ctx := context.Background()
|
|
|
|
for _, path := range args {
|
|
yamlArt, err := loadYAMLArtifact(path)
|
|
if err != nil {
|
|
log.Fatalf("exo: failed to load %s: %v", path, err)
|
|
}
|
|
|
|
resp, err := client.CreateArtifact(ctx, &pb.CreateArtifactRequest{
|
|
Artifact: yamlArt,
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("exo: failed to import %s: %v", path, err)
|
|
}
|
|
fmt.Printf("imported %s -> %s\n", path, resp.Id)
|
|
}
|
|
}
|
|
|
|
func runTag(args []string) {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(os.Stderr, "usage: exo tag <add|list> [...]")
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := dial()
|
|
ctx := context.Background()
|
|
|
|
switch args[0] {
|
|
case "add":
|
|
if len(args) < 2 {
|
|
fmt.Fprintln(os.Stderr, "usage: exo tag add <tag> [...]")
|
|
os.Exit(1)
|
|
}
|
|
for _, tag := range args[1:] {
|
|
_, err := client.CreateTag(ctx, &pb.CreateTagRequest{Tag: tag})
|
|
if err != nil {
|
|
log.Fatalf("exo: failed to create tag %q: %v", tag, err)
|
|
}
|
|
fmt.Printf("created tag: %s\n", tag)
|
|
}
|
|
|
|
case "list":
|
|
resp, err := client.ListTags(ctx, &pb.ListTagsRequest{})
|
|
if err != nil {
|
|
log.Fatalf("exo: failed to list tags: %v", err)
|
|
}
|
|
for _, tag := range resp.Tags {
|
|
fmt.Println(tag)
|
|
}
|
|
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown tag subcommand: %s\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func runCat(args []string) {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(os.Stderr, "usage: exo cat <add|list> [...]")
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := dial()
|
|
ctx := context.Background()
|
|
|
|
switch args[0] {
|
|
case "add":
|
|
if len(args) < 2 {
|
|
fmt.Fprintln(os.Stderr, "usage: exo cat add <category> [...]")
|
|
os.Exit(1)
|
|
}
|
|
for _, cat := range args[1:] {
|
|
_, err := client.CreateCategory(ctx, &pb.CreateCategoryRequest{Category: cat})
|
|
if err != nil {
|
|
log.Fatalf("exo: failed to create category %q: %v", cat, err)
|
|
}
|
|
fmt.Printf("created category: %s\n", cat)
|
|
}
|
|
|
|
case "list":
|
|
resp, err := client.ListCategories(ctx, &pb.ListCategoriesRequest{})
|
|
if err != nil {
|
|
log.Fatalf("exo: failed to list categories: %v", err)
|
|
}
|
|
for _, cat := range resp.Categories {
|
|
fmt.Println(cat)
|
|
}
|
|
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown cat subcommand: %s\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func runSearch(args []string) {
|
|
if len(args) < 2 {
|
|
fmt.Fprintln(os.Stderr, "usage: exo search tag <tag>")
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := dial()
|
|
ctx := context.Background()
|
|
|
|
switch args[0] {
|
|
case "tag":
|
|
resp, err := client.SearchByTag(ctx, &pb.SearchByTagRequest{Tag: args[1]})
|
|
if err != nil {
|
|
log.Fatalf("exo: search failed: %v", err)
|
|
}
|
|
if len(resp.ArtifactIds) == 0 {
|
|
fmt.Println("no results")
|
|
return
|
|
}
|
|
for _, id := range resp.ArtifactIds {
|
|
fmt.Println(id)
|
|
}
|
|
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown search type: %s\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func runNode(args []string) {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(os.Stderr, "usage: exo node <create|get> [...]")
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := dialKG()
|
|
ctx := context.Background()
|
|
|
|
switch args[0] {
|
|
case "create":
|
|
if len(args) < 2 {
|
|
fmt.Fprintln(os.Stderr, "usage: exo node create <name>")
|
|
os.Exit(1)
|
|
}
|
|
resp, err := client.CreateNode(ctx, &pb.CreateNodeRequest{
|
|
Name: args[1],
|
|
Type: "note",
|
|
})
|
|
if err != nil {
|
|
log.Fatalf("exo: failed to create node: %v", err)
|
|
}
|
|
fmt.Printf("created node: %s\n", resp.Id)
|
|
|
|
case "get":
|
|
if len(args) < 2 {
|
|
fmt.Fprintln(os.Stderr, "usage: exo node get <id>")
|
|
os.Exit(1)
|
|
}
|
|
resp, err := client.GetNode(ctx, &pb.GetNodeRequest{Id: args[1]})
|
|
if err != nil {
|
|
log.Fatalf("exo: failed to get node: %v", err)
|
|
}
|
|
n := resp.Node
|
|
fmt.Printf("ID: %s\n", n.Id)
|
|
fmt.Printf("Name: %s\n", n.Name)
|
|
fmt.Printf("Type: %s\n", n.Type)
|
|
fmt.Printf("Parent: %s\n", n.ParentId)
|
|
fmt.Printf("Created: %s\n", n.Created)
|
|
fmt.Printf("Modified: %s\n", n.Modified)
|
|
if len(n.Children) > 0 {
|
|
fmt.Printf("Children: %v\n", n.Children)
|
|
}
|
|
if len(n.Tags) > 0 {
|
|
fmt.Printf("Tags: %v\n", n.Tags)
|
|
}
|
|
if len(resp.Cells) > 0 {
|
|
fmt.Printf("Cells: %d\n", len(resp.Cells))
|
|
for _, c := range resp.Cells {
|
|
fmt.Printf(" [%d] %s (%s): %s\n", c.Ordinal, c.Id, c.Type, string(c.Contents))
|
|
}
|
|
}
|
|
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown node subcommand: %s\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
}
|