Skip to content
Open
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
215 changes: 179 additions & 36 deletions cmd/engram/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
versioncheck "github.com/Gentleman-Programming/engram/internal/version"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/term"
mcpserver "github.com/mark3labs/mcp-go/server"
)

Expand Down Expand Up @@ -589,28 +590,25 @@ func cmdSync(cfg store.Config) {
doStatus := false
doAll := false
project := ""
projectsFlag := ""
for i := 2; i < len(os.Args); i++ {
switch os.Args[i] {
case "--import":
switch {
case os.Args[i] == "--import":
doImport = true
case "--status":
case os.Args[i] == "--status":
doStatus = true
case "--all":
case os.Args[i] == "--all":
doAll = true
case "--project":
if i+1 < len(os.Args) {
project = os.Args[i+1]
i++
}
}
}

// Default project to current directory name (so sync only exports
// memories for THIS project, not everything in the global DB).
// --all skips project filtering entirely — exports everything.
if !doAll && project == "" {
if cwd, err := os.Getwd(); err == nil {
project = filepath.Base(cwd)
case os.Args[i] == "--project" && i+1 < len(os.Args):
project = os.Args[i+1]
i++
case strings.HasPrefix(os.Args[i], "--projects="):
// Task 1.1: --projects=val (equals syntax)
projectsFlag = strings.TrimPrefix(os.Args[i], "--projects=")
case os.Args[i] == "--projects" && i+1 < len(os.Args):
// Task 1.1: --projects val (space syntax)
projectsFlag = os.Args[i+1]
i++
}
}

Expand Down Expand Up @@ -660,34 +658,179 @@ func cmdSync(cfg store.Config) {
return
}

// Task 1.3: TTY detection
isTTY := term.IsTerminal(os.Stdin.Fd())

// Task 1.2 + 1.3: Resolve projects via precedence chain
selected, err := resolveProjects(s, doAll, projectsFlag, project, isTTY)
if err != nil {
fatal(err)
}

// Task 1.3: empty slice = cancelled
if selected != nil && len(selected) == 0 {
fmt.Println("Sync cancelled.")
return
}

// Export: DB → new chunk
username := engramsync.GetUsername()
if doAll {

// Task 1.4: Export loop
// nil = export all (--all path, preserve existing behavior)
if selected == nil {
fmt.Println("Exporting ALL memories (all projects)...")
result, err := syncExport(sy, username, "")
if err != nil {
fatal(err)
}

if result.IsEmpty {
fmt.Println("Nothing new to sync — all memories already exported.")
return
}

fmt.Printf("Created chunk %s\n", result.ChunkID)
fmt.Printf(" Sessions: %d\n", result.SessionsExported)
fmt.Printf(" Observations: %d\n", result.ObservationsExported)
fmt.Printf(" Prompts: %d\n", result.PromptsExported)
fmt.Println()
fmt.Println("Add to git:")
fmt.Printf(" git add .engram/ && git commit -m \"sync engram memories\"\n")
} else if len(selected) == 1 {
// Single project: preserve existing --project output format for backward compat
proj := selected[0]
fmt.Printf("Exporting memories for project %q...\n", proj)
result, err := syncExport(sy, username, proj)
if err != nil {
fatal(err)
}

if result.IsEmpty {
fmt.Printf("Nothing new to sync for project %q — all memories already exported.\n", proj)
return
}

fmt.Printf("Created chunk %s\n", result.ChunkID)
fmt.Printf(" Sessions: %d\n", result.SessionsExported)
fmt.Printf(" Observations: %d\n", result.ObservationsExported)
fmt.Printf(" Prompts: %d\n", result.PromptsExported)
fmt.Println()
fmt.Println("Add to git:")
fmt.Printf(" git add .engram/ && git commit -m \"sync engram memories\"\n")
} else {
fmt.Printf("Exporting memories for project %q...\n", project)
// Multi-project loop: iterate selected projects, accumulate results
totalSessions, totalObs, totalPrompts, exported := 0, 0, 0, 0
for _, proj := range selected {
result, err := syncExport(sy, username, proj)
if err != nil {
fatal(err)
}
if result.IsEmpty {
fmt.Printf(" %s: nothing new to sync\n", proj)
continue
}
fmt.Printf(" %s: chunk %s (%d obs)\n", proj, result.ChunkID, result.ObservationsExported)
totalSessions += result.SessionsExported
totalObs += result.ObservationsExported
totalPrompts += result.PromptsExported
exported++
}
if exported == 0 {
fmt.Println("Nothing new to sync — all projects already up to date.")
return
}
fmt.Printf("\nExported %d project(s): %d sessions, %d observations, %d prompts\n", exported, totalSessions, totalObs, totalPrompts)
fmt.Println("\nAdd to git:")
fmt.Printf(" git add .engram/ && git commit -m \"sync engram memories\"\n")
}
result, err := syncExport(sy, username, project)
if err != nil {
fatal(err)
}

// resolveProjects determines which projects to export based on flag precedence.
//
// Precedence (highest to lowest):
// 1. doAll → return nil (nil = export all, no filter)
// 2. projectsFlag non-empty → parse comma-separated, trim, deduplicate
// 3. projectFlag non-empty → return []string{projectFlag}
// 4. isTTY → return nil for now (stub: TUI will be wired in Phase 5)
// 5. CWD default → filepath.Base(os.Getwd()) → return []string{cwd}
//
// Return semantics:
// - nil = export all (--all path)
// - []string{...} = specific projects to export
// - []string{} = cancelled (caller should print "Sync cancelled." and return)
func resolveProjects(s *store.Store, doAll bool, projectsFlag, projectFlag string, isTTY bool) ([]string, error) {
// 1. --all flag → export everything
if doAll {
if projectsFlag != "" {
fmt.Fprintln(os.Stderr, "warning: --all overrides --projects")
}
return nil, nil
}

// 2. --projects flag → parse comma-separated list, trim whitespace, deduplicate
// Falls through to CWD default if all values were blank (e.g. --projects=" ")
if projectsFlag != "" {
parts := strings.Split(projectsFlag, ",")
seen := map[string]bool{}
var result []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" && !seen[p] {
seen[p] = true
result = append(result, p)
}
}
if len(result) > 0 {
return result, nil
}
// All values were blank — fall through to lower precedence
}

if result.IsEmpty {
if doAll {
fmt.Println("Nothing new to sync — all memories already exported.")
} else {
fmt.Printf("Nothing new to sync for project %q — all memories already exported.\n", project)
// 3. --project flag (singular) → single project
if projectFlag != "" {
return []string{projectFlag}, nil
}

// 4. TTY → prompt user; if y, launch TUI project selector
if isTTY {
fmt.Print("Show projects pending sync? [y/n]: ")
var answer string
fmt.Scanln(&answer)
answer = strings.ToLower(strings.TrimSpace(answer))
if answer == "y" || answer == "yes" {
stats, err := storeStats(s)
if err != nil {
return nil, fmt.Errorf("get projects: %w", err)
}
if len(stats.Projects) == 0 {
fmt.Println("No projects found in memory.")
return []string{}, nil // empty = nothing to sync → "Sync cancelled."
}
resultCh := make(chan []string, 1)
m := tui.NewProjectSelector(s, stats.Projects, resultCh)
p := newTeaProgram(m)
if _, err := runTeaProgram(p); err != nil {
return nil, fmt.Errorf("project selector: %w", err)
}
selected := <-resultCh
// nil means cancelled (q/esc or enter with zero items checked) →
// fall through to CWD default below
if selected != nil {
return selected, nil
}
// fall through to CWD default
}
return
// User answered "n" or cancelled → fall through to CWD default
}

fmt.Printf("Created chunk %s\n", result.ChunkID)
fmt.Printf(" Sessions: %d\n", result.SessionsExported)
fmt.Printf(" Observations: %d\n", result.ObservationsExported)
fmt.Printf(" Prompts: %d\n", result.PromptsExported)
fmt.Println()
fmt.Println("Add to git:")
fmt.Printf(" git add .engram/ && git commit -m \"sync engram memories\"\n")
// 5. CWD default → use current directory name as project filter
if cwd, err := os.Getwd(); err == nil {
return []string{filepath.Base(cwd)}, nil
}

// If we can't determine CWD, export all as a safe fallback
return nil, nil
}

func cmdSetup() {
Expand Down
99 changes: 99 additions & 0 deletions cmd/engram/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -579,3 +579,102 @@ func TestCmdSearchLocalMode(t *testing.T) {
t.Fatalf("expected local search results, got: %q", stdout)
}
}

// TestResolveProjects covers the full precedence chain of resolveProjects().
// The isTTY=true path (interactive TUI) is not unit-tested here because it
// requires a real terminal and interactive input — that path is covered by
// manual/integration testing only.
func TestResolveProjects(t *testing.T) {
cfg := testConfig(t)
s, err := store.New(cfg)
if err != nil {
t.Fatalf("store.New: %v", err)
}
t.Cleanup(func() { _ = s.Close() })

tests := []struct {
name string
doAll bool
projectsFlag string
projectFlag string
isTTY bool
cwdName string // if non-empty, chdir to a temp dir with this base name
wantNil bool // true = expect nil return
wantProjects []string
}{
{
name: "doAll=true returns nil regardless of flags",
doAll: true,
wantNil: true,
},
{
name: "doAll=true wins over --projects flag",
doAll: true,
projectsFlag: "engram,dots",
wantNil: true,
},
{
name: "projectsFlag comma-separated returns trimmed deduplicated list",
projectsFlag: "engram,dots",
wantProjects: []string{"engram", "dots"},
},
{
name: "projectsFlag with extra whitespace is trimmed",
projectsFlag: " engram , dots ",
wantProjects: []string{"engram", "dots"},
},
{
name: "projectsFlag with duplicates deduplicates",
projectsFlag: "engram,engram,dots",
wantProjects: []string{"engram", "dots"},
},
{
name: "projectFlag (singular) returns single-item slice",
projectFlag: "myproject",
wantProjects: []string{"myproject"},
},
{
name: "non-TTY with no flags returns CWD-based default",
isTTY: false,
cwdName: "my-repo",
wantProjects: []string{"my-repo"},
},
{
name: "projectsFlag takes precedence over projectFlag",
projectsFlag: "alpha,beta",
projectFlag: "gamma",
wantProjects: []string{"alpha", "beta"},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.cwdName != "" {
dir := filepath.Join(t.TempDir(), tc.cwdName)
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
withCwd(t, dir)
}

got, err := resolveProjects(s, tc.doAll, tc.projectsFlag, tc.projectFlag, tc.isTTY)
if err != nil {
t.Fatalf("resolveProjects returned error: %v", err)
}
if tc.wantNil {
if got != nil {
t.Fatalf("expected nil, got %v", got)
}
return
}
if len(got) != len(tc.wantProjects) {
t.Fatalf("got %v, want %v", got, tc.wantProjects)
}
for i, want := range tc.wantProjects {
if got[i] != want {
t.Fatalf("got[%d] = %q, want %q", i, got[i], want)
}
}
})
}
}
23 changes: 23 additions & 0 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const (
ScreenSessions
ScreenSessionDetail
ScreenSetup
ScreenProjectSelector
)

// ─── Custom Messages ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -136,6 +137,13 @@ type Model struct {
SetupAllowlistApplied bool // true = allowlist was added successfully
SetupAllowlistError string // error message if allowlist injection failed
SetupSpinner spinner.Model

// Project selector screen
ProjectSelectorItems []string
ProjectSelectorChecked []bool
ProjectSelectorCursor int
ProjectSelectorCounts []int // pending count per project (0 = unknown/not populated)
projectSelectorResultCh chan<- []string // unexported, write-only channel
}

// New creates a new TUI model connected to the given store.
Expand All @@ -158,6 +166,21 @@ func New(s *store.Store, version string) Model {
}
}

// NewProjectSelector creates a TUI model pre-configured for the project
// selection screen. items is the list of project names to display.
// resultCh receives the selected project names when the user confirms,
// or nil if the user cancels (q/esc/enter with nothing checked).
func NewProjectSelector(s *store.Store, items []string, resultCh chan<- []string) Model {
m := New(s, "")
m.Screen = ScreenProjectSelector
m.ProjectSelectorItems = items
m.ProjectSelectorChecked = make([]bool, len(items))
m.ProjectSelectorCounts = make([]int, len(items)) // zeros — unknown count; extension point for future population
m.ProjectSelectorCursor = 0
m.projectSelectorResultCh = resultCh
return m
}

// Init loads initial data (stats for the dashboard).
func (m Model) Init() tea.Cmd {
return tea.Batch(
Expand Down
Loading