summaryrefslogtreecommitdiff
path: root/internal/cli
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cli')
-rw-r--r--internal/cli/logs.go107
-rw-r--r--internal/cli/logs_test.go165
-rw-r--r--internal/cli/root.go1
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