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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ shelltime codex install
| `shelltime cc uninstall` | Remove Claude Code OTEL configuration from `~/.claude/settings.json` |
| `shelltime cc statusline` | Emit statusline JSON for Claude Code |
| `shelltime cc backfill` | Upload past Claude Code usage from local transcripts |
| `shelltime cc pr --session-id <id> <url>...` | Link pull requests to a Claude Code session (called by the ShellTime Claude Code mod) |
| `shelltime codex install` | Add ShellTime OTEL config to `~/.codex/config.toml` |
| `shelltime codex uninstall` | Remove ShellTime OTEL config from `~/.codex/config.toml` |
| `shelltime codex backfill` | Upload past Codex usage from local session files |
Expand Down Expand Up @@ -207,6 +208,16 @@ shelltime codex backfill # upload Codex sessions
- Flags: `--since` / `--until` (`YYYY-MM-DD`) limit the range, `--no-prompts` uploads prompt lengths without the text, and `--ai-summary` also generates AI session summaries, which use your monthly AI credits.
- Claude Code deletes transcripts after 30 days by default (`cleanupPeriodDays`), so only recent history may be available.

## Linking Pull Requests to AI Sessions

The [ShellTime Claude Code mod](https://github.com/shelltime/claude-code-mods) watches for `gh pr create` in Claude Code's Bash tool. When it sees one, it runs:

```bash
shelltime cc pr --session-id <claude-code-session-id> https://github.com/owner/repo/pull/123 [more URLs...]
```

The command hands the URLs to the daemon, and the daemon sends them to ShellTime, where they appear on the session. If no daemon is running, the CLI sends them itself. A session can link any number of PRs. Sending the same URL again does nothing. The command does nothing if you are not logged in.

## Security and Privacy

- **Data masking** redacts sensitive command content before it leaves your machine.
Expand Down
1 change: 1 addition & 0 deletions commands/cc.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ var CCCommand = &cli.Command{
CCUninstallCommand,
CCStatuslineCommand,
CCBackfillCommand,
CCPullRequestCommand,
},
}

Expand Down
84 changes: 84 additions & 0 deletions commands/cc_pr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package commands

import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"time"

"github.com/malamtime/cli/daemon"
"github.com/malamtime/cli/model"
"github.com/urfave/cli/v2"
"go.opentelemetry.io/otel/trace"
)

var CCPullRequestCommand = &cli.Command{
Name: "pr",
Usage: "Link pull requests opened in a Claude Code session to it (called by the ShellTime Claude Code mod after `gh pr create`)",
ArgsUsage: "<pr-url> [pr-url...]",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "session-id",
Usage: "Claude Code session id",
Required: true,
},
},
Action: commandCCPullRequest,
}

func commandCCPullRequest(c *cli.Context) error {
ctx, span := commandTracer.Start(c.Context, "cc.pr", trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
SetupLogger(os.ExpandEnv("$HOME/" + model.COMMAND_BASE_STORAGE_FOLDER))

sessionID := strings.TrimSpace(c.String("session-id"))
urls := uniqueNonEmpty(c.Args().Slice())
if sessionID == "" || len(urls) == 0 {
return fmt.Errorf("usage: shelltime cc pr --session-id <id> <pr-url> [pr-url...]")
}

config, err := configService.ReadConfigFile(ctx)
if err != nil {
return err
}
if config.Token == "" {
slog.Debug("cc pr: not logged in, skipping")
return nil
}

socketPath := config.SocketPath
if socketPath == "" {
socketPath = model.DefaultSocketPath
}
// The daemon sends them in the background; with no daemon, send them here.
err = daemon.SendSessionPullRequests(socketPath, sessionID, urls)
if err == nil {
return nil
}
slog.Debug("cc pr: daemon unreachable, sending directly", slog.Any("err", err))

ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := model.SendSessionPullRequests(ctx, config, sessionID, urls); err != nil {
slog.Error("cc pr: failed to send pull requests", slog.String("sessionId", sessionID), slog.Any("err", err))
return err
}
return nil
}

// uniqueNonEmpty trims each value and drops blanks and repeats, keeping order.
func uniqueNonEmpty(values []string) []string {
seen := make(map[string]bool, len(values))
result := make([]string, 0, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" || seen[v] {
continue
}
seen[v] = true
result = append(result, v)
}
return result
}
146 changes: 146 additions & 0 deletions commands/cc_pr_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package commands

import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"

"github.com/malamtime/cli/daemon"
"github.com/malamtime/cli/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/trace/noop"
)

