Skip to content

Commit 8dccc52

Browse files
feat(cli): add engram delete command to remove observations (#434)
Closes #209. Adds `engram delete <id> [--hard]` reusing the existing store.DeleteObservation() API; soft-delete is the default, --hard permanently removes the row.
1 parent 6b56d7e commit 8dccc52

2 files changed

Lines changed: 145 additions & 1 deletion

File tree

cmd/engram/main.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ var (
8888
storeSearch = func(s *store.Store, query string, opts store.SearchOptions) ([]store.SearchResult, error) {
8989
return s.Search(query, opts)
9090
}
91-
storeAddObservation = func(s *store.Store, p store.AddObservationParams) (int64, error) { return s.AddObservation(p) }
91+
storeAddObservation = func(s *store.Store, p store.AddObservationParams) (int64, error) { return s.AddObservation(p) }
92+
storeDeleteObservation = func(s *store.Store, id int64, hard bool) error { return s.DeleteObservation(id, hard) }
9293
storeTimeline = func(s *store.Store, observationID int64, before, after int) (*store.TimelineResult, error) {
9394
return s.Timeline(observationID, before, after)
9495
}
@@ -628,6 +629,8 @@ func main() {
628629
cmdSearch(cfg)
629630
case "save":
630631
cmdSave(cfg)
632+
case "delete":
633+
cmdDelete(cfg)
631634
case "timeline":
632635
cmdTimeline(cfg)
633636
case "conflicts":
@@ -1052,6 +1055,46 @@ func cmdSave(cfg store.Config) {
10521055
fmt.Printf("Memory saved: #%d %q (%s)\n", id, title, typ)
10531056
}
10541057

1058+
func cmdDelete(cfg store.Config) {
1059+
if len(os.Args) < 3 {
1060+
fmt.Fprintln(os.Stderr, "usage: engram delete <observation_id> [--hard]")
1061+
exitFunc(1)
1062+
return
1063+
}
1064+
1065+
id, err := strconv.ParseInt(os.Args[2], 10, 64)
1066+
if err != nil {
1067+
fmt.Fprintf(os.Stderr, "error: invalid observation id %q\n", os.Args[2])
1068+
exitFunc(1)
1069+
return
1070+
}
1071+
1072+
hard := false
1073+
for i := 3; i < len(os.Args); i++ {
1074+
if os.Args[i] == "--hard" {
1075+
hard = true
1076+
}
1077+
}
1078+
1079+
s, err := storeNew(cfg)
1080+
if err != nil {
1081+
fatal(err)
1082+
return
1083+
}
1084+
defer s.Close()
1085+
1086+
if err := storeDeleteObservation(s, id, hard); err != nil {
1087+
fatal(err)
1088+
return
1089+
}
1090+
1091+
kind := "soft-deleted"
1092+
if hard {
1093+
kind = "hard-deleted"
1094+
}
1095+
fmt.Printf("Observation #%d %s\n", id, kind)
1096+
}
1097+
10551098
func cmdTimeline(cfg store.Config) {
10561099
if len(os.Args) < 3 {
10571100
fmt.Fprintln(os.Stderr, "usage: engram timeline <observation_id> [--before N] [--after N]")
@@ -2286,6 +2329,7 @@ Commands:
22862329
tui Launch interactive terminal UI
22872330
search <query> Search memories [--type TYPE] [--project PROJECT] [--scope SCOPE] [--limit N]
22882331
save <title> <msg> Save a memory [--type TYPE] [--project PROJECT] [--scope SCOPE]
2332+
delete <obs_id> Delete an observation [--hard] (soft-delete by default; --hard removes permanently)
22892333
timeline <obs_id> Show chronological context around an observation [--before N] [--after N]
22902334
conflicts <sub> Inspect and manage memory conflict relations
22912335
list [--project P] [--status S] [--since RFC3339] [--limit N]

cmd/engram/main_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1456,3 +1456,103 @@ func TestObsidianExportWatchModeCallsInjectedWatcher(t *testing.T) {
14561456
t.Fatalf("expected non-nil Logf in WatcherConfig")
14571457
}
14581458
}
1459+
1460+
// ─── Delete command tests ─────────────────────────────────────────────────────
1461+
1462+
func TestCmdDeleteSoftDeleteSuccess(t *testing.T) {
1463+
cfg := testConfig(t)
1464+
id := mustSeedObservation(t, cfg, "s-del", "proj-del", "decision", "to-delete", "delete me", "project")
1465+
1466+
withArgs(t, "engram", "delete", strconv.FormatInt(id, 10))
1467+
stdout, stderr := captureOutput(t, func() { cmdDelete(cfg) })
1468+
if stderr != "" {
1469+
t.Fatalf("expected no stderr, got: %q", stderr)
1470+
}
1471+
if !strings.Contains(stdout, "deleted") {
1472+
t.Fatalf("expected deletion confirmation, got: %q", stdout)
1473+
}
1474+
if !strings.Contains(stdout, strconv.FormatInt(id, 10)) {
1475+
t.Fatalf("expected id in output, got: %q", stdout)
1476+
}
1477+
}
1478+
1479+
func TestCmdDeleteHardDeleteSuccess(t *testing.T) {
1480+
cfg := testConfig(t)
1481+
id := mustSeedObservation(t, cfg, "s-del2", "proj-del2", "decision", "hard-delete", "hard delete me", "project")
1482+
1483+
withArgs(t, "engram", "delete", strconv.FormatInt(id, 10), "--hard")
1484+
stdout, stderr := captureOutput(t, func() { cmdDelete(cfg) })
1485+
if stderr != "" {
1486+
t.Fatalf("expected no stderr, got: %q", stderr)
1487+
}
1488+
if !strings.Contains(stdout, "deleted") {
1489+
t.Fatalf("expected deletion confirmation, got: %q", stdout)
1490+
}
1491+
if !strings.Contains(stdout, strconv.FormatInt(id, 10)) {
1492+
t.Fatalf("expected id in output, got: %q", stdout)
1493+
}
1494+
}
1495+
1496+
func TestCmdDeleteNonExistentID(t *testing.T) {
1497+
cfg := testConfig(t)
1498+
1499+
exited := false
1500+
oldExit := exitFunc
1501+
exitFunc = func(code int) { exited = true }
1502+
t.Cleanup(func() { exitFunc = oldExit })
1503+
1504+
withArgs(t, "engram", "delete", "999999")
1505+
_, stderr := captureOutput(t, func() { cmdDelete(cfg) })
1506+
1507+
if !exited {
1508+
t.Fatalf("expected exitFunc to be called for non-existent observation")
1509+
}
1510+
if !strings.Contains(stderr, "not found") && !strings.Contains(stderr, "observation") {
1511+
t.Fatalf("expected not-found error in stderr, got: %q", stderr)
1512+
}
1513+
}
1514+
1515+
func TestCmdDeleteMissingIDArg(t *testing.T) {
1516+
cfg := testConfig(t)
1517+
1518+
exited := false
1519+
oldExit := exitFunc
1520+
exitFunc = func(code int) { exited = true }
1521+
t.Cleanup(func() { exitFunc = oldExit })
1522+
1523+
withArgs(t, "engram", "delete")
1524+
_, stderr := captureOutput(t, func() { cmdDelete(cfg) })
1525+
1526+
if !exited {
1527+
t.Fatalf("expected exitFunc to be called when no ID arg provided")
1528+
}
1529+
if !strings.Contains(stderr, "usage") {
1530+
t.Fatalf("expected usage message in stderr, got: %q", stderr)
1531+
}
1532+
}
1533+
1534+
func TestCmdDeleteInvalidIDArg(t *testing.T) {
1535+
cfg := testConfig(t)
1536+
1537+
exited := false
1538+
oldExit := exitFunc
1539+
exitFunc = func(code int) { exited = true }
1540+
t.Cleanup(func() { exitFunc = oldExit })
1541+
1542+
withArgs(t, "engram", "delete", "not-a-number")
1543+
_, stderr := captureOutput(t, func() { cmdDelete(cfg) })
1544+
1545+
if !exited {
1546+
t.Fatalf("expected exitFunc to be called for invalid id")
1547+
}
1548+
if !strings.Contains(stderr, "invalid") {
1549+
t.Fatalf("expected invalid id error in stderr, got: %q", stderr)
1550+
}
1551+
}
1552+
1553+
func TestCmdDeleteInUsage(t *testing.T) {
1554+
stdout, _ := captureOutput(t, func() { printUsage() })
1555+
if !strings.Contains(stdout, "delete") {
1556+
t.Fatalf("expected 'delete' in usage output, got: %q", stdout)
1557+
}
1558+
}

0 commit comments

Comments
 (0)