Files
exo/cmd/exo/main.go
Kyle Isom 051a85d846 Add Phase 4 knowledge graph: nodes, cells, facts, edges, gRPC service
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>
2026-03-21 10:05:43 -07:00

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