diff --git a/go.mod b/go.mod index 6f81194bf78d10e1c41c372cf5e1f294f9c2441f..8b5c9ca00e49711113f092b3a252d1b5447fecb1 100644 --- a/go.mod +++ b/go.mod @@ -55,10 +55,10 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tidwall/sjson v1.2.5 github.com/zeebo/xxh3 v1.0.2 - golang.org/x/mod v0.30.0 - golang.org/x/net v0.47.0 - golang.org/x/sync v0.18.0 - golang.org/x/text v0.31.0 + golang.org/x/mod v0.31.0 + golang.org/x/net v0.48.0 + golang.org/x/sync v0.19.0 + golang.org/x/text v0.32.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 mvdan.cc/sh/moreinterp v0.0.0-20250902163504-3cf4fd5717a5 mvdan.cc/sh/v3 v3.12.1-0.20250902163504-3cf4fd5717a5 @@ -163,12 +163,12 @@ require ( go.opentelemetry.io/otel/trace v1.37.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect - golang.org/x/crypto v0.45.0 // indirect + golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/image v0.27.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/api v0.239.0 // indirect google.golang.org/genai v1.37.0 // indirect diff --git a/go.sum b/go.sum index 7b3d8cfd5fc76a63ed0c105e9a5dba9972031135..d2db7bcc46134a6da1fabf7009a575bcab81d4a8 100644 --- a/go.sum +++ b/go.sum @@ -370,8 +370,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= @@ -381,8 +381,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -394,8 +394,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -405,8 +405,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -423,8 +423,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -436,8 +436,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -447,8 +447,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -457,8 +457,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo= google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50= diff --git a/internal/agent/tools/download.go b/internal/agent/tools/download.go index 23bebcd602a82d8f8db1d0c99d5e7d5c53c48767..353b312a29d410c6485f76fc8dd42a4b9dcdefb1 100644 --- a/internal/agent/tools/download.go +++ b/internal/agent/tools/download.go @@ -114,12 +114,6 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client * return fantasy.NewTextErrorResponse(fmt.Sprintf("Request failed with status code: %d", resp.StatusCode)), nil } - // Check content length if available - maxSize := int64(100 * 1024 * 1024) // 100MB - if resp.ContentLength > maxSize { - return fantasy.NewTextErrorResponse(fmt.Sprintf("File too large: %d bytes (max %d bytes)", resp.ContentLength, maxSize)), nil - } - // Create parent directories if they don't exist if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err) @@ -132,20 +126,14 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client * } defer outFile.Close() - // Copy data with size limit - limitedReader := io.LimitReader(resp.Body, maxSize) - bytesWritten, err := io.Copy(outFile, limitedReader) + // Copy data without an explicit size limit. + // The overall download is still constrained by the HTTP client's timeout + // and any upstream server limits. + bytesWritten, err := io.Copy(outFile, resp.Body) if err != nil { return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err) } - // Check if we hit the size limit - if bytesWritten == maxSize { - // Clean up the file since it might be incomplete - os.Remove(filePath) - return fantasy.NewTextErrorResponse(fmt.Sprintf("File too large: exceeded %d bytes limit", maxSize)), nil - } - contentType := resp.Header.Get("Content-Type") responseMsg := fmt.Sprintf("Successfully downloaded %d bytes to %s", bytesWritten, relPath) if contentType != "" { diff --git a/internal/agent/tools/search.go b/internal/agent/tools/search.go index 6c5aeb4a1ee11d5033d6f3c0554ece2ea9f542b3..64c3219f169b1c8ce8284b86203e84bfb19d0e59 100644 --- a/internal/agent/tools/search.go +++ b/internal/agent/tools/search.go @@ -38,6 +38,10 @@ func searchDuckDuckGo(ctx context.Context, client *http.Client, query string, ma req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", BrowserUserAgent) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + req.Header.Set("Accept-Language", "en-US,en;q=0.5") + req.Header.Set("Accept-Encoding", "gzip, deflate") + req.Header.Set("Referer", "https://duckduckgo.com/") resp, err := client.Do(req) if err != nil { @@ -45,8 +49,10 @@ func searchDuckDuckGo(ctx context.Context, client *http.Client, query string, ma } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("search failed with status code: %d", resp.StatusCode) + // Accept both 200 (OK) and 202 (Accepted). + // DuckDuckGo may still return 202 for rate limiting or bot detection. + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + return nil, fmt.Errorf("search failed with status code: %d (DuckDuckGo may be rate limiting requests)", resp.StatusCode) } body, err := io.ReadAll(resp.Body) diff --git a/internal/cmd/projects.go b/internal/cmd/projects.go new file mode 100644 index 0000000000000000000000000000000000000000..15c747834129b06829fc46832e3e1a09538de3d5 --- /dev/null +++ b/internal/cmd/projects.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "encoding/json" + "os" + + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" + "github.com/charmbracelet/crush/internal/projects" + "github.com/charmbracelet/x/term" + "github.com/spf13/cobra" +) + +var projectsCmd = &cobra.Command{ + Use: "projects", + Short: "List all tracked project directories", + Long: `List all directories where Crush has been used. +This includes the working directory, data directory path, and last accessed time.`, + Example: ` +# List all projects in a table +crush projects + +# Output as JSON +crush projects --json + `, + RunE: func(cmd *cobra.Command, args []string) error { + jsonOutput, _ := cmd.Flags().GetBool("json") + + projectList, err := projects.List() + if err != nil { + return err + } + + if jsonOutput { + output := struct { + Projects []projects.Project `json:"projects"` + }{Projects: projectList} + + data, err := json.Marshal(output) + if err != nil { + return err + } + cmd.Println(string(data)) + return nil + } + + if len(projectList) == 0 { + cmd.Println("No projects tracked yet.") + return nil + } + + if term.IsTerminal(os.Stdout.Fd()) { + // We're in a TTY: make it fancy. + t := table.New(). + Border(lipgloss.RoundedBorder()). + StyleFunc(func(row, col int) lipgloss.Style { + return lipgloss.NewStyle().Padding(0, 2) + }). + Headers("Path", "Data Dir", "Last Accessed") + + for _, p := range projectList { + t.Row(p.Path, p.DataDir, p.LastAccessed.Local().Format("2006-01-02 15:04")) + } + lipgloss.Println(t) + return nil + } + + // Not a TTY: plain output + for _, p := range projectList { + cmd.Printf("%s\t%s\t%s\n", p.Path, p.DataDir, p.LastAccessed.Format("2006-01-02T15:04:05Z07:00")) + } + return nil + }, +} + +func init() { + projectsCmd.Flags().Bool("json", false, "Output as JSON") +} diff --git a/internal/cmd/projects_test.go b/internal/cmd/projects_test.go new file mode 100644 index 0000000000000000000000000000000000000000..50585ea23582d5e40a75d1fab7e601161e7f3327 --- /dev/null +++ b/internal/cmd/projects_test.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/charmbracelet/crush/internal/projects" + "github.com/stretchr/testify/require" +) + +func TestProjectsEmpty(t *testing.T) { + // Use a temp directory for projects.json + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + var b bytes.Buffer + projectsCmd.SetOut(&b) + projectsCmd.SetErr(&b) + projectsCmd.SetIn(bytes.NewReader(nil)) + err := projectsCmd.RunE(projectsCmd, nil) + require.NoError(t, err) + require.Equal(t, "No projects tracked yet.\n", b.String()) +} + +func TestProjectsJSON(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + // Register a project + err := projects.Register("/test/project", "/test/project/.crush") + require.NoError(t, err) + + var b bytes.Buffer + projectsCmd.SetOut(&b) + projectsCmd.SetErr(&b) + projectsCmd.SetIn(bytes.NewReader(nil)) + + // Set the --json flag + projectsCmd.Flags().Set("json", "true") + defer projectsCmd.Flags().Set("json", "false") + + err = projectsCmd.RunE(projectsCmd, nil) + require.NoError(t, err) + + // Parse the JSON output + var result struct { + Projects []projects.Project `json:"projects"` + } + err = json.Unmarshal(b.Bytes(), &result) + require.NoError(t, err) + + require.Len(t, result.Projects, 1) + require.Equal(t, "/test/project", result.Projects[0].Path) + require.Equal(t, "/test/project/.crush", result.Projects[0].DataDir) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index a5c4545794f34fbc0de233566ba150bb6e856f96..bf88abb9eb5354a33c647cf3fe43b412148ec84f 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -19,6 +19,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/db" "github.com/charmbracelet/crush/internal/event" + "github.com/charmbracelet/crush/internal/projects" "github.com/charmbracelet/crush/internal/stringext" "github.com/charmbracelet/crush/internal/tui" "github.com/charmbracelet/crush/internal/ui/common" @@ -42,6 +43,7 @@ func init() { rootCmd.AddCommand( runCmd, dirsCmd, + projectsCmd, updateProvidersCmd, logsCmd, schemaCmd, @@ -201,6 +203,12 @@ func setupApp(cmd *cobra.Command) (*app.App, error) { return nil, err } + // Register this project in the centralized projects list. + if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil { + slog.Warn("Failed to register project", "error", err) + // Non-fatal: continue even if registration fails + } + // Connect to DB; this will also run migrations. conn, err := db.Connect(ctx, cfg.Options.DataDirectory) if err != nil { diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 76e74a686f5d68be2090ba2ea15bd3049e173b25..fe214ecb570ac6c8c89fef46fd653661d540b4ba 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -8,6 +8,7 @@ import ( "os/signal" "strings" + "github.com/charmbracelet/crush/internal/event" "github.com/spf13/cobra" ) @@ -58,8 +59,14 @@ crush run --quiet "Generate a README for this project" return fmt.Errorf("no prompt provided") } + event.SetInteractive(true) + event.AppInitialized() + return app.RunNonInteractive(ctx, os.Stdout, prompt, quiet) }, + PostRun: func(cmd *cobra.Command, args []string) { + event.AppExited() + }, } func init() { diff --git a/internal/event/event.go b/internal/event/event.go index ca02c6d89d67be1756b166aea152da165b2712c9..462e3f3ba53a0fc10d77822cf404a12d66b0bdec 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -26,9 +26,14 @@ var ( Set("TERM", os.Getenv("TERM")). Set("SHELL", filepath.Base(os.Getenv("SHELL"))). Set("Version", version.Version). - Set("GoVersion", runtime.Version()) + Set("GoVersion", runtime.Version()). + Set("Interactive", false) ) +func SetInteractive(interactive bool) { + baseProps = baseProps.Set("interactive", interactive) +} + func Init() { c, err := posthog.NewWithConfig(key, posthog.Config{ Endpoint: endpoint, diff --git a/internal/projects/projects.go b/internal/projects/projects.go new file mode 100644 index 0000000000000000000000000000000000000000..f909fc2cf6e3e13030746cc8804ebc4af2514ba5 --- /dev/null +++ b/internal/projects/projects.go @@ -0,0 +1,126 @@ +package projects + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "sync" + "time" + + "github.com/charmbracelet/crush/internal/config" +) + +const projectsFileName = "projects.json" + +// Project represents a tracked project directory. +type Project struct { + Path string `json:"path"` + DataDir string `json:"data_dir"` + LastAccessed time.Time `json:"last_accessed"` +} + +// ProjectList holds the list of tracked projects. +type ProjectList struct { + Projects []Project `json:"projects"` +} + +var mu sync.Mutex + +// projectsFilePath returns the path to the projects.json file. +func projectsFilePath() string { + return filepath.Join(filepath.Dir(config.GlobalConfigData()), projectsFileName) +} + +// Load reads the projects list from disk. +func Load() (*ProjectList, error) { + mu.Lock() + defer mu.Unlock() + + path := projectsFilePath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &ProjectList{Projects: []Project{}}, nil + } + return nil, err + } + + var list ProjectList + if err := json.Unmarshal(data, &list); err != nil { + return nil, err + } + + return &list, nil +} + +// Save writes the projects list to disk. +func Save(list *ProjectList) error { + mu.Lock() + defer mu.Unlock() + + path := projectsFilePath() + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + + data, err := json.MarshalIndent(list, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0o600) +} + +// Register adds or updates a project in the list. +func Register(workingDir, dataDir string) error { + list, err := Load() + if err != nil { + return err + } + + now := time.Now().UTC() + + // Check if project already exists + found := false + for i, p := range list.Projects { + if p.Path == workingDir { + list.Projects[i].DataDir = dataDir + list.Projects[i].LastAccessed = now + found = true + break + } + } + + if !found { + list.Projects = append(list.Projects, Project{ + Path: workingDir, + DataDir: dataDir, + LastAccessed: now, + }) + } + + // Sort by last accessed (most recent first) + slices.SortFunc(list.Projects, func(a, b Project) int { + if a.LastAccessed.After(b.LastAccessed) { + return -1 + } + if a.LastAccessed.Before(b.LastAccessed) { + return 1 + } + return 0 + }) + + return Save(list) +} + +// List returns all tracked projects sorted by last accessed. +func List() ([]Project, error) { + list, err := Load() + if err != nil { + return nil, err + } + return list.Projects, nil +} diff --git a/internal/projects/projects_test.go b/internal/projects/projects_test.go new file mode 100644 index 0000000000000000000000000000000000000000..2919410a4f57706d2e42e8cf760cfa8c7df43882 --- /dev/null +++ b/internal/projects/projects_test.go @@ -0,0 +1,180 @@ +package projects + +import ( + "path/filepath" + "testing" + "time" +) + +func TestRegisterAndList(t *testing.T) { + // Create a temporary directory for the test + tmpDir := t.TempDir() + + // Override the projects file path for testing + t.Setenv("XDG_DATA_HOME", tmpDir) + + // Test registering a project + err := Register("/home/user/project1", "/home/user/project1/.crush") + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + // List projects + projects, err := List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(projects) != 1 { + t.Fatalf("Expected 1 project, got %d", len(projects)) + } + + if projects[0].Path != "/home/user/project1" { + t.Errorf("Expected path /home/user/project1, got %s", projects[0].Path) + } + + if projects[0].DataDir != "/home/user/project1/.crush" { + t.Errorf("Expected data_dir /home/user/project1/.crush, got %s", projects[0].DataDir) + } + + // Register another project + err = Register("/home/user/project2", "/home/user/project2/.crush") + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + projects, err = List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(projects) != 2 { + t.Fatalf("Expected 2 projects, got %d", len(projects)) + } + + // Most recent should be first + if projects[0].Path != "/home/user/project2" { + t.Errorf("Expected most recent project first, got %s", projects[0].Path) + } +} + +func TestRegisterUpdatesExisting(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + // Register a project + err := Register("/home/user/project1", "/home/user/project1/.crush") + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + projects, _ := List() + firstAccess := projects[0].LastAccessed + + // Wait a bit and re-register + time.Sleep(10 * time.Millisecond) + + err = Register("/home/user/project1", "/home/user/project1/.crush-new") + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + projects, _ = List() + + if len(projects) != 1 { + t.Fatalf("Expected 1 project after update, got %d", len(projects)) + } + + if projects[0].DataDir != "/home/user/project1/.crush-new" { + t.Errorf("Expected updated data_dir, got %s", projects[0].DataDir) + } + + if !projects[0].LastAccessed.After(firstAccess) { + t.Error("Expected LastAccessed to be updated") + } +} + +func TestLoadEmptyFile(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + // List before any projects exist + projects, err := List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(projects) != 0 { + t.Errorf("Expected 0 projects, got %d", len(projects)) + } +} + +func TestProjectsFilePath(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + expected := filepath.Join(tmpDir, "crush", "projects.json") + actual := projectsFilePath() + + if actual != expected { + t.Errorf("Expected %s, got %s", expected, actual) + } +} + +func TestRegisterWithParentDataDir(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + // Register a project where .crush is in a parent directory. + // e.g., working in /home/user/monorepo/packages/app but .crush is at /home/user/monorepo/.crush + err := Register("/home/user/monorepo/packages/app", "/home/user/monorepo/.crush") + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + projects, err := List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(projects) != 1 { + t.Fatalf("Expected 1 project, got %d", len(projects)) + } + + if projects[0].Path != "/home/user/monorepo/packages/app" { + t.Errorf("Expected path /home/user/monorepo/packages/app, got %s", projects[0].Path) + } + + if projects[0].DataDir != "/home/user/monorepo/.crush" { + t.Errorf("Expected data_dir /home/user/monorepo/.crush, got %s", projects[0].DataDir) + } +} + +func TestRegisterWithExternalDataDir(t *testing.T) { + tmpDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tmpDir) + + // Register a project where .crush is in a completely different location. + // e.g., project at /home/user/project but data stored at /var/data/crush/myproject + err := Register("/home/user/project", "/var/data/crush/myproject") + if err != nil { + t.Fatalf("Register failed: %v", err) + } + + projects, err := List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + + if len(projects) != 1 { + t.Fatalf("Expected 1 project, got %d", len(projects)) + } + + if projects[0].Path != "/home/user/project" { + t.Errorf("Expected path /home/user/project, got %s", projects[0].Path) + } + + if projects[0].DataDir != "/var/data/crush/myproject" { + t.Errorf("Expected data_dir /var/data/crush/myproject, got %s", projects[0].DataDir) + } +} diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index dc94b9773bcdc1432476fa09c129cfa0f0a48a1c..38012c235df4d455c1b826f6a5ff491783ea1f5e 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -311,7 +311,7 @@ func (m *messageCmp) renderThinkingContent() string { } } lineStyle := t.S().Subtle.Background(t.BgBaseLighter) - result := lineStyle.Width(m.textWidth()).Padding(0, 1).Render(m.thinkingViewport.View()) + result := lineStyle.Width(m.textWidth()).Padding(0, 1, 0, 0).Render(m.thinkingViewport.View()) if footer != "" { result += "\n\n" + footer } diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go index a4c852cd6fb85b2e8fb3e83e607c5b9a87652dd3..690d29e6c380e46777b57982913132a24c56448f 100644 --- a/internal/tui/components/dialogs/commands/arguments.go +++ b/internal/tui/components/dialogs/commands/arguments.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/dialogs" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/crush/internal/uicmd" ) const ( @@ -18,20 +19,10 @@ const ( ) // ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog. -type ShowArgumentsDialogMsg struct { - CommandID string - Description string - ArgNames []string - OnSubmit func(args map[string]string) tea.Cmd -} +type ShowArgumentsDialogMsg = uicmd.ShowArgumentsDialogMsg // CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed. -type CloseArgumentsDialogMsg struct { - Submit bool - CommandID string - Content string - Args map[string]string -} +type CloseArgumentsDialogMsg = uicmd.CloseArgumentsDialogMsg // CommandArgumentsDialog represents the commands dialog. type CommandArgumentsDialog interface { diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index 1e6bfd9fc0791ba45b8c76edc3ca745e0fa53528..4b23c1b4862dbc8484d9759443b56842886ca4d9 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -23,6 +23,7 @@ import ( "github.com/charmbracelet/crush/internal/tui/exp/list" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/crush/internal/uicmd" ) const ( @@ -44,13 +45,11 @@ const ( type listModel = list.FilterableList[list.CompletionItem[Command]] // Command represents a command that can be executed -type Command struct { - ID string - Title string - Description string - Shortcut string // Optional shortcut for the command - Handler func(cmd Command) tea.Cmd -} +type ( + Command = uicmd.Command + CommandRunCustomMsg = uicmd.CommandRunCustomMsg + ShowMCPPromptArgumentsDialogMsg = uicmd.ShowMCPPromptArgumentsDialogMsg +) // CommandsDialog represents the commands dialog. type CommandsDialog interface { @@ -121,12 +120,12 @@ func NewCommandDialog(sessionID string) CommandsDialog { } func (c *commandDialogCmp) Init() tea.Cmd { - commands, err := LoadCustomCommands() + commands, err := uicmd.LoadCustomCommands() if err != nil { return util.ReportError(err) } c.userCommands = commands - c.mcpPrompts.SetSlice(loadMCPPrompts()) + c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts()) return c.setCommandType(c.selected) } @@ -142,7 +141,7 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) { case pubsub.Event[mcp.Event]: // Reload MCP prompts when MCP state changes if msg.Type == pubsub.UpdatedEvent { - c.mcpPrompts.SetSlice(loadMCPPrompts()) + c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts()) // If we're currently viewing MCP prompts, refresh the list if c.selected == MCPPrompts { return c, c.setCommandType(MCPPrompts) diff --git a/internal/tui/components/dialogs/commands/loader.go b/internal/uicmd/uicmd.go similarity index 85% rename from internal/tui/components/dialogs/commands/loader.go rename to internal/uicmd/uicmd.go index 95875a6810febde86d011e681e8d89cf5483f934..870116a7f47af8358e492feeec51ed6a2823061e 100644 --- a/internal/tui/components/dialogs/commands/loader.go +++ b/internal/uicmd/uicmd.go @@ -1,4 +1,7 @@ -package commands +// Package uicmd provides functionality to load and handle custom commands +// from markdown files and MCP prompts. +// TODO: Move this into internal/ui after refactoring. +package uicmd import ( "cmp" @@ -18,6 +21,31 @@ import ( "github.com/charmbracelet/crush/internal/tui/util" ) +// Command represents a command that can be executed +type Command struct { + ID string + Title string + Description string + Shortcut string // Optional shortcut for the command + Handler func(cmd Command) tea.Cmd +} + +// ShowArgumentsDialogMsg is a message that is sent to show the arguments dialog. +type ShowArgumentsDialogMsg struct { + CommandID string + Description string + ArgNames []string + OnSubmit func(args map[string]string) tea.Cmd +} + +// CloseArgumentsDialogMsg is a message that is sent when the arguments dialog is closed. +type CloseArgumentsDialogMsg struct { + Submit bool + CommandID string + Content string + Args map[string]string +} + const ( userCommandPrefix = "user:" projectCommandPrefix = "project:" @@ -35,7 +63,10 @@ type commandSource struct { } func LoadCustomCommands() ([]Command, error) { - cfg := config.Get() + return LoadCustomCommandsFromConfig(config.Get()) +} + +func LoadCustomCommandsFromConfig(cfg *config.Config) ([]Command, error) { if cfg == nil { return nil, fmt.Errorf("config not loaded") } @@ -221,7 +252,7 @@ type CommandRunCustomMsg struct { Content string } -func loadMCPPrompts() []Command { +func LoadMCPPrompts() []Command { var commands []Command for mcpName, prompts := range mcp.Prompts() { for _, prompt := range prompts {