Skip to content

Commit 3687c2f

Browse files
fix: harden CLI and plugin edge cases
1 parent c4004e1 commit 3687c2f

7 files changed

Lines changed: 586 additions & 51 deletions

File tree

cmd/engram/cloud.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,11 @@ func cmdCloud(cfg store.Config) {
133133
fmt.Fprintln(os.Stderr, "supported subcommands: status, enroll, config, serve, upgrade")
134134
exitFunc(1)
135135
}
136+
if os.Args[2] == "--help" || os.Args[2] == "-h" || os.Args[2] == "help" {
137+
fmt.Println("usage: engram cloud <subcommand> [options]")
138+
fmt.Println("supported subcommands: status, enroll, config, serve, upgrade")
139+
return
140+
}
136141

137142
switch os.Args[2] {
138143
case "status":
@@ -559,6 +564,14 @@ func cmdCloudStatus(cfg store.Config) {
559564
}
560565

561566
func cmdCloudEnroll(cfg store.Config) {
567+
if len(os.Args) >= 4 {
568+
arg := strings.TrimSpace(os.Args[3])
569+
if arg == "--help" || arg == "-h" || arg == "help" {
570+
fmt.Println("usage: engram cloud enroll <project>")
571+
fmt.Println("Enroll a local-first project for explicit cloud replication.")
572+
return
573+
}
574+
}
562575
if len(os.Args) < 4 || strings.TrimSpace(os.Args[3]) == "" {
563576
fmt.Fprintln(os.Stderr, "usage: engram cloud enroll <project>")
564577
exitFunc(1)

cmd/engram/main.go

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -530,10 +530,11 @@ func main() {
530530
exitFunc(1)
531531
}
532532

533-
// Check for updates on every invocation.
534-
if result := checkForUpdates(version); result.Status != versioncheck.StatusUpToDate && result.Message != "" {
535-
fmt.Fprintln(os.Stderr, result.Message)
536-
fmt.Fprintln(os.Stderr)
533+
if shouldCheckForUpdates(os.Args[1:]) {
534+
printUpdateCheckResult(checkForUpdates(version))
535+
}
536+
if handleConfigFreeCommand(os.Args[1:]) {
537+
return
537538
}
538539

539540
cfg, cfgErr := store.DefaultConfig()
@@ -600,6 +601,50 @@ func main() {
600601
}
601602
}
602603

604+
func shouldCheckForUpdates(args []string) bool {
605+
if len(args) == 0 {
606+
return false
607+
}
608+
command := strings.ToLower(strings.TrimSpace(args[0]))
609+
switch command {
610+
case "mcp", "serve":
611+
return false
612+
case "cloud":
613+
return len(args) < 2 || strings.ToLower(strings.TrimSpace(args[1])) != "serve"
614+
}
615+
return true
616+
}
617+
618+
func handleConfigFreeCommand(args []string) bool {
619+
if len(args) == 0 {
620+
return false
621+
}
622+
switch strings.ToLower(strings.TrimSpace(args[0])) {
623+
case "version", "--version", "-v":
624+
fmt.Printf("engram %s\n", version)
625+
return true
626+
case "help", "--help", "-h":
627+
printUsage()
628+
return true
629+
case "cloud":
630+
if len(args) >= 2 {
631+
subcommand := strings.ToLower(strings.TrimSpace(args[1]))
632+
if subcommand == "--help" || subcommand == "-h" || subcommand == "help" {
633+
cmdCloud(store.Config{})
634+
return true
635+
}
636+
}
637+
}
638+
return false
639+
}
640+
641+
func printUpdateCheckResult(result versioncheck.CheckResult) {
642+
if result.Status != versioncheck.StatusUpToDate && result.Message != "" {
643+
fmt.Fprintln(os.Stderr, result.Message)
644+
fmt.Fprintln(os.Stderr)
645+
}
646+
}
647+
603648
// ─── Commands ────────────────────────────────────────────────────────────────
604649

605650
func cmdServe(cfg store.Config) {
@@ -882,7 +927,13 @@ func cmdSave(cfg store.Config) {
882927
if project != "" {
883928
sessionID = "manual-save-" + project
884929
}
885-
s.CreateSession(sessionID, project, "")
930+
cwd, err := os.Getwd()
931+
if err != nil {
932+
fatal(err)
933+
}
934+
if err := s.CreateSession(sessionID, project, cwd); err != nil {
935+
fatal(err)
936+
}
886937
id, err := storeAddObservation(s, store.AddObservationParams{
887938
SessionID: sessionID,
888939
Type: typ,
@@ -1113,6 +1164,9 @@ func cmdSync(cfg store.Config) {
11131164
projectProvided := false
11141165
for i := 2; i < len(os.Args); i++ {
11151166
switch os.Args[i] {
1167+
case "--help", "-h", "help":
1168+
printSyncUsage()
1169+
return
11161170
case "--import":
11171171
doImport = true
11181172
case "--status":
@@ -1317,6 +1371,12 @@ func cmdSync(cfg store.Config) {
13171371
fmt.Printf(" git add .engram/ && git commit -m \"sync engram memories\"\n")
13181372
}
13191373

1374+
func printSyncUsage() {
1375+
fmt.Println("usage: engram sync [--import | --status] [--all] [--cloud --project PROJECT]")
1376+
fmt.Println("Local sync exports project-scoped chunks to .engram/ by default.")
1377+
fmt.Println("Cloud sync requires an explicit --project and never runs from --help.")
1378+
}
1379+
13201380
// storeAdapter wraps *store.Store to satisfy obsidian.StoreReader.
13211381
// The real store.Stats() returns (*store.Stats, error); the interface expects *store.Stats.
13221382
type storeAdapter struct{ s *store.Store }

cmd/engram/main_extra_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,114 @@ func TestCloudCommandIsolationDoesNotMutateLocalState(t *testing.T) {
563563
}
564564
}
565565

566+
func TestCmdSaveCreatesManualSessionWithCWDDirectory(t *testing.T) {
567+
stubExitWithPanic(t)
568+
stubRuntimeHooks(t)
569+
570+
cfg := testConfig(t)
571+
cwd := t.TempDir()
572+
oldWd, err := os.Getwd()
573+
if err != nil {
574+
t.Fatalf("get wd: %v", err)
575+
}
576+
if err := os.Chdir(cwd); err != nil {
577+
t.Fatalf("chdir: %v", err)
578+
}
579+
t.Cleanup(func() { _ = os.Chdir(oldWd) })
580+
581+
withArgs(t, "engram", "save", "Manual title", "Manual content", "--project", "manual-proj")
582+
_, stderr, recovered := captureOutputAndRecover(t, func() { cmdSave(cfg) })
583+
if recovered != nil || stderr != "" {
584+
t.Fatalf("cmdSave should succeed, panic=%v stderr=%q", recovered, stderr)
585+
}
586+
587+
s, err := store.New(cfg)
588+
if err != nil {
589+
t.Fatalf("open store: %v", err)
590+
}
591+
defer s.Close()
592+
session, err := s.GetSession("manual-save-manual-proj")
593+
if err != nil {
594+
t.Fatalf("get manual session: %v", err)
595+
}
596+
wantDir, err := filepath.EvalSymlinks(cwd)
597+
if err != nil {
598+
t.Fatalf("resolve cwd symlinks: %v", err)
599+
}
600+
gotDir, err := filepath.EvalSymlinks(session.Directory)
601+
if err != nil {
602+
t.Fatalf("resolve session directory symlinks: %v", err)
603+
}
604+
if gotDir != wantDir {
605+
t.Fatalf("manual session directory = %q, want %q", session.Directory, cwd)
606+
}
607+
}
608+
609+
func TestCloudEnrollAndSyncHelpDoNotMutateLocalState(t *testing.T) {
610+
stubExitWithPanic(t)
611+
stubRuntimeHooks(t)
612+
613+
for _, tc := range []struct {
614+
name string
615+
args []string
616+
run func(store.Config)
617+
}{
618+
{name: "cloud enroll help", args: []string{"engram", "cloud", "enroll", "--help"}, run: cmdCloud},
619+
{name: "sync help", args: []string{"engram", "sync", "--help"}, run: cmdSync},
620+
} {
621+
t.Run(tc.name, func(t *testing.T) {
622+
tmpHome := t.TempDir()
623+
cfg := testConfig(t)
624+
cfg.DataDir = filepath.Join(tmpHome, ".engram")
625+
626+
withArgs(t, tc.args...)
627+
stdout, stderr, recovered := captureOutputAndRecover(t, func() { tc.run(cfg) })
628+
if recovered != nil || stderr != "" {
629+
t.Fatalf("help should return cleanly, panic=%v stderr=%q stdout=%q", recovered, stderr, stdout)
630+
}
631+
if !strings.Contains(stdout, "usage:") {
632+
t.Fatalf("expected usage output, got %q", stdout)
633+
}
634+
if _, err := os.Stat(filepath.Join(cfg.DataDir, "engram.db")); err == nil {
635+
t.Fatalf("help should not create local database")
636+
}
637+
})
638+
}
639+
}
640+
641+
func TestUpdateChecksSkipCriticalStartupCommands(t *testing.T) {
642+
if shouldCheckForUpdates([]string{"mcp"}) {
643+
t.Fatal("mcp startup must not run update check")
644+
}
645+
if shouldCheckForUpdates([]string{"serve"}) {
646+
t.Fatal("serve startup must not run update check")
647+
}
648+
if shouldCheckForUpdates([]string{"cloud", "serve"}) {
649+
t.Fatal("cloud serve startup must not run update check")
650+
}
651+
if !shouldCheckForUpdates([]string{"version"}) {
652+
t.Fatal("normal commands should keep update output")
653+
}
654+
}
655+
656+
func TestMainCloudHelpDoesNotCreateLocalDatabase(t *testing.T) {
657+
stubRuntimeHooks(t)
658+
dataDir := filepath.Join(t.TempDir(), ".engram")
659+
t.Setenv("ENGRAM_DATA_DIR", dataDir)
660+
withArgs(t, "engram", "cloud", "--help")
661+
662+
stdout, stderr, recovered := captureOutputAndRecover(t, func() { main() })
663+
if recovered != nil || stderr != "" {
664+
t.Fatalf("cloud help should return cleanly, panic=%v stderr=%q stdout=%q", recovered, stderr, stdout)
665+
}
666+
if !strings.Contains(stdout, "usage: engram cloud") {
667+
t.Fatalf("expected cloud usage output, got %q", stdout)
668+
}
669+
if _, err := os.Stat(filepath.Join(dataDir, "engram.db")); err == nil {
670+
t.Fatal("cloud help should not create local database")
671+
}
672+
}
673+
566674
func TestCmdCloudStatusDistinguishesAuthAndSyncReadiness(t *testing.T) {
567675
stubExitWithPanic(t)
568676
stubRuntimeHooks(t)

0 commit comments

Comments
 (0)