Commitfc06f4a9Recorded25 Mar 2026Repositorytube
Implement tube CLI and MCP server
Message
Dual CLI/MCP server for unified YouTube + Twitch management. Five modules: config (env credentials), youtube (upload, videos, analytics, livestream, playlists), twitch (channel, schedule, analytics, chat), tools (7 MCP tools with cross-platform support), and main (CLI dispatch + MCP server entry point).
24 tests covering config, YouTube formatters, and Twitch formatters.
Changed
.gitignore | 2 ++
README.md | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
src/tube/config.sgl | 46 +++++++++++++++++++++++++++
src/tube/main.sgl | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/tube/tools.sgl | 293 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/tube/twitch.sgl | 178 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/tube/youtube.sgl | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-config.sgl | 46 +++++++++++++++++++++++++++
test/test-twitch.sgl | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-youtube.sgl | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 1256 insertions(+), 1 deletion(-)Diff
.gitignoreadded
@@ -0,0 +1,2 @@
+1
build/+2
libREADME.mdmodified
@@ -1,3 +1,107 @@
1
# tube 2
−3
Video platform management CLI and MCP server — YouTube + Twitch 3
No newline at end of file+4
Video platform management CLI and MCP server — YouTube + Twitch unified in one interface.+5
+6
Upload videos, edit metadata, check analytics, schedule streams, manage chat, and get a cross-platform overview of your content channels.+7
+8
## Installation+9
+10
Requires a C toolchain. On Guix:+11
+12
```bash+13
guix shell -m ../sigil/manifest.scm+14
```+15
+16
Build:+17
+18
```bash+19
sigil build --redirects dev-redirects.sgl+20
```+21
+22
Run tests:+23
+24
```bash+25
sigil test+26
```+27
+28
## Usage+29
+30
### CLI Mode+31
+32
```bash+33
# Platform overview (subscribers, followers, live status)+34
tube status+35
+36
# List recent YouTube videos with stats+37
tube videos --limit 20+38
+39
# Upload a video to YouTube+40
tube upload video.mp4 --title "My Video" --description "About this video" --privacy unlisted+41
+42
# Analytics summary (YouTube + Twitch combined)+43
tube analytics --days 30+44
+45
# View stream schedule across platforms+46
tube stream-schedule --platform both+47
+48
# Start a broadcast+49
tube go-live --title "Weekly Livestream" --platform both+50
+51
# Send a chat message+52
tube chat "Hello everyone!" --platform twitch+53
```+54
+55
### MCP Server Mode+56
+57
```bash+58
tube serve+59
```+60
+61
This starts an MCP server exposing the following tools:+62
+63
| Tool | Description |+64
|------|-------------|+65
| `tube/upload` | Upload video to YouTube |+66
| `tube/videos` | List recent videos with stats |+67
| `tube/analytics` | Cross-platform analytics summary |+68
| `tube/schedule` | View stream schedule (YouTube + Twitch) |+69
| `tube/go-live` | Start broadcast on one or both platforms |+70
| `tube/chat` | Send message to live chat |+71
| `tube/status` | Platform overview (subs, followers, live status) |+72
+73
## Configuration+74
+75
Set these environment variables:+76
+77
| Variable | Required | Description |+78
|----------|----------|-------------|+79
| `YOUTUBE_ACCESS_TOKEN` | For YouTube write ops | YouTube OAuth2 access token |+80
| `YOUTUBE_API_KEY` | For YouTube read ops | YouTube Data API key |+81
| `TWITCH_CLIENT_ID` | For Twitch | Twitch application client ID |+82
| `TWITCH_ACCESS_TOKEN` | For Twitch | Twitch OAuth2 access token |+83
| `TUBE_DEFAULT_PLATFORM` | No | Default platform: `youtube`, `twitch`, or `both` (default: `both`) |+84
+85
You only need credentials for the platforms you want to use. If you only have YouTube credentials, Twitch operations will gracefully report that credentials are missing, and vice versa.+86
+87
## Architecture+88
+89
```+90
src/tube/+91
config.sgl — Environment-based configuration+92
youtube.sgl — YouTube operations (wraps sigil-youtube)+93
twitch.sgl — Twitch operations (wraps sigil-twitch)+94
tools.sgl — MCP tool definitions + shared helpers+95
main.sgl — Dual CLI/MCP entry point+96
```+97
+98
tube follows the dual CLI/MCP server pattern used by [fjo](https://codeberg.org/sigil/fjo) and [tally](https://codeberg.org/sigil/tally). The same business logic serves both the command-line interface and MCP tool handlers.+99
+100
## Dependencies+101
+102
- [sigil-youtube](https://codeberg.org/sigil/sigil-youtube) — YouTube Data API v3 client+103
- [sigil-twitch](https://codeberg.org/sigil/sigil-twitch) — Twitch Helix API client+104
- [sigil-mcp](https://codeberg.org/sigil/sigil) — MCP server framework+105
+106
## License+107
+108
BSD-3-Clausesrc/tube/config.sgladded
@@ -0,0 +1,46 @@
+1
;;; (tube config) - Configuration from environment variables.+2
;;;+3
;;; Reads YouTube and Twitch credentials from environment.+4
;;; YOUTUBE_ACCESS_TOKEN / YOUTUBE_API_KEY for YouTube,+5
;;; TWITCH_CLIENT_ID / TWITCH_ACCESS_TOKEN for Twitch,+6
;;; TUBE_DEFAULT_PLATFORM for default platform preference.+7
+8
(define-library (tube config)+9
(import (sigil core)+10
(sigil struct)+11
(sigil process)+12
(sigil env))+13
(export tube-config+14
tube-config?+15
tube-config-youtube-access-token+16
tube-config-youtube-api-key+17
tube-config-twitch-client-id+18
tube-config-twitch-access-token+19
tube-config-default-platform+20
+21
load-tube-config)+22
(begin+23
+24
(define-struct tube-config+25
(youtube-access-token default: #f)+26
(youtube-api-key default: #f)+27
(twitch-client-id default: #f)+28
(twitch-access-token default: #f)+29
(default-platform default: "both"))+30
+31
;;; Load configuration from environment variables.+32
;;;+33
;;; - YOUTUBE_ACCESS_TOKEN — OAuth2 token for YouTube (required for mutations)+34
;;; - YOUTUBE_API_KEY — API key for YouTube read-only operations+35
;;; - TWITCH_CLIENT_ID — Twitch application client ID+36
;;; - TWITCH_ACCESS_TOKEN — Twitch OAuth2 token+37
;;; - TUBE_DEFAULT_PLATFORM — default platform: "youtube", "twitch", or "both"+38
(define (load-tube-config)+39
(tube-config+40
youtube-access-token: (getenv "YOUTUBE_ACCESS_TOKEN")+41
youtube-api-key: (getenv "YOUTUBE_API_KEY")+42
twitch-client-id: (getenv "TWITCH_CLIENT_ID")+43
twitch-access-token: (getenv "TWITCH_ACCESS_TOKEN")+44
default-platform: (getenv-default "TUBE_DEFAULT_PLATFORM" "both")))+45
+46
))src/tube/main.sgladded
@@ -0,0 +1,185 @@
+1
;;; (tube main) - Entry point for tube CLI and MCP server.+2
;;;+3
;;; Dispatches between MCP server mode (`tube serve`) and CLI mode+4
;;; (`tube <command> <args>`). Follows the fjo/tally dual-mode pattern.+5
+6
(define-library (tube main)+7
(import (sigil core)+8
(sigil string)+9
(sigil process)+10
(sigil mcp server)+11
(tube config)+12
(tube youtube)+13
(tube twitch)+14
(tube tools))+15
(export main)+16
(begin+17
+18
(define (run-server config)+19
(let ((server (mcp-server name: "tube" version: "0.1.0")))+20
(register-tube-tools! server config)+21
(mcp-server-run server)))+22
+23
(define (print-usage)+24
(display "tube - Video platform management tool (YouTube + Twitch)\n\n")+25
(display "Usage:\n")+26
(display " tube serve Start MCP server\n")+27
(display " tube upload <file> [--title T] [--description D] [--privacy P]\n")+28
(display " Upload video to YouTube\n")+29
(display " tube videos [--limit N] List recent YouTube videos\n")+30
(display " tube analytics [--days N] [--platform P] Analytics summary\n")+31
(display " tube stream-schedule [--platform P] Show upcoming streams\n")+32
(display " tube go-live [--title T] [--platform P] Start a broadcast\n")+33
(display " tube chat <message> [--platform P] Send chat message\n")+34
(display " tube status [--platform P] Platform overview\n\n")+35
(display "Platforms: youtube, twitch, both (default: both)\n\n")+36
(display "Environment:\n")+37
(display " YOUTUBE_ACCESS_TOKEN YouTube OAuth2 token\n")+38
(display " YOUTUBE_API_KEY YouTube API key (read-only)\n")+39
(display " TWITCH_CLIENT_ID Twitch application client ID\n")+40
(display " TWITCH_ACCESS_TOKEN Twitch OAuth2 token\n")+41
(display " TUBE_DEFAULT_PLATFORM Default platform (youtube/twitch/both)\n"))+42
+43
;;; Find a --flag value in an argument list.+44
(define (find-flag args flag default)+45
(let loop ((rest args))+46
(cond+47
((null? rest) default)+48
((and (string=? (car rest) flag)+49
(not (null? (cdr rest))))+50
(cadr rest))+51
(else (loop (cdr rest))))))+52
+53
;;; Collect non-flag arguments (positional args).+54
(define (positional-args args)+55
(let loop ((rest args) (acc '()))+56
(cond+57
((null? rest) (reverse acc))+58
((and (> (string-length (car rest)) 1)+59
(char=? (string-ref (car rest) 0) #\-)+60
(char=? (string-ref (car rest) 1) #\-))+61
;; Skip --flag and its value+62
(if (null? (cdr rest))+63
(reverse acc)+64
(loop (cddr rest) acc)))+65
(else+66
(loop (cdr rest) (cons (car rest) acc))))))+67
+68
;; ============================================================+69
;; CLI Command Handlers+70
;; ============================================================+71
+72
(define (cli-upload config args)+73
(let* ((positionals (positional-args args))+74
(file (if (pair? positionals) (car positionals) #f))+75
(title (find-flag args "--title" "Untitled"))+76
(description (find-flag args "--description" ""))+77
(privacy (find-flag args "--privacy" "private")))+78
(unless file+79
(display "Error: file path is required.\n")+80
(exit 1))+81
;; Read file data+82
(let* ((port (open-binary-input-file file))+83
(data (read-bytevector-all port)))+84
(close-input-port port)+85
(let ((result (tube-upload-video config data title description privacy)))+86
(display (string-append+87
"Uploaded: " (youtube-video-title result) "\n"+88
"Video ID: " (youtube-video-id result) "\n"+89
"Privacy: " privacy "\n"))))))+90
+91
(define (cli-videos config args)+92
(let* ((limit (string->number (find-flag args "--limit" "10")))+93
(videos (tube-list-videos config limit)))+94
(if (null? videos)+95
(display "No videos found.\n")+96
(begin+97
(display (string-append+98
"Recent videos (" (number->string (length videos)) "):\n\n"))+99
(for-each (lambda (v)+100
(display (format-video v))+101
(display "\n\n"))+102
videos)))))+103
+104
(define (cli-analytics config args start-default end-default)+105
(let* ((days (find-flag args "--days" "30"))+106
(platform (find-flag args "--platform"+107
(tube-config-default-platform config)))+108
(params #{ days: (string->number days)+109
platform: platform+110
start_date: start-default+111
end_date: end-default }))+112
;; Reuse the tool handler+113
(display (tool-analytics config params start-default end-default))+114
(newline)))+115
+116
(define (cli-schedule config args)+117
(let* ((platform (find-flag args "--platform"+118
(tube-config-default-platform config)))+119
(params #{ platform: platform }))+120
(display (tool-schedule config params))+121
(newline)))+122
+123
(define (cli-go-live config args)+124
(let* ((title (find-flag args "--title" "Live Stream"))+125
(platform (find-flag args "--platform"+126
(tube-config-default-platform config)))+127
(params #{ title: title platform: platform }))+128
(display (tool-go-live config params))+129
(newline)))+130
+131
(define (cli-chat config args)+132
(let* ((positionals (positional-args args))+133
(message (if (pair? positionals)+134
(string-join positionals " ")+135
#f))+136
(platform (find-flag args "--platform" "twitch")))+137
(unless message+138
(display "Error: chat message is required.\n")+139
(exit 1))+140
(let ((params #{ message: message platform: platform }))+141
(display (tool-chat config params))+142
(newline))))+143
+144
(define (cli-status config args)+145
(let* ((platform (find-flag args "--platform"+146
(tube-config-default-platform config)))+147
(params #{ platform: platform }))+148
(display (tool-status config params))+149
(newline)))+150
+151
;; ============================================================+152
;; Entry Point+153
;; ============================================================+154
+155
(define (main)+156
(let ((config (load-tube-config))+157
(args (cdr (command-line)))+158
(start-default (default-start-date))+159
(end-default (default-end-date)))+160
(cond+161
((null? args)+162
(print-usage)+163
(exit 1))+164
((string=? (car args) "serve")+165
(run-server config))+166
((string=? (car args) "upload")+167
(cli-upload config (cdr args)))+168
((string=? (car args) "videos")+169
(cli-videos config (cdr args)))+170
((string=? (car args) "analytics")+171
(cli-analytics config (cdr args) start-default end-default))+172
((string=? (car args) "stream-schedule")+173
(cli-schedule config (cdr args)))+174
((string=? (car args) "go-live")+175
(cli-go-live config (cdr args)))+176
((string=? (car args) "chat")+177
(cli-chat config (cdr args)))+178
((string=? (car args) "status")+179
(cli-status config (cdr args)))+180
(else+181
(display (string-append "Unknown command: " (car args) "\n\n"))+182
(print-usage)+183
(exit 1)))))+184
+185
))src/tube/tools.sgladded
@@ -0,0 +1,293 @@
+1
;;; (tube tools) - MCP tool definitions and shared helpers for tube.+2
;;;+3
;;; Provides MCP tool registration for YouTube and Twitch operations.+4
;;; Tools present unified cross-platform data where appropriate.+5
+6
(define-library (tube tools)+7
(import (sigil core)+8
(sigil string)+9
(sigil dict)+10
(sigil struct)+11
(sigil json)+12
(sigil time)+13
(sigil mcp server)+14
(sigil youtube)+15
(sigil youtube analytics)+16
(sigil youtube live)+17
(sigil youtube playlist)+18
(sigil twitch)+19
(sigil twitch schedule)+20
(sigil twitch analytics)+21
(tube config)+22
(tube youtube)+23
(tube twitch))+24
(export register-tube-tools!+25
default-start-date+26
default-end-date)+27
(begin+28
+29
;;; Compute default start date (30 days ago).+30
;;; Must be called outside async contexts due to VM yield bug+31
;;; with current-second.+32
(define (default-start-date)+33
(format-date (- (current-second) (* 30 86400))))+34
+35
;;; Compute default end date (today).+36
;;; Must be called outside async contexts due to VM yield bug+37
;;; with current-second.+38
(define (default-end-date)+39
(format-date (current-second)))+40
+41
;;; Check if a platform is enabled based on config and requested platform.+42
(define (platform-enabled? config platform requested)+43
(let ((req (or requested (tube-config-default-platform config))))+44
(or (string=? req "both")+45
(string=? req platform))))+46
+47
;;; Collect results from enabled platforms. Each provider is a pair+48
;;; of (platform-name . thunk). Returns results from enabled platforms+49
;;; that succeed, or the fallback message if none produced data.+50
(define (collect-platform-results config requested providers fallback)+51
(let ((results+52
(filter identity+53
(map (lambda (provider)+54
(let ((platform (car provider))+55
(thunk (cdr provider)))+56
(if (platform-enabled? config platform requested)+57
(guard (exn (else #f))+58
(thunk))+59
#f)))+60
providers))))+61
(if (null? results)+62
fallback+63
(string-join results "\n\n"))))+64
+65
;; ============================================================+66
;; Tool Handlers+67
;; ============================================================+68
+69
(define (tool-upload config params)+70
(let* ((file (dict-ref params file:))+71
(title (dict-ref params title: "Untitled"))+72
(description (dict-ref params description: ""))+73
(privacy (dict-ref params privacy: "private")))+74
(unless file+75
(error "file parameter is required"))+76
(let ((result (tube-upload-video config file title description privacy)))+77
(string-append "Video uploaded successfully.\n"+78
"Video ID: " (youtube-video-id result) "\n"+79
"Title: " (youtube-video-title result) "\n"+80
"Privacy: " privacy))))+81
+82
(define (tool-videos config params)+83
(let* ((limit (dict-ref params limit: 10))+84
(videos (tube-list-videos config limit)))+85
(if (null? videos)+86
"No videos found."+87
(string-append+88
"Recent videos (" (number->string (length videos)) "):\n\n"+89
(string-join (map format-video videos) "\n\n")))))+90
+91
(define (tool-analytics config params start-default end-default)+92
(let* ((platform (dict-ref params platform:+93
(tube-config-default-platform config)))+94
(start-date (dict-ref params start_date: start-default))+95
(end-date (dict-ref params end_date: end-default)))+96
(collect-platform-results config platform+97
(list+98
(cons "youtube"+99
(lambda ()+100
(let* ((yt-data (tube-video-analytics config start-date end-date))+101
(rows (dict-ref yt-data rows: '())))+102
(if (null? rows)+103
"No YouTube data for this period."+104
(string-append+105
"YouTube Analytics (" start-date " to " end-date "):\n"+106
"Day\tViews\tWatch Min\tAvg Duration\tNew Subs\n"+107
(string-join (map format-analytics-row rows) "\n"))))))+108
(cons "twitch"+109
(lambda ()+110
(let* ((followers (tube-twitch-followers config 1))+111
(total (dict-ref followers total: 0))+112
(clips (tube-twitch-clips config 5)))+113
(string-append+114
"Twitch Analytics:\n"+115
"Total Followers: " (number->string total) "\n"+116
(if (null? clips)+117
"No recent clips."+118
(string-append+119
"Recent Clips:\n"+120
(string-join (map format-twitch-clip clips) "\n"))))))))+121
"No analytics data available. Check platform credentials.")))+122
+123
(define (tool-schedule config params)+124
(let ((platform (dict-ref params platform:+125
(tube-config-default-platform config))))+126
(collect-platform-results config platform+127
(list+128
(cons "youtube"+129
(lambda ()+130
(let ((broadcasts (tube-list-broadcasts config "upcoming")))+131
(if (null? broadcasts)+132
"YouTube: No upcoming broadcasts."+133
(string-append+134
"YouTube Upcoming Broadcasts:\n"+135
(string-join (map format-broadcast broadcasts) "\n"))))))+136
(cons "twitch"+137
(lambda ()+138
(let ((segments (tube-twitch-schedule config)))+139
(if (null? segments)+140
"Twitch: No scheduled streams."+141
(string-append+142
"Twitch Schedule:\n"+143
(string-join (map format-schedule-segment segments) "\n")))))))+144
"No schedule data available. Check platform credentials.")))+145
+146
(define (tool-go-live config params)+147
(let* ((platform (dict-ref params platform:+148
(tube-config-default-platform config)))+149
(title (dict-ref params title: "Live Stream"))+150
;; Pre-compute date outside platform lambdas to avoid+151
;; calling format-date inside async context+152
(today (default-end-date)))+153
(collect-platform-results config platform+154
(list+155
(cons "youtube"+156
(lambda ()+157
(let* ((scheduled (string-append today "T00:00:00Z"))+158
(broadcast (tube-create-broadcast config title scheduled)))+159
(string-append+160
"YouTube broadcast created: "+161
(youtube-broadcast-id broadcast)))))+162
(cons "twitch"+163
(lambda ()+164
(let ((client (require-twitch-client config))+165
(channel (tube-twitch-channel-info config)))+166
(twitch-modify-channel client+167
(twitch-channel-id channel)+168
#{ title: title })+169
(string-append "Twitch channel title set to: " title)))))+170
"Failed to start broadcast. Check platform credentials.")))+171
+172
(define (tool-chat config params)+173
(let* ((message (dict-ref params message:))+174
(platform (dict-ref params platform: "twitch")))+175
(unless message+176
(error "message parameter is required"))+177
(cond+178
((string=? platform "twitch")+179
(tube-twitch-send-chat config message)+180
(string-append "Chat message sent to Twitch: " message))+181
((string=? platform "youtube")+182
"YouTube live chat is not yet supported via this tool.")+183
(else+184
(error (string-append "Unknown platform: " platform))))))+185
+186
(define (tool-status config params)+187
(let ((platform (dict-ref params platform:+188
(tube-config-default-platform config))))+189
(collect-platform-results config platform+190
(list+191
(cons "youtube" (lambda () (tube-youtube-status config)))+192
(cons "twitch" (lambda () (tube-twitch-status config))))+193
"No platform data available. Check credentials.")))+194
+195
;; ============================================================+196
;; MCP Registration+197
;; ============================================================+198
+199
;;; Register all tube MCP tools on the given server.+200
;;; Precomputes default dates here (before async context) to avoid+201
;;; the VM yield bug with current-second in async goroutines.+202
(define (register-tube-tools! server config)+203
(let ((start-default (default-start-date))+204
(end-default (default-end-date)))+205
+206
(mcp-server-register-tool! server+207
name: "tube/upload"+208
description: "Upload a video to YouTube. Provide the file path, title, optional description, and privacy level."+209
schema: #{ type: "object"+210
properties: #{+211
file: #{ type: "string"+212
description: "Path to the video file to upload" }+213
title: #{ type: "string"+214
description: "Video title" }+215
description: #{ type: "string"+216
description: "Video description" }+217
privacy: #{ type: "string"+218
description: "Privacy status: public, private, or unlisted (default: private)"+219
enum: #["public" "private" "unlisted"] } }+220
required: #["file"] }+221
handler: (lambda (params) (tool-upload config params)))+222
+223
(mcp-server-register-tool! server+224
name: "tube/videos"+225
description: "List recent YouTube videos with view counts, likes, and comment counts."+226
schema: #{ type: "object"+227
properties: #{+228
limit: #{ type: "integer"+229
description: "Number of videos to return (default: 10)" } } }+230
handler: (lambda (params) (tool-videos config params)))+231
+232
(mcp-server-register-tool! server+233
name: "tube/analytics"+234
description: "Get analytics summary combining YouTube and Twitch data. Shows views, watch time, subscribers/followers, and engagement."+235
schema: #{ type: "object"+236
properties: #{+237
days: #{ type: "integer"+238
description: "Number of days to look back (default: 30)" }+239
platform: #{ type: "string"+240
description: "Platform: youtube, twitch, or both (default: both)"+241
enum: #["youtube" "twitch" "both"] }+242
start_date: #{ type: "string"+243
description: "Start date in YYYY-MM-DD format" }+244
end_date: #{ type: "string"+245
description: "End date in YYYY-MM-DD format" } } }+246
handler: (lambda (params) (tool-analytics config params start-default end-default)))+247
+248
(mcp-server-register-tool! server+249
name: "tube/schedule"+250
description: "View stream schedule across YouTube (upcoming broadcasts) and Twitch (schedule segments)."+251
schema: #{ type: "object"+252
properties: #{+253
platform: #{ type: "string"+254
description: "Platform: youtube, twitch, or both (default: both)"+255
enum: #["youtube" "twitch" "both"] } } }+256
handler: (lambda (params) (tool-schedule config params)))+257
+258
(mcp-server-register-tool! server+259
name: "tube/go-live"+260
description: "Start a broadcast on one or both platforms. Creates a YouTube broadcast and/or sets the Twitch channel title."+261
schema: #{ type: "object"+262
properties: #{+263
title: #{ type: "string"+264
description: "Stream title" }+265
platform: #{ type: "string"+266
description: "Platform: youtube, twitch, or both (default: both)"+267
enum: #["youtube" "twitch" "both"] } } }+268
handler: (lambda (params) (tool-go-live config params)))+269
+270
(mcp-server-register-tool! server+271
name: "tube/chat"+272
description: "Send a message to live chat on the specified platform."+273
schema: #{ type: "object"+274
properties: #{+275
message: #{ type: "string"+276
description: "Chat message to send" }+277
platform: #{ type: "string"+278
description: "Platform: youtube or twitch (default: twitch)"+279
enum: #["youtube" "twitch"] } }+280
required: #["message"] }+281
handler: (lambda (params) (tool-chat config params)))+282
+283
(mcp-server-register-tool! server+284
name: "tube/status"+285
description: "Overview of both platforms — subscriber/follower counts, recent activity, live status, and channel info."+286
schema: #{ type: "object"+287
properties: #{+288
platform: #{ type: "string"+289
description: "Platform: youtube, twitch, or both (default: both)"+290
enum: #["youtube" "twitch" "both"] } } }+291
handler: (lambda (params) (tool-status config params)))))+292
+293
))src/tube/twitch.sgladded
@@ -0,0 +1,178 @@
+1
;;; (tube twitch) - Twitch-specific operations wrapping sigil-twitch.+2
;;;+3
;;; Channel info, stream scheduling, analytics (followers, subs, clips),+4
;;; and chat operations. All operations that need the broadcaster ID+5
;;; resolve it once via with-twitch-context to avoid repeated API calls.+6
+7
(define-library (tube twitch)+8
(import (sigil core)+9
(sigil string)+10
(sigil dict)+11
(sigil struct)+12
(sigil json)+13
(sigil twitch)+14
(sigil twitch schedule)+15
(sigil twitch analytics)+16
(sigil twitch chat)+17
(tube config))+18
(export require-twitch-client+19
tube-twitch-channel-info+20
tube-twitch-status+21
tube-twitch-schedule+22
tube-twitch-create-segment+23
tube-twitch-followers+24
tube-twitch-subscribers+25
tube-twitch-clips+26
tube-twitch-videos+27
tube-twitch-send-chat+28
format-twitch-channel+29
format-schedule-segment+30
format-twitch-clip+31
format-twitch-video)+32
(begin+33
+34
;;; Build a twitch-client from config, raising if no credentials.+35
(define (require-twitch-client config)+36
(let ((client-id (tube-config-twitch-client-id config))+37
(token (tube-config-twitch-access-token config)))+38
(unless (and client-id token)+39
(error "Twitch credentials not set. Set TWITCH_CLIENT_ID and TWITCH_ACCESS_TOKEN."))+40
(twitch-client client-id: client-id+41
access-token: token)))+42
+43
;;; Resolve client + broadcaster ID together. Caches the broadcaster ID+44
;;; lookup so multiple operations in the same tool handler share one call.+45
(define (with-twitch-context config proc)+46
(let* ((client (require-twitch-client config))+47
(users (twitch-users client))+48
(broadcaster-id+49
(if (and (pair? users) (car users))+50
(twitch-user-id (car users))+51
(error "Could not determine Twitch user ID"))))+52
(proc client broadcaster-id)))+53
+54
;;; Format a twitch-channel record.+55
(define (format-twitch-channel channel)+56
(string-append+57
(twitch-channel-name channel)+58
(let ((title (twitch-channel-title channel)))+59
(if (and title (not (string=? title "")))+60
(string-append " - " title)+61
""))+62
(let ((game (twitch-channel-game-name channel)))+63
(if (and game (not (string=? game "")))+64
(string-append " [" game "]")+65
""))))+66
+67
;;; Get channel info for the authenticated user.+68
(define (tube-twitch-channel-info config)+69
(with-twitch-context config+70
(lambda (client broadcaster-id)+71
(let ((channels (twitch-channel-info client broadcaster-id)))+72
(if (pair? channels)+73
(car channels)+74
(error "Could not fetch Twitch channel info"))))))+75
+76
;;; Get a Twitch channel status overview.+77
(define (tube-twitch-status config)+78
(with-twitch-context config+79
(lambda (client broadcaster-id)+80
(let* ((channels (twitch-channel-info client broadcaster-id))+81
(channel (if (pair? channels) (car channels) #f))+82
(followers (twitch-followers client broadcaster-id))+83
(follower-count (if followers (dict-ref followers total: 0) 0))+84
(streams (twitch-streams client user-id: broadcaster-id))+85
(is-live (and (pair? streams) (car streams))))+86
(string-append+87
"Twitch Channel: " (if channel (twitch-channel-name channel) "unknown") "\n"+88
"Followers: " (number->string follower-count) "\n"+89
"Status: " (if is-live+90
(string-append "LIVE - "+91
(twitch-stream-title (car streams))+92
" (" (number->string (twitch-stream-viewer-count (car streams)))+93
" viewers)")+94
"Offline"))))))+95
+96
;;; Format a schedule segment.+97
(define (format-schedule-segment seg)+98
(string-append+99
(twitch-schedule-segment-start-time seg) " - "+100
(twitch-schedule-segment-end-time seg) " "+101
(or (twitch-schedule-segment-title seg) "(no title)")+102
(let ((cat (twitch-schedule-segment-category seg)))+103
(if cat+104
(string-append " [" (dict-ref cat name: "unknown") "]")+105
""))+106
(if (twitch-schedule-segment-is-recurring seg)+107
" (recurring)"+108
"")))+109
+110
;;; Get the stream schedule.+111
(define (tube-twitch-schedule config)+112
(with-twitch-context config+113
(lambda (client broadcaster-id)+114
(let ((result (twitch-schedule client broadcaster-id)))+115
(dict-ref result segments: '())))))+116
+117
;;; Create a new schedule segment.+118
(define (tube-twitch-create-segment config start-time timezone+119
. rest)+120
(with-twitch-context config+121
(lambda (client broadcaster-id)+122
(let ((title (if (pair? rest) (car rest) #f))+123
(duration (if (and (pair? rest) (pair? (cdr rest)))+124
(cadr rest) 60)))+125
(twitch-create-segment client broadcaster-id start-time timezone+126
duration: duration+127
title: title)))))+128
+129
;;; Get follower data.+130
(define (tube-twitch-followers config . rest)+131
(with-twitch-context config+132
(lambda (client broadcaster-id)+133
(let ((limit (if (pair? rest) (car rest) 20)))+134
(twitch-followers client broadcaster-id first: limit)))))+135
+136
;;; Get subscriber data.+137
(define (tube-twitch-subscribers config)+138
(with-twitch-context config+139
(lambda (client broadcaster-id)+140
(twitch-subscribers client broadcaster-id))))+141
+142
;;; Format a twitch-clip record.+143
(define (format-twitch-clip clip)+144
(string-append+145
(twitch-clip-title clip)+146
" (" (number->string (twitch-clip-view-count clip)) " views)"+147
" - " (twitch-clip-url clip)))+148
+149
;;; Get recent clips.+150
(define (tube-twitch-clips config . rest)+151
(with-twitch-context config+152
(lambda (client broadcaster-id)+153
(let ((limit (if (pair? rest) (car rest) 10)))+154
(twitch-clips client broadcaster-id: broadcaster-id first: limit)))))+155
+156
;;; Format a twitch-video record.+157
(define (format-twitch-video video)+158
(string-append+159
(twitch-video-id video) " "+160
(twitch-video-title video)+161
" [" (twitch-video-type video) "]"+162
" (" (number->string (twitch-video-view-count video)) " views)"))+163
+164
;;; Get recent VODs/videos.+165
(define (tube-twitch-videos config . rest)+166
(with-twitch-context config+167
(lambda (client broadcaster-id)+168
(let ((limit (if (pair? rest) (car rest) 10)))+169
(twitch-videos client user-id: broadcaster-id first: limit)))))+170
+171
;;; Send a chat message to the channel.+172
(define (tube-twitch-send-chat config message)+173
(with-twitch-context config+174
(lambda (client broadcaster-id)+175
(twitch-send-chat-message client broadcaster-id broadcaster-id+176
message))))+177
+178
))src/tube/youtube.sgladded
@@ -0,0 +1,181 @@
+1
;;; (tube youtube) - YouTube-specific operations wrapping sigil-youtube.+2
;;;+3
;;; Upload with metadata, video listing with stats, analytics dashboard,+4
;;; livestream management, and playlist management.+5
+6
(define-library (tube youtube)+7
(import (sigil core)+8
(sigil string)+9
(sigil dict)+10
(sigil struct)+11
(sigil json)+12
(sigil youtube)+13
(sigil youtube upload)+14
(sigil youtube analytics)+15
(sigil youtube live)+16
(sigil youtube playlist)+17
(tube config))+18
(export require-youtube-client+19
tube-upload-video+20
tube-list-videos+21
tube-video-analytics+22
tube-youtube-status+23
tube-list-broadcasts+24
tube-create-broadcast+25
tube-go-live-youtube+26
tube-list-playlists+27
format-video+28
format-analytics-row+29
format-broadcast+30
format-playlist)+31
(begin+32
+33
;;; Build a youtube-client from config, raising if no credentials.+34
(define (require-youtube-client config)+35
(let ((token (tube-config-youtube-access-token config))+36
(key (tube-config-youtube-api-key config)))+37
(unless (or token key)+38
(error "No YouTube credentials set. Set YOUTUBE_ACCESS_TOKEN or YOUTUBE_API_KEY."))+39
(youtube-client access-token: token+40
api-key: key)))+41
+42
;; ============================================================+43
;; Upload+44
;; ============================================================+45
+46
;;; Upload a video to YouTube with metadata.+47
;;; Returns the video resource dict on success.+48
(define (tube-upload-video config file-data title+49
. rest)+50
(let* ((client (require-youtube-client config))+51
(description (if (and (pair? rest) (car rest)) (car rest) ""))+52
(privacy (if (and (pair? rest) (pair? (cdr rest)) (cadr rest))+53
(cadr rest) "private"))+54
(metadata #{ snippet: #{ title: title+55
description: description+56
categoryId: "28" }+57
status: #{ privacyStatus: privacy } }))+58
(youtube-upload-video client metadata file-data)))+59
+60
;; ============================================================+61
;; Video Listing+62
;; ============================================================+63
+64
;;; Format a youtube-video record as a display string.+65
(define (format-video video)+66
(let ((stats (youtube-video-statistics video)))+67
(string-append+68
(youtube-video-id video) " "+69
(youtube-video-title video)+70
(if stats+71
(string-append+72
"\n Views: " (dict-ref stats viewCount: "0")+73
" Likes: " (dict-ref stats likeCount: "0")+74
" Comments: " (dict-ref stats commentCount: "0"))+75
""))))+76
+77
;;; List recent videos from the authenticated channel.+78
;;; Returns a list of youtube-video records.+79
(define (tube-list-videos config . rest)+80
(let* ((client (require-youtube-client config))+81
(limit (if (pair? rest) (car rest) 10))+82
(channel (youtube-channel-mine client))+83
(uploads-id (youtube-channel-uploads-playlist-id channel))+84
(items (youtube-playlist-items client uploads-id+85
max-results: limit))+86
(video-ids (map youtube-playlist-item-video-id items)))+87
(if (null? video-ids)+88
'()+89
(youtube-videos client video-ids))))+90
+91
;; ============================================================+92
;; Analytics+93
;; ============================================================+94
+95
;;; Format an analytics row as a display string.+96
(define (format-analytics-row row)+97
(string-join (map (lambda (v)+98
(if (string? v) v (number->string v)))+99
row)+100
"\t"))+101
+102
;;; Get a YouTube analytics summary for the given date range.+103
;;; Returns a dict with column-headers and rows.+104
(define (tube-video-analytics config start-date end-date)+105
(let ((client (require-youtube-client config)))+106
(youtube-analytics-query client start-date end-date+107
"views,estimatedMinutesWatched,averageViewDuration,subscribersGained"+108
dimensions: "day"+109
sort: "-day")))+110
+111
;; ============================================================+112
;; YouTube Status+113
;; ============================================================+114
+115
;;; Get YouTube channel status overview.+116
;;; Returns a formatted string with subscriber count, video count,+117
;;; and recent video info.+118
(define (tube-youtube-status config)+119
(let* ((client (require-youtube-client config))+120
(channel (youtube-channel-mine client)))+121
(string-append+122
"YouTube Channel: " (youtube-channel-title channel) "\n"+123
"Subscribers: " (number->string (youtube-channel-subscriber-count channel)) "\n"+124
"Total Videos: " (number->string (youtube-channel-video-count channel)))))+125
+126
;; ============================================================+127
;; Livestream+128
;; ============================================================+129
+130
;;; Format a youtube-broadcast record.+131
(define (format-broadcast broadcast)+132
(string-append+133
(youtube-broadcast-id broadcast) " "+134
(youtube-broadcast-title broadcast) " "+135
"[" (youtube-broadcast-lifecycle-status broadcast) "]"+136
(let ((start (youtube-broadcast-scheduled-start broadcast)))+137
(if start (string-append " Scheduled: " start) ""))))+138
+139
;;; List upcoming/active broadcasts.+140
(define (tube-list-broadcasts config . rest)+141
(let* ((client (require-youtube-client config))+142
(status (if (pair? rest) (car rest) "upcoming")))+143
(youtube-list-broadcasts client broadcast-status: status)))+144
+145
;;; Create a new broadcast and stream, bind them.+146
;;; Returns the broadcast record.+147
(define (tube-create-broadcast config title scheduled-start)+148
(let* ((client (require-youtube-client config))+149
(broadcast (youtube-create-broadcast client title scheduled-start+150
enable-auto-start: #t+151
enable-auto-stop: #t))+152
(stream (youtube-create-stream client+153
(string-append title " stream"))))+154
(youtube-bind-broadcast client+155
(youtube-broadcast-id broadcast)+156
(youtube-stream-id stream))+157
broadcast))+158
+159
;;; Transition a broadcast to live.+160
(define (tube-go-live-youtube config broadcast-id)+161
(let ((client (require-youtube-client config)))+162
(youtube-transition-broadcast client broadcast-id "live")))+163
+164
;; ============================================================+165
;; Playlists+166
;; ============================================================+167
+168
;;; Format a youtube-playlist record.+169
(define (format-playlist playlist)+170
(string-append+171
(youtube-playlist-id playlist) " "+172
(youtube-playlist-title playlist)+173
" (" (number->string (youtube-playlist-item-count playlist)) " items)"+174
" [" (youtube-playlist-privacy-status playlist) "]"))+175
+176
;;; List playlists for the authenticated channel.+177
(define (tube-list-playlists config)+178
(let ((client (require-youtube-client config)))+179
(youtube-playlists client)))+180
+181
))test/test-config.sgladded
@@ -0,0 +1,46 @@
+1
;;; Test suite for (tube config)+2
;;;+3
;;; Tests configuration loading from environment variables.+4
+5
(import (sigil test)+6
(sigil string)+7
(sigil struct)+8
(sigil process)+9
(tube config))+10
+11
;; ========== Config Defaults ==========+12
+13
(test-group "tube-config defaults"+14
(test "default platform is both"+15
(let ((config (tube-config)))+16
(assert-equal "both" (tube-config-default-platform config))))+17
+18
(test "default tokens are #f"+19
(let ((config (tube-config)))+20
(assert-false (tube-config-youtube-access-token config))+21
(assert-false (tube-config-youtube-api-key config))+22
(assert-false (tube-config-twitch-client-id config))+23
(assert-false (tube-config-twitch-access-token config)))))+24
+25
;; ========== Config Construction ==========+26
+27
(test-group "tube-config construction"+28
(test "creates config with all fields"+29
(let ((config (tube-config+30
youtube-access-token: "yt-token"+31
youtube-api-key: "yt-key"+32
twitch-client-id: "tw-id"+33
twitch-access-token: "tw-token"+34
default-platform: "youtube")))+35
(assert-equal "yt-token" (tube-config-youtube-access-token config))+36
(assert-equal "yt-key" (tube-config-youtube-api-key config))+37
(assert-equal "tw-id" (tube-config-twitch-client-id config))+38
(assert-equal "tw-token" (tube-config-twitch-access-token config))+39
(assert-equal "youtube" (tube-config-default-platform config))))+40
+41
(test "tube-config? predicate works"+42
(assert-true (tube-config? (tube-config)))+43
(assert-false (tube-config? "not a config"))+44
(assert-false (tube-config? 42))))+45
+46
(run-tests)test/test-twitch.sgladded
@@ -0,0 +1,114 @@
+1
;;; Test suite for (tube twitch)+2
;;;+3
;;; Tests Twitch helper functions using fixture data.+4
;;; Does not require a Twitch API connection.+5
+6
(import (sigil test)+7
(sigil string)+8
(sigil struct)+9
(sigil dict)+10
(sigil twitch)+11
(sigil twitch schedule)+12
(sigil twitch analytics)+13
(tube config)+14
(tube twitch))+15
+16
;; ========== Client Construction ==========+17
+18
(test-group "require-twitch-client"+19
(test "raises when no credentials set"+20
(let ((config (tube-config)))+21
(assert-error (require-twitch-client config))))+22
+23
(test "raises when only client-id set"+24
(let ((config (tube-config twitch-client-id: "test-id")))+25
(assert-error (require-twitch-client config))))+26
+27
(test "raises when only access-token set"+28
(let ((config (tube-config twitch-access-token: "test-token")))+29
(assert-error (require-twitch-client config))))+30
+31
(test "creates client with both credentials"+32
(let* ((config (tube-config+33
twitch-client-id: "test-id"+34
twitch-access-token: "test-token"))+35
(client (require-twitch-client config)))+36
(assert-true (twitch-client? client))+37
(assert-equal "test-id" (twitch-client-client-id client))+38
(assert-equal "test-token" (twitch-client-access-token client)))))+39
+40
;; ========== Formatting ==========+41
+42
(test-group "format-twitch-channel"+43
(test "formats channel with title and game"+44
(let* ((channel (twitch-channel+45
id: "123"+46
name: "systemcrafters"+47
title: "Building with Sigil"+48
game-name: "Science & Technology"))+49
(result (format-twitch-channel channel)))+50
(assert-true (string-contains? result "systemcrafters"))+51
(assert-true (string-contains? result "Building with Sigil"))+52
(assert-true (string-contains? result "Science & Technology"))))+53
+54
(test "formats channel without title"+55
(let* ((channel (twitch-channel+56
id: "123"+57
name: "systemcrafters"+58
title: ""))+59
(result (format-twitch-channel channel)))+60
(assert-true (string-contains? result "systemcrafters"))+61
(assert-false (string-contains? result " - ")))))+62
+63
(test-group "format-schedule-segment"+64
(test "formats recurring segment"+65
(let* ((seg (twitch-schedule-segment+66
id: "seg1"+67
start-time: "2026-03-28T18:00:00Z"+68
end-time: "2026-03-28T20:00:00Z"+69
title: "Weekly Stream"+70
category: #{ name: "Just Chatting" }+71
is-recurring: #t))+72
(result (format-schedule-segment seg)))+73
(assert-true (string-contains? result "2026-03-28T18:00:00Z"))+74
(assert-true (string-contains? result "Weekly Stream"))+75
(assert-true (string-contains? result "Just Chatting"))+76
(assert-true (string-contains? result "recurring"))))+77
+78
(test "formats non-recurring segment without category"+79
(let* ((seg (twitch-schedule-segment+80
id: "seg2"+81
start-time: "2026-04-01T20:00:00Z"+82
end-time: "2026-04-01T22:00:00Z"+83
title: "Special Event"+84
is-recurring: #f))+85
(result (format-schedule-segment seg)))+86
(assert-true (string-contains? result "Special Event"))+87
(assert-false (string-contains? result "recurring")))))+88
+89
(test-group "format-twitch-clip"+90
(test "formats clip"+91
(let* ((clip (twitch-clip+92
id: "clip1"+93
url: "https://clips.twitch.tv/test"+94
title: "Amazing Moment"+95
view-count: 500))+96
(result (format-twitch-clip clip)))+97
(assert-true (string-contains? result "Amazing Moment"))+98
(assert-true (string-contains? result "500"))+99
(assert-true (string-contains? result "https://clips.twitch.tv/test")))))+100
+101
(test-group "format-twitch-video"+102
(test "formats video"+103
(let* ((video (twitch-video+104
id: "v123"+105
title: "Past Stream"+106
type: "archive"+107
view-count: 200))+108
(result (format-twitch-video video)))+109
(assert-true (string-contains? result "v123"))+110
(assert-true (string-contains? result "Past Stream"))+111
(assert-true (string-contains? result "archive"))+112
(assert-true (string-contains? result "200")))))+113
+114
(run-tests)test/test-youtube.sgladded
@@ -0,0 +1,106 @@
+1
;;; Test suite for (tube youtube)+2
;;;+3
;;; Tests YouTube helper functions using fixture data.+4
;;; Does not require a YouTube API connection.+5
+6
(import (sigil test)+7
(sigil string)+8
(sigil struct)+9
(sigil dict)+10
(sigil youtube)+11
(sigil youtube live)+12
(sigil youtube playlist)+13
(tube config)+14
(tube youtube))+15
+16
;; ========== Client Construction ==========+17
+18
(test-group "require-youtube-client"+19
(test "raises when no credentials set"+20
(let ((config (tube-config)))+21
(assert-error (require-youtube-client config))))+22
+23
(test "creates client with access token"+24
(let* ((config (tube-config youtube-access-token: "test-token"))+25
(client (require-youtube-client config)))+26
(assert-true (youtube-client? client))+27
(assert-equal "test-token" (youtube-client-access-token client))))+28
+29
(test "creates client with api key only"+30
(let* ((config (tube-config youtube-api-key: "test-key"))+31
(client (require-youtube-client config)))+32
(assert-true (youtube-client? client))+33
(assert-equal "test-key" (youtube-client-api-key client)))))+34
+35
;; ========== Formatting ==========+36
+37
(test-group "format-video"+38
(test "formats video with stats"+39
(let* ((video (youtube-video+40
id: "abc123"+41
title: "Test Video"+42
description: "A test"+43
statistics: #{ viewCount: "1000"+44
likeCount: "50"+45
commentCount: "10" }))+46
(result (format-video video)))+47
(assert-true (string-contains? result "abc123"))+48
(assert-true (string-contains? result "Test Video"))+49
(assert-true (string-contains? result "1000"))+50
(assert-true (string-contains? result "50"))+51
(assert-true (string-contains? result "10"))))+52
+53
(test "formats video without stats"+54
(let* ((video (youtube-video+55
id: "def456"+56
title: "No Stats Video"))+57
(result (format-video video)))+58
(assert-true (string-contains? result "def456"))+59
(assert-true (string-contains? result "No Stats Video")))))+60
+61
(test-group "format-broadcast"+62
(test "formats broadcast with schedule"+63
(let* ((broadcast (youtube-broadcast+64
id: "bc123"+65
title: "Weekly Stream"+66
lifecycle-status: "upcoming"+67
scheduled-start: "2026-03-28T18:00:00Z"))+68
(result (format-broadcast broadcast)))+69
(assert-true (string-contains? result "bc123"))+70
(assert-true (string-contains? result "Weekly Stream"))+71
(assert-true (string-contains? result "upcoming"))+72
(assert-true (string-contains? result "2026-03-28"))))+73
+74
(test "formats broadcast without schedule"+75
(let* ((broadcast (youtube-broadcast+76
id: "bc456"+77
title: "Ad Hoc Stream"+78
lifecycle-status: "live"))+79
(result (format-broadcast broadcast)))+80
(assert-true (string-contains? result "live")))))+81
+82
(test-group "format-playlist"+83
(test "formats playlist"+84
(let* ((playlist (youtube-playlist+85
id: "pl123"+86
title: "My Playlist"+87
item-count: 42+88
privacy-status: "public"))+89
(result (format-playlist playlist)))+90
(assert-true (string-contains? result "pl123"))+91
(assert-true (string-contains? result "My Playlist"))+92
(assert-true (string-contains? result "42"))+93
(assert-true (string-contains? result "public")))))+94
+95
;; ========== Analytics Formatting ==========+96
+97
(test-group "format-analytics-row"+98
(test "formats mixed string and number row"+99
(let ((result (format-analytics-row '("2026-03-20" 150 320 45 5))))+100
(assert-equal "2026-03-20\t150\t320\t45\t5" result)))+101
+102
(test "formats all-string row"+103
(let ((result (format-analytics-row '("a" "b" "c"))))+104
(assert-equal "a\tb\tc" result))))+105
+106
(run-tests)