Skip to content
Merged
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
1 change: 1 addition & 0 deletions cmd/logos/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
7 changes: 6 additions & 1 deletion cmd/logos/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
101 changes: 101 additions & 0 deletions cmd/logos/import_pick.go
Original file line number Diff line number Diff line change
@@ -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 <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
}
174 changes: 174 additions & 0 deletions cmd/logos/import_pick_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 3 additions & 4 deletions cmd/logos/ingest.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package main

import (
"bufio"
"encoding/json"
"fmt"
"os"
Expand Down Expand Up @@ -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")
}
}
Expand Down
18 changes: 18 additions & 0 deletions cmd/logos/picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,26 @@ 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
// Enter, Enter can arrive in one read; a reader per menu kept the second Enter
// 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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion cmd/logos/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading