feat: add cli/status adapter and run_codex.py script
This commit is contained in:
parent
eb8834ac5c
commit
e81bc94dd6
7 changed files with 321 additions and 0 deletions
19
apps/node/internal/adapters/cli/status/claude.go
Normal file
19
apps/node/internal/adapters/cli/status/claude.go
Normal 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)
|
||||
}
|
||||
133
apps/node/internal/adapters/cli/status/codex.go
Normal file
133
apps/node/internal/adapters/cli/status/codex.go
Normal 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)
|
||||
}
|
||||
27
apps/node/internal/adapters/cli/status/codex_test.go
Normal file
27
apps/node/internal/adapters/cli/status/codex_test.go
Normal 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)
|
||||
}
|
||||
20
apps/node/internal/adapters/cli/status/gemini.go
Normal file
20
apps/node/internal/adapters/cli/status/gemini.go
Normal 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)
|
||||
}
|
||||
47
apps/node/internal/adapters/cli/status/parser.go
Normal file
47
apps/node/internal/adapters/cli/status/parser.go
Normal 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, "")
|
||||
}
|
||||
20
apps/node/internal/adapters/cli/status/status.go
Normal file
20
apps/node/internal/adapters/cli/status/status.go
Normal 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
55
run_codex.py
Normal 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)
|
||||
Loading…
Reference in a new issue