From c3cc9464b9f3dd0d4d854d21a7257cd74ae5a13f Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 14 May 2026 11:28:17 -0400 Subject: [PATCH] feat(cli): add promote, cards, copy, demote, delete, edit commands Complete CLI crystallization loop. Promote generates card_data (template slots, checklist steps, link URLs). Cards view sorted by use_count. Copy increments usage. Demote strips card layer. Delete does soft then hard. Edit opens $EDITOR. --- cmd/cards.go | 73 +++++++++++++++++++++ cmd/copy.go | 49 ++++++++++++++ cmd/delete.go | 48 ++++++++++++++ cmd/demote.go | 43 ++++++++++++ cmd/edit.go | 83 ++++++++++++++++++++++++ cmd/promote.go | 133 ++++++++++++++++++++++++++++++++++++++ internal/display/glyph.go | 4 +- 7 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 cmd/cards.go create mode 100644 cmd/copy.go create mode 100644 cmd/delete.go create mode 100644 cmd/demote.go create mode 100644 cmd/edit.go create mode 100644 cmd/promote.go diff --git a/cmd/cards.go b/cmd/cards.go new file mode 100644 index 0000000..7f85fff --- /dev/null +++ b/cmd/cards.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" + "github.com/spf13/cobra" +) + +var ( + cardsTag string + cardsType string +) + +var cardsCmd = &cobra.Command{ + Use: "cards", + Short: "list crystallized cards by usage", + RunE: runCards, +} + +func init() { + cardsCmd.Flags().StringVar(&cardsTag, "tag", "", "filter by tag") + cardsCmd.Flags().StringVar(&cardsType, "type", "", "filter by card type") + rootCmd.AddCommand(cardsCmd) +} + +func runCards(_ *cobra.Command, _ []string) error { + store, err := openStore() + if err != nil { + return err + } + defer store.Close() + + p := db.DefaultListParams() + p.CardsOnly = true + p.Sort = "use_count" + p.Order = "desc" + + if cardsTag != "" { + p.Tag = &cardsTag + } + if cardsType != "" { + if !db.ValidCardType(cardsType) { + return fmt.Errorf("invalid_type — %q is not a valid card type", cardsType) + } + ct := db.CardType(cardsType) + p.CardTypeFilter = &ct + } + + entities, err := store.List(p) + if err != nil { + return err + } + + for _, e := range entities { + glyph := display.DisplayGlyph(e.Glyph, e.CardType) + shortID := display.FormatID(e.ID) + + var tagStr string + for _, tag := range e.Tags { + tagStr += " #" + tag + } + + fmt.Printf("%s %-40s %-16s %3d× %s\n", + glyph, e.Body, + strings.TrimSpace(tagStr), + e.UseCount, shortID) + } + + return nil +} diff --git a/cmd/copy.go b/cmd/copy.go new file mode 100644 index 0000000..a7b3001 --- /dev/null +++ b/cmd/copy.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + + "github.com/atotto/clipboard" + "github.com/lerko/nib/internal/display" + "github.com/spf13/cobra" +) + +var copyCmd = &cobra.Command{ + Use: "copy ", + Short: "copy entity body to clipboard", + Args: cobra.ExactArgs(1), + RunE: runCopy, +} + +func init() { + rootCmd.AddCommand(copyCmd) +} + +func runCopy(_ *cobra.Command, args []string) error { + store, err := openStore() + if err != nil { + return err + } + defer store.Close() + + id, err := store.Resolve(args[0]) + if err != nil { + return fmt.Errorf("not_found — no entity with id %s", args[0]) + } + + e, err := store.Get(id) + if err != nil { + return err + } + + if err := clipboard.WriteAll(e.Body); err != nil { + return fmt.Errorf("clipboard: %w", err) + } + + if err := store.IncrementUse(id); err != nil { + return err + } + + fmt.Printf("(copied to clipboard) %s\n", display.FormatID(id)) + return nil +} diff --git a/cmd/delete.go b/cmd/delete.go new file mode 100644 index 0000000..24eecbf --- /dev/null +++ b/cmd/delete.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" + "github.com/spf13/cobra" +) + +var deleteCmd = &cobra.Command{ + Use: "delete ", + Short: "delete an entity (soft, then hard)", + Args: cobra.ExactArgs(1), + RunE: runDelete, +} + +func init() { + rootCmd.AddCommand(deleteCmd) +} + +func runDelete(_ *cobra.Command, args []string) error { + store, err := openStore() + if err != nil { + return err + } + defer store.Close() + + id, err := store.Resolve(args[0]) + if err != nil { + return fmt.Errorf("not_found — no entity with id %s", args[0]) + } + + result, err := store.SoftDelete(id) + if err != nil { + return err + } + + shortID := display.FormatID(id) + switch result { + case db.DeletedSoft: + fmt.Printf("deleted %s\n", shortID) + case db.DeletedHard: + fmt.Printf("permanently deleted %s\n", shortID) + } + + return nil +} diff --git a/cmd/demote.go b/cmd/demote.go new file mode 100644 index 0000000..18fe1cb --- /dev/null +++ b/cmd/demote.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "fmt" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" + "github.com/spf13/cobra" +) + +var demoteCmd = &cobra.Command{ + Use: "demote ", + Short: "strip card layer, return to fluid", + Args: cobra.ExactArgs(1), + RunE: runDemote, +} + +func init() { + rootCmd.AddCommand(demoteCmd) +} + +func runDemote(_ *cobra.Command, args []string) error { + store, err := openStore() + if err != nil { + return err + } + defer store.Close() + + id, err := store.Resolve(args[0]) + if err != nil { + return fmt.Errorf("not_found — no entity with id %s", args[0]) + } + + if err := store.Demote(id); err != nil { + if err == db.ErrAlreadyFluid { + return fmt.Errorf("invalid_demote — entity %s is already fluid", display.FormatID(id)) + } + return err + } + + fmt.Printf("demoted %s → note\n", display.FormatID(id)) + return nil +} diff --git a/cmd/edit.go b/cmd/edit.go new file mode 100644 index 0000000..ade00c9 --- /dev/null +++ b/cmd/edit.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" + "github.com/spf13/cobra" +) + +var editCmd = &cobra.Command{ + Use: "edit ", + Short: "edit entity body in $EDITOR", + Args: cobra.ExactArgs(1), + RunE: runEdit, +} + +func init() { + rootCmd.AddCommand(editCmd) +} + +func runEdit(_ *cobra.Command, args []string) error { + store, err := openStore() + if err != nil { + return err + } + defer store.Close() + + id, err := store.Resolve(args[0]) + if err != nil { + return fmt.Errorf("not_found — no entity with id %s", args[0]) + } + + e, err := store.Get(id) + if err != nil { + return err + } + + tmpfile, err := os.CreateTemp("", "nib-*.md") + if err != nil { + return err + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.WriteString(e.Body); err != nil { + tmpfile.Close() + return err + } + tmpfile.Close() + + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + + cmd := exec.Command(editor, tmpfile.Name()) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("editor: %w", err) + } + + newBody, err := os.ReadFile(tmpfile.Name()) + if err != nil { + return err + } + + body := string(newBody) + if body == e.Body { + fmt.Println("(no changes)") + return nil + } + + if err := store.Update(id, &db.EntityUpdate{Body: &body}); err != nil { + return err + } + + fmt.Printf("updated %s\n", display.FormatID(id)) + return nil +} diff --git a/cmd/promote.go b/cmd/promote.go new file mode 100644 index 0000000..37d60b0 --- /dev/null +++ b/cmd/promote.go @@ -0,0 +1,133 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/lerko/nib/internal/db" + "github.com/lerko/nib/internal/display" + "github.com/spf13/cobra" +) + +var promoteCmd = &cobra.Command{ + Use: "promote [type]", + Short: "promote a fluid entity to a card", + Args: cobra.RangeArgs(1, 2), + RunE: runPromote, +} + +func init() { + rootCmd.AddCommand(promoteCmd) +} + +func runPromote(_ *cobra.Command, args []string) error { + store, err := openStore() + if err != nil { + return err + } + defer store.Close() + + id, err := store.Resolve(args[0]) + if err != nil { + return fmt.Errorf("not_found — no entity with id %s", args[0]) + } + + cardType := db.CardSnippet + if len(args) > 1 { + if !db.ValidCardType(args[1]) { + return fmt.Errorf("invalid_type — %q is not a valid card type", args[1]) + } + cardType = db.CardType(args[1]) + } + + e, err := store.Get(id) + if err != nil { + return err + } + + cardData := generateCardData(cardType, e.Body) + + if err := store.Promote(id, cardType, cardData); err != nil { + if err == db.ErrAlreadyPromoted { + return fmt.Errorf("invalid_promote — entity %s is already a %s", + display.FormatID(id), *e.CardType) + } + return err + } + + fmt.Printf("promoted %s → %s\n", display.FormatID(id), cardType) + return nil +} + +var templateSlotRe = regexp.MustCompile(`\$\{(\w+)\}`) + +func generateCardData(ct db.CardType, body string) *string { + var data string + switch ct { + case db.CardTemplate: + matches := templateSlotRe.FindAllStringSubmatch(body, -1) + type slot struct { + Name string `json:"name"` + Default string `json:"default"` + } + var slots []slot + seen := map[string]bool{} + for _, m := range matches { + name := m[1] + if !seen[name] { + slots = append(slots, slot{Name: name, Default: ""}) + seen[name] = true + } + } + if slots == nil { + slots = []slot{} + } + b, _ := json.Marshal(map[string]any{"slots": slots}) + data = string(b) + + case db.CardChecklist: + type step struct { + Text string `json:"text"` + Done bool `json:"done"` + } + var steps []step + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[ ]") || strings.HasPrefix(line, "[x]") { + text := strings.TrimSpace(line[3:]) + done := strings.HasPrefix(line, "[x]") + steps = append(steps, step{Text: text, Done: done}) + } + } + if steps == nil { + steps = []step{{Text: body, Done: false}} + } + b, _ := json.Marshal(map[string]any{"steps": steps}) + data = string(b) + + case db.CardDecision: + b, _ := json.Marshal(map[string]any{ + "chose": "", + "why": "", + "rejected": []string{}, + }) + data = string(b) + + case db.CardLink: + url := "" + for _, word := range strings.Fields(body) { + if strings.HasPrefix(word, "http://") || strings.HasPrefix(word, "https://") { + url = word + break + } + } + b, _ := json.Marshal(map[string]any{"url": url}) + data = string(b) + + default: + data = "{}" + } + return &data +} diff --git a/internal/display/glyph.go b/internal/display/glyph.go index cdd371f..422046a 100644 --- a/internal/display/glyph.go +++ b/internal/display/glyph.go @@ -29,8 +29,8 @@ func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string { } func FormatID(id string) string { - if len(id) > 6 { - return id[:6] + if len(id) > 12 { + return id[:12] } return id }