feat: add cli/status adapter and run_codex.py script

This commit is contained in:
toki 2026-05-04 14:54:57 +09:00
parent eb8834ac5c
commit e81bc94dd6
7 changed files with 321 additions and 0 deletions

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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, "")
}

View file

@ -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)
}

55
run_codex.py Normal file
View file

@ -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)