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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
/ark
|
/ark
|
||||||
*.db
|
*.db
|
||||||
|
bin/
|
||||||
14
PROGRESS.md
14
PROGRESS.md
@@ -46,7 +46,19 @@ Tracks implementation progress against the phases in `PROJECT_PLAN.md`.
|
|||||||
- `proto/exo/v1/*.pb.go` (generated)
|
- `proto/exo/v1/*.pb.go` (generated)
|
||||||
- `server/server.go`, `server/server_test.go`
|
- `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
|
## Phase 4: Knowledge Graph — NOT STARTED
|
||||||
|
|
||||||
|
|||||||
205
cmd/exo/main.go
Normal file
205
cmd/exo/main.go
Normal 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
128
cmd/exo/yaml.go
Normal 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
88
cmd/exod/main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user