ScuttleBot

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

Keyboard Shortcuts

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