feat: CLI fn ops — init, entity, relation, graph, snapshot

Subcomando ops integrado en el CLI fn. Soporta CRUD de entities con
snapshot automatico de tipos, CRUD de relations con validacion de ciclos,
dump ASCII del grafo, y listado de tipos snapshotted. Variable de entorno
FN_REGISTRY_ROOT para acceder al registry desde cualquier directorio.
This commit is contained in:
2026-03-28 04:37:59 +01:00
parent 2d3f4b4448
commit 5b83b3f128
2 changed files with 638 additions and 1 deletions
+4 -1
View File
@@ -29,6 +29,8 @@ func main() {
cmdShow(os.Args[2:])
case "add":
cmdAdd(os.Args[2:])
case "ops":
cmdOps(os.Args[2:])
case "help", "-h", "--help":
printUsage()
default:
@@ -46,7 +48,8 @@ Usage:
fn search [-k kind] [-p purity] [-l lang] [-d domain] <query>
fn list [-k kind] [-d domain] [-l lang]
fn show <id> Muestra entrada completa
fn add [-k kind] Abre $EDITOR con template`)
fn add [-k kind] Abre $EDITOR con template
fn ops <subcommand> Gestiona operations.db (fn ops help)`)
}
func root() string {
+634
View File
@@ -0,0 +1,634 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/tabwriter"
ops "fn-registry/fn_operations"
"fn-registry/registry"
)
const opsDBName = "operations.db"
func cmdOps(args []string) {
if len(args) < 1 {
printOpsUsage()
os.Exit(1)
}
switch args[0] {
case "init":
cmdOpsInit(args[1:])
case "entity":
cmdOpsEntity(args[1:])
case "relation":
cmdOpsRelation(args[1:])
case "graph":
cmdOpsGraph()
case "snapshot":
cmdOpsSnapshot(args[1:])
case "help", "-h", "--help":
printOpsUsage()
default:
fmt.Fprintf(os.Stderr, "unknown ops command: %s\n", args[0])
printOpsUsage()
os.Exit(1)
}
}
func printOpsUsage() {
fmt.Println(`fn ops — operations CLI
Usage:
fn ops init [path] Crea operations.db en path (default: .)
fn ops entity add <flags> Añade entity
fn ops entity list [-d domain] [-s status] Lista entities
fn ops entity show <id> Muestra entity
fn ops entity delete <id> Elimina entity
fn ops relation add <flags> Añade relation
fn ops relation list [entity_id] Lista relations
fn ops relation show <id> Muestra relation
fn ops relation delete <id> Elimina relation
fn ops graph Grafo ASCII de entities y relations
fn ops snapshot list Lista tipos snapshotted
Entity flags:
--id <id> --name <name> --type-ref <type_id> --source <source>
--domain <domain> --status <status> --description <desc>
--tags <t1,t2> --metadata <json> --notes <text>
Relation flags:
--id <id> --name <name> --from <entity_id> --to <entity_id>
--via <function_id> --direction <uni|bi|inverse> --status <status>
--purity <pure|impure> --weight <0.0-1.0> --description <desc>
--tags <t1,t2> --notes <text>`)
}
// --- ops init ---
func cmdOpsInit(args []string) {
dir := "."
if len(args) > 0 {
dir = args[0]
}
path := filepath.Join(dir, opsDBName)
if _, err := os.Stat(path); err == nil {
fmt.Fprintf(os.Stderr, "operations.db already exists at %s\n", path)
os.Exit(1)
}
// Copy from template if available, otherwise create fresh
templatePath := filepath.Join(root(), "fn_operations", "project_template", "operations.db")
if _, err := os.Stat(templatePath); err == nil {
src, err := os.Open(templatePath)
if err != nil {
fmt.Fprintf(os.Stderr, "error opening template: %v\n", err)
os.Exit(1)
}
defer src.Close()
if err := os.MkdirAll(dir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "error creating directory: %v\n", err)
os.Exit(1)
}
dst, err := os.Create(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error creating db: %v\n", err)
os.Exit(1)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
fmt.Fprintf(os.Stderr, "error copying template: %v\n", err)
os.Exit(1)
}
fmt.Printf("operations.db created at %s (from template)\n", path)
return
}
// Create fresh
db, err := ops.Open(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
db.Close()
fmt.Printf("operations.db created at %s\n", path)
}
// --- ops entity ---
func cmdOpsEntity(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops entity <add|list|show|delete> ...")
os.Exit(1)
}
switch args[0] {
case "add":
cmdOpsEntityAdd(args[1:])
case "list":
cmdOpsEntityList(args[1:])
case "show":
cmdOpsEntityShow(args[1:])
case "delete":
cmdOpsEntityDelete(args[1:])
default:
fmt.Fprintf(os.Stderr, "unknown entity command: %s\n", args[0])
os.Exit(1)
}
}
func cmdOpsEntityAdd(args []string) {
var e ops.Entity
e.Status = ops.StatusActive
var tagsStr, metadataStr string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--id":
i++; e.ID = args[i]
case "--name":
i++; e.Name = args[i]
case "--type-ref":
i++; e.TypeRef = args[i]
case "--source":
i++; e.Source = args[i]
case "--domain":
i++; e.Domain = args[i]
case "--status":
i++; e.Status = ops.EntityStatus(args[i])
case "--description":
i++; e.Description = args[i]
case "--tags":
i++; tagsStr = args[i]
case "--metadata":
i++; metadataStr = args[i]
case "--notes":
i++; e.Notes = args[i]
}
}
if e.Name == "" || e.TypeRef == "" || e.Source == "" {
fmt.Fprintln(os.Stderr, "required: --name, --type-ref, --source")
os.Exit(1)
}
if e.ID == "" {
e.ID = e.Name
}
if tagsStr != "" {
e.Tags = strings.Split(tagsStr, ",")
}
if metadataStr != "" {
json.Unmarshal([]byte(metadataStr), &e.Metadata)
}
opsDB := openOpsDB()
defer opsDB.Close()
// Try to open registry for type snapshot
regDB := tryOpenRegistryDB()
if regDB != nil {
defer regDB.Close()
}
if err := ops.InsertEntityWithSnapshot(opsDB, regDB, &e); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Entity %s added\n", e.ID)
}
func cmdOpsEntityList(args []string) {
var domain string
var status ops.EntityStatus
for i := 0; i < len(args); i++ {
switch args[i] {
case "-d":
i++; domain = args[i]
case "-s":
i++; status = ops.EntityStatus(args[i])
}
}
db := openOpsDB()
defer db.Close()
entities, err := db.ListEntities(domain, status)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(entities) == 0 {
fmt.Println("No entities.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tTYPE_REF\tSTATUS\tSOURCE\tDOMAIN")
for _, e := range entities {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", e.ID, e.TypeRef, e.Status, e.Source, e.Domain)
}
w.Flush()
}
func cmdOpsEntityShow(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops entity show <id>")
os.Exit(1)
}
db := openOpsDB()
defer db.Close()
e, err := db.GetEntity(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if e == nil {
fmt.Fprintf(os.Stderr, "entity not found: %s\n", args[0])
os.Exit(1)
}
fmt.Printf("ID: %s\n", e.ID)
fmt.Printf("Name: %s\n", e.Name)
fmt.Printf("Type ref: %s\n", e.TypeRef)
fmt.Printf("Status: %s\n", e.Status)
fmt.Printf("Source: %s\n", e.Source)
fmt.Printf("Domain: %s\n", e.Domain)
fmt.Printf("Description: %s\n", e.Description)
fmt.Printf("Tags: %s\n", strings.Join(e.Tags, ", "))
if len(e.Metadata) > 0 {
meta, _ := json.MarshalIndent(e.Metadata, " ", " ")
fmt.Printf("Metadata: %s\n", meta)
}
if e.Notes != "" {
fmt.Printf("Notes: %s\n", e.Notes)
}
fmt.Printf("Created: %s\n", e.CreatedAt.Format("2006-01-02 15:04:05"))
fmt.Printf("Updated: %s\n", e.UpdatedAt.Format("2006-01-02 15:04:05"))
}
func cmdOpsEntityDelete(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops entity delete <id>")
os.Exit(1)
}
db := openOpsDB()
defer db.Close()
if err := db.DeleteEntity(args[0]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Entity %s deleted\n", args[0])
}
// --- ops relation ---
func cmdOpsRelation(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops relation <add|list|show|delete> ...")
os.Exit(1)
}
switch args[0] {
case "add":
cmdOpsRelationAdd(args[1:])
case "list":
cmdOpsRelationList(args[1:])
case "show":
cmdOpsRelationShow(args[1:])
case "delete":
cmdOpsRelationDelete(args[1:])
default:
fmt.Fprintf(os.Stderr, "unknown relation command: %s\n", args[0])
os.Exit(1)
}
}
func cmdOpsRelationAdd(args []string) {
var r ops.Relation
r.Direction = ops.DirUnidirectional
r.Status = ops.RelDesigned
var tagsStr string
for i := 0; i < len(args); i++ {
switch args[i] {
case "--id":
i++; r.ID = args[i]
case "--name":
i++; r.Name = args[i]
case "--from":
i++; r.FromEntity = args[i]
case "--to":
i++; r.ToEntity = args[i]
case "--via":
i++; r.Via = args[i]
case "--direction":
i++; r.Direction = ops.Direction(args[i])
case "--status":
i++; r.Status = ops.RelationStatus(args[i])
case "--purity":
i++; r.Purity = args[i]
case "--weight":
i++
var w float64
fmt.Sscanf(args[i], "%f", &w)
r.Weight = &w
case "--description":
i++; r.Description = args[i]
case "--tags":
i++; tagsStr = args[i]
case "--notes":
i++; r.Notes = args[i]
}
}
if r.Name == "" || r.ToEntity == "" {
fmt.Fprintln(os.Stderr, "required: --name, --to (and --from for simple relations)")
os.Exit(1)
}
if r.ID == "" && r.FromEntity != "" {
via := "semantic"
if r.Via != "" {
via = r.Via
}
r.ID = fmt.Sprintf("%s__to__%s__via__%s", r.FromEntity, r.ToEntity, via)
}
if tagsStr != "" {
r.Tags = strings.Split(tagsStr, ",")
}
db := openOpsDB()
defer db.Close()
if err := ops.InsertRelationSafe(db, &r); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Relation %s added\n", r.ID)
}
func cmdOpsRelationList(args []string) {
var entityID string
if len(args) > 0 {
entityID = args[0]
}
db := openOpsDB()
defer db.Close()
rels, err := db.ListRelations(entityID)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(rels) == 0 {
fmt.Println("No relations.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tNAME\tFROM\tTO\tVIA\tDIRECTION\tSTATUS")
for _, r := range rels {
from := r.FromEntity
if from == "" {
from = "(inputs)"
}
via := r.Via
if via == "" {
via = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
r.ID, r.Name, from, r.ToEntity, via, r.Direction, r.Status)
}
w.Flush()
}
func cmdOpsRelationShow(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops relation show <id>")
os.Exit(1)
}
db := openOpsDB()
defer db.Close()
r, err := db.GetRelation(args[0])
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if r == nil {
fmt.Fprintf(os.Stderr, "relation not found: %s\n", args[0])
os.Exit(1)
}
fmt.Printf("ID: %s\n", r.ID)
fmt.Printf("Name: %s\n", r.Name)
fmt.Printf("From: %s\n", r.FromEntity)
fmt.Printf("To: %s\n", r.ToEntity)
fmt.Printf("Via: %s\n", r.Via)
fmt.Printf("Description: %s\n", r.Description)
fmt.Printf("Purity: %s\n", r.Purity)
fmt.Printf("Direction: %s\n", r.Direction)
if r.Weight != nil {
fmt.Printf("Weight: %.2f\n", *r.Weight)
}
fmt.Printf("Status: %s\n", r.Status)
fmt.Printf("Tags: %s\n", strings.Join(r.Tags, ", "))
if r.Notes != "" {
fmt.Printf("Notes: %s\n", r.Notes)
}
// Show inputs if any
inputs, _ := db.GetRelationInputs(r.ID)
if len(inputs) > 0 {
fmt.Println("\nInputs:")
for _, ri := range inputs {
ord := ""
if ri.Order != nil {
ord = fmt.Sprintf(" (order: %d)", *ri.Order)
}
fmt.Printf(" %s [%s]%s\n", ri.EntityID, ri.Role, ord)
}
}
}
func cmdOpsRelationDelete(args []string) {
if len(args) < 1 {
fmt.Fprintln(os.Stderr, "usage: fn ops relation delete <id>")
os.Exit(1)
}
db := openOpsDB()
defer db.Close()
if err := db.DeleteRelation(args[0]); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Relation %s deleted\n", args[0])
}
// --- ops graph ---
func cmdOpsGraph() {
db := openOpsDB()
defer db.Close()
g, err := ops.GetEntityGraph(db)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(g.Entities) == 0 {
fmt.Println("Empty graph.")
return
}
fmt.Println("Entities:")
for _, e := range g.Entities {
fmt.Printf(" [%s] (%s) status:%s source:%s\n", e.ID, e.TypeRef, e.Status, e.Source)
}
if len(g.Relations) > 0 {
fmt.Println("\nRelations:")
for _, r := range g.Relations {
via := ""
if r.Via != "" {
via = fmt.Sprintf(" via:%s", r.Via)
}
inputs, hasInputs := g.Inputs[r.ID]
if hasInputs {
sources := make([]string, len(inputs))
for i, ri := range inputs {
sources[i] = fmt.Sprintf("%s[%s]", ri.EntityID, ri.Role)
}
fmt.Printf(" (%s) %s → %s%s\n",
strings.Join(sources, " + "), r.Name, r.ToEntity, via)
} else {
from := r.FromEntity
if from == "" {
from = "?"
}
dir := "→"
if r.Direction == ops.DirBidirectional {
dir = "↔"
}
fmt.Printf(" %s %s %s %s%s\n", from, dir, r.Name, r.ToEntity, via)
}
}
}
}
// --- ops snapshot ---
func cmdOpsSnapshot(args []string) {
if len(args) < 1 || args[0] != "list" {
fmt.Fprintln(os.Stderr, "usage: fn ops snapshot list")
os.Exit(1)
}
db := openOpsDB()
defer db.Close()
snaps, err := db.ListTypeSnapshots()
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
if len(snaps) == 0 {
fmt.Println("No type snapshots.")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tVERSION\tLANG\tALGEBRAIC\tSNAPPED_AT")
for _, s := range snaps {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
s.ID, s.Version, s.Lang, s.Algebraic, s.SnappedAt.Format("2006-01-02 15:04"))
}
w.Flush()
}
// --- helpers ---
func findOpsDB() string {
dir, _ := os.Getwd()
for {
path := filepath.Join(dir, opsDBName)
if _, err := os.Stat(path); err == nil {
return path
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return filepath.Join(".", opsDBName)
}
func openOpsDB() *ops.DB {
path := findOpsDB()
db, err := ops.Open(path)
if err != nil {
fmt.Fprintf(os.Stderr, "error opening operations.db: %v\n", err)
fmt.Fprintln(os.Stderr, "Run 'fn ops init' first to create one.")
os.Exit(1)
}
return db
}
func tryOpenRegistryDB() *registry.DB {
// Try FN_REGISTRY_ROOT env var first
if envRoot := os.Getenv("FN_REGISTRY_ROOT"); envRoot != "" {
path := filepath.Join(envRoot, dbName)
if _, err := os.Stat(path); err == nil {
db, err := registry.Open(path)
if err == nil {
return db
}
}
}
// Try root() (finds go.mod walking up from cwd)
path := filepath.Join(root(), dbName)
if _, err := os.Stat(path); err == nil {
db, err := registry.Open(path)
if err == nil {
return db
}
}
// Try executable's directory
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
path := filepath.Join(exeDir, dbName)
if _, err := os.Stat(path); err == nil {
db, err := registry.Open(path)
if err == nil {
return db
}
}
}
return nil
}