Detailed changes
@@ -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/version"
@@ -40,6 +41,7 @@ func init() {
rootCmd.AddCommand(
runCmd,
dirsCmd,
+ projectsCmd,
updateProvidersCmd,
logsCmd,
schemaCmd,
@@ -199,6 +201,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 {
@@ -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)
+ }
+}