Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 39 additions & 6 deletions cli/cmd/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func init() {
logCmd.Flags().StringVar(&logAgent, "agent", "", "Filter by agent platform (e.g. claude-code, cursor)")
logCmd.Flags().BoolVarP(&logFollow, "follow", "f", false, "Stream new entries in real-time")
logCmd.Flags().BoolVarP(&logInteractive, "interactive", "i", false, "Open live interactive log viewer")
logCmd.Flags().StringVar(&logExport, "export", "", "Export format: csv")
logCmd.Flags().StringVar(&logExport, "export", "", "Export log entries as CSV to the given file path")
logCmd.Flags().IntVar(&logLimit, "limit", 0, "Maximum number of entries to return (0 = no limit)")
}

Expand Down Expand Up @@ -90,9 +90,6 @@ func runLog(cmd *cobra.Command, args []string) error {
if logLimit < 0 {
return fmt.Errorf("log: --limit must be >= 0")
}
if logExport != "" && logExport != "csv" {
return fmt.Errorf("log: unsupported export format %q (supported: csv)", logExport)
}

root, warn, err := reporoot.Find()
if err != nil {
Expand Down Expand Up @@ -189,8 +186,8 @@ func runLog(cmd *cobra.Command, args []string) error {
return nil
}

if logExport == "csv" {
return writeLogCSV(os.Stdout, entries)
if logExport != "" {
return writeLogCSVFile(logExport, entries)
}

if len(entries) == 0 {
Expand Down Expand Up @@ -446,6 +443,42 @@ func writeLogCSV(w io.Writer, entries []store.UnifiedEntry) error {
return cw.Error()
}

func writeLogCSVFile(path string, entries []store.UnifiedEntry) error {
resolvedPath, err := resolveExportPath(path)
if err != nil {
return fmt.Errorf("log: --export: %w", err)
}

f, err := os.Create(resolvedPath)
if err != nil {
return fmt.Errorf("log: --export: create %q: %w", resolvedPath, err)
}
defer f.Close()

if err := writeLogCSV(f, entries); err != nil {
return fmt.Errorf("log: --export: write %q: %w", resolvedPath, err)
}
return nil
}

func resolveExportPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("file path cannot be empty")
}
if path == "~" || strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
if path == "~" {
path = home
} else {
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
}
}
return path, nil
}

// formatTimestamp returns a relative "Xh ago" / "Ym ago" string for entries
// within the last 24 hours, and an absolute timestamp otherwise.
func formatTimestamp(t time.Time) string {
Expand Down
103 changes: 103 additions & 0 deletions cli/cmd/log_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package cmd

import (
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/cordon-co/cordon-cli/cli/internal/store"
)

func TestResolveExportPath(t *testing.T) {
t.Setenv("HOME", "/tmp/cordon-home")

tests := []struct {
name string
in string
want string
wantErr string
}{
{
name: "absolute path is unchanged",
in: "/tmp/out.csv",
want: "/tmp/out.csv",
},
{
name: "expands tilde path",
in: "~/out.csv",
want: "/tmp/cordon-home/out.csv",
},
{
name: "rejects empty path",
in: " ",
wantErr: "file path cannot be empty",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveExportPath(tt.in)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("resolveExportPath(%q) expected error %q, got nil", tt.in, tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("resolveExportPath(%q) error = %q, want contains %q", tt.in, err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("resolveExportPath(%q) unexpected error: %v", tt.in, err)
}
if got != tt.want {
t.Fatalf("resolveExportPath(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}

func TestWriteLogCSVFile_WritesCSV(t *testing.T) {
outPath := filepath.Join(t.TempDir(), "audit.csv")
entries := []store.UnifiedEntry{
{
Time: time.Date(2026, 3, 22, 10, 11, 12, 0, time.UTC),
EventType: "hook_allow",
ToolName: "Write",
FilePath: "cli/cmd/log.go",
Agent: "claude-code",
},
}

if err := writeLogCSVFile(outPath, entries); err != nil {
t.Fatalf("writeLogCSVFile() error = %v", err)
}

b, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("ReadFile(%q) error = %v", outPath, err)
}
content := string(b)
if !strings.Contains(content, "timestamp,event_type,tool_name,file_path,file_rule_id,pass_id,user,agent,session_id,detail") {
t.Fatalf("CSV content missing header: %q", content)
}
if !strings.Contains(content, "2026-03-22T10:11:12Z,hook_allow,Write,cli/cmd/log.go,,,,claude-code,,") {
t.Fatalf("CSV content missing row: %q", content)
}
}

func TestWriteLogCSVFile_ExpandsHomePath(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
outPath := "~/log-export.csv"

if err := writeLogCSVFile(outPath, nil); err != nil {
t.Fatalf("writeLogCSVFile(%q) error = %v", outPath, err)
}

expanded := filepath.Join(home, "log-export.csv")
if _, err := os.Stat(expanded); err != nil {
t.Fatalf("expected file at %q: %v", expanded, err)
}
}
Loading