Skip to content
Draft
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ cli/
│ ├── correlation/ # W3C trace_id/span_id generation and per-session persistence
│ ├── envelope/ # Raw event envelope types
│ ├── git/ # Git context extraction
│ ├── outbound/ # http.RoundTripper that mirrors every outbound request to a local ndjson file (drives `promptconduit watch`)
│ ├── sync/ # Transcript sync and parsing (Claude Code parser, state management)
│ ├── transcript/ # Transcript parsing and attachment extraction
│ └── updater/ # GitHub-release version check + self-replace upgrade
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,38 @@ promptconduit version

# Upgrade to the latest release
promptconduit upgrade

# Tail outbound HTTP traffic in real time (great for debugging hooks)
promptconduit watch
promptconduit watch --verbose # also pretty-print request/response bodies
promptconduit watch --lines 20 # backfill the last 20 entries before going live
```

### Tail outbound traffic

`promptconduit watch` streams every HTTP request the CLI makes to the
platform — hook envelope sends, transcript syncs, insights queries,
skills traffic — to your terminal as it happens. Useful for answering
"is my hook actually sending anything when Claude Code fires?" without
spelunking the debug log.

Each request appears as a one-line summary by default:

```
15:30:42 POST /v1/events/raw 3.2KB → 200 (87ms)
```

Add `--verbose` to also see the pretty-printed JSON body underneath
each summary.

Implementation note: every request the CLI makes is mirrored to
`~/.config/promptconduit/outbound.ndjson` (mode `0600`). Authorization,
Cookie, and similar credential headers are redacted before write;
bodies are capped at 64KB per row and the file rotates to
`outbound.ndjson.1` when it crosses 50MB. Update-check traffic from
`internal/updater` uses a separate HTTP client and is **not** mirrored
— that traffic is predictable and noisy and isn't what `watch` is for.

### Sync Command

The `sync` command uploads historical conversation transcripts to the platform. **This is a manual process** - there is no automatic syncing of transcripts.
Expand Down
6 changes: 5 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func init() {
rootCmd.AddCommand(skillsCmd)
rootCmd.AddCommand(debugCmd)
rootCmd.AddCommand(upgradeCmd)
rootCmd.AddCommand(watchCmd)
}

var versionCmd = &cobra.Command{
Expand Down Expand Up @@ -194,7 +195,10 @@ func skipUpdateCheckFor(cmd *cobra.Command) bool {
}
name := commandPathRoot(cmd)
switch name {
case "hook", "upgrade":
case "hook", "upgrade", "watch":
// hook is per-event and must stay fast; upgrade and watch
// drive their own long-running loops and shouldn't have a
// random update banner interrupt them.
return true
}
return false
Expand Down
80 changes: 80 additions & 0 deletions cmd/watch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package cmd

import (
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"

"github.com/promptconduit/cli/internal/client"
"github.com/promptconduit/cli/internal/outbound"
"github.com/spf13/cobra"
)

var (
watchVerbose bool
watchLines int
)

var watchCmd = &cobra.Command{
Use: "watch",
Short: "Tail outbound HTTP traffic from the CLI in real time",
SilenceUsage: true,
SilenceErrors: true,
Long: `Stream every HTTP request the CLI makes to the platform — hook
envelope sends, transcript syncs, insights queries, skills traffic, the
whole lot — to your terminal as it happens.

Each request appears as a one-line summary by default:

15:30:42 POST /v1/events/raw 3.2KB → 200 (87ms)

Use --verbose to also print the request body (and response body, when
the server returned one) pretty-printed beneath the summary. Useful for
checking what your hook is actually sending when events aren't showing
up on the dashboard.

The underlying file lives at ~/.config/promptconduit/outbound.ndjson
(mode 0600). Authorization, Cookie, and similar credential headers are
redacted before write; request bodies are capped at 64KB per row and
the file rotates to outbound.ndjson.1 at 50MB.

Examples:
promptconduit watch # tail live traffic
promptconduit watch --verbose # include full bodies
promptconduit watch --lines 20 # backfill the last 20 entries`,
RunE: runWatch,
}

