feat(cli): add read-only list and show subcommands

tapir list — table of stored summaries (date, title|id, channel, AI,
fallback), recent-first. tapir show <video-id> — 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 20:58:09 +02:00
co-authored by Claude Opus 4.8
parent c153ff35ce
commit d3498898c0
5 changed files with 392 additions and 5 deletions
+35
View File
@@ -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
}
+130
View File
@@ -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 ""
}
+105
View File
@@ -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] + "…"
}
+36 -5
View File
@@ -1,10 +1,41 @@
// Command tapir is the service entrypoint. Scaffold: it identifies itself so // Command tapir is the service entrypoint and CLI. Subcommands are dispatched off
// the CI smoke test has something to grep for, and exits. Wiring the HTTP // os.Args[1]; each lives in its own file (list.go, show.go, …). The switch is kept
// server, watcher, adapters, and config is part of the build. // deliberately flat so concurrently-added subcommands union cleanly.
package main package main
import "fmt" import (
"context"
"fmt"
"os"
)
func main() { 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 <command> [args]")
fmt.Fprintln(os.Stderr, "commands:")
fmt.Fprintln(os.Stderr, " list [-limit N] list stored summaries, recent first")
fmt.Fprintln(os.Stderr, " show <video-id> show one summary in full")
} }
+86
View File
@@ -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 <video-id>.
func runShow(ctx context.Context, args []string) error {
if len(args) != 1 || strings.TrimSpace(args[0]) == "" {
return errors.New("usage: tapir show <video-id>")
}
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)
}
}