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
90 changes: 90 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package cli

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/sendbird/ccx/internal/extract"
"github.com/sendbird/ccx/internal/session"
"github.com/sendbird/ccx/internal/tmux"
)

// Run executes a CLI subcommand (urls or files) and prints results to stdout.
// Returns an error if no session can be found in the current tmux window.
func Run(command, claudeDir string) error {
filePath, err := findSessionFile(claudeDir)
if err != nil {
return err
}

var items []extract.Item
switch command {
case "urls":
items = extract.SessionURLs(filePath)
case "files":
items = extract.SessionFilePaths(filePath)
default:
return fmt.Errorf("unknown command: %s", command)
}

if len(items) == 0 {
return fmt.Errorf("no %s found in session", command)
}

for _, item := range items {
cat := strings.ToUpper(item.Category)
if len(cat) < 5 {
cat += strings.Repeat(" ", 5-len(cat))
}
fmt.Fprintf(os.Stdout, "%s\t%s\t%s\n", cat, item.Label, item.URL)
}
return nil
}

// findSessionFile detects the Claude session in the same tmux window
// and returns its JSONL file path.
func findSessionFile(claudeDir string) (string, error) {
projPaths := tmux.CurrentWindowClaudes()
if len(projPaths) == 0 {
// Non-tmux fallback
live := tmux.FindLiveProjectPaths()
for p := range live {
projPaths = append(projPaths, p)
}
}
if len(projPaths) == 0 {
return "", fmt.Errorf("no Claude session found in current window")
}

sessions := session.LoadCachedSessions(claudeDir)
if len(sessions) == 0 {
return "", fmt.Errorf("no cached sessions found (run ccx once first)")
}

// Match project path → most recently modified session
for _, projPath := range projPaths {
absProj, _ := filepath.Abs(projPath)
if absProj == "" {
absProj = projPath
}
var best *session.Session
for i := range sessions {
absSP, _ := filepath.Abs(sessions[i].ProjectPath)
if absSP == "" {
absSP = sessions[i].ProjectPath
}
if absSP == absProj {
if best == nil || sessions[i].ModTime.After(best.ModTime) {
best = &sessions[i]
}
}
}
if best != nil {
return best.FilePath, nil
}
}

return "", fmt.Errorf("no session found matching project paths: %s", strings.Join(projPaths, ", "))
}
18 changes: 17 additions & 1 deletion internal/extract/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ type Item struct {
}

// urlRegex matches http/https URLs in text.
var urlRegex = regexp.MustCompile(`https?://[^\s<>"'\x60\x29\x5D]+`)
// Excludes: whitespace, angle brackets, quotes, backtick, paren, bracket,
// and common unicode chars that leak from markdown (bullets, dashes, quotes).
var urlRegex = regexp.MustCompile(`https?://[^\s<>"'\x60\x29\x5D\x{2022}\x{2013}\x{2014}\x{2018}\x{2019}\x{201C}\x{201D}]+`)

// trailingJunkRe strips capitalized words or markdown bold artifacts that appear
// after a non-alpha character, indicating accidental concatenation from adjacent
// prose (e.g. "1234Then", "59153**Fix**", "pull/1234GH").
var trailingJunkRe = regexp.MustCompile(`([^a-zA-Z/])(?:\*{1,2}[^*]+\*{0,2}|[A-Z][a-zA-Z]*)$`)