func init() {
watchCmd.Flags().BoolVarP(&watchVerbose, "verbose", "v", false, "include full request/response bodies under each summary line")
watchCmd.Flags().IntVar(&watchLines, "lines", 0, "backfill the last N entries before going live")
}

func runWatch(cmd *cobra.Command, args []string) error {
path := filepath.Join(client.ConfigDir(), outbound.MirrorFileName)
color := outbound.IsTerminal(os.Stdout)

ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
defer cancel()

fmt.Fprintf(cmd.ErrOrStderr(), "Watching %s — Ctrl-C to stop.\n", path)

lines := outbound.Tail(ctx, path, watchLines)
for raw := range lines {
entry, err := outbound.ParseLine(raw)
if err != nil {
// Best-effort: show the unparseable raw line so the user
// can still see something went through.
fmt.Fprintln(cmd.OutOrStdout(), string(raw))
continue
}
fmt.Fprintln(cmd.OutOrStdout(), outbound.RenderSummary(entry, watchVerbose, color))
}

// Ctrl-C / SIGTERM is the normal way to leave watch; treat as a
// successful exit rather than an error so cobra doesn't print a
// "Error: context canceled" banner.
return nil
}
13 changes: 10 additions & 3 deletions internal/client/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"

"github.com/promptconduit/cli/internal/envelope"
"github.com/promptconduit/cli/internal/outbound"
)

// APIResponse represents a response from the API
Expand All @@ -33,15 +35,20 @@ type Client struct {
version string
}