func setupCCPullRequest(t *testing.T) *model.MockConfigService {
t.Helper()
otel.SetTracerProvider(noop.NewTracerProvider())
SKIP_LOGGER_SETTINGS = true
orig := configService
mc := model.NewMockConfigService(t)
configService = mc
t.Cleanup(func() { configService = orig })
return mc
}

func runCCPullRequest(args ...string) error {
app := &cli.App{Name: "t", Commands: []*cli.Command{CCCommand}}
return app.Run(append([]string{"t", "cc", "pr"}, args...))
}

func TestCCPullRequest_SendsToDaemon(t *testing.T) {
mc := setupCCPullRequest(t)

socketPath := filepath.Join(t.TempDir(), "daemon.sock")
ln, err := net.Listen("unix", socketPath)
require.NoError(t, err)
t.Cleanup(func() { ln.Close() })

got := make(chan daemon.SocketMessage, 1)
go func() {
conn, aerr := ln.Accept()
if aerr != nil {
return
}
defer conn.Close()
var msg daemon.SocketMessage
if derr := json.NewDecoder(conn).Decode(&msg); derr == nil {
got <- msg
}
}()

mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{
Token: "tok",
SocketPath: socketPath,
}, nil)

require.NoError(t, runCCPullRequest(
"--session-id", "sess-1",
"https://github.com/o/r/pull/1",
" https://github.com/o/r/pull/1 ",
"https://github.com/o/r2/pull/2",
))

select {
case msg := <-got:
assert.Equal(t, daemon.SocketMessageTypeSessionPullRequests, msg.Type)
payload, ok := msg.Payload.(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "sess-1", payload["sessionId"])
assert.Equal(t, []interface{}{"https://github.com/o/r/pull/1", "https://github.com/o/r2/pull/2"}, payload["urls"])
case <-time.After(time.Second):
t.Fatal("daemon did not receive the pull requests")
}
}

func TestCCPullRequest_FallsBackToServerWithoutDaemon(t *testing.T) {
mc := setupCCPullRequest(t)

var gotPath string
var body struct {
SessionID string `json:"sessionId"`
URLs []string `json:"urls"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&body)
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(server.Close)

mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{
Token: "tok",
APIEndpoint: server.URL,
SocketPath: filepath.Join(t.TempDir(), "absent.sock"),
}, nil)

require.NoError(t, runCCPullRequest("--session-id", "sess-1", "https://github.com/o/r/pull/1"))
assert.Equal(t, "/api/v1/cc/session-pull-requests", gotPath)
assert.Equal(t, "sess-1", body.SessionID)
assert.Equal(t, []string{"https://github.com/o/r/pull/1"}, body.URLs)
}

func TestCCPullRequest_ServerErrorIsReturned(t *testing.T) {
mc := setupCCPullRequest(t)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"Invalid request body"}`))
}))
t.Cleanup(server.Close)

mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{
Token: "tok",
APIEndpoint: server.URL,
SocketPath: filepath.Join(t.TempDir(), "absent.sock"),
}, nil)

err := runCCPullRequest("--session-id", "sess-1", "not-a-pr")
require.Error(t, err)
assert.Contains(t, err.Error(), "Invalid request body")
}

func TestCCPullRequest_SkipsWithoutToken(t *testing.T) {
mc := setupCCPullRequest(t)
mc.On("ReadConfigFile", mock.Anything).Return(model.ShellTimeConfig{
SocketPath: filepath.Join(t.TempDir(), "absent.sock"),
}, nil)

require.NoError(t, runCCPullRequest("--session-id", "sess-1", "https://github.com/o/r/pull/1"))
}

func TestCCPullRequest_RequiresSessionAndURL(t *testing.T) {
setupCCPullRequest(t)

assert.Error(t, runCCPullRequest("https://github.com/o/r/pull/1"), "no --session-id")
assert.Error(t, runCCPullRequest("--session-id", "sess-1"), "no url")
assert.Error(t, runCCPullRequest("--session-id", " ", "https://github.com/o/r/pull/1"), "blank session id")
assert.Error(t, runCCPullRequest("--session-id", "sess-1", " "), "blank url")
}
21 changes: 21 additions & 0 deletions daemon/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,27 @@ func SendSessionProject(socketPath string, sessionID, projectPath string) {
json.NewEncoder(conn).Encode(msg)
}

// SendSessionPullRequests hands pull request URLs opened in a Claude Code
// session to the daemon, which sends them to the server. It returns the dial or
// write error so the caller can send them directly when no daemon is running.
func SendSessionPullRequests(socketPath string, sessionID string, urls []string) error {
conn, err := net.DialTimeout("unix", socketPath, 100*time.Millisecond)
if err != nil {
return err
}
defer conn.Close()

msg := SocketMessage{
Type: SocketMessageTypeSessionPullRequests,
Payload: SessionPullRequestsRequest{
SessionID: sessionID,
URLs: urls,
},
}

return json.NewEncoder(conn).Encode(msg)
}

