From d3498898c0ef74cfc3950dc2d115b3237d4455cb Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 2 Jun 2026 20:58:09 +0200 Subject: [PATCH] feat(cli): add read-only list and show subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tapir list — table of stored summaries (date, title|id, channel, AI, fallback), recent-first. tapir show — full summary with highlights and takeaways. DSN + user id from TAPIR_DB_DSN/TAPIR_USER_ID, never hardcoded. main.go gains a minimal os.Args[1] dispatcher kept flat so Worker F's auth/run cases union cleanly at merge. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/tapir/env.go | 35 +++++++++++ cmd/tapir/format_test.go | 130 +++++++++++++++++++++++++++++++++++++++ cmd/tapir/list.go | 105 +++++++++++++++++++++++++++++++ cmd/tapir/main.go | 41 ++++++++++-- cmd/tapir/show.go | 86 ++++++++++++++++++++++++++ 5 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 cmd/tapir/env.go create mode 100644 cmd/tapir/format_test.go create mode 100644 cmd/tapir/list.go create mode 100644 cmd/tapir/show.go diff --git a/cmd/tapir/env.go b/cmd/tapir/env.go new file mode 100644 index 0000000..0fbb22d --- /dev/null +++ b/cmd/tapir/env.go @@ -0,0 +1,35 @@ +package main + +import ( + "context" + "fmt" + "os" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +// Env var names for the read-only CLI. DSN and user id are never hardcoded — the +// store holds confidential summaries, so the caller supplies both. +const ( + envDSN = "TAPIR_DB_DSN" + envUserID = "TAPIR_USER_ID" +) + +// openStoreFromEnv reads TAPIR_DB_DSN and TAPIR_USER_ID and opens a store. The +// returned close func releases the pool; callers defer it. Both vars are +// required: an empty value is a usage error, not a silent default. +func openStoreFromEnv(ctx context.Context) (s *store.Store, userID string, closeFn func(), err error) { + dsn := os.Getenv(envDSN) + if dsn == "" { + return nil, "", nil, fmt.Errorf("%s is required", envDSN) + } + userID = os.Getenv(envUserID) + if userID == "" { + return nil, "", nil, fmt.Errorf("%s is required", envUserID) + } + s, err = store.New(ctx, dsn) + if err != nil { + return nil, "", nil, err + } + return s, userID, s.Close, nil +} diff --git a/cmd/tapir/format_test.go b/cmd/tapir/format_test.go new file mode 100644 index 0000000..c45de52 --- /dev/null +++ b/cmd/tapir/format_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +func TestFormatListColumnsAndOrdering(t *testing.T) { + rows := []store.SummaryRow{ + { + VideoID: "vid-newer", + Title: "Newer Title", + Channel: "youtube", + AIProvider: "anthropic", + PublishedAt: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + }, + { + VideoID: "vid-older", + Title: "Older Title", + Channel: "youtube", + AIProvider: "local", + FallbackUsed: true, + PublishedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + }, + } + + var b strings.Builder + require.NoError(t, formatList(&b, rows)) + out := b.String() + + require.Contains(t, out, "DATE") + require.Contains(t, out, "TITLE") + require.Contains(t, out, "CHANNEL") + require.Contains(t, out, "FALLBACK") + require.Contains(t, out, "Newer Title") + require.Contains(t, out, "2026-02-01") + require.Contains(t, out, "anthropic") + + // formatList preserves caller ordering (the store query sorts recent-first). + require.Less(t, strings.Index(out, "Newer Title"), strings.Index(out, "Older Title")) + + // fallback flag rendered per row. + older := lineContaining(t, out, "Older Title") + require.Contains(t, older, "yes") + newer := lineContaining(t, out, "Newer Title") + require.Contains(t, newer, "no") +} + +func TestFormatListFallsBackToVideoIDWhenNoTitle(t *testing.T) { + rows := []store.SummaryRow{{VideoID: "raw-video-id", CreatedAt: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)}} + + var b strings.Builder + require.NoError(t, formatList(&b, rows)) + out := b.String() + + require.Contains(t, out, "raw-video-id", "missing title falls back to video id") + require.Contains(t, out, "2026-03-01", "missing publish date falls back to created_at") +} + +func TestFormatListEmpty(t *testing.T) { + var b strings.Builder + require.NoError(t, formatList(&b, nil)) + require.Contains(t, b.String(), "no summaries yet") +} + +func TestFormatShowFullSummary(t *testing.T) { + row := store.SummaryRow{ + VideoID: "vid-1", + Title: "Deep Dive", + Channel: "youtube", + URL: "https://example/watch", + PublishedAt: time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC), + Summary: "The core argument.", + Highlights: []string{"first point", "second point"}, + Takeaways: []string{"do this"}, + AIProvider: "local", + AIModel: "qwen", + FallbackUsed: true, + } + + var b strings.Builder + require.NoError(t, formatShow(&b, row)) + out := b.String() + + require.Contains(t, out, "Deep Dive") + require.Contains(t, out, "2026-05-02") + require.Contains(t, out, "https://example/watch") + require.Contains(t, out, "vid-1") + require.Contains(t, out, "local/qwen") + require.Contains(t, out, "fallback") + require.Contains(t, out, "The core argument.") + require.Contains(t, out, "first point") + require.Contains(t, out, "second point") + require.Contains(t, out, "do this") +} + +func TestFormatShowOmitsEmptySections(t *testing.T) { + row := store.SummaryRow{ + VideoID: "vid-2", + Summary: "Body only.", + AIProvider: "local", + } + + var b strings.Builder + require.NoError(t, formatShow(&b, row)) + out := b.String() + + require.Contains(t, out, "vid-2", "no title -> falls back to video id") + require.Contains(t, out, "Body only.") + require.NotContains(t, out, "Highlights:") + require.NotContains(t, out, "Takeaways:") + require.NotContains(t, out, "URL:") + require.NotContains(t, out, "Channel:") +} + +func lineContaining(t *testing.T, text, needle string) string { + t.Helper() + for _, ln := range strings.Split(text, "\n") { + if strings.Contains(ln, needle) { + return ln + } + } + t.Fatalf("no line containing %q in:\n%s", needle, text) + return "" +} diff --git a/cmd/tapir/list.go b/cmd/tapir/list.go new file mode 100644 index 0000000..eef4120 --- /dev/null +++ b/cmd/tapir/list.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +// runList prints the user's stored summaries as a table, most recent first. +// Read-only. Usage: tapir list [-limit N]. +func runList(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("list", flag.ContinueOnError) + limit := fs.Int("limit", 50, "maximum number of summaries to show") + if err := fs.Parse(args); err != nil { + return err + } + + s, userID, closeFn, err := openStoreFromEnv(ctx) + if err != nil { + return err + } + defer closeFn() + + rows, err := s.ListSummaries(ctx, userID, *limit) + if err != nil { + return err + } + return formatList(os.Stdout, rows) +} + +// formatList renders rows as an aligned table. Pure: no DB, no env — so the +// column layout and fallbacks are unit-testable without Postgres. Title falls +// back to the video id and Channel to "-" when the videos row is absent. +func formatList(w io.Writer, rows []store.SummaryRow) error { + if len(rows) == 0 { + _, err := fmt.Fprintln(w, "no summaries yet") + return err + } + + // Writes to the tabwriter buffer; any error surfaces on Flush. + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "DATE\tTITLE\tCHANNEL\tAI\tFALLBACK") + for _, r := range rows { + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + listDate(r), + truncate(displayTitle(r), 60), + orDash(r.Channel), + orDash(r.AIProvider), + fallbackFlag(r.FallbackUsed), + ) + } + return tw.Flush() +} + +// listDate prefers the video's publish date; absent that, when the summary was +// created. Date only — the table is for scanning, not timestamps. +func listDate(r store.SummaryRow) string { + t := r.PublishedAt + if t.IsZero() { + t = r.CreatedAt + } + if t.IsZero() { + return "-" + } + return t.Format("2006-01-02") +} + +// displayTitle falls back to the video id when no title is stored, so a summary +// produced before the run loop populated videos is still identifiable. +func displayTitle(r store.SummaryRow) string { + if strings.TrimSpace(r.Title) != "" { + return r.Title + } + return r.VideoID +} + +func fallbackFlag(used bool) string { + if used { + return "yes" + } + return "no" +} + +func orDash(s string) string { + if strings.TrimSpace(s) == "" { + return "-" + } + return s +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 1 { + return s[:max] + } + return s[:max-1] + "…" +} diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go index dd1e7a6..7e878a9 100644 --- a/cmd/tapir/main.go +++ b/cmd/tapir/main.go @@ -1,10 +1,41 @@ -// Command tapir is the service entrypoint. Scaffold: it identifies itself so -// the CI smoke test has something to grep for, and exits. Wiring the HTTP -// server, watcher, adapters, and config is part of the build. +// Command tapir is the service entrypoint and CLI. Subcommands are dispatched off +// os.Args[1]; each lives in its own file (list.go, show.go, …). The switch is kept +// deliberately flat so concurrently-added subcommands union cleanly. package main -import "fmt" +import ( + "context" + "fmt" + "os" +) func main() { - fmt.Println("tapir: scaffold — not yet implemented") + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + + ctx := context.Background() + var err error + switch os.Args[1] { + case "list": + err = runList(ctx, os.Args[2:]) + case "show": + err = runShow(ctx, os.Args[2:]) + default: + usage() + os.Exit(2) + } + + if err != nil { + fmt.Fprintln(os.Stderr, "tapir:", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprintln(os.Stderr, "usage: tapir [args]") + fmt.Fprintln(os.Stderr, "commands:") + fmt.Fprintln(os.Stderr, " list [-limit N] list stored summaries, recent first") + fmt.Fprintln(os.Stderr, " show show one summary in full") } diff --git a/cmd/tapir/show.go b/cmd/tapir/show.go new file mode 100644 index 0000000..870e76b --- /dev/null +++ b/cmd/tapir/show.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/store" +) + +// runShow prints the full summary for one video: text, highlights, takeaways, +// and provenance. Read-only. Usage: tapir show . +func runShow(ctx context.Context, args []string) error { + if len(args) != 1 || strings.TrimSpace(args[0]) == "" { + return errors.New("usage: tapir show ") + } + videoID := args[0] + + s, userID, closeFn, err := openStoreFromEnv(ctx) + if err != nil { + return err + } + defer closeFn() + + row, err := s.GetSummaryByVideo(ctx, userID, videoID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return fmt.Errorf("no summary for video %q", videoID) + } + return err + } + return formatShow(os.Stdout, *row) +} + +// formatShow renders a single summary for terminal reading. Pure: no DB, no env, +// so it is unit-testable. Empty sections are omitted rather than printed blank. +func formatShow(w io.Writer, r store.SummaryRow) error { + var b strings.Builder + + fmt.Fprintf(&b, "%s\n", displayTitle(r)) + if d := listDate(r); d != "-" { + fmt.Fprintf(&b, "Date: %s\n", d) + } + if strings.TrimSpace(r.Channel) != "" { + fmt.Fprintf(&b, "Channel: %s\n", r.Channel) + } + if strings.TrimSpace(r.URL) != "" { + fmt.Fprintf(&b, "URL: %s\n", r.URL) + } + fmt.Fprintf(&b, "Video ID: %s\n", r.VideoID) + fmt.Fprintf(&b, "AI: %s\n", aiProvenance(r)) + + fmt.Fprintf(&b, "\n%s\n", strings.TrimSpace(r.Summary)) + + writeList(&b, "Highlights", r.Highlights) + writeList(&b, "Takeaways", r.Takeaways) + + _, err := io.WriteString(w, b.String()) + return err +} + +// aiProvenance summarises which model produced the summary and whether the local +// stack fell back to a cloud provider — the Stage-0 "is local good enough?" signal. +func aiProvenance(r store.SummaryRow) string { + parts := orDash(r.AIProvider) + if strings.TrimSpace(r.AIModel) != "" { + parts += "/" + r.AIModel + } + if r.FallbackUsed { + parts += " (fallback)" + } + return parts +} + +func writeList(b *strings.Builder, heading string, items []string) { + if len(items) == 0 { + return + } + fmt.Fprintf(b, "\n%s:\n", heading) + for _, it := range items { + fmt.Fprintf(b, " - %s\n", it) + } +}