// Package-level vars to avoid per-call allocation.
var (
Expand Down Expand Up @@ -95,6 +102,11 @@ func CleanURL(raw string) string {
raw = strings.TrimRight(raw, `\`)
// Strip trailing punctuation that leaks from prose/markdown
raw = strings.TrimRight(raw, ".,;:!?)'\"")
// Strip trailing capitalized words/markdown absorbed from adjacent text
// (e.g. "...1234GH", "...55Then", "...**Fix**"). Keep the preceding char ($1).
raw = trailingJunkRe.ReplaceAllString(raw, "$1")
// Re-strip punctuation that may be exposed after word removal
raw = strings.TrimRight(raw, ".,;:!?)'\"")

// Validate
u, err := url.Parse(raw)
Expand All @@ -105,6 +117,10 @@ func CleanURL(raw string) string {
if strings.ContainsAny(u.Host, " \t\n\\") {
return ""
}
// Reject bare-domain URLs with no path, query, or fragment (e.g. "https://github.com/")
if strings.TrimRight(u.Path, "/") == "" && u.RawQuery == "" && u.Fragment == "" {
return ""
}
return raw
}

Expand Down
31 changes: 30 additions & 1 deletion internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -2155,10 +2155,39 @@ func (a *App) handleBulkActionsMenu(key string) (tea.Model, tea.Cmd) {
a.tagInput.SetValue("")
a.tagInput.Focus()
return a, a.tagInput.Cursor.BlinkCmd()
case akm.URLs:
return a.openBulkURLMenu(selected, false)
case akm.Files:
return a.openBulkURLMenu(selected, true)
}
return a, nil
}

// openBulkURLMenu merges URLs or file paths from multiple sessions into the URL menu.
func (a *App) openBulkURLMenu(selected []session.Session, files bool) (tea.Model, tea.Cmd) {
seen := make(map[string]bool)
var merged []extract.Item
for _, s := range selected {
var items []extract.Item
if files {
items = extract.SessionFilePaths(s.FilePath)
} else {
items = extract.SessionURLs(s.FilePath)
}
for _, item := range items {
if !seen[item.URL] {
seen[item.URL] = true
merged = append(merged, item)
}
}
}
scope := fmt.Sprintf("%d sessions", len(selected))
if files {
scope += " files"
}
return a.openURLMenuFromItems(merged, scope)
}

func (a *App) bulkDelete(selected []session.Session) (tea.Model, tea.Cmd) {
deleted, skipped := 0, 0
deletedIDs := make(map[string]bool)
Expand Down Expand Up @@ -3777,7 +3806,7 @@ func (a *App) renderActionsHintBox() string {
header := fmt.Sprintf("%d selected", len(a.selectedSet))
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(colorPrimary).Render(header))
lines = append(lines, hl.Render(displayKey(akm.Delete))+d.Render(":delete")+sp+hl.Render(displayKey(akm.Resume))+d.Render(":resume")+sp+hl.Render(displayKey(akm.Kill))+d.Render(":kill")+sp+hl.Render(displayKey(akm.Input))+d.Render(":input"))
lines = append(lines, hl.Render(displayKey(akm.Tags))+d.Render(":tags"))
lines = append(lines, hl.Render(displayKey(akm.URLs))+d.Render(":urls")+sp+hl.Render(displayKey(akm.Files))+d.Render(":files")+sp+hl.Render(displayKey(akm.Tags))+d.Render(":tags"))
} else {
sess := a.actionsSess
lines = append(lines, hl.Render(displayKey(akm.Delete))+d.Render(":delete")+sp+hl.Render(displayKey(akm.Move))+d.Render(":move")+sp+hl.Render(displayKey(akm.Resume))+d.Render(":resume")+sp+hl.Render(displayKey(akm.CopyPath))+d.Render(":copy-path"))
Expand Down
22 changes: 22 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"

tea "github.com/charmbracelet/bubbletea"
"github.com/sendbird/ccx/internal/cli"
"github.com/sendbird/ccx/internal/session"
"github.com/sendbird/ccx/internal/tmux"
"github.com/sendbird/ccx/internal/tui"
Expand Down Expand Up @@ -43,6 +44,27 @@ func main() {
fmt.Fprintf(os.Stderr, "Flags:\n")
flag.PrintDefaults()
}
// Handle subcommands before flag parsing (urls/files aren't registered flags)
if len(os.Args) > 1 {
switch os.Args[1] {
case "urls", "files":
dir := os.Getenv("CLAUDE_CONFIG_DIR")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
dir = home + "/.claude"
}
if err := cli.Run(os.Args[1], dir); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
}

flag.Parse()

if showVersion {
Expand Down
Loading