From aa810522d8aea2a07e3472063bb156ed8b818a67 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 30 Apr 2026 15:02:47 +0000
Subject: [PATCH 1/2] Initial plan
From 8b111b22674c84d172c3f74a9f61b52cf858e741 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 30 Apr 2026 15:08:44 +0000
Subject: [PATCH 2/2] Add interactive front-end menu (shell) to notes_app CLI
Agent-Logs-Url: https://github.com/ShockaHolmes/NoteForge/sessions/3d7019f2-b35b-4f23-a7e1-2946e175f52f
Co-authored-by: ShockaHolmes <78627489+ShockaHolmes@users.noreply.github.com>
---
python/src/notes_app/cli/shell.py | 189 +++++++++++++++++
python/tests/test_shell.py | 342 ++++++++++++++++++++++++++++++
2 files changed, 531 insertions(+)
create mode 100644 python/src/notes_app/cli/shell.py
create mode 100644 python/tests/test_shell.py
diff --git a/python/src/notes_app/cli/shell.py b/python/src/notes_app/cli/shell.py
new file mode 100644
index 0000000..9909461
--- /dev/null
+++ b/python/src/notes_app/cli/shell.py
@@ -0,0 +1,189 @@
+"""Interactive shell (menu) for the notes application."""
+
+import shlex
+import sys
+from collections.abc import Callable
+
+from notes_app.cli.commands.backup_command import run_backup
+from notes_app.cli.commands.create_command import run_create
+from notes_app.cli.commands.delete_command import run_delete
+from notes_app.cli.commands.help_command import render_help
+from notes_app.cli.commands.list_command import run_list
+from notes_app.cli.commands.read_command import run_read
+from notes_app.cli.commands.restore_command import run_restore
+from notes_app.cli.commands.search_command import run_search
+from notes_app.cli.commands.update_command import run_update
+from notes_app.services.backup_service import BackupService
+from notes_app.services.note_service import NoteService
+
+PROMPT = "notes> "
+PROGRAM_NAME = "notes shell"
+
+SUPPORTED_COMMANDS = (
+ "help",
+ "list",
+ "create",
+ "search",
+ "read",
+ "update",
+ "delete",
+ "backup",
+ "restore",
+ "quit",
+)
+
+
+def _show_help() -> None:
+ """Display help for all available commands."""
+ print(render_help(PROGRAM_NAME))
+
+
+def _handle_command(
+ command: str,
+ args: list[str],
+ service: NoteService,
+ backup_service: BackupService | None,
+) -> bool:
+ """Dispatch a single command. Returns False when the user requests quit."""
+ if command == "quit":
+ return False
+
+ if command == "help":
+ _show_help()
+
+ elif command == "list":
+ print(run_list(service))
+
+ elif command == "create":
+ if len(args) < 2:
+ print("Error: create requires
and .")
+ print('Usage: create "Title" "Content"')
+ else:
+ title = args[0]
+ content = " ".join(args[1:])
+ print(run_create(service, title=title, content=content))
+
+ elif command == "search":
+ if not args:
+ print("Error: search requires .")
+ print("Usage: search ")
+ else:
+ query = " ".join(args)
+ print(run_search(service, query=query))
+
+ elif command == "read":
+ if not args:
+ print("Error: read requires .")
+ print("Usage: read ")
+ else:
+ output, ok = run_read(service, note_id=args[0])
+ if ok:
+ print(output)
+ else:
+ print(output, file=sys.stderr)
+
+ elif command == "update":
+ if len(args) < 2:
+ print("Error: update requires and at least one flag.")
+ print('Usage: update [--title "..."] [--tags "tag1,tag2"] [--content "..."]')
+ else:
+ output, ok = run_update(service, note_id=args[0], args=list(args[1:]))
+ if ok:
+ print(output)
+ else:
+ print(output, file=sys.stderr)
+
+ elif command == "delete":
+ if not args:
+ print("Error: delete requires .")
+ print("Usage: delete ")
+ else:
+ output, ok = run_delete(service, note_id=args[0])
+ if ok:
+ print(output)
+ else:
+ print(output, file=sys.stderr)
+
+ elif command == "backup":
+ if backup_service is None:
+ print("Error: backup service is not available.", file=sys.stderr)
+ else:
+ output_dir = args[0] if args else None
+ output, ok = run_backup(backup_service, output_dir=output_dir)
+ if ok:
+ print(output)
+ else:
+ print(output, file=sys.stderr)
+
+ elif command == "restore":
+ if backup_service is None:
+ print("Error: backup service is not available.", file=sys.stderr)
+ elif not args:
+ print("Error: restore requires .")
+ print("Usage: restore ")
+ else:
+ output, ok = run_restore(backup_service, backup_path=args[0])
+ if ok:
+ print(output)
+ else:
+ print(output, file=sys.stderr)
+
+ else:
+ print(f"Unknown command: '{command}'")
+ print(f"Supported commands: {', '.join(SUPPORTED_COMMANDS)}")
+ print("Type 'help' for usage details.")
+
+ return True
+
+
+def run_shell(
+ service: NoteService,
+ backup_service: BackupService | None = None,
+ input_fn: Callable[[str], str] = input,
+) -> None:
+ """Run the interactive notes shell until the user quits."""
+ print("Future Proof Notes Manager - Interactive Shell")
+ print("Type 'help' for available commands, 'quit' to exit.")
+ print()
+
+ while True:
+ try:
+ line = input_fn(PROMPT).strip()
+ except EOFError:
+ print()
+ break
+ except KeyboardInterrupt:
+ print("\nUse 'quit' to exit.")
+ continue
+
+ if not line:
+ continue
+
+ try:
+ parts = shlex.split(line)
+ except ValueError:
+ print("Error: could not parse input (unmatched quotes?).")
+ continue
+
+ command = parts[0].lower()
+ args = parts[1:]
+
+ keep_running = _handle_command(command, args, service, backup_service)
+ if not keep_running:
+ break
+
+
+def main() -> int:
+ """Start the interactive notes shell."""
+ from notes_app.cli.main import build_backup_service, build_service # local import avoids cycles
+
+ service = build_service()
+ backup_service = build_backup_service()
+
+ run_shell(service, backup_service=backup_service)
+ print("\nGoodbye!")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/python/tests/test_shell.py b/python/tests/test_shell.py
new file mode 100644
index 0000000..8645665
--- /dev/null
+++ b/python/tests/test_shell.py
@@ -0,0 +1,342 @@
+from datetime import datetime, timezone
+from io import StringIO
+
+import pytest
+
+from notes_app.cli.shell import SUPPORTED_COMMANDS, run_shell
+from notes_app.models.note import Note
+from notes_app.repositories.note_repository import NoteRepository
+from notes_app.services.note_service import NoteService
+
+
+# ---------------------------------------------------------------------------
+# Test helpers
+# ---------------------------------------------------------------------------
+
+
+class InMemoryNoteRepository(NoteRepository):
+ def __init__(self, notes: list[Note] | None = None):
+ self._notes: list[Note] = list(notes or [])
+
+ def save(self, note: Note) -> None:
+ self._notes = [n for n in self._notes if n.id != note.id]
+ self._notes.append(note)
+
+ def list_notes(self) -> list[Note]:
+ return list(self._notes)
+
+ def get_by_id(self, note_id: str) -> Note | None:
+ return next((n for n in self._notes if n.id == note_id), None)
+
+ def delete_by_id(self, note_id: str) -> bool:
+ before = len(self._notes)
+ self._notes = [n for n in self._notes if n.id != note_id]
+ return len(self._notes) != before
+
+
+def _make_note(note_id: str = "sample", title: str = "Sample Note") -> Note:
+ now = datetime(2026, 1, 1, tzinfo=timezone.utc)
+ return Note(
+ id=note_id,
+ title=title,
+ created=now,
+ modified=now,
+ tags=("demo",),
+ content="sample content",
+ )
+
+
+def _make_service(notes: list[Note] | None = None) -> NoteService:
+ return NoteService(InMemoryNoteRepository(notes))
+
+
+def _make_input_fn(lines: list[str]):
+ """Return an input function that yields successive lines then raises EOFError."""
+ iterator = iter(lines)
+
+ def _input(_prompt: str = "") -> str:
+ try:
+ return next(iterator)
+ except StopIteration:
+ raise EOFError
+
+ return _input
+
+
+# ---------------------------------------------------------------------------
+# Tests: banner and welcome output
+# ---------------------------------------------------------------------------
+
+
+def test_shell_prints_welcome_banner(capsys) -> None:
+ run_shell(_make_service(), input_fn=_make_input_fn(["quit"]))
+
+ out = capsys.readouterr().out
+ assert "Interactive Shell" in out
+ assert "help" in out
+ assert "quit" in out
+
+
+# ---------------------------------------------------------------------------
+# Tests: quit command
+# ---------------------------------------------------------------------------
+
+
+def test_shell_quit_exits_cleanly(capsys) -> None:
+ run_shell(_make_service(), input_fn=_make_input_fn(["quit"]))
+
+ out = capsys.readouterr().out
+ # No crash, no leftover error
+ assert out is not None
+
+
+def test_shell_eof_exits_cleanly(capsys) -> None:
+ # Empty input sequence triggers EOFError immediately
+ run_shell(_make_service(), input_fn=_make_input_fn([]))
+ # Should complete without raising
+ capsys.readouterr()
+
+
+# ---------------------------------------------------------------------------
+# Tests: help command
+# ---------------------------------------------------------------------------
+
+
+def test_shell_help_lists_all_commands(capsys) -> None:
+ run_shell(_make_service(), input_fn=_make_input_fn(["help", "quit"]))
+
+ out = capsys.readouterr().out
+ assert "Commands:" in out
+ assert "create " in out
+ assert "list" in out
+ assert "search " in out
+ assert "read " in out
+ assert "update " in out
+ assert "delete " in out
+
+
+# ---------------------------------------------------------------------------
+# Tests: list command
+# ---------------------------------------------------------------------------
+
+
+def test_shell_list_with_notes(capsys) -> None:
+ service = _make_service([_make_note()])
+ run_shell(service, input_fn=_make_input_fn(["list", "quit"]))
+
+ out = capsys.readouterr().out
+ assert "Notes:" in out
+ assert "sample.md" in out
+
+
+def test_shell_list_empty(capsys) -> None:
+ run_shell(_make_service(), input_fn=_make_input_fn(["list", "quit"]))
+
+ out = capsys.readouterr().out
+ assert "No notes found." in out
+
+
+# ---------------------------------------------------------------------------
+# Tests: create command
+# ---------------------------------------------------------------------------
+
+
+def test_shell_create_note(capsys) -> None:
+ service = _make_service()
+ run_shell(service, input_fn=_make_input_fn(['create "Hello World" "Some content"', "quit"]))
+
+ out = capsys.readouterr().out
+ assert "hello-world.md" in out
+ assert service.get_note("hello-world") is not None
+
+
+def test_shell_create_missing_args_shows_error(capsys) -> None:
+ run_shell(_make_service(), input_fn=_make_input_fn(["create Title", "quit"]))
+
+ out = capsys.readouterr().out
+ assert "Error: create requires" in out
+
+
+# ---------------------------------------------------------------------------
+# Tests: search command
+# ---------------------------------------------------------------------------
+
+
+def test_shell_search_finds_match(capsys) -> None:
+ service = _make_service([_make_note()])
+ run_shell(service, input_fn=_make_input_fn(["search sample", "quit"]))
+
+ out = capsys.readouterr().out
+ assert "match(es) found." in out
+
+
+def test_shell_search_no_query_shows_error(capsys) -> None:
+ run_shell(_make_service(), input_fn=_make_input_fn(["search", "quit"]))
+
+ out = capsys.readouterr().out
+ assert "Error: search requires" in out
+
+
+# ---------------------------------------------------------------------------
+# Tests: read command
+# ---------------------------------------------------------------------------
+
+
+def test_shell_read_existing_note(capsys) -> None:
+ service = _make_service([_make_note()])
+ run_shell(service, input_fn=_make_input_fn(["read sample", "quit"]))
+
+ out = capsys.readouterr().out
+ assert "title: Sample Note" in out
+ assert "sample content" in out
+
+
+def test_shell_read_missing_note_shows_error(capsys) -> None:
+ run_shell(_make_service(), input_fn=_make_input_fn(["read nonexistent", "quit"]))
+
+ err = capsys.readouterr().err
+ assert "not found" in err.lower()
+
+
+def test_shell_read_missing_id_shows_error(capsys) -> None:
+ run_shell(_make_service(), input_fn=_make_input_fn(["read", "quit"]))
+
+ out = capsys.readouterr().out
+ assert "Error: read requires" in out
+
+
+# ---------------------------------------------------------------------------
+# Tests: update command
+# ---------------------------------------------------------------------------
+
+
+def test_shell_update_note_title(capsys) -> None:
+ service = _make_service([_make_note()])
+ run_shell(
+ service,
+ input_fn=_make_input_fn(['update sample --title "New Title"', "quit"]),
+ )
+
+ out = capsys.readouterr().out
+ assert "Updated note 'sample.md'" in out
+ updated = service.get_note("sample")
+ assert updated is not None
+ assert updated.title == "New Title"
+
+
+def test_shell_update_missing_args_shows_error(capsys) -> None:
+ run_shell(_make_service(), input_fn=_make_input_fn(["update sample", "quit"]))
+
+ out = capsys.readouterr().out
+ assert "Error: update requires" in out
+
+
+# ---------------------------------------------------------------------------
+# Tests: delete command
+# ---------------------------------------------------------------------------
+
+
+def test_shell_delete_note_after_confirmation(capsys) -> None:
+ service = _make_service([_make_note()])
+
+ # The delete command calls input() internally for confirmation.
+ # We need to supply 'y' for the inner confirmation prompt via confirm_fn.
+ # Since run_shell uses our input_fn for the shell prompt but run_delete
+ # uses its own confirm_fn (defaulting to built-in input), we monkeypatch
+ # delete_command in shell to pass confirm_fn.
+ import notes_app.cli.shell as shell_module
+ import notes_app.cli.commands.delete_command as delete_mod
+
+ original_run_delete = shell_module.run_delete
+
+ def patched_run_delete(svc, note_id): # type: ignore[misc]
+ return delete_mod.run_delete(svc, note_id=note_id, confirm_fn=lambda _p: "y")
+
+ shell_module.run_delete = patched_run_delete # type: ignore[assignment]
+ try:
+ run_shell(service, input_fn=_make_input_fn(["delete sample", "quit"]))
+ finally:
+ shell_module.run_delete = original_run_delete
+
+ out = capsys.readouterr().out
+ assert "Deleted note 'sample.md'" in out
+ assert service.get_note("sample") is None
+
+
+def test_shell_delete_missing_id_shows_error(capsys) -> None:
+ run_shell(_make_service(), input_fn=_make_input_fn(["delete", "quit"]))
+
+ out = capsys.readouterr().out
+ assert "Error: delete requires" in out
+
+
+# ---------------------------------------------------------------------------
+# Tests: unknown command
+# ---------------------------------------------------------------------------
+
+
+def test_shell_unknown_command_shows_hint(capsys) -> None:
+ run_shell(_make_service(), input_fn=_make_input_fn(["frobnicate", "quit"]))
+
+ out = capsys.readouterr().out
+ assert "Unknown command: 'frobnicate'" in out
+ assert "help" in out
+
+
+# ---------------------------------------------------------------------------
+# Tests: empty input is ignored
+# ---------------------------------------------------------------------------
+
+
+def test_shell_empty_input_is_ignored(capsys) -> None:
+ run_shell(_make_service(), input_fn=_make_input_fn(["", " ", "quit"]))
+ # Should complete without errors
+ capsys.readouterr()
+
+
+# ---------------------------------------------------------------------------
+# Tests: unmatched quotes produce error not crash
+# ---------------------------------------------------------------------------
+
+
+def test_shell_unmatched_quotes_gives_error(capsys) -> None:
+ run_shell(_make_service(), input_fn=_make_input_fn(['create "bad quote', "quit"]))
+
+ out = capsys.readouterr().out
+ assert "could not parse input" in out
+
+
+# ---------------------------------------------------------------------------
+# Tests: keyboard interrupt
+# ---------------------------------------------------------------------------
+
+
+def test_shell_keyboard_interrupt_continues(capsys) -> None:
+ call_count = 0
+
+ def _input_raising(_prompt: str = "") -> str:
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ raise KeyboardInterrupt
+ return "quit"
+
+ run_shell(_make_service(), input_fn=_input_raising)
+
+ out = capsys.readouterr().out
+ assert "quit" in out.lower()
+
+
+# ---------------------------------------------------------------------------
+# Tests: SUPPORTED_COMMANDS includes all expected commands
+# ---------------------------------------------------------------------------
+
+
+def test_supported_commands_include_all_note_operations() -> None:
+ for cmd in ("help", "list", "create", "search", "read", "update", "delete", "quit"):
+ assert cmd in SUPPORTED_COMMANDS
+
+
+def test_supported_commands_include_backup_and_restore() -> None:
+ assert "backup" in SUPPORTED_COMMANDS
+ assert "restore" in SUPPORTED_COMMANDS