Add Phase 3 CLI tools: exo client and exod server binaries

- exod: gRPC daemon with auto-migration, graceful shutdown (SIGINT/SIGTERM),
  configurable listen address and data directory via flags
- exo: CLI client with import (YAML artifacts), tag add/list, cat add/list,
  search by tag commands; connects via EXO_ADDR env var
- Add bin/ to .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 09:59:54 -07:00
parent b64177baa8
commit a336dc1ebb
5 changed files with 436 additions and 2 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/ark
*.db
bin/

View File

@@ -46,7 +46,19 @@ Tracks implementation progress against the phases in `PROJECT_PLAN.md`.
- `proto/exo/v1/*.pb.go` (generated)
- `server/server.go`, `server/server_test.go`
## Phase 3: CLI Tools — NOT STARTED
## Phase 3: CLI Tools — COMPLETE
**Deliverables:**
- [x] `exod` server binary: gRPC daemon with startup, graceful shutdown (SIGINT/SIGTERM), auto-migration, flag-based configuration (`-listen`, `-base`, `-version`)
- [x] `exo` CLI binary: `import` (YAML artifacts), `tag add/list`, `cat add/list`, `search tag`
- [x] YAML import parser in CLI (converts to proto messages for gRPC transport)
- [x] Environment variable support (`EXO_ADDR` for server address)
- [x] Makefile `build` target produces both binaries in `bin/`
- [x] Version injection via `-ldflags`
**Files created:**
- `cmd/exod/main.go`
- `cmd/exo/main.go`, `cmd/exo/yaml.go`
## Phase 4: Knowledge Graph — NOT STARTED

205
cmd/exo/main.go Normal file
View File

@@ -0,0 +1,205 @@
// 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
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:])
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
usage()
}
}
func dial() pb.ArtifactServiceClient {
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)
}
// Connection will be closed when the process exits.
return pb.NewArtifactServiceClient(conn)
}
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)
}
}

128
cmd/exo/yaml.go Normal file
View File

@@ -0,0 +1,128 @@
package main
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
pb "git.wntrmute.dev/kyle/exo/proto/exo/v1"
)
// yamlArtifact mirrors the YAML format from ark/go-v2 for import compatibility.
type yamlArtifact struct {
ID string `yaml:"id"`
Type string `yaml:"type"`
Citation *yamlCitation `yaml:"citation"`
Latest string `yaml:"latest"`
Tags []string `yaml:"tags"`
Categories []string `yaml:"categories"`
Snapshots []yamlSnapshot `yaml:"snapshots"`
Metadata []yamlMetadata `yaml:"metadata"`
}
type yamlCitation struct {
ID string `yaml:"id"`
DOI string `yaml:"doi"`
Title string `yaml:"title"`
Year int `yaml:"year"`
Published string `yaml:"published"`
Authors []string `yaml:"authors"`
Publisher *yamlPublisher `yaml:"publisher"`
Source string `yaml:"source"`
Abstract string `yaml:"abstract"`
}
type yamlPublisher struct {
Name string `yaml:"name"`
Address string `yaml:"address"`
}
type yamlSnapshot struct {
ID string `yaml:"id"`
Stored int64 `yaml:"stored"`
Datetime string `yaml:"datetime"`
Citation *yamlCitation `yaml:"citation"`
Source string `yaml:"source"`
Blobs []yamlBlobRef `yaml:"blobs"`
}
type yamlBlobRef struct {
Format string `yaml:"format"`
Path string `yaml:"path"`
}
type yamlMetadata struct {
Key string `yaml:"key"`
Contents string `yaml:"contents"`
Type string `yaml:"type"`
}
func loadYAMLArtifact(path string) (*pb.Artifact, error) {
data, err := os.ReadFile(path) //nolint:gosec // user-provided import file
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", path, err)
}
var ya yamlArtifact
if err := yaml.Unmarshal(data, &ya); err != nil {
return nil, fmt.Errorf("failed to parse %s: %w", path, err)
}
art := &pb.Artifact{
Id: ya.ID,
Type: ya.Type,
Latest: ya.Latest,
Tags: ya.Tags,
Categories: ya.Categories,
}
if ya.Citation != nil {
art.Citation = yamlCitationToProto(ya.Citation)
}
for _, m := range ya.Metadata {
art.Metadata = append(art.Metadata, &pb.MetadataEntry{
Key: m.Key,
Value: &pb.Value{Contents: m.Contents, Type: m.Type},
})
}
for _, s := range ya.Snapshots {
snap := &pb.Snapshot{
Id: s.ID,
StoredAt: s.Stored,
Datetime: s.Datetime,
Source: s.Source,
}
if s.Citation != nil {
snap.Citation = yamlCitationToProto(s.Citation)
}
// Note: blob file reading is handled by the server via the blob store.
// For import, we could read and send blob data here, but that would
// require streaming. For now, blob paths are noted but not sent.
art.Snapshots = append(art.Snapshots, snap)
}
return art, nil
}
func yamlCitationToProto(yc *yamlCitation) *pb.Citation {
c := &pb.Citation{
Id: yc.ID,
Doi: yc.DOI,
Title: yc.Title,
Year: int32(yc.Year), //nolint:gosec // year values are always small
Published: yc.Published,
Authors: yc.Authors,
Source: yc.Source,
Abstract: yc.Abstract,
}
if yc.Publisher != nil {
c.Publisher = &pb.Publisher{
Name: yc.Publisher.Name,
Address: yc.Publisher.Address,
}
}
return c
}

