merge: demo wiring — tapir auth/run + config (Worker F, agent/demo-wiring)
CI / Lint / Test / Vet (push) Successful in 6s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped

# Conflicts:
#	cmd/tapir/main.go
This commit is contained in:
2026-06-02 21:29:48 +02:00
15 changed files with 1489 additions and 17 deletions
+105 -9
View File
@@ -1,12 +1,33 @@
// 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.
// 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, …). It serves
// the Stage-0 demo:
//
// tapir auth mint a YouTube refresh token interactively (one-time setup)
// tapir run detect new videos, summarize them, deliver to the store
// tapir list list stored summaries, recent first
// tapir show show one stored summary in full
//
// 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 (
"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() {
@@ -15,27 +36,102 @@ func main() {
os.Exit(2)
}
ctx := context.Background()
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 "list":
err = runList(ctx, os.Args[2:])
case "show":
err = runShow(ctx, os.Args[2:])
case "auth":
err = cmdAuth(ctx, log)
case "run":
err = cmdRun(ctx, log)
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "tapir:", err)
log.Error("command failed", "command", os.Args[1], "err", 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")
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
tapir list [-limit N] list stored summaries, recent first
tapir show <video-id> show one summary in full
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)
}