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:
+123
-5
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user