Detailed changes
@@ -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
@@ -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=
@@ -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 != "" {
@@ -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)
@@ -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")
+}
@@ -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)
+}
@@ -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 {
@@ -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() {
@@ -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,
@@ -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
+}
@@ -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)
+ }
+}
@@ -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
}
@@ -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 {
@@ -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)
internal/tui/components/dialogs/commands/loader.go → 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 {