88
cmd/exod/main.go Normal file
View File

@@ -0,0 +1,88 @@
// Command exod is the gRPC backend daemon for the exo system.
// It is the sole owner of the SQLite database and blob store.
package main
import (
"flag"
"log"
"net"
"os"
"os/signal"
"syscall"
"google.golang.org/grpc"
"git.wntrmute.dev/kyle/exo/blob"
"git.wntrmute.dev/kyle/exo/config"
"git.wntrmute.dev/kyle/exo/db"
pb "git.wntrmute.dev/kyle/exo/proto/exo/v1"
"git.wntrmute.dev/kyle/exo/server"
)
var version = "dev"
func main() {
var (
listenAddr = flag.String("listen", "", "gRPC listen address (default from config)")
basePath = flag.String("base", "", "base data directory (default $HOME/exo)")
showVer = flag.Bool("version", false, "print version and exit")
)
flag.Parse()
if *showVer {
log.Printf("exod %s", version)
os.Exit(0)
}
cfg := config.Default()
if *basePath != "" {
cfg = config.FromBasePath(*basePath)
}
if *listenAddr != "" {
cfg.GRPCListenAddr = *listenAddr
}
// Ensure data directories exist.
if err := os.MkdirAll(cfg.BasePath, 0o750); err != nil {
log.Fatalf("exod: failed to create base directory: %v", err)
}
if err := os.MkdirAll(cfg.BlobStorePath, 0o750); err != nil {
log.Fatalf("exod: failed to create blob store directory: %v", err)
}
// Open and migrate the database.
database, err := db.Open(cfg.DatabasePath)
if err != nil {
log.Fatalf("exod: failed to open database: %v", err)
}
defer func() { _ = database.Close() }()
if err := db.Migrate(database); err != nil {
log.Fatalf("exod: failed to migrate database: %v", err)
}
blobStore := blob.NewStore(cfg.BlobStorePath)
// Start gRPC server.
lis, err := net.Listen("tcp", cfg.GRPCListenAddr)
if err != nil {
log.Fatalf("exod: failed to listen on %s: %v", cfg.GRPCListenAddr, err)
}
grpcServer := grpc.NewServer()
pb.RegisterArtifactServiceServer(grpcServer, server.NewArtifactServer(database, blobStore))
// Graceful shutdown on SIGINT/SIGTERM.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
log.Printf("exod: received %s, shutting down", sig)
grpcServer.GracefulStop()
}()
log.Printf("exod %s listening on %s", version, cfg.GRPCListenAddr)
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("exod: gRPC server error: %v", err)
}
}