From e81bc94dd6c3dc3199b62abffb68982126f5a8d5 Mon Sep 17 00:00:00 2001 From: toki Date: Mon, 4 May 2026 14:54:57 +0900 Subject: [PATCH] feat: add cli/status adapter and run_codex.py script --- .../internal/adapters/cli/status/claude.go | 19 +++ .../internal/adapters/cli/status/codex.go | 133 ++++++++++++++++++ .../adapters/cli/status/codex_test.go | 27 ++++ .../internal/adapters/cli/status/gemini.go | 20 +++ .../internal/adapters/cli/status/parser.go | 47 +++++++ .../internal/adapters/cli/status/status.go | 20 +++ run_codex.py | 55 ++++++++ 7 files changed, 321 insertions(+) create mode 100644 apps/node/internal/adapters/cli/status/claude.go create mode 100644 apps/node/internal/adapters/cli/status/codex.go create mode 100644 apps/node/internal/adapters/cli/status/codex_test.go create mode 100644 apps/node/internal/adapters/cli/status/gemini.go create mode 100644 apps/node/internal/adapters/cli/status/parser.go create mode 100644 apps/node/internal/adapters/cli/status/status.go create mode 100644 run_codex.py diff --git a/apps/node/internal/adapters/cli/status/claude.go b/apps/node/internal/adapters/cli/status/claude.go new file mode 100644 index 0000000..fa8350c --- /dev/null +++ b/apps/node/internal/adapters/cli/status/claude.go @@ -0,0 +1,19 @@ +package status + +import ( + "context" +) + +type ClaudeChecker struct{} + +func NewClaudeChecker() *ClaudeChecker { + return &ClaudeChecker{} +} + +func (c *ClaudeChecker) Check(ctx context.Context) (*UsageStatus, error) { + // TODO: Implement claude status check + + rawOutput := "claude status placeholder" + + return ParseStatusOutput(ctx, rawOutput) +} diff --git a/apps/node/internal/adapters/cli/status/codex.go b/apps/node/internal/adapters/cli/status/codex.go new file mode 100644 index 0000000..a01d50b --- /dev/null +++ b/apps/node/internal/adapters/cli/status/codex.go @@ -0,0 +1,133 @@ +package status + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" + "time" + + "github.com/creack/pty" +) + +type CodexChecker struct{} + +func NewCodexChecker() *CodexChecker { + return &CodexChecker{} +} + +func (c *CodexChecker) Check(ctx context.Context) (*UsageStatus, error) { + cmd := exec.CommandContext(ctx, "codex", "--no-alt-screen") + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 40, Cols: 120}) + if err != nil { + return nil, fmt.Errorf("failed to start pty: %w", err) + } + defer func() { + _ = ptmx.Close() + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + }() + + chunks := make(chan string, 100) + go func() { + buf := make([]byte, 4096) + for { + n, err := ptmx.Read(buf) + if n > 0 { + chunks <- string(buf[:n]) + } + if err != nil { + close(chunks) + return + } + } + }() + + var fullOutput string + waitFor := func(pattern *regexp.Regexp, timeout time.Duration) error { + timeoutCh := time.After(timeout) + for { + if pattern.MatchString(cleanANSI(fullOutput)) { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-timeoutCh: + return fmt.Errorf("timeout waiting for %s", pattern.String()) + case chunk, ok := <-chunks: + if !ok { + return io.EOF + } + fullOutput += chunk + } + } + } + + sendText := func(text string) { + for _, char := range text { + _, _ = ptmx.Write([]byte{byte(char)}) + time.Sleep(50 * time.Millisecond) // fast typing + } + } + + // 1. Wait for startup ready prompt + readyRegex := regexp.MustCompile(`(Tip:|model:)`) + if err := waitFor(readyRegex, 15*time.Second); err != nil { + return nil, fmt.Errorf("codex startup failed: %w", err) + } + + // 2. Request status + sendText("/status\r") + + // 3. Wait for either refresh message or actual limits + statusOrRefreshRegex := regexp.MustCompile(`(?:refresh requested.*run /status again|Weekly limit:)`) + if err := waitFor(statusOrRefreshRegex, 15*time.Second); err != nil { + return nil, fmt.Errorf("failed waiting for first status: %w", err) + } + + cleanOutput := cleanANSI(fullOutput) + if strings.Contains(cleanOutput, "refresh requested") && !strings.Contains(cleanOutput, "Weekly limit:") { + // Wait a moment for the backend to fetch new limits + time.Sleep(2 * time.Second) + + // Clear current line (Ctrl+U) and re-request + sendText("\x15/status\r") + + // Clear local buffer to ensure we find the *new* limits + fullOutput = "" + limitsRegex := regexp.MustCompile(`Weekly limit:`) + if err := waitFor(limitsRegex, 15*time.Second); err != nil { + return nil, fmt.Errorf("failed waiting for second status: %w", err) + } + } + + // Wait a tiny bit more to ensure progress bars are fully printed + time.Sleep(500 * time.Millisecond) + + // Flush remaining chunks quickly +loop: + for { + select { + case chunk, ok := <-chunks: + if !ok { + break loop + } + fullOutput += chunk + default: + break loop + } + } + + // Graceful exit + sendText("/exit\r") + time.Sleep(500 * time.Millisecond) // Give it time to exit cleanly + + return ParseStatusOutput(ctx, fullOutput) +} diff --git a/apps/node/internal/adapters/cli/status/codex_test.go b/apps/node/internal/adapters/cli/status/codex_test.go new file mode 100644 index 0000000..99095d0 --- /dev/null +++ b/apps/node/internal/adapters/cli/status/codex_test.go @@ -0,0 +1,27 @@ +package status + +import ( + "context" + "fmt" + "testing" + "time" +) + +func TestCodexChecker(t *testing.T) { + t.Skip("Skipping interactive TUI test by default") + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + checker := NewCodexChecker() + status, err := checker.Check(ctx) + if err != nil { + t.Fatalf("Failed to check codex status: %v", err) + } + + fmt.Printf("Raw Output:\n%s\n", status.RawOutput) + fmt.Printf("Daily Limit: %s\n", status.DailyLimit) + fmt.Printf("Daily Reset Time: %s\n", status.DailyResetTime) + fmt.Printf("Weekly Limit: %s\n", status.WeeklyLimit) + fmt.Printf("Weekly Reset Time: %s\n", status.WeeklyResetTime) +} diff --git a/apps/node/internal/adapters/cli/status/gemini.go b/apps/node/internal/adapters/cli/status/gemini.go new file mode 100644 index 0000000..d49e610 --- /dev/null +++ b/apps/node/internal/adapters/cli/status/gemini.go @@ -0,0 +1,20 @@ +package status + +import ( + "context" +) + +type GeminiChecker struct{} + +func NewGeminiChecker() *GeminiChecker { + return &GeminiChecker{} +} + +func (g *GeminiChecker) Check(ctx context.Context) (*UsageStatus, error) { + // TODO: Implement gemini status check using exec.Command + // e.g., cmd := exec.CommandContext(ctx, "gemini", "status") + + rawOutput := "gemini status placeholder" // Replace with actual output + + return ParseStatusOutput(ctx, rawOutput) +} diff --git a/apps/node/internal/adapters/cli/status/parser.go b/apps/node/internal/adapters/cli/status/parser.go new file mode 100644 index 0000000..6c492ec --- /dev/null +++ b/apps/node/internal/adapters/cli/status/parser.go @@ -0,0 +1,47 @@ +package status + +import ( + "context" + "regexp" + "strings" +) + +var ( + // Regex patterns based on the Codex output format + // "5h limit: [████████████████████] 98% left (resets 18:38)" + // "Weekly limit: [████░░░░░░░░░░░░░░░░] 22% left (resets 10:20 on 8 May)" + dailyLimitRegex = regexp.MustCompile(`5h limit:.*?\]\s*([^%\n]+%)[^\(]*\(resets\s+([^\)]+)\)`) + weeklyLimitRegex = regexp.MustCompile(`Weekly limit:.*?\]\s*([^%\n]+%)[^\(]*\(resets\s+([^\)]+)\)`) +) + +// ParseStatusOutput cleans the raw string and attempts to parse limits. +func ParseStatusOutput(ctx context.Context, raw string) (*UsageStatus, error) { + cleanRaw := cleanANSI(raw) + + status := &UsageStatus{ + RawOutput: cleanRaw, + } + + // 1. Direct Parsing + if match := dailyLimitRegex.FindStringSubmatch(cleanRaw); len(match) > 2 { + status.DailyLimit = strings.TrimSpace(match[1]) + status.DailyResetTime = strings.TrimSpace(match[2]) + } + if match := weeklyLimitRegex.FindStringSubmatch(cleanRaw); len(match) > 2 { + status.WeeklyLimit = strings.TrimSpace(match[1]) + status.WeeklyResetTime = strings.TrimSpace(match[2]) + } + + // 2. LLM Fallback (Stub) + // If direct parsing failed but we expect data, we could call an LLM here + // via a centralized LLM adapter or internal utility, passing `cleanRaw` + // to extract the schema as JSON. + + return status, nil +} + +// cleanANSI removes ANSI escape codes from the string. +func cleanANSI(str string) string { + ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`) + return ansiRegex.ReplaceAllString(str, "") +} diff --git a/apps/node/internal/adapters/cli/status/status.go b/apps/node/internal/adapters/cli/status/status.go new file mode 100644 index 0000000..2515950 --- /dev/null +++ b/apps/node/internal/adapters/cli/status/status.go @@ -0,0 +1,20 @@ +package status + +import ( + "context" +) + +// UsageStatus represents the parsed usage limits of an agent CLI. +type UsageStatus struct { + RawOutput string `json:"raw_output"` + DailyLimit string `json:"daily_limit,omitempty"` + DailyResetTime string `json:"daily_reset_time,omitempty"` + WeeklyLimit string `json:"weekly_limit,omitempty"` + WeeklyResetTime string `json:"weekly_reset_time,omitempty"` + // Additional fields can be added as needed +} + +// Checker defines the interface for checking CLI status/usage. +type Checker interface { + Check(ctx context.Context) (*UsageStatus, error) +} diff --git a/run_codex.py b/run_codex.py new file mode 100644 index 0000000..a2c7d04 --- /dev/null +++ b/run_codex.py @@ -0,0 +1,55 @@ +import pty +import os +import sys +import time +import select +import termios +import struct +import fcntl + +def set_winsize(fd, row, col): + winsize = struct.pack("HHHH", row, col, 0, 0) + fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) + +def send_text(fd, text): + for char in text: + os.write(fd, char.encode('utf-8')) + time.sleep(0.1) + +pid, fd = pty.fork() +if pid == 0: + os.environ['TERM'] = 'xterm-256color' + os.execvp('codex', ['codex', '--no-alt-screen']) +else: + set_winsize(fd, 40, 120) + time.sleep(6) + + # First /status + send_text(fd, "/status\r") + time.sleep(8) + + # Clear line by pressing ESC, or Backspace + send_text(fd, "\x1b") # ESC + time.sleep(1) + + # Second /status + send_text(fd, "/status\r") + time.sleep(5) + + output = b"" + while True: + r, _, _ = select.select([fd], [], [], 2) + if r: + try: + data = os.read(fd, 4096) + if not data: break + output += data + except OSError: + break + else: + break + + print(output.decode('utf-8', errors='ignore')) + + send_text(fd, "/exit\r") + time.sleep(1)