Skip to content

Commit

Permalink
✨ Add tail
Browse files Browse the repository at this point in the history
  • Loading branch information
wesen committed Feb 11, 2025
1 parent 40a1174 commit be881a1
Show file tree
Hide file tree
Showing 4 changed files with 416 additions and 2 deletions.
19 changes: 18 additions & 1 deletion changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -987,4 +987,21 @@ Added ability to temporarily disable MCP servers without removing their configur
- ✨ Added `disable-server` and `enable-server` commands to claude-config
- 🏗️ Added `DisabledMCPServers` field to configuration format
- 📝 Updated list-servers command to show disabled status
- 🔧 Added helper functions for managing server state
- 🔧 Added helper functions for managing server state

# Added Log Tailing Support

Added ability to tail Claude log files in real-time:
- ✨ Added `tail` command to claude-config for monitoring log files
- 🔍 Support for tailing specific server logs by name
- 🎯 Added `--all` flag to tail all log files simultaneously
- 🛠️ Graceful shutdown support with Ctrl+C
- 📝 Real-time log monitoring with automatic file reopening

# Added File Helpers Package

Added a new package for file manipulation helpers:
- ✨ Added `FindStartPosForLastNLines` function for efficient seeking to last N lines
- 🧪 Added comprehensive table-driven tests with various edge cases
- 📝 Added detailed documentation and examples
- 🔍 Optimized for large files with buffered reading
115 changes: 114 additions & 1 deletion cmd/go-go-mcp/cmds/claude_config.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
package cmds

import (
"context"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"

"github.com/go-go-golems/go-go-mcp/pkg/config"
"github.com/hpcloud/tail"
"github.com/spf13/cobra"
)

func NewClaudeConfigCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "claude-config",
Use: "claude",
Short: "Manage Claude desktop configuration",
Long: `Commands for managing the Claude desktop configuration file.`,
}
Expand All @@ -25,6 +31,7 @@ func NewClaudeConfigCommand() *cobra.Command {
newClaudeConfigListServersCommand(),
newClaudeConfigEnableServerCommand(),
newClaudeConfigDisableServerCommand(),
newClaudeConfigTailCommand(),
)

return cmd
Expand Down Expand Up @@ -316,3 +323,109 @@ func newClaudeConfigDisableServerCommand() *cobra.Command {

return cmd
}

func newClaudeConfigTailCommand() *cobra.Command {
var all bool

cmd := &cobra.Command{
Use: "tail [server-names...]",
Short: "Tail Claude log files",
Long: `Tail the Claude log files in real-time.
Without arguments, tails the main mcp.log file.
With server names, tails the corresponding mcp-server-XXX.log files.
Use --all to tail all log files.`,
RunE: func(cmd *cobra.Command, args []string) error {
xdgConfigPath, err := os.UserConfigDir()
if err != nil {
return fmt.Errorf("could not get user config directory: %w", err)
}
logDir := filepath.Join(xdgConfigPath, "Claude", "logs")

// Create a context that can be cancelled
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Set up signal handling for graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
cancel()
}()

// Determine which files to tail
var filesToTail []string
if all {
// Find all log files
entries, err := os.ReadDir(logDir)
if err != nil {
return fmt.Errorf("could not read log directory: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() && strings.HasPrefix(entry.Name(), "mcp") && strings.HasSuffix(entry.Name(), ".log") {
filesToTail = append(filesToTail, filepath.Join(logDir, entry.Name()))
}
}
} else if len(args) == 0 {
// Only tail main log file
filesToTail = append(filesToTail, filepath.Join(logDir, "mcp.log"))
} else {
// Tail specified server logs
for _, serverName := range args {
filesToTail = append(filesToTail, filepath.Join(logDir, fmt.Sprintf("mcp-server-%s.log", serverName)))
}
}

// Create a WaitGroup to wait for all tailers to finish
var wg sync.WaitGroup

// Start tailing each file
for _, file := range filesToTail {
wg.Add(1)
go func(filename string) {
defer wg.Done()

t, err := tail.TailFile(filename, tail.Config{
Follow: true,
ReOpen: true,
Logger: tail.DiscardingLogger,
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error tailing %s: %v\n", filename, err)
return
}
defer t.Cleanup()

// Print the filename as a header
fmt.Printf("==> %s <==\n", filename)

// Read lines until context is cancelled
for {
select {
case line, ok := <-t.Lines:
if !ok {
return
}
if line.Err != nil {
fmt.Fprintf(os.Stderr, "Error reading %s: %v\n", filename, line.Err)
continue
}
fmt.Printf("%s: %s\n", filepath.Base(filename), line.Text)
case <-ctx.Done():
return
}
}
}(file)
}

// Wait for all tailers to finish
wg.Wait()

return nil
},
}

cmd.Flags().BoolVarP(&all, "all", "a", false, "Tail all log files")

return cmd
}
86 changes: 86 additions & 0 deletions pkg/helpers/file_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package helpers

import (
"io"
"os"
)

// FindStartPosForLastNLines finds the position in a file where the last N lines begin.
// It returns the byte offset from the start of the file and any error encountered.
// If n <= 0, it returns 0 (start of file).
// If the file has fewer lines than n, it returns 0 (start of file).
func FindStartPosForLastNLines(filename string, n int) (int64, error) {
if n <= 0 {
return 0, nil
}

file, err := os.Open(filename)
if err != nil {
return 0, err
}
defer file.Close()

// Get file size
stat, err := file.Stat()
if err != nil {
return 0, err
}
fileSize := stat.Size()
if fileSize == 0 {
return 0, nil
}

// Start from end
pos := fileSize
// Use a 4KB buffer size
buf := make([]byte, 4096)
linesFound := 0

// Handle the case where the file doesn't end with a newline
// by treating the end of file as an implicit line ending
lastByteRead := false
lastByteWasNewline := false

for pos > 0 && linesFound <= n {
// Calculate how much to read
bytesToRead := int64(len(buf))
if pos < bytesToRead {
bytesToRead = pos
}

// Read a chunk from the right position
pos -= bytesToRead
_, err := file.Seek(pos, io.SeekStart)
if err != nil {
return 0, err
}

// Read the chunk
bytesRead, err := file.Read(buf[:bytesToRead])
if err != nil {
return 0, err
}

// Count newlines in this chunk, going backwards
for i := bytesRead - 1; i >= 0; i-- {
if !lastByteRead {
lastByteRead = true
lastByteWasNewline = buf[i] == '\n'
continue
}

_ = lastByteWasNewline

if buf[i] == '\n' {
linesFound++
if linesFound >= n {
// Add 1 to skip the newline itself
return pos + int64(i) + 1, nil
}
}
}
}

// If we get here, we need to read from the start of the file
return 0, nil
}
Loading

0 comments on commit be881a1

Please sign in to comment.