feat(cmd): wire auth/run dispatcher + demo docs

main.go dispatches `tapir auth` (interactive OAuth → persist refresh token via
SecretStore) and `tapir run` (wire YouTube source + local summarizer + store
sink, build engine, run the dedup-aware loop). Config-driven so live creds plug
in at demo time; SIGINT stops the loop cleanly. Block kept minimal so Worker E's
list/show cases union cleanly at merge.

Add .env.example documenting every TAPIR_* var and a README demo runbook. Pin
the summarizer alias-as-config decision and record the max_tokens fix in
docs/homelab-integration.md (clears two `confirm` items).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 21:03:21 +02:00
co-authored by Claude Opus 4.8
parent 5645c2c012
commit 61796db16b
4 changed files with 171 additions and 15 deletions
+123 -5
View File
@@ -1,10 +1,128 @@
// 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. It dispatches the Stage-0 demo
// subcommands:
//
// tapir auth mint a YouTube refresh token interactively (one-time setup)
// tapir run detect new videos, summarize them, deliver to the store
//
// Standalone-vs-homelab is a wiring choice (ADR-003): every dependency is
// resolved from config (internal/config) and the SecretStore port, so live
// credentials plug in at runtime without code changes.
package main
import "fmt"
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"gitea.d-ma.be/mathias/tapir/internal/adapters/llm"
"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/adapters/summarizer"
"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
"gitea.d-ma.be/mathias/tapir/internal/auth"
"gitea.d-ma.be/mathias/tapir/internal/config"
"gitea.d-ma.be/mathias/tapir/internal/runner"
"gitea.d-ma.be/mathias/tapir/internal/usecase"
)
func main() {
fmt.Println("tapir: scaffold — not yet implemented")
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
var err error
switch os.Args[1] {
case "auth":
err = cmdAuth(ctx, log)
case "run":
err = cmdRun(ctx, log)
default:
usage()
os.Exit(2)
}
if err != nil {
log.Error("command failed", "command", os.Args[1], "err", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprint(os.Stderr, `tapir — summarize new videos from your YouTube subscriptions
usage:
tapir auth one-time: authorize YouTube and store a refresh token
tapir run detect new videos, summarize, deliver to your store
configuration is via TAPIR_* environment variables (see .env.example).
`)
}
// cmdAuth runs the interactive OAuth flow and persists the refresh token.
func cmdAuth(ctx context.Context, log *slog.Logger) error {
cfg, err := config.Load()
if err != nil {
return err
}
if err := cfg.ValidateForAuth(); err != nil {
return err
}
secretStore := secrets.NewFileStore(cfg.SecretsFile)
authCfg := auth.Config{
ClientID: cfg.YTClientID,
ClientSecret: cfg.YTClientSecret,
RedirectURL: "http://" + cfg.OAuthRedirectAddr + "/callback",
TokenRef: cfg.YTTokenRef,
}
log.Info("starting youtube authorization", "redirect", authCfg.RedirectURL, "token_ref", cfg.YTTokenRef)
return auth.Run(ctx, authCfg, secretStore, os.Stdout)
}
// cmdRun wires the adapters and engine, then runs the watch→summarize→deliver
// loop. With TAPIR_POLL_INTERVAL unset it performs a single pass.
func cmdRun(ctx context.Context, log *slog.Logger) error {
cfg, err := config.Load()
if err != nil {
return err
}
if err := cfg.ValidateForRun(); err != nil {
return err
}
st, err := store.New(ctx, cfg.DBDSN)
if err != nil {
return err
}
defer st.Close()
secretStore := secrets.NewFileStore(cfg.SecretsFile)
src := youtube.New(youtube.Config{
ClientID: cfg.YTClientID,
ClientSecret: cfg.YTClientSecret,
TokenSecretRef: cfg.YTTokenRef,
PreferredLanguages: []string{"en"},
}, secretStore)
// Local Primary only; no BYO fallback for the demo (fallback nil).
primary := summarizer.Endpoint{
Client: llm.New(cfg.GatewayURL, cfg.GatewayKey, cfg.SummarizerModel, cfg.SummarizerTimeout),
Provider: "local",
Model: cfg.SummarizerModel,
}
sum := summarizer.New(primary, nil)
engine := usecase.NewEngine(src, sum, st)
r := runner.New(src, st, engine, cfg.UserID, log)
log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel,
"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval)
return r.Loop(ctx, cfg.PollInterval)
}