diff --git a/cmd/logos/help.go b/cmd/logos/help.go index b8813ca..1cd162f 100644 --- a/cmd/logos/help.go +++ b/cmd/logos/help.go @@ -135,6 +135,7 @@ MEMORY the connections a dream proposed overnight BRINGING IT IN + logos import at a terminal, a menu: tick any of the sources below logos import --from claude-mem [--db PATH] [--project P] [--dry-run] [--yes] bring claude-mem's observations over as memories logos import --from claude-md|cursor-rules|claude-auto-memory [--dir DIR] [--dry-run] [--yes] diff --git a/cmd/logos/import.go b/cmd/logos/import.go index e871430..0e1e759 100644 --- a/cmd/logos/import.go +++ b/cmd/logos/import.go @@ -36,12 +36,17 @@ const claudeMemConfidence = 0.6 // are about to be recalled into an agent's context as if they were known. func runImport(args []string) error { from := strings.TrimSpace(flagStr(args, "--from", "")) + // No source named: a person at a terminal gets a menu of them; a script + // or a hook still gets the usage error, never a wait on a keypress. + if from == "" && pickerAllowed(false) { + return runImportPicked(args) + } switch from { case claudeMDSource, cursorRulesSource, claudeAutoMemorySource: return runImportRules(from, args) } if from != claudeMemSource { - return fmt.Errorf("usage: logos import --from claude-mem [--db PATH] [--project P] [--dry-run] [--yes]\n logos import --from claude-mem --auto (what the import switch runs)\n logos import --from claude-md|cursor-rules|claude-auto-memory [--dir DIR] [--project P] [--dry-run] [--yes]\n logos import --from git ... (seed from git history; see logos help all)") + return fmt.Errorf("usage: logos import (at a terminal: a menu of the sources)\n logos import --from claude-mem [--db PATH] [--project P] [--dry-run] [--yes]\n logos import --from claude-mem --auto (what the import switch runs)\n logos import --from claude-md|cursor-rules|claude-auto-memory [--dir DIR] [--project P] [--dry-run] [--yes]\n logos import --from git ... (seed from git history; see logos help all)") } if hasFlag(args, "--auto") { return runImportAuto() diff --git a/cmd/logos/import_pick.go b/cmd/logos/import_pick.go new file mode 100644 index 0000000..708a64f --- /dev/null +++ b/cmd/logos/import_pick.go @@ -0,0 +1,101 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +// importSource is one place `logos import` reads from, as its menu offers it. +// Each runs exactly what `logos import --from ` runs, so a source picked +// from the menu previews and asks the same way it does when typed. +type importSource struct { + from, label string + run func(args []string) error +} + +// importSources is in the order help lists them. The label says where each +// one lands, because that differs: claude-mem becomes memories, the rules +// files wait for review, transcripts become checkpoint candidates. A func, not +// a var: the claude-mem row calls runImport, which opens this menu. +func importSources() []importSource { + return []importSource{ + {claudeMemSource, "claude-mem's observations — become memories", func(args []string) error { + return runImport(append([]string{"--from", claudeMemSource}, args...)) + }}, + {claudeMDSource, "CLAUDE.md and AGENTS.md — queued for review", func(args []string) error { + return runImportRules(claudeMDSource, args) + }}, + {cursorRulesSource, "Cursor rules — queued for review", func(args []string) error { + return runImportRules(cursorRulesSource, args) + }}, + {claudeAutoMemorySource, "Claude Code's auto memory — queued for review", func(args []string) error { + return runImportRules(claudeAutoMemorySource, args) + }}, + {"transcripts", "agent transcripts — become checkpoint candidates", runIngest}, + {"git", "this repository's git history — seeds a cold vault", runBootstrap}, + } +} + +// chooseImportSources asks which sources to import from. Enter with nothing +// ticked takes the highlighted row, so one source is still one keypress; +// ok=false is a cancel. +func chooseImportSources() ([]importSource, bool, error) { + sources := importSources() + rows := make([]string, len(sources)) + for i, s := range sources { + rows[i] = s.label + } + checked := make([]bool, len(sources)) + i, res, _, err := pickOnTerminal(menu{title: "Import from which sources?", hint: "Space to tick, Enter to import, q to cancel", rows: rows, checked: checked}) + if err != nil { + return nil, false, err + } + // Nothing comes before this menu, so Esc is a cancel too. + if res != picked { + return nil, false, nil + } + var chosen []importSource + for j, c := range checked { + if c { + chosen = append(chosen, sources[j]) + } + } + if len(chosen) == 0 { + chosen = []importSource{sources[i]} + } + return chosen, true, nil +} + +// runImportPicked is a bare `logos import` at a terminal. The flags given +// (--dry-run, --yes, --project) go to every source chosen. One source failing +// does not stop the rest — they share nothing — but each failure is named as +// it happens and again in the error the command exits with, so a run that +// imported two of three never reads as having imported all three. +func runImportPicked(args []string) error { + chosen, ok, err := chooseImportSources() + if err != nil { + return fmt.Errorf("could not show the import menu (%v); name a source with --from", err) + } + if !ok { + fmt.Println("Nothing imported — the menu was cancelled.") + return nil + } + var failed []string + for i, s := range chosen { + if len(chosen) > 1 { + if i > 0 { + fmt.Println() + } + fmt.Printf("── %d of %d: logos import --from %s\n\n", i+1, len(chosen), s.from) + } + if err := s.run(args); err != nil { + fmt.Fprintf(os.Stderr, "logos: import --from %s: %v\n", s.from, err) + failed = append(failed, s.from) + } + } + if len(failed) > 0 { + return fmt.Errorf("%d of %d sources failed: %s", len(failed), len(chosen), strings.Join(failed, ", ")) + } + return nil +} diff --git a/cmd/logos/import_pick_test.go b/cmd/logos/import_pick_test.go new file mode 100644 index 0000000..c8ff421 --- /dev/null +++ b/cmd/logos/import_pick_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "bufio" + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "testing/iotest" +) + +// ticks stands in for the terminal: it ticks the given rows of the import +// menu, leaves the cursor on sel, and ends the menu with res. +func ticks(t *testing.T, sel int, res pick, rows ...int) *int { + t.Helper() + shown := 0 + was := pickOnTerminal + pickOnTerminal = func(m menu) (int, pick, int, error) { + shown++ + if m.checked == nil { + t.Fatalf("the import menu %q is not a checklist", m.title) + } + for _, r := range rows { + m.checked[r] = true + } + return sel, res, len(m.rows) + 1, nil + } + t.Cleanup(func() { pickOnTerminal = was }) + return &shown +} + +func atTerminal(t *testing.T, yes bool) { + t.Helper() + was := interactive + interactive = func() bool { return yes } + t.Cleanup(func() { interactive = was }) +} + +func fromOf(sources []importSource) []string { + var out []string + for _, s := range sources { + out = append(out, s.from) + } + return out +} + +func TestSpaceTicksAChecklistRowAndEnterLeavesTheTicks(t *testing.T) { + checked := make([]bool, 3) + var screen bytes.Buffer + // A byte per read, as typed: a batch of keys redraws once, at its end. + typed := bufio.NewReader(iotest.OneByteReader(strings.NewReader(" jj j \r"))) + i, res, _ := runPicker(typed, &screen, menu{title: "t", hint: "h", rows: []string{"a", "b", "c"}, checked: checked}, 80) + if res != picked || i != 2 { + t.Fatalf("got row %d, %v; want row 2 picked", i, res) + } + if want := []bool{true, false, true}; checked[0] != want[0] || checked[1] != want[1] || checked[2] != want[2] { + t.Errorf("ticks %v, want %v", checked, want) + } + if !strings.Contains(screen.String(), "> [x] c") || !strings.Contains(screen.String(), " [ ] b") { + t.Errorf("the checklist does not draw its boxes:\n%q", screen.String()) + } +} + +func TestSpaceDoesNothingInAMenuThatIsNotAChecklist(t *testing.T) { + var screen bytes.Buffer + if i, res, _ := runPicker(keysOf(" j\r"), &screen, menu{title: "t", hint: "h", rows: []string{"a", "b"}}, 80); res != picked || i != 1 { + t.Fatalf("got row %d, %v; want row 1 picked", i, res) + } + if strings.Contains(screen.String(), "[ ]") { + t.Errorf("a plain menu drew checkboxes:\n%q", screen.String()) + } +} + +func TestTheImportMenuImportsEveryTickedSourceInOrder(t *testing.T) { + ticks(t, 0, picked, 4, 1) + chosen, ok, err := chooseImportSources() + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if got := strings.Join(fromOf(chosen), " "); got != "claude-md transcripts" { + t.Errorf("chose %q, want the two ticked, in menu order", got) + } +} + +func TestEnterWithNothingTickedImportsTheHighlightedSource(t *testing.T) { + ticks(t, 5, picked) + chosen, ok, err := chooseImportSources() + if err != nil || !ok || len(chosen) != 1 || chosen[0].from != "git" { + t.Fatalf("chose %v ok=%v err=%v, want only git", fromOf(chosen), ok, err) + } +} + +func TestCancellingTheImportMenuImportsNothing(t *testing.T) { + for _, res := range []pick{cancelled, wentBack} { + ticks(t, 0, res, 0, 1) + if chosen, ok, err := chooseImportSources(); err != nil || ok || chosen != nil { + t.Errorf("after %v: chose %v ok=%v err=%v, want a cancel", res, fromOf(chosen), ok, err) + } + } +} + +func TestABareImportWithoutATerminalStillPrintsUsage(t *testing.T) { + atTerminal(t, false) + shown := ticks(t, 0, picked, 0) + err := runImport(nil) + if err == nil || !strings.Contains(err.Error(), "usage: logos import") { + t.Fatalf("got %v, want the usage error", err) + } + if *shown != 0 { + t.Error("the menu opened without a terminal") + } +} + +// The session-start hook must never wait on a keypress, terminal or not. +func TestABareImportInsideAHookNeverOpensTheMenu(t *testing.T) { + atTerminal(t, true) + t.Setenv("LOGOS_HOOK", "1") + shown := ticks(t, 0, picked, 0) + if err := runImport(nil); err == nil || !strings.Contains(err.Error(), "usage: logos import") { + t.Fatalf("got %v, want the usage error", err) + } + if *shown != 0 { + t.Error("the menu opened inside a hook") + } +} + +func TestASourceThatFailsFromTheMenuIsNamedInTheExitError(t *testing.T) { + atTerminal(t, true) + t.Setenv("LOGOS_HOOK", "") + t.Setenv("LOGOS_VAULT", filepath.Join(t.TempDir(), "no-vault-here")) + ticks(t, 0, picked, 4) + err := runImport(nil) + if err == nil || !strings.Contains(err.Error(), "1 of 1 sources failed: transcripts") { + t.Fatalf("got %v, want the failed source named", err) + } +} + +// A menu and the prompt after it read one stream. The Enter that ends the +// menu and the answer typed after it can arrive in one read; a prompt reading +// stdin on its own would find the answer gone into the menu's buffer. +func TestAnAnswerTypedRightAfterTheMenuReachesThePrompt(t *testing.T) { + wasKb, wasFrom, wasAns, wasAnsFrom := keyboard, keyboardFrom, answers, answersFrom + t.Cleanup(func() { keyboard, keyboardFrom, answers, answersFrom = wasKb, wasFrom, wasAns, wasAnsFrom }) + keyboard, keyboardFrom, answers = keysOf("\ry\n"), os.Stdin, nil + + var screen bytes.Buffer + if _, res, _ := runPicker(keyboard, &screen, menu{title: "t", hint: "h", rows: []string{"a"}, checked: []bool{false}}, 80); res != picked { + t.Fatalf("menu ended %v, want picked", res) + } + if !confirmNo("Import these?") { + t.Error("the y typed after the menu's Enter did not reach the prompt") + } +} + +// Ticking transcripts in the menu is how most people meet ingest's one-time +// consent question, so it reads the menu's stream like every other prompt. +func TestIngestConsentTypedRightAfterTheMenuIsGranted(t *testing.T) { + wasKb, wasFrom, wasAns, wasAnsFrom := keyboard, keyboardFrom, answers, answersFrom + t.Cleanup(func() { keyboard, keyboardFrom, answers, answersFrom = wasKb, wasFrom, wasAns, wasAnsFrom }) + keyboard, keyboardFrom, answers = keysOf("\ry\n"), os.Stdin, nil + + var screen bytes.Buffer + if _, res, _ := runPicker(keyboard, &screen, menu{title: "t", hint: "h", rows: []string{"a"}, checked: []bool{false}}, 80); res != picked { + t.Fatalf("menu ended %v, want picked", res) + } + vault := t.TempDir() + if err := os.MkdirAll(filepath.Dir(ingestConsentPath(vault)), 0o755); err != nil { + t.Fatal(err) + } + if err := ensureIngestConsent(vault, false); err != nil { + t.Fatalf("the y typed after the menu's Enter did not grant consent: %v", err) + } +} diff --git a/cmd/logos/ingest.go b/cmd/logos/ingest.go index d424677..a716c5e 100644 --- a/cmd/logos/ingest.go +++ b/cmd/logos/ingest.go @@ -1,7 +1,6 @@ package main import ( - "bufio" "encoding/json" "fmt" "os" @@ -385,9 +384,9 @@ func ensureIngestConsent(vaultDir string, assumeYes bool) error { fmt.Println("(Claude Code under ~/.claude/projects, Codex under ~/.codex/sessions, and") fmt.Println("others via txcript). Only a checkpoint candidate is written to the vault —") fmt.Println("never the transcript itself. This is asked once.") - fmt.Print("\nallow ingest to read transcripts on this machine? [y/N] ") - line, _ := bufio.NewReader(os.Stdin).ReadString('\n') - if a := strings.TrimSpace(strings.ToLower(line)); a != "y" && a != "yes" { + // Through the prompts' shared reader: reached from the import menu, + // a reader of its own would lose a y typed right after the Enter. + if !confirmNo("\nallow ingest to read transcripts on this machine?") { return fmt.Errorf("ingest needs consent to read transcripts; not granted") } } diff --git a/cmd/logos/picker.go b/cmd/logos/picker.go index cf4e808..cba4550 100644 --- a/cmd/logos/picker.go +++ b/cmd/logos/picker.go @@ -114,10 +114,15 @@ func pickAgent(agents []launcher) (*launcher, pick, int, error) { // menu is one list on screen: what it asks, its rows, the row selected when it // opens, and how many lines above the cursor it is drawn over (0 for below // whatever is there). +// +// checked, when set, makes it a checklist: Space ticks the selected row, and +// the ticks are left in checked for the caller, which shares its backing +// array with the copy the picker was given. type menu struct { title, hint string rows []string sel, over int + checked []bool } // keyboard is stdin, read through one buffer for every menu. A fast typist's @@ -125,6 +130,10 @@ type menu struct { // in the first menu's buffer and threw it away with it. var keyboard = bufio.NewReader(os.Stdin) +// keyboardFrom is the stdin keyboard was built over, so a prompt after a menu +// reads on from keyboard's buffer instead of past it. +var keyboardFrom = os.Stdin + // pickOnTerminal is runPicker on the real terminal, in raw mode for exactly // as long as the menu is up. It returns the row, how the menu was left, and // how many lines it drew. A var so a test can play the keypresses of two @@ -183,6 +192,13 @@ func runPicker(in *bufio.Reader, out io.Writer, m menu, width int) (int, pick, i if i == sel { mark = "> " } + if m.checked != nil { + if m.checked[i] { + mark += "[x] " + } else { + mark += "[ ] " + } + } lines = append(lines, text.Ellipsize(mark+rows[i], width-1)) } if len(rows) > pickerWindow { @@ -212,6 +228,8 @@ func runPicker(in *bufio.Reader, out io.Writer, m menu, width int) (int, pick, i sel = max(sel-1, 0) case b == 'j': sel = min(sel+1, len(rows)-1) + case b == ' ' && m.checked != nil: + m.checked[sel] = !m.checked[sel] case b == 0x1b: // An arrow arrives as ESC [ A (or ESC O A in application mode) // in one write, so its tail is already buffered; an ESC with diff --git a/cmd/logos/setup.go b/cmd/logos/setup.go index 378514e..9f9a574 100644 --- a/cmd/logos/setup.go +++ b/cmd/logos/setup.go @@ -913,7 +913,14 @@ func readAnswer(prompt string) (answer string, ok bool) { // prompt swallowed the rest of a piped script into the first and left the // next prompt at EOF. Rebuilt only if stdin itself was swapped. if answers == nil || answersFrom != os.Stdin { - answers, answersFrom = bufio.NewScanner(os.Stdin), os.Stdin + // After a menu, through the menu's own reader: an Enter and the + // answer typed after it can arrive in one read, and a scanner of its + // own would never see the answer the menu's buffer already took. + var in io.Reader = os.Stdin + if os.Stdin == keyboardFrom { + in = keyboard + } + answers, answersFrom = bufio.NewScanner(in), os.Stdin } sc := answers if !sc.Scan() {