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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
/ark
|
||||
*.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)
|
||||
- `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
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