From 0a99f08836835820efcfdc2d08c0c6b152d3ce98 Mon Sep 17 00:00:00 2001 From: Jad Bitar Date: Tue, 16 Jun 2026 08:57:56 -0400 Subject: [PATCH 1/4] fix(youtube): distinguish exit codes for usage/missing-dep/fetch --- cmd/foo-youtube/main.go | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/cmd/foo-youtube/main.go b/cmd/foo-youtube/main.go index 5e34de6..e918726 100644 --- a/cmd/foo-youtube/main.go +++ b/cmd/foo-youtube/main.go @@ -11,6 +11,16 @@ import ( var version = "dev" +// Exit codes follow the kit cross-tool convention (§8.1): 1 generic, +// 2 usage (bad/missing args), 5 a missing external dependency. Fetch +// failures are runtime errors against an otherwise-valid request, so +// they map to the generic 1. +const ( + exitFetch = 1 + exitUsage = 2 + exitMissingDep = 5 +) + type extInfo struct { Name string `json:"name"` Version string `json:"version"` @@ -62,18 +72,18 @@ func main() { if len(args) == 0 { fmt.Fprintln(os.Stderr, "error: YouTube URL required") fmt.Fprintln(os.Stderr, "usage: foo-youtube [flags] ") - os.Exit(1) + os.Exit(exitUsage) } url := args[0] if !isYouTubeURL(url) { fmt.Fprintf(os.Stderr, "error: invalid YouTube URL: %s\n", url) - os.Exit(1) + os.Exit(exitUsage) } if err := checkYTDLP(); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(1) + os.Exit(exitMissingDep) } var md *videoMetadata @@ -82,7 +92,7 @@ func main() { md, err = fetchMetadata(url) if err != nil { fmt.Fprintf(os.Stderr, "error fetching metadata: %v\n", err) - os.Exit(1) + os.Exit(exitFetch) } } @@ -92,7 +102,7 @@ func main() { transcriptText, err = fetchTranscript(url, *timestamps) if err != nil { fmt.Fprintf(os.Stderr, "error fetching transcript: %v\n", err) - os.Exit(1) + os.Exit(exitFetch) } } From ba4fc581143b8579180764ba0d442f3f9ca1c01a Mon Sep 17 00:00:00 2001 From: Jad Bitar Date: Tue, 16 Jun 2026 09:02:54 -0400 Subject: [PATCH 2/4] refactor(youtube): migrate to kit/console/cli root --- cmd/foo-youtube/main.go | 233 +++++++++++++++++++++++++++++++++------- 1 file changed, 192 insertions(+), 41 deletions(-) diff --git a/cmd/foo-youtube/main.go b/cmd/foo-youtube/main.go index e918726..5f5fd83 100644 --- a/cmd/foo-youtube/main.go +++ b/cmd/foo-youtube/main.go @@ -1,12 +1,22 @@ +// foo-youtube is a standalone binary that extracts a YouTube video's +// transcript and metadata as markdown. It is an external plugin for +// foo: the host discovers it on $PATH and interrogates it via +// --ext-info (kit ai/ext/discover). It imports zero foo internal +// packages. package main import ( + "context" "encoding/json" - "flag" + "errors" "fmt" "os" "os/exec" "strings" + + "github.com/spf13/cobra" + kitcli "hop.top/kit/go/console/cli" + "hop.top/kit/go/console/output" ) var version = "dev" @@ -14,13 +24,50 @@ var version = "dev" // Exit codes follow the kit cross-tool convention (§8.1): 1 generic, // 2 usage (bad/missing args), 5 a missing external dependency. Fetch // failures are runtime errors against an otherwise-valid request, so -// they map to the generic 1. +// they map to the generic 1. Cobra itself exits 2 on flag/arg parse +// failures, which lines up with exitUsage. const ( exitFetch = 1 exitUsage = 2 exitMissingDep = 5 ) +// codeMissingDep is the structured error code emitted when the yt-dlp +// dependency is absent. Its exit code (5) matches the §8.1 slot kit +// reserves for environment/auth failures; the label is plugin-specific. +const codeMissingDep = "MISSING_DEPENDENCY" + +// exitError carries a kit structured-error envelope out of RunE. kit's +// RunE middleware reads AsCLIError to render + return the envelope; main +// then reads its ExitCode to pick the process exit status (§8.1). +type exitError struct { + cli *output.Error +} + +func (e *exitError) Error() string { return e.cli.Error() } + +func (e *exitError) AsCLIError() *output.Error { return e.cli } + +func usageErrorf(format string, a ...any) *exitError { + return &exitError{cli: output.UsageError(fmt.Sprintf(format, a...))} +} + +func missingDepError(err error) *exitError { + return &exitError{cli: &output.Error{ + Code: codeMissingDep, + Message: err.Error(), + ExitCode: exitMissingDep, + }} +} + +func fetchErrorf(format string, a ...any) *exitError { + return &exitError{cli: &output.Error{ + Code: output.CodeGeneric, + Message: fmt.Sprintf(format, a...), + ExitCode: exitFetch, + }} +} + type extInfo struct { Name string `json:"name"` Version string `json:"version"` @@ -43,80 +90,184 @@ type comment struct { Text string `json:"text"` } +// extInfoArg is the host's discovery probe. kit's ai/ext/discover runs +// the binary as `foo-youtube --ext-info` and json-decodes stdout, so +// this is a hard wire contract: emit ONLY the JSON object, exit 0. +const extInfoArg = "--ext-info" + func main() { - transcript := flag.Bool("transcript", true, "Extract transcript") - timestamps := flag.Bool("timestamps", false, "Include timestamps in transcript") - comments := flag.Bool("comments", false, "Include top comments") - metadata := flag.Bool("metadata", true, "Include video metadata") - showExtInfo := flag.Bool("ext-info", false, "Print extension info as JSON") - - flag.Parse() - - if *showExtInfo { - info := extInfo{ - Name: "youtube", - Version: version, - Description: "YouTube transcript and metadata extraction", - Capabilities: []string{"discover"}, + // Honor the --ext-info wire contract before cobra parses anything. + // The host invokes the binary with exactly this single flag and + // parses stdout as JSON, so we must keep stdout clean of any cobra + // help/usage chrome and guarantee exit 0. + for _, a := range os.Args[1:] { + if a == extInfoArg { + if err := printExtInfo(os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "error encoding ext-info: %v\n", err) + os.Exit(exitFetch) + } + return } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - if err := enc.Encode(info); err != nil { - fmt.Fprintf(os.Stderr, "error encoding ext-info: %v\n", err) - os.Exit(1) + } + + root := newRoot() + if err := root.Execute(context.Background()); err != nil { + os.Exit(exitCodeFor(err)) + } +} + +// exitCodeFor maps a RunE error onto the §8.1 exit-code set. kit's RunE +// middleware returns the *output.Error envelope it rendered, so its +// ExitCode is authoritative. A bare *exitError (no middleware in the +// path) and cobra's own flag/arg errors fall back sensibly. +func exitCodeFor(err error) int { + var oe *output.Error + if errors.As(err, &oe) && oe.ExitCode != 0 { + return oe.ExitCode + } + var ee *exitError + if errors.As(err, &ee) && ee.cli != nil && ee.cli.ExitCode != 0 { + return ee.cli.ExitCode + } + // Cobra reports bad flags / too many args as a plain error before + // our RunE runs; treat those as usage errors. + return exitUsage +} + +func printExtInfo(w *os.File) error { + info := extInfo{ + Name: "youtube", + Version: version, + Description: "YouTube transcript and metadata extraction", + Capabilities: []string{"discover"}, + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(info) +} + +func newRoot() *kitcli.Root { + var ( + transcript bool + timestamps bool + comments bool + metadata bool + ) + + root := kitcli.New(kitcli.Config{ + Name: "foo-youtube", + Version: version, + Short: "YouTube transcript and metadata extraction", + Help: kitcli.HelpConfig{ + Disclaimer: `foo-youtube fetches a YouTube video's transcript and metadata +via yt-dlp and renders them as markdown on stdout. + +It is an external plugin for foo: the host discovers it on $PATH and +interrogates it with --ext-info.`, + }, + }, kitcli.WithStatus(kitcli.StatusConfig{})) + + root.Cmd.Use = "foo-youtube [flags] " + root.Cmd.Args = cobra.MaximumNArgs(1) + root.Cmd.SilenceUsage = true + root.Cmd.SilenceErrors = true + + flags := root.Cmd.Flags() + flags.BoolVar(&metadata, "metadata", true, "Include video metadata") + flags.BoolVar(&metadata, "no-metadata", false, "Skip video metadata") + flags.BoolVar(&transcript, "transcript", true, "Extract transcript") + flags.BoolVar(&transcript, "no-transcript", false, "Skip transcript extraction") + flags.BoolVar(×tamps, "timestamps", false, "Include timestamps in transcript") + flags.BoolVar(&comments, "comments", false, "Include top comments") + + // --ext-info is registered for help/discoverability parity; the real + // handling happens pre-cobra in main so the JSON contract stays + // clean. Hidden because it is a host-facing probe, not a user verb. + var extInfoFlag bool + flags.BoolVar(&extInfoFlag, "ext-info", false, "Print extension info as JSON (used by the foo host)") + _ = flags.MarkHidden("ext-info") + + root.Cmd.RunE = func(cmd *cobra.Command, args []string) error { + // Paired negation: --no-X overrides the default-true switch. + if cmd.Flags().Changed("no-metadata") { + metadata = !boolFlag(cmd, "no-metadata") + } + if cmd.Flags().Changed("no-transcript") { + transcript = !boolFlag(cmd, "no-transcript") } - return + return run(cmd, args, runOpts{ + metadata: metadata, + transcript: transcript, + timestamps: timestamps, + comments: comments, + }) } - args := flag.Args() + kitcli.SetSideEffect(root.Cmd, kitcli.SideEffectRead) + kitcli.SetIdempotency(root.Cmd, kitcli.IdempotencyYes) + return root +} + +func boolFlag(cmd *cobra.Command, name string) bool { + v, _ := cmd.Flags().GetBool(name) + return v +} + +type runOpts struct { + metadata bool + transcript bool + timestamps bool + comments bool +} + +func run(cmd *cobra.Command, args []string, opts runOpts) error { if len(args) == 0 { - fmt.Fprintln(os.Stderr, "error: YouTube URL required") - fmt.Fprintln(os.Stderr, "usage: foo-youtube [flags] ") - os.Exit(exitUsage) + return usageErrorf("YouTube URL required") } url := args[0] if !isYouTubeURL(url) { - fmt.Fprintf(os.Stderr, "error: invalid YouTube URL: %s\n", url) - os.Exit(exitUsage) + return usageErrorf("invalid YouTube URL: %s", url) } if err := checkYTDLP(); err != nil { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - os.Exit(exitMissingDep) + return missingDepError(err) } var md *videoMetadata - if *metadata { + if opts.metadata { var err error md, err = fetchMetadata(url) if err != nil { - fmt.Fprintf(os.Stderr, "error fetching metadata: %v\n", err) - os.Exit(exitFetch) + return fetchErrorf("fetching metadata: %v", err) } } var transcriptText string - if *transcript { + if opts.transcript { var err error - transcriptText, err = fetchTranscript(url, *timestamps) + transcriptText, err = fetchTranscript(url, opts.timestamps) if err != nil { - fmt.Fprintf(os.Stderr, "error fetching transcript: %v\n", err) - os.Exit(exitFetch) + return fetchErrorf("fetching transcript: %v", err) } } var commentList []comment - if *comments { + if opts.comments { var err error commentList, err = fetchComments(url) if err != nil { - fmt.Fprintf(os.Stderr, "warning: could not fetch comments: %v\n", err) - // Non-fatal: continue without comments + fmt.Fprintf(cmd.ErrOrStderr(), "warning: could not fetch comments: %v\n", err) + // Non-fatal: continue without comments. } } - renderMarkdown(os.Stdout, md, transcriptText, commentList) + out, ok := cmd.OutOrStdout().(*os.File) + if !ok { + out = os.Stdout + } + renderMarkdown(out, md, transcriptText, commentList) + return nil } func isYouTubeURL(url string) bool { From b6f9394d3dc1b22cfa066deabe316660e683e80c Mon Sep 17 00:00:00 2001 From: Jad Bitar Date: Tue, 16 Jun 2026 09:03:54 -0400 Subject: [PATCH 3/4] docs(youtube): document positional in help --- cmd/foo-youtube/main.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/foo-youtube/main.go b/cmd/foo-youtube/main.go index 5f5fd83..5194f30 100644 --- a/cmd/foo-youtube/main.go +++ b/cmd/foo-youtube/main.go @@ -162,6 +162,10 @@ func newRoot() *kitcli.Root { Disclaimer: `foo-youtube fetches a YouTube video's transcript and metadata via yt-dlp and renders them as markdown on stdout. +Arguments: + YouTube video URL (youtube.com/watch, youtu.be, or + youtube.com/shorts). Required. + It is an external plugin for foo: the host discovers it on $PATH and interrogates it with --ext-info.`, }, From 938471144fda6d5a824ccb27f7908cee10d0a503 Mon Sep 17 00:00:00 2001 From: Jad Bitar Date: Tue, 16 Jun 2026 09:17:04 -0400 Subject: [PATCH 4/4] feat(youtube): publish capture events to bus wire kit/bus + NetworkAdapter (source foo-youtube); peers env FOO_YOUTUBE_BUS_PEERS, auth FOO_BUS_TOKEN/BUS_TOKEN; connect best-effort, never fatal. publish capture.metadata.fetched + capture.transcript.fetched after successful extraction, payload {url}. failed fetch publishes nothing. ext-info contract + exit codes unchanged. --- cmd/foo-youtube/main.go | 65 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/cmd/foo-youtube/main.go b/cmd/foo-youtube/main.go index 5194f30..c3e5180 100644 --- a/cmd/foo-youtube/main.go +++ b/cmd/foo-youtube/main.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "os" "os/exec" "strings" @@ -17,10 +18,20 @@ import ( "github.com/spf13/cobra" kitcli "hop.top/kit/go/console/cli" "hop.top/kit/go/console/output" + kitbus "hop.top/kit/go/runtime/bus" ) var version = "dev" +// eventBus carries capture events to external subscribers (aps, ctxt, +// tlc) via the network adapter. A bare bus.New() publishes in-process to +// nobody; wireBusNetwork attaches the adapter when peers are configured. +// nil until run() initializes it; publishEvent tolerates the nil bus. +var ( + eventBus kitbus.Bus + busNet *kitbus.NetworkAdapter +) + // Exit codes follow the kit cross-tool convention (§8.1): 1 generic, // 2 usage (bad/missing args), 5 a missing external dependency. Fetch // failures are runtime errors against an otherwise-valid request, so @@ -238,6 +249,14 @@ func run(cmd *cobra.Command, args []string, opts runOpts) error { return missingDepError(err) } + // Wire the event bus once the request is validated and the dependency + // is present, before any extraction runs. A failed fetch below + // returns early and publishes nothing. + if eventBus == nil { + eventBus = kitbus.New() + wireBusNetwork(cmd.Context()) + } + var md *videoMetadata if opts.metadata { var err error @@ -245,6 +264,7 @@ func run(cmd *cobra.Command, args []string, opts runOpts) error { if err != nil { return fetchErrorf("fetching metadata: %v", err) } + publishEvent(cmd.Context(), "foo-youtube.capture.metadata.fetched", map[string]any{"url": url}) } var transcriptText string @@ -254,6 +274,7 @@ func run(cmd *cobra.Command, args []string, opts runOpts) error { if err != nil { return fetchErrorf("fetching transcript: %v", err) } + publishEvent(cmd.Context(), "foo-youtube.capture.transcript.fetched", map[string]any{"url": url}) } var commentList []comment @@ -274,6 +295,50 @@ func run(cmd *cobra.Command, args []string, opts runOpts) error { return nil } +// wireBusNetwork attaches a NetworkAdapter to the in-process bus so the +// capture events foo-youtube publishes reach external subscribers (aps, +// ctxt, tlc) over WebSocket. A bare bus.New() publishes to nobody; the +// adapter subscribes to every local topic and forwards to each peer. +// +// Peers are read from FOO_YOUTUBE_BUS_PEERS (comma-separated ws:// URLs); +// with none set, the adapter is skipped and events stay in-process. +// Connects are best-effort: a failure is logged and never fatal. An auth +// token from FOO_BUS_TOKEN / BUS_TOKEN is attached when present, sharing +// the host's token names. +func wireBusNetwork(ctx context.Context) { + if eventBus == nil { + return + } + raw := strings.TrimSpace(os.Getenv("FOO_YOUTUBE_BUS_PEERS")) + if raw == "" { + return + } + var opts []kitbus.NetworkOption + if auth, ok := kitbus.AuthFromEnv("FOO_BUS_TOKEN", "BUS_TOKEN"); ok { + opts = append(opts, kitbus.WithAuth(auth)) + } + busNet = kitbus.NewNetworkAdapter(eventBus, opts...) + for _, addr := range strings.Split(raw, ",") { + addr = strings.TrimSpace(addr) + if addr == "" { + continue + } + if err := busNet.Connect(ctx, addr); err != nil { + slog.Warn("bus.network.connect.failed", slog.String("addr", addr), slog.Any("err", err)) + } + } +} + +// publishEvent emits one capture event onto the bus, tolerating a nil +// bus (no-op). The source segment is the binary name so subscribers can +// filter foo-youtube traffic from the host and sibling sidecars. +func publishEvent(ctx context.Context, topic string, payload any) { + if eventBus == nil { + return + } + _ = eventBus.Publish(ctx, kitbus.NewEvent(kitbus.Topic(topic), "foo-youtube", payload)) +} + func isYouTubeURL(url string) bool { return strings.Contains(url, "youtube.com/") || strings.Contains(url, "youtu.be/") ||