// NewClient creates a new API client
// NewClient creates a new API client. Every outbound HTTP request is
// mirrored to ~/.config/promptconduit/outbound.ndjson so users can run
// `promptconduit watch` to see what the CLI is uploading in real time.
func NewClient(config *Config, version string) *Client {
mirror := outbound.New(filepath.Join(ConfigDir(), outbound.MirrorFileName), http.DefaultTransport)
return &Client{
config: config,
httpClient: &http.Client{
Timeout: time.Duration(config.TimeoutSeconds) * time.Second,
Timeout: time.Duration(config.TimeoutSeconds) * time.Second,
Transport: mirror,
},
longHttpClient: &http.Client{
Timeout: 600 * time.Second, // 10 min for large transcript sync (chunked complete can be slow)
Timeout: 600 * time.Second, // 10 min for large transcript sync (chunked complete can be slow)
Transport: mirror,
},
version: version,
}
Expand Down
77 changes: 77 additions & 0 deletions internal/outbound/entry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Package outbound mirrors every HTTP request the CLI makes to a local
// ndjson file, so users can run `promptconduit watch` and see what their
// hooks are actually uploading to the platform.
//
// The mirror is a single http.RoundTripper that wraps http.DefaultTransport
// and is installed into every *http.Client constructed by
// internal/client.NewClient. Both the foreground command and the
// `hook --send-event` subprocess thus log into the same file. The file
// is owner-only-readable (0600), bodies are capped at 64KB, and the file
// rotates to a .1 backup when it crosses 50MB.
package outbound

import (
"encoding/json"
"net/http"
"time"
)

// MirrorFileName is the basename of the on-disk mirror, written into
// client.ConfigDir(). Exposed so callers (the watch command, the HTTP
// client wiring) agree on a single source of truth.
const MirrorFileName = "outbound.ndjson"

// Entry is one line in outbound.ndjson — one HTTP request + response.
//
// Field ordering matches the ndjson layout for easy `jq` use. Bodies are
// stored as strings (not raw JSON) so the mirror file remains
// well-formed even when the body bytes are themselves valid JSON
// containing embedded newlines.
type Entry struct {
TS time.Time `json:"ts"`
Method string `json:"method"`
URL string `json:"url"`
ReqHeaders map[string]string `json:"req_headers,omitempty"`
ReqContentType string `json:"req_content_type,omitempty"`
ReqBody string `json:"req_body,omitempty"`
ReqTruncated bool `json:"req_truncated,omitempty"`
ReqOriginalSize int `json:"req_original_size_bytes,omitempty"`
Status int `json:"status,omitempty"`
RespContentType string `json:"resp_content_type,omitempty"`
RespBody string `json:"resp_body,omitempty"`
RespTruncated bool `json:"resp_truncated,omitempty"`
LatencyMs int64 `json:"latency_ms"`
Error string `json:"error,omitempty"`
}

// MarshalLine produces one ndjson line for the entry (no trailing newline).
// Use AppendLine to write to a file; this is split out so it can be tested
// without disk.
func (e Entry) MarshalLine() ([]byte, error) {
return json.Marshal(e)
}

// headerMap returns a single-value header map keyed by canonical name.
// Multi-value headers are joined with ", "; this is fine for an
// observability surface and avoids the cost of nested slices in ndjson.
func headerMap(h http.Header) map[string]string {
if len(h) == 0 {
return nil
}
out := make(map[string]string, len(h))
for k, v := range h {
if len(v) == 1 {
out[k] = v[0]
continue
}
joined := ""
for i, s := range v {
if i > 0 {
joined += ", "
}
joined += s
}
out[k] = joined
}
return out
}
23 changes: 23 additions & 0 deletions internal/outbound/inode_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//go:build !windows

package outbound

import (
"os"
"syscall"
)

// inodeOf returns the file's inode number on Unix. Used by Tail to
// detect rotation: when the inode changes under the same path, the
// follower reopens at offset 0.
func inodeOf(path string) (uint64, error) {
info, err := os.Stat(path)
if err != nil {
return 0, err
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, nil
}
return uint64(st.Ino), nil
}
10 changes: 10 additions & 0 deletions internal/outbound/inode_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//go:build windows

package outbound

// inodeOf returns 0 on Windows — there is no portable inode equivalent
// in stdlib. Tail falls back to size-shrink detection for rotation,
// which is adequate for this observability surface.
func inodeOf(path string) (uint64, error) {
return 0, nil
}
49 changes: 49 additions & 0 deletions internal/outbound/locking_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//go:build !windows

package outbound

import (
"os"
"sync"
"syscall"
)

// inProcessAppendMu serializes appendLine calls from a single process.
// Across processes we lean on flock for lines >4KB and on POSIX's
// O_APPEND atomicity guarantee for smaller writes.
var inProcessAppendMu sync.Mutex

// appendLine writes line + "\n" to path. POSIX guarantees an O_APPEND
// write of <=PIPE_BUF (typically 4KB) is atomic between processes, so
// small lines never interleave. For larger lines we take an exclusive
// file lock (flock LOCK_EX) for the duration of the write.
//
// Within a single process, inProcessAppendMu prevents tearing if two
// goroutines append concurrently — important because the parent CLI
// can fire several requests in quick succession.
func appendLine(path string, line []byte) error {
inProcessAppendMu.Lock()
defer inProcessAppendMu.Unlock()

f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer f.Close()

needFlock := len(line)+1 > 4096
if needFlock {
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
return err
}
// LOCK_UN is implicit on file close, but be explicit for clarity.
defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
}

// One Write call so the kernel sees the line+newline as a unit.
buf := make([]byte, len(line)+1)
copy(buf, line)
buf[len(line)] = '\n'
_, err = f.Write(buf)
return err
}
32 changes: 32 additions & 0 deletions internal/outbound/locking_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//go:build windows

package outbound

import (
"os"
"sync"
)

// On Windows we don't have a portable cross-process advisory lock in the
// stdlib, so we serialize within a single process and accept best-effort
// behavior across processes. In practice the only concurrent writers are
// the foreground CLI process and the `hook --send-event` subprocess it
// spawns, which fire on different cadences, so collisions are very rare.
var inProcessAppendMu sync.Mutex

func appendLine(path string, line []byte) error {
inProcessAppendMu.Lock()
defer inProcessAppendMu.Unlock()

f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer f.Close()

buf := make([]byte, len(line)+1)
copy(buf, line)
buf[len(line)] = '\n'
_, err = f.Write(buf)
return err
}
Loading
Loading