ScuttleBot

scuttlebot / cmd / gemini-relay / main.go
Blame History Raw 947 lines
1
package main
2
3
import (
4
"bufio"
5
"context"
6
"errors"
7
"fmt"
8
"hash/crc32"
9
"io"
10
"os"
11
"os/exec"
12
"os/signal"
13
"path/filepath"
14
"sort"
15
"strings"
16
"sync"
17
"syscall"
18
"time"
19
20
"encoding/json"
21
22
"github.com/conflicthq/scuttlebot/pkg/ircagent"
23
"github.com/conflicthq/scuttlebot/pkg/relaymirror"
24
"github.com/conflicthq/scuttlebot/pkg/sessionrelay"
25
"github.com/creack/pty"
26
"golang.org/x/term"
27
"gopkg.in/yaml.v3"
28
)
29
30
const (
31
defaultRelayURL = "http://localhost:8080"
32
defaultIRCAddr = "127.0.0.1:6667"
33
defaultChannel = "general"
34
defaultTransport = sessionrelay.TransportHTTP
35
defaultPollInterval = 2 * time.Second
36
defaultConnectWait = 30 * time.Second
37
defaultInjectDelay = 150 * time.Millisecond
38
defaultBusyWindow = 1500 * time.Millisecond
39
defaultMirrorLineMax = 360
40
defaultHeartbeat = 60 * time.Second
41
defaultConfigFile = ".config/scuttlebot-relay.env"
42
bracketedPasteStart = "\x1b[200~"
43
bracketedPasteEnd = "\x1b[201~"
44
)
45
46
var serviceBots = map[string]struct{}{
47
"bridge": {},
48
"oracle": {},
49
"sentinel": {},
50
"steward": {},
51
"scribe": {},
52
"warden": {},
53
"snitch": {},
54
"herald": {},
55
"scroll": {},
56
"systembot": {},
57
"auditbot": {},
58
}
59
60
type config struct {
61
GeminiBin string
62
ConfigFile string
63
Transport sessionrelay.Transport
64
URL string
65
Token string
66
IRCAddr string
67
IRCPass string
68
IRCAgentType string
69
IRCDeleteOnClose bool
70
Channel string
71
Channels []string
72
ChannelStateFile string
73
SessionID string
74
Nick string
75
HooksEnabled bool
76
InterruptOnMessage bool
77
PollInterval time.Duration
78
HeartbeatInterval time.Duration
79
TargetCWD string
80
Args []string
81
}
82
83
type message = sessionrelay.Message
84
85
type relayState struct {
86
mu sync.RWMutex
87
lastBusy time.Time
88
}
89
90
func main() {
91
cfg, err := loadConfig(os.Args[1:])
92
if err != nil {
93
fmt.Fprintln(os.Stderr, "gemini-relay:", err)
94
os.Exit(1)
95
}
96
97
if err := run(cfg); err != nil {
98
fmt.Fprintln(os.Stderr, "gemini-relay:", err)
99
os.Exit(1)
100
}
101
}
102
103
func run(cfg config) error {
104
fmt.Fprintf(os.Stderr, "gemini-relay: nick %s\n", cfg.Nick)
105
relayRequested := cfg.HooksEnabled && shouldRelaySession(cfg.Args)
106
107
ctx, cancel := context.WithCancel(context.Background())
108
defer cancel()
109
_ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile)
110
defer func() { _ = sessionrelay.RemoveChannelStateFile(cfg.ChannelStateFile) }()
111
112
var relay sessionrelay.Connector
113
relayActive := false
114
var onlineAt time.Time
115
if relayRequested {
116
conn, err := sessionrelay.New(sessionrelay.Config{
117
Transport: cfg.Transport,
118
URL: cfg.URL,
119
Token: cfg.Token,
120
Channel: cfg.Channel,
121
Channels: cfg.Channels,
122
Nick: cfg.Nick,
123
IRC: sessionrelay.IRCConfig{
124
Addr: cfg.IRCAddr,
125
Pass: cfg.IRCPass,
126
AgentType: cfg.IRCAgentType,
127
DeleteOnClose: cfg.IRCDeleteOnClose,
128
},
129
})
130
if err != nil {
131
fmt.Fprintf(os.Stderr, "gemini-relay: relay disabled: %v\n", err)
132
} else {
133
connectCtx, connectCancel := context.WithTimeout(ctx, defaultConnectWait)
134
if err := conn.Connect(connectCtx); err != nil {
135
fmt.Fprintf(os.Stderr, "gemini-relay: relay disabled: %v\n", err)
136
_ = conn.Close(context.Background())
137
} else {
138
relay = conn
139
relayActive = true
140
if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
141
fmt.Fprintf(os.Stderr, "gemini-relay: channel state disabled: %v\n", err)
142
}
143
onlineAt = time.Now()
144
_ = relay.Post(context.Background(), fmt.Sprintf(
145
"online in %s; mention %s to interrupt before the next action",
146
filepath.Base(cfg.TargetCWD), cfg.Nick,
147
))
148
}
149
connectCancel()
150
}
151
}
152
if relay != nil {
153
defer func() {
154
closeCtx, closeCancel := context.WithTimeout(context.Background(), defaultConnectWait)
155
defer closeCancel()
156
_ = relay.Close(closeCtx)
157
}()
158
}
159
160
cmd := exec.Command(cfg.GeminiBin, cfg.Args...)
161
startedAt := time.Now()
162
cmd.Env = append(os.Environ(),
163
"SCUTTLEBOT_CONFIG_FILE="+cfg.ConfigFile,
164
"SCUTTLEBOT_URL="+cfg.URL,
165
"SCUTTLEBOT_TOKEN="+cfg.Token,
166
"SCUTTLEBOT_CHANNEL="+cfg.Channel,
167
"SCUTTLEBOT_CHANNELS="+strings.Join(cfg.Channels, ","),
168
"SCUTTLEBOT_CHANNEL_STATE_FILE="+cfg.ChannelStateFile,
169
"SCUTTLEBOT_HOOKS_ENABLED="+boolString(cfg.HooksEnabled),
170
"SCUTTLEBOT_SESSION_ID="+cfg.SessionID,
171
"SCUTTLEBOT_NICK="+cfg.Nick,
172
)
173
if relayActive {
174
go presenceLoopPtr(ctx, &relay, cfg.HeartbeatInterval)
175
}
176
177
if !isInteractiveTTY() {
178
cmd.Stdin = os.Stdin
179
cmd.Stdout = os.Stdout
180
cmd.Stderr = os.Stderr
181
err := cmd.Run()
182
if err != nil {
183
exitCode := exitStatus(err)
184
if relayActive {
185
_ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode))
186
}
187
return err
188
}
189
if relayActive {
190
_ = relay.Post(context.Background(), "offline (exit 0)")
191
}
192
return nil
193
}
194
195
ptmx, err := pty.Start(cmd)
196
if err != nil {
197
return err
198
}
199
defer func() { _ = ptmx.Close() }()
200
201
state := &relayState{}
202
203
if err := pty.InheritSize(os.Stdin, ptmx); err == nil {
204
resizeCh := make(chan os.Signal, 1)
205
signal.Notify(resizeCh, syscall.SIGWINCH)
206
defer signal.Stop(resizeCh)
207
go func() {
208
for range resizeCh {
209
_ = pty.InheritSize(os.Stdin, ptmx)
210
}
211
}()
212
resizeCh <- syscall.SIGWINCH
213
}
214
215
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
216
if err != nil {
217
return err
218
}
219
defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }()
220
221
go func() {
222
_, _ = io.Copy(ptmx, os.Stdin)
223
}()
224
// Dual-path mirroring: PTY for real-time text + session file for metadata.
225
ptyMirror := relaymirror.NewPTYMirror(defaultMirrorLineMax, 500*time.Millisecond, func(line string) {
226
if relayActive {
227
// no-op: session file mirror handles IRC output
228
}
229
})
230
ptyMirror.BusyCallback = func(now time.Time) {
231
state.mu.Lock()
232
state.lastBusy = now
233
state.mu.Unlock()
234
}
235
go func() {
236
_ = ptyMirror.Copy(ptmx, os.Stdout)
237
}()
238
if relayActive {
239
// Start Gemini session file tailing for structured metadata.
240
go geminiSessionMirrorLoop(ctx, relay, cfg, ptyMirror)
241
go relayInputLoop(ctx, relay, cfg, state, ptmx, onlineAt)
242
go handleReconnectSignal(ctx, &relay, cfg, state, ptmx, startedAt)
243
}
244
245
err = cmd.Wait()
246
cancel()
247
248
exitCode := exitStatus(err)
249
if relayActive {
250
_ = relay.Post(context.Background(), fmt.Sprintf("offline (exit %d)", exitCode))
251
}
252
return err
253
}
254
255
func relayInputLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, state *relayState, ptyFile *os.File, since time.Time) {
256
lastSeen := since
257
ticker := time.NewTicker(cfg.PollInterval)
258
defer ticker.Stop()
259
260
for {
261
select {
262
case <-ctx.Done():
263
return
264
case <-ticker.C:
265
messages, err := relay.MessagesSince(ctx, lastSeen)
266
if err != nil {
267
continue
268
}
269
batch, newest := filterMessages(messages, lastSeen, cfg.Nick, cfg.IRCAgentType)
270
if len(batch) == 0 {
271
continue
272
}
273
lastSeen = newest
274
pending := make([]message, 0, len(batch))
275
for _, msg := range batch {
276
handled, err := handleRelayCommand(ctx, relay, cfg, msg)
277
if err != nil {
278
if ctx.Err() == nil {
279
_ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
280
}
281
return
282
}
283
if handled {
284
continue
285
}
286
pending = append(pending, msg)
287
}
288
if len(pending) == 0 {
289
continue
290
}
291
if err := injectMessages(ptyFile, cfg, state, relay.ControlChannel(), pending); err != nil {
292
if ctx.Err() == nil {
293
_ = relay.Post(context.Background(), fmt.Sprintf("input loop error: %v — session may be unsteerable", err))
294
}
295
return
296
}
297
}
298
}
299
}
300
301
func handleReconnectSignal(ctx context.Context, relayPtr *sessionrelay.Connector, cfg config, state *relayState, ptmx *os.File, startedAt time.Time) {
302
sigCh := make(chan os.Signal, 1)
303
signal.Notify(sigCh, syscall.SIGUSR1)
304
defer signal.Stop(sigCh)
305
306
for {
307
select {
308
case <-ctx.Done():
309
return
310
case <-sigCh:
311
}
312
313
fmt.Fprintf(os.Stderr, "gemini-relay: received SIGUSR1, reconnecting IRC...\n")
314
old := *relayPtr
315
if old != nil {
316
_ = old.Close(context.Background())
317
}
318
319
// Retry with backoff.
320
wait := 2 * time.Second
321
for attempt := 0; attempt < 10; attempt++ {
322
if ctx.Err() != nil {
323
return
324
}
325
time.Sleep(wait)
326
327
conn, err := sessionrelay.New(sessionrelay.Config{
328
Transport: cfg.Transport,
329
URL: cfg.URL,
330
Token: cfg.Token,
331
Channel: cfg.Channel,
332
Channels: cfg.Channels,
333
Nick: cfg.Nick,
334
IRC: sessionrelay.IRCConfig{
335
Addr: cfg.IRCAddr,
336
Pass: "", // force re-registration
337
AgentType: cfg.IRCAgentType,
338
DeleteOnClose: cfg.IRCDeleteOnClose,
339
},
340
})
341
if err != nil {
342
wait = min(wait*2, 30*time.Second)
343
continue
344
}
345
346
connectCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
347
if err := conn.Connect(connectCtx); err != nil {
348
_ = conn.Close(context.Background())
349
cancel()
350
wait = min(wait*2, 30*time.Second)
351
continue
352
}
353
cancel()
354
355
*relayPtr = conn
356
now := time.Now()
357
_ = conn.Post(context.Background(), fmt.Sprintf(
358
"reconnected in %s; mention %s to interrupt",
359
filepath.Base(cfg.TargetCWD), cfg.Nick,
360
))
361
fmt.Fprintf(os.Stderr, "gemini-relay: reconnected, restarting input loop\n")
362
363
// Restart input loop with the new connector.
364
go relayInputLoop(ctx, conn, cfg, state, ptmx, now)
365
break
366
}
367
}
368
}
369
370
func presenceLoopPtr(ctx context.Context, relayPtr *sessionrelay.Connector, interval time.Duration) {
371
if interval <= 0 {
372
return
373
}
374
ticker := time.NewTicker(interval)
375
defer ticker.Stop()
376
for {
377
select {
378
case <-ctx.Done():
379
return
380
case <-ticker.C:
381
if r := *relayPtr; r != nil {
382
_ = r.Touch(ctx)
383
}
384
}
385
}
386
}
387
388
func injectMessages(writer io.Writer, cfg config, state *relayState, controlChannel string, batch []message) error {
389
lines := make([]string, 0, len(batch))
390
for _, msg := range batch {
391
text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
392
if text == "" {
393
text = strings.TrimSpace(msg.Text)
394
}
395
channelPrefix := ""
396
if msg.Channel != "" {
397
channelPrefix = "[" + strings.TrimPrefix(msg.Channel, "#") + "] "
398
}
399
if msg.Channel == "" || msg.Channel == controlChannel {
400
channelPrefix = "[" + strings.TrimPrefix(controlChannel, "#") + "] "
401
}
402
lines = append(lines, fmt.Sprintf("%s%s: %s", channelPrefix, msg.Nick, text))
403
}
404
405
var block strings.Builder
406
block.WriteString("[IRC operator messages]\n")
407
for _, line := range lines {
408
block.WriteString(line)
409
block.WriteByte('\n')
410
}
411
412
notice := "\r\n" + block.String() + "\r\n"
413
_, _ = os.Stdout.WriteString(notice)
414
415
if cfg.InterruptOnMessage && state.shouldInterrupt(time.Now()) {
416
if _, err := writer.Write([]byte{3}); err != nil {
417
return err
418
}
419
time.Sleep(defaultInjectDelay)
420
}
421
422
// Gemini treats bracketed paste as literal input, which avoids shell-mode
423
// toggles and other shortcut handling for operator text like "!" or "??".
424
paste := bracketedPasteStart + block.String() + bracketedPasteEnd
425
if _, err := writer.Write([]byte(paste)); err != nil {
426
return err
427
}
428
time.Sleep(defaultInjectDelay)
429
_, err := writer.Write([]byte{'\r'})
430
return err
431
}
432
433
func handleRelayCommand(ctx context.Context, relay sessionrelay.Connector, cfg config, msg message) (bool, error) {
434
text := ircagent.TrimAddressedText(strings.TrimSpace(msg.Text), cfg.Nick)
435
if text == "" {
436
text = strings.TrimSpace(msg.Text)
437
}
438
439
cmd, ok := sessionrelay.ParseBrokerCommand(text)
440
if !ok {
441
return false, nil
442
}
443
444
postStatus := func(channel, text string) error {
445
if channel == "" {
446
channel = relay.ControlChannel()
447
}
448
return relay.PostTo(ctx, channel, text)
449
}
450
451
switch cmd.Name {
452
case "channels":
453
return true, postStatus(msg.Channel, fmt.Sprintf("channels: %s (control %s)", sessionrelay.FormatChannels(relay.Channels()), relay.ControlChannel()))
454
case "join":
455
if cmd.Channel == "" {
456
return true, postStatus(msg.Channel, "usage: /join #channel")
457
}
458
if err := relay.JoinChannel(ctx, cmd.Channel); err != nil {
459
return true, postStatus(msg.Channel, fmt.Sprintf("join %s failed: %v", cmd.Channel, err))
460
}
461
if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
462
return true, postStatus(msg.Channel, fmt.Sprintf("joined %s, but channel state update failed: %v", cmd.Channel, err))
463
}
464
return true, postStatus(msg.Channel, fmt.Sprintf("joined %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
465
case "part":
466
if cmd.Channel == "" {
467
return true, postStatus(msg.Channel, "usage: /part #channel")
468
}
469
if err := relay.PartChannel(ctx, cmd.Channel); err != nil {
470
return true, postStatus(msg.Channel, fmt.Sprintf("part %s failed: %v", cmd.Channel, err))
471
}
472
if err := sessionrelay.WriteChannelStateFile(cfg.ChannelStateFile, relay.ControlChannel(), relay.Channels()); err != nil {
473
return true, postStatus(msg.Channel, fmt.Sprintf("parted %s, but channel state update failed: %v", cmd.Channel, err))
474
}
475
replyChannel := msg.Channel
476
if sameChannel(replyChannel, cmd.Channel) {
477
replyChannel = relay.ControlChannel()
478
}
479
return true, postStatus(replyChannel, fmt.Sprintf("parted %s; channels: %s", cmd.Channel, sessionrelay.FormatChannels(relay.Channels())))
480
default:
481
return false, nil
482
}
483
}
484
485
func copyPTYOutput(src io.Reader, dst io.Writer, state *relayState) {
486
buf := make([]byte, 4096)
487
for {
488
n, err := src.Read(buf)
489
if n > 0 {
490
state.observeOutput(buf[:n], time.Now())
491
if _, writeErr := dst.Write(buf[:n]); writeErr != nil {
492
return
493
}
494
}
495
if err != nil {
496
return
497
}
498
}
499
}
500
501
func (s *relayState) observeOutput(data []byte, now time.Time) {
502
if s == nil {
503
return
504
}
505
// Gemini CLI uses different busy indicators, but we can look for generic prompt signals
506
// or specific strings if we know them. For now, we'll keep it simple or add generic ones.
507
if strings.Contains(strings.ToLower(string(data)), "esc to interrupt") ||
508
strings.Contains(strings.ToLower(string(data)), "working...") {
509
s.mu.Lock()
510
s.lastBusy = now
511
s.mu.Unlock()
512
}
513
}
514
515
func (s *relayState) shouldInterrupt(now time.Time) bool {
516
if s == nil {
517
return false
518
}
519
s.mu.RLock()
520
lastBusy := s.lastBusy
521
s.mu.RUnlock()
522
return !lastBusy.IsZero() && now.Sub(lastBusy) <= defaultBusyWindow
523
}
524
525
// geminiSessionMirrorLoop discovers and polls a Gemini CLI session file
526
// for structured tool call metadata, emitting it via PostWithMeta.
527
func geminiSessionMirrorLoop(ctx context.Context, relay sessionrelay.Connector, cfg config, ptyDedup *relaymirror.PTYMirror) {
528
// Discover the Gemini session file directory.
529
home, err := os.UserHomeDir()
530
if err != nil {
531
fmt.Fprintf(os.Stderr, "gemini-relay: session mirror: %v\n", err)
532
return
533
}
534
chatsDir := filepath.Join(home, ".gemini", "tmp", slugify(cfg.TargetCWD), "chats")
535
if err := os.MkdirAll(chatsDir, 0755); err != nil {
536
// Directory doesn't exist yet — Gemini CLI creates it on first run.
537
}
538
existing := relaymirror.SnapshotDir(chatsDir)
539
540
// Wait for a new session file.
541
watcher := relaymirror.NewSessionWatcher(chatsDir, "session-", 60*time.Second)
542
sessionPath, err := watcher.Discover(ctx, existing)
543
if err != nil {
544
if ctx.Err() == nil {
545
fmt.Fprintf(os.Stderr, "gemini-relay: session discovery: %v\n", err)
546
}
547
return
548
}
549
fmt.Fprintf(os.Stderr, "gemini-relay: session file discovered: %s\n", sessionPath)
550
551
// Poll the session file for new messages.
552
msgIdx := 0
553
tick := time.NewTicker(2 * time.Second)
554
defer tick.Stop()
555
for {
556
select {
557
case <-ctx.Done():
558
return
559
case <-tick.C:
560
msgs, newIdx, err := relaymirror.PollGeminiSession(sessionPath, msgIdx)
561
if err != nil {
562
continue
563
}
564
msgIdx = newIdx
565
for _, msg := range msgs {
566
if msg.Type != "gemini" {
567
continue
568
}
569
for _, tc := range msg.ToolCalls {
570
meta, _ := json.Marshal(map[string]any{
571
"type": "tool_result",
572
"data": map[string]any{
573
"tool": tc.Name,
574
"status": tc.Status,
575
"args": tc.Args,
576
},
577
})
578
text := fmt.Sprintf("[%s] %s", tc.Name, tc.Status)
579
if ptyDedup != nil {
580
ptyDedup.MarkSeen(text)
581
}
582
_ = relay.PostWithMeta(ctx, text, meta)
583
}
584
}
585
}
586
}
587
}
588
589
func slugify(s string) string {
590
s = strings.ReplaceAll(s, "/", "-")
591
s = strings.TrimPrefix(s, "-")
592
if s == "" {
593
return "default"
594
}
595
return s
596
}
597
598
func filterMessages(messages []message, since time.Time, nick, agentType string) ([]message, time.Time) {
599
filtered := make([]message, 0, len(messages))
600
newest := since
601
for _, msg := range messages {
602
if msg.At.IsZero() || !msg.At.After(since) {
603
continue
604
}
605
if msg.At.After(newest) {
606
newest = msg.At
607
}
608
if msg.Nick == nick {
609
continue
610
}
611
if _, ok := serviceBots[msg.Nick]; ok {
612
continue
613
}
614
if ircagent.HasAnyPrefix(msg.Nick, ircagent.DefaultActivityPrefixes()) {
615
continue
616
}
617
if !ircagent.MentionsNick(msg.Text, nick) && !ircagent.MatchesGroupMention(msg.Text, nick, agentType) {
618
continue
619
}
620
filtered = append(filtered, msg)
621
}
622
sort.Slice(filtered, func(i, j int) bool {
623
return filtered[i].At.Before(filtered[j].At)
624
})
625
return filtered, newest
626
}
627
628
func loadConfig(args []string) (config, error) {
629
fileConfig := readEnvFile(configFilePath())
630
631
cfg := config{
632
GeminiBin: getenvOr(fileConfig, "GEMINI_BIN", "gemini"),
633
ConfigFile: getenvOr(fileConfig, "SCUTTLEBOT_CONFIG_FILE", configFilePath()),
634
Transport: sessionrelay.Transport(strings.ToLower(getenvOr(fileConfig, "SCUTTLEBOT_TRANSPORT", string(defaultTransport)))),
635
URL: getenvOr(fileConfig, "SCUTTLEBOT_URL", defaultRelayURL),
636
Token: getenvOr(fileConfig, "SCUTTLEBOT_TOKEN", ""),
637
IRCAddr: getenvOr(fileConfig, "SCUTTLEBOT_IRC_ADDR", defaultIRCAddr),
638
IRCPass: getenvOr(fileConfig, "SCUTTLEBOT_IRC_PASS", ""),
639
IRCAgentType: getenvOr(fileConfig, "SCUTTLEBOT_IRC_AGENT_TYPE", "worker"),
640
IRCDeleteOnClose: getenvBoolOr(fileConfig, "SCUTTLEBOT_IRC_DELETE_ON_CLOSE", true),
641
HooksEnabled: getenvBoolOr(fileConfig, "SCUTTLEBOT_HOOKS_ENABLED", true),
642
InterruptOnMessage: getenvBoolOr(fileConfig, "SCUTTLEBOT_INTERRUPT_ON_MESSAGE", true),
643
PollInterval: getenvDurationOr(fileConfig, "SCUTTLEBOT_POLL_INTERVAL", defaultPollInterval),
644
HeartbeatInterval: getenvDurationAllowZeroOr(fileConfig, "SCUTTLEBOT_PRESENCE_HEARTBEAT", defaultHeartbeat),
645
Args: append([]string(nil), args...),
646
}
647
648
controlChannel := getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL", defaultChannel)
649
cfg.Channels = sessionrelay.ChannelSlugs(sessionrelay.ParseEnvChannels(controlChannel, getenvOr(fileConfig, "SCUTTLEBOT_CHANNELS", "")))
650
if len(cfg.Channels) > 0 {
651
cfg.Channel = cfg.Channels[0]
652
}
653
654
target, err := targetCWD(args)
655
if err != nil {
656
return config{}, err
657
}
658
cfg.TargetCWD = target
659
660
// Merge per-repo config if present.
661
if rc, err := loadRepoConfig(target); err == nil && rc != nil {
662
cfg.Channels = mergeChannels(cfg.Channels, rc.allChannels())
663
}
664
665
sessionID := getenvOr(fileConfig, "SCUTTLEBOT_SESSION_ID", "")
666
if sessionID == "" {
667
sessionID = getenvOr(fileConfig, "GEMINI_SESSION_ID", "")
668
}
669
if sessionID == "" {
670
sessionID = defaultSessionID(target)
671
}
672
cfg.SessionID = sanitize(sessionID)
673
674
nick := getenvOr(fileConfig, "SCUTTLEBOT_NICK", "")
675
if nick == "" {
676
nick = fmt.Sprintf("gemini-%s-%s", sanitize(filepath.Base(target)), cfg.SessionID)
677
}
678
cfg.Nick = sanitize(nick)
679
cfg.ChannelStateFile = getenvOr(fileConfig, "SCUTTLEBOT_CHANNEL_STATE_FILE", defaultChannelStateFile(cfg.Nick))
680
681
if cfg.Channel == "" {
682
cfg.Channel = defaultChannel
683
cfg.Channels = []string{defaultChannel}
684
}
685
if cfg.Transport == sessionrelay.TransportHTTP && cfg.Token == "" {
686
cfg.HooksEnabled = false
687
}
688
return cfg, nil
689
}
690
691
func defaultChannelStateFile(nick string) string {
692
return filepath.Join(os.TempDir(), fmt.Sprintf(".scuttlebot-channels-%s.env", sanitize(nick)))
693
}
694
695
func sameChannel(a, b string) bool {
696
return strings.TrimPrefix(a, "#") == strings.TrimPrefix(b, "#")
697
}
698
699
func configFilePath() string {
700
if value := os.Getenv("SCUTTLEBOT_CONFIG_FILE"); value != "" {
701
return value
702
}
703
home, err := os.UserHomeDir()
704
if err != nil {
705
return filepath.Join(".config", "scuttlebot-relay.env") // Fallback
706
}
707
return filepath.Join(home, ".config", "scuttlebot-relay.env")
708
}
709
710
func readEnvFile(path string) map[string]string {
711
values := make(map[string]string)
712
file, err := os.Open(path)
713
if err != nil {
714
return values
715
}
716
defer file.Close()
717
718
scanner := bufio.NewScanner(file)
719
for scanner.Scan() {
720
line := strings.TrimSpace(scanner.Text())
721
if line == "" || strings.HasPrefix(line, "#") {
722
continue
723
}
724
line = strings.TrimPrefix(line, "export ")
725
key, value, ok := strings.Cut(line, "=")
726
if !ok {
727
continue
728
}
729
values[strings.TrimSpace(key)] = strings.TrimSpace(strings.Trim(value, `"'`))
730
}
731
return values
732
}
733
734
func getenvOr(file map[string]string, key, fallback string) string {
735
if value := os.Getenv(key); value != "" {
736
return value
737
}
738
if value := file[key]; value != "" {
739
return value
740
}
741
return fallback
742
}
743
744
func getenvBoolOr(file map[string]string, key string, fallback bool) bool {
745
value := getenvOr(file, key, "")
746
if value == "" {
747
return fallback
748
}
749
switch strings.ToLower(value) {
750
case "0", "false", "no", "off":
751
return false
752
default:
753
return true
754
}
755
}
756
757
func getenvDurationOr(file map[string]string, key string, fallback time.Duration) time.Duration {
758
value := getenvOr(file, key, "")
759
if value == "" {
760
return fallback
761
}
762
if strings.IndexFunc(value, func(r rune) bool { return r < '0' || r > '9' }) == -1 {
763
value += "s"
764
}
765
d, err := time.ParseDuration(value)
766
if err != nil || d <= 0 {
767
return fallback
768
}
769
return d
770
}
771
772
func getenvDurationAllowZeroOr(file map[string]string, key string, fallback time.Duration) time.Duration {
773
value := getenvOr(file, key, "")
774
if value == "" {
775
return fallback
776
}
777
if strings.IndexFunc(value, func(r rune) bool { return r < '0' || r > '9' }) == -1 {
778
value += "s"
779
}
780
d, err := time.ParseDuration(value)
781
if err != nil || d < 0 {
782
return fallback
783
}
784
return d
785
}
786
787
func targetCWD(args []string) (string, error) {
788
cwd, err := os.Getwd()
789
if err != nil {
790
return "", err
791
}
792
target := cwd
793
var prev string
794
for _, arg := range args {
795
switch {
796
case prev == "-C" || prev == "--cd":
797
target = arg
798
prev = ""
799
continue
800
case arg == "-C" || arg == "--cd":
801
prev = arg
802
continue
803
case strings.HasPrefix(arg, "-C="):
804
target = strings.TrimPrefix(arg, "-C=")
805
case strings.HasPrefix(arg, "--cd="):
806
target = strings.TrimPrefix(arg, "--cd=")
807
}
808
}
809
if filepath.IsAbs(target) {
810
return target, nil
811
}
812
return filepath.Abs(target)
813
}
814
815
func sanitize(value string) string {
816
var b strings.Builder
817
for _, r := range value {
818
switch {
819
case r >= 'a' && r <= 'z':
820
b.WriteRune(r)
821
case r >= 'A' && r <= 'Z':
822
b.WriteRune(r)
823
case r >= '0' && r <= '9':
824
b.WriteRune(r)
825
case r == '-' || r == '_':
826
b.WriteRune(r)
827
default:
828
b.WriteRune('-')
829
}
830
}
831
result := strings.Trim(b.String(), "-")
832
if result == "" {
833
return "session"
834
}
835
return result
836
}
837
838
func defaultSessionID(target string) string {
839
sum := crc32.ChecksumIEEE([]byte(fmt.Sprintf("%s|%d|%d|%d", target, os.Getpid(), os.Getppid(), time.Now().UnixNano())))
840
return fmt.Sprintf("%08x", sum)
841
}
842
843
func isInteractiveTTY() bool {
844
return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
845
}
846
847
func boolString(v bool) string {
848
if v {
849
return "1"
850
}
851
return "0"
852
}
853
854
func shouldRelaySession(args []string) bool {
855
for _, arg := range args {
856
switch arg {
857
case "-h", "--help", "-V", "--version":
858
return false
859
}
860
}
861
862
for _, arg := range args {
863
if strings.HasPrefix(arg, "-") {
864
continue
865
}
866
switch arg {
867
case "help", "completion":
868
return false
869
default:
870
return true
871
}
872
}
873
874
return true
875
}
876
877
func exitStatus(err error) int {
878
if err == nil {
879
return 0
880
}
881
var exitErr *exec.ExitError
882
if errors.As(err, &exitErr) {
883
return exitErr.ExitCode()
884
}
885
return 1
886
}
887
888
// repoConfig is the per-repo .scuttlebot.yaml format.
889
type repoConfig struct {
890
Channel string `yaml:"channel"`
891
Channels []string `yaml:"channels"`
892
}
893
894
// allChannels returns the singular channel (if set) prepended to the channels list.
895
func (rc *repoConfig) allChannels() []string {
896
if rc.Channel == "" {
897
return rc.Channels
898
}
899
return append([]string{rc.Channel}, rc.Channels...)
900
}
901
902
// loadRepoConfig walks up from dir looking for .scuttlebot.yaml.
903
// Stops at the git root (directory containing .git) or the filesystem root.
904
// Returns nil, nil if no config file is found.
905
func loadRepoConfig(dir string) (*repoConfig, error) {
906
current := dir
907
for {
908
candidate := filepath.Join(current, ".scuttlebot.yaml")
909
if data, err := os.ReadFile(candidate); err == nil {
910
var rc repoConfig
911
if err := yaml.Unmarshal(data, &rc); err != nil {
912
return nil, fmt.Errorf("loadRepoConfig: parse %s: %w", candidate, err)
913
}
914
fmt.Fprintf(os.Stderr, "scuttlebot: loaded repo config from %s\n", candidate)
915
return &rc, nil
916
}
917
918
// Stop if this directory is a git root.
919
if info, err := os.Stat(filepath.Join(current, ".git")); err == nil && info.IsDir() {
920
return nil, nil
921
}
922
923
parent := filepath.Dir(current)
924
if parent == current {
925
return nil, nil
926
}
927
current = parent
928
}
929
}
930
931
// mergeChannels appends extra channels to existing, deduplicating.
932
func mergeChannels(existing, extra []string) []string {
933
seen := make(map[string]struct{}, len(existing))
934
for _, ch := range existing {
935
seen[ch] = struct{}{}
936
}
937
merged := append([]string(nil), existing...)
938
for _, ch := range extra {
939
if _, ok := seen[ch]; ok {
940
continue
941
}
942
seen[ch] = struct{}{}
943
merged = append(merged, ch)
944
}
945
return merged
946
}
947

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button