diff options
| -rw-r--r-- | internal/cli/logs.go | 107 | ||||
| -rw-r--r-- | internal/cli/logs_test.go | 165 | ||||
| -rw-r--r-- | internal/cli/root.go | 1 |
3 files changed, 273 insertions, 0 deletions
diff --git a/internal/cli/logs.go b/internal/cli/logs.go new file mode 100644 index 0000000..cabb12a --- /dev/null +++ b/internal/cli/logs.go @@ -0,0 +1,107 @@ +package cli + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + "github.com/thepeterstone/claudomator/internal/storage" +) + +type executionStore interface { + GetExecution(id string) (*storage.Execution, error) +} + +func newLogsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "logs <exec-id>", + Short: "Show logs for an execution", + Args: cobra.ExactArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + store, err := storage.Open(cfg.DBPath) + if err != nil { + return fmt.Errorf("opening db: %w", err) + } + defer store.Close() + return renderLogs(args[0], store, cmd.OutOrStdout()) + }, + } + return cmd +} + +type streamMessage struct { + Type string `json:"type"` + Message *assistantMsg `json:"message,omitempty"` +} + +type assistantMsg struct { + Content []contentBlock `json:"content"` +} + +type contentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` +} + +func renderLogs(execID string, store executionStore, w io.Writer) error { + exec, err := store.GetExecution(execID) + if err != nil { + fmt.Fprintf(w, "execution %s not found\n", execID) + return fmt.Errorf("execution %s not found", execID) + } + + if exec.StdoutPath == "" { + fmt.Fprintln(w, "no output recorded") + return nil + } + + fi, err := os.Stat(exec.StdoutPath) + if err != nil || fi.Size() == 0 { + fmt.Fprintln(w, "no output recorded") + return nil + } + + f, err := os.Open(exec.StdoutPath) + if err != nil { + fmt.Fprintln(w, "no output recorded") + return nil + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var msg streamMessage + if err := json.Unmarshal(line, &msg); err != nil { + continue + } + if msg.Type != "assistant" || msg.Message == nil { + continue + } + for _, block := range msg.Message.Content { + switch block.Type { + case "text": + fmt.Fprintln(w, block.Text) + case "tool_use": + summary := string(block.Input) + if len(summary) > 80 { + summary = summary[:80] + } + fmt.Fprintf(w, " > %s (%s)\n", block.Name, summary) + } + } + } + + fmt.Fprintf(w, "Cost: $%.4f Exit: %d\n", exec.CostUSD, exec.ExitCode) + return nil +} diff --git a/internal/cli/logs_test.go b/internal/cli/logs_test.go new file mode 100644 index 0000000..d1447c3 --- /dev/null +++ b/internal/cli/logs_test.go @@ -0,0 +1,165 @@ +package cli + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/thepeterstone/claudomator/internal/storage" +) + +type fakeExecutionStore struct { + exec *storage.Execution + err error +} + +func (f *fakeExecutionStore) GetExecution(id string) (*storage.Execution, error) { + return f.exec, f.err +} + +func TestLogsCmd_PrintsAssistantText(t *testing.T) { + dir := t.TempDir() + logFile := filepath.Join(dir, "stdout.log") + line := `{"type":"assistant","message":{"content":[{"type":"text","text":"Hello from Claude"}]}}` + if err := os.WriteFile(logFile, []byte(line+"\n"), 0644); err != nil { + t.Fatal(err) + } + + store := &fakeExecutionStore{ + exec: &storage.Execution{ + ID: "exec-1", + StdoutPath: logFile, + CostUSD: 0.0042, + ExitCode: 0, + }, + } + + var buf bytes.Buffer + if err := renderLogs("exec-1", store, &buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "Hello from Claude") { + t.Errorf("expected text output, got: %q", out) + } + if !strings.Contains(out, "Cost: $0.0042") { + t.Errorf("expected cost in footer, got: %q", out) + } + if !strings.Contains(out, "Exit: 0") { + t.Errorf("expected exit code in footer, got: %q", out) + } +} + +func TestLogsCmd_PrintsToolUse(t *testing.T) { + dir := t.TempDir() + logFile := filepath.Join(dir, "stdout.log") + line := `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls -la"}}]}}` + if err := os.WriteFile(logFile, []byte(line+"\n"), 0644); err != nil { + t.Fatal(err) + } + + store := &fakeExecutionStore{ + exec: &storage.Execution{ + ID: "exec-2", + StdoutPath: logFile, + CostUSD: 0.0001, + ExitCode: 0, + }, + } + + var buf bytes.Buffer + if err := renderLogs("exec-2", store, &buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, " > Bash") { + t.Errorf("expected tool_use prefix, got: %q", out) + } + if !strings.Contains(out, "ls -la") { + t.Errorf("expected tool input summary, got: %q", out) + } +} + +func TestLogsCmd_ExecutionNotFound(t *testing.T) { + store := &fakeExecutionStore{ + err: errors.New("not found"), + } + + var buf bytes.Buffer + err := renderLogs("exec-missing", store, &buf) + if err == nil { + t.Fatal("expected error for missing execution") + } + + out := buf.String() + if !strings.Contains(out, "execution exec-missing not found") { + t.Errorf("expected not-found message, got: %q", out) + } +} + +func TestLogsCmd_EmptyLog(t *testing.T) { + dir := t.TempDir() + logFile := filepath.Join(dir, "stdout.log") + if err := os.WriteFile(logFile, []byte(""), 0644); err != nil { + t.Fatal(err) + } + + store := &fakeExecutionStore{ + exec: &storage.Execution{ + ID: "exec-empty", + StdoutPath: logFile, + CostUSD: 0, + ExitCode: 0, + }, + } + + var buf bytes.Buffer + if err := renderLogs("exec-empty", store, &buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "no output recorded") { + t.Errorf("expected no-output message, got: %q", out) + } +} + +func TestLogsCmd_SkipsThinkingAndNonAssistant(t *testing.T) { + dir := t.TempDir() + logFile := filepath.Join(dir, "stdout.log") + content := strings.Join([]string{ + `{"type":"system","message":"session start"}`, + `{"type":"user","message":{"content":[{"type":"text","text":"do something"}]}}`, + `{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"let me think"},{"type":"text","text":"Done."}]}}`, + }, "\n") + "\n" + if err := os.WriteFile(logFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + store := &fakeExecutionStore{ + exec: &storage.Execution{ + ID: "exec-3", + StdoutPath: logFile, + CostUSD: 0.0010, + ExitCode: 0, + }, + } + + var buf bytes.Buffer + if err := renderLogs("exec-3", store, &buf); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if strings.Contains(out, "let me think") { + t.Errorf("thinking block should be skipped, got: %q", out) + } + if !strings.Contains(out, "Done.") { + t.Errorf("expected text output, got: %q", out) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 56e9aaa..4f8dad6 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -30,6 +30,7 @@ func NewRootCmd() *cobra.Command { newListCmd(), newStatusCmd(), newInitCmd(), + newLogsCmd(), ) return cmd |