// RequestListCommands asks the daemon for the locally buffered commands (used
// by `shelltime ls` in bolt mode, since the CLI can't open the locked DB).
func RequestListCommands(socketPath string, timeout time.Duration) (*ListCommandsResponse, error) {
Expand Down
39 changes: 39 additions & 0 deletions daemon/client_socket_cov_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,45 @@ func TestX3SendSessionProject_DeliversToServer(t *testing.T) {
}
}

func TestSendSessionPullRequests_DialFailureReturnsError(t *testing.T) {
err := SendSessionPullRequests(filepath.Join(t.TempDir(), "absent.sock"), "sess", []string{"https://github.com/o/r/pull/1"})
require.Error(t, err)
}

func TestSendSessionPullRequests_DeliversToDaemon(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "spr.sock")
ln, err := net.Listen("unix", socketPath)
require.NoError(t, err)
t.Cleanup(func() { ln.Close() })

got := make(chan SocketMessage, 1)
go func() {
conn, aerr := ln.Accept()
if aerr != nil {
return
}
defer conn.Close()
var msg SocketMessage
if derr := json.NewDecoder(conn).Decode(&msg); derr == nil {
got <- msg
}
}()

urls := []string{"https://github.com/o/r/pull/1", "https://github.com/o/r2/pull/2"}
require.NoError(t, SendSessionPullRequests(socketPath, "sess-1", urls))

select {
case msg := <-got:
assert.Equal(t, SocketMessageTypeSessionPullRequests, msg.Type)
payload, ok := msg.Payload.(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "sess-1", payload["sessionId"])
assert.Equal(t, []interface{}{urls[0], urls[1]}, payload["urls"])
case <-time.After(time.Second):
t.Fatal("session_pull_requests message not delivered")
}
}

// TestX3SocketHandler_StartListenError covers the net.Listen failure branch of
// SocketHandler.Start: a socket path inside a non-existent directory cannot be
// bound.
Expand Down
33 changes: 33 additions & 0 deletions daemon/socket.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ const (
SocketMessageTypeStatus SocketMessageType = "status"
SocketMessageTypeCCInfo SocketMessageType = "cc_info"
SocketMessageTypeSessionProject SocketMessageType = "session_project"
// SocketMessageTypeSessionPullRequests links pull requests opened by
// `gh pr create` in a Claude Code session to it (fire-and-forget).
SocketMessageTypeSessionPullRequests SocketMessageType = "session_pull_requests"
// SocketMessageTypeTrackPre / TrackPost carry a single raw command event the
// daemon persists to its bolt-backed CommandStore (used when the bolt storage
// engine is enabled).
Expand Down Expand Up @@ -51,6 +54,11 @@ type SessionProjectRequest struct {
ProjectPath string `json:"projectPath"`
}

type SessionPullRequestsRequest struct {
SessionID string `json:"sessionId"`
URLs []string `json:"urls"`
}

type CCInfoTimeRange string

const (
Expand Down Expand Up @@ -236,11 +244,36 @@ func (p *SocketHandler) handleConnection(conn net.Conn) {
slog.Debug("session_project update dispatched", slog.String("sessionId", sessionID))
}
}
case SocketMessageTypeSessionPullRequests:
p.handleSessionPullRequests(msg)
default:
slog.Error("Unknown message type:", slog.String("messageType", string(msg.Type)))
}
}

func (p *SocketHandler) handleSessionPullRequests(msg SocketMessage) {
buf, err := json.Marshal(msg.Payload)
if err != nil {
slog.Error("Error encoding session_pull_requests payload", slog.Any("err", err))
return
}
var req SessionPullRequestsRequest
if err := json.Unmarshal(buf, &req); err != nil {
slog.Error("Error decoding session_pull_requests payload", slog.Any("err", err))
return
}
if req.SessionID == "" || len(req.URLs) == 0 {
return
}
go func() {
if err := model.SendSessionPullRequests(context.Background(), *p.config, req.SessionID, req.URLs); err != nil {
slog.Warn("Failed to send session pull requests", slog.String("sessionId", req.SessionID), slog.Any("err", err))
return
}
slog.Debug("session_pull_requests sent", slog.String("sessionId", req.SessionID), slog.Int("count", len(req.URLs)))
}()
}

func (p *SocketHandler) handleStatus(conn net.Conn) {
uptime := time.Since(startedAt)
response := StatusResponse{
Expand Down
Loading
Loading