diff --git a/gemini-cli/README.md b/gemini-cli/README.md new file mode 100644 index 0000000000..aeab872ce6 --- /dev/null +++ b/gemini-cli/README.md @@ -0,0 +1,3 @@ +# How to Use Google's Gemini CLI for AI Code Assistance + +This folder contains code associated with the Real Python tutorial [How to Use Google's Gemini CLI for AI Code Assistance](https://realpython.com/how-to-use-gemini-cli/). diff --git a/gemini-cli/todolist/README.md b/gemini-cli/todolist/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gemini-cli/todolist/pyproject.toml b/gemini-cli/todolist/pyproject.toml new file mode 100644 index 0000000000..d9b04162ed --- /dev/null +++ b/gemini-cli/todolist/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["uv_build>=0.9.5"] +build-backend = "uv_build" + +[project] +name = "todolist" +version = "0.1.0" +description = "A command-line todo app" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "openai>=2.6.1", + "peewee>=3.18.2", + "platformdirs>=4.5.0", + "rich>=14.2.0", +] + +[project.scripts] +todo = "todolist.__main__:main" + +[dependency-groups] +dev = [ + "pytest>=9.0.0", + "ruff>=0.14.4", +] + diff --git a/gemini-cli/todolist/src/todolist/__init__.py b/gemini-cli/todolist/src/todolist/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/gemini-cli/todolist/src/todolist/__main__.py b/gemini-cli/todolist/src/todolist/__main__.py new file mode 100644 index 0000000000..afdef8b1dc --- /dev/null +++ b/gemini-cli/todolist/src/todolist/__main__.py @@ -0,0 +1,124 @@ +from collections.abc import Iterable + +from rich import print +from rich.console import Console +from rich.prompt import Confirm + +from todolist.cli import Callbacks, process_cli +from todolist.database import Task, TaskList +from todolist.emojis import find_matching_emojis, has_emoji_support +from todolist.exporter import export_database_to_json +from todolist.renderer import render_long, render_short +from todolist.status import TaskListStatus + + +def main() -> None: + process_cli( + Callbacks(add, remove, done, undo, rename, show, clear, lists, export) + ) + + +def add(list_name: str, tasks: Iterable[str]) -> None: + """Add one or more tasks to a list""" + task_list, _ = TaskList.get_or_create(name=list_name) + created_tasks = [] + for task_name in tasks: + task, created = Task.get_or_create(name=task_name, task_list=task_list) + if created: + created_tasks.append(task) + if created_tasks and has_emoji_support(): + with Console().status("Choosing emoji...", spinner="arc"): + task_names = tuple(task.name for task in created_tasks) + emojis = find_matching_emojis(task_names) + for task, emoji in zip(created_tasks, emojis): + task.emoji = emoji + task.save() + show(list_name) + + +def remove(list_name: str, tasks: Iterable[str]) -> None: + """Remove tasks from a list""" + if task_list := TaskList.get_or_none(name=list_name): + for task_name in tasks: + Task.delete().where( + (Task.name == task_name) & (Task.task_list == task_list) + ).execute() + show(list_name) + else: + print(f"List not found: {list_name!r}") + + +def done(list_name: str, tasks: Iterable[str]) -> None: + """Mark tasks as completed""" + _mark(list_name, tasks, is_done=True) + + +def undo(list_name: str, tasks: Iterable[str]) -> None: + """Mark tasks as pending""" + _mark(list_name, tasks, is_done=False) + + +def rename(list_name: str, old: str, new: str) -> None: + """Rename a task on the given list""" + if task_list := TaskList.get_or_none(TaskList.name == list_name): + if task := Task.get_or_none(task_list=task_list, name=old): + task.name = new + task.save() + show(list_name) + else: + print(f"Task not found: {old!r}") + else: + print(f"List not found: {list_name!r}") + + +def show(list_name: str) -> None: + """Show the status of tasks""" + if status := TaskListStatus.find_one(list_name): + render_long(status) + else: + if list_name.lower() == "default": + print("You're all caught up :sparkles:") + else: + print(f"List not found: {list_name!r}") + + +def clear(list_name: str) -> None: + """Clear all tasks from a list""" + if task_list := TaskList.get_or_none(TaskList.name == list_name): + prompt = f"Are you sure to remove the {list_name!r} list?" + if Confirm.ask(prompt, default=False): + task_list.delete_instance(recursive=True) + else: + print(f"List not found: {list_name!r}") + + +def lists() -> None: + """Display the available task lists""" + for status in TaskListStatus.find_all(): + render_short(status) + + +def export() -> None: + """Dump all task lists to JSON""" + export_database_to_json() + + +def _mark(list_name: str, tasks: Iterable[str], is_done: bool) -> None: + if task_list := TaskList.get_or_none(name=list_name): + for task_name in tasks: + if instance := Task.get_or_none( + task_list=task_list, name=task_name + ): + instance.done = is_done + instance.save() + else: + print(f"Task not found: {task_name!r}") + break + else: + show(list_name) + else: + print(f"List not found: {list_name!r}") + + +if __name__ == "__main__": + main() diff --git a/gemini-cli/todolist/src/todolist/cli.py b/gemini-cli/todolist/src/todolist/cli.py new file mode 100644 index 0000000000..d3712e5a0a --- /dev/null +++ b/gemini-cli/todolist/src/todolist/cli.py @@ -0,0 +1,113 @@ +from argparse import ArgumentParser +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from inspect import signature + +type PlainCallback = Callable[[], None] +type ListCallback = Callable[[str], None] +type TaskCallback = Callable[[str, Iterable[str]], None] + + +@dataclass(frozen=True) +class Callbacks: + add: TaskCallback + remove: TaskCallback + done: TaskCallback + undo: TaskCallback + rename: Callable[[str, str, str], None] + show: ListCallback + clear: ListCallback + lists: PlainCallback + export: PlainCallback + + @property + def task_callbacks(self) -> tuple[TaskCallback, ...]: + return ( + self.add, + self.remove, + self.done, + self.undo, + ) + + @property + def list_callbacks(self) -> tuple[ListCallback, ...]: + return ( + self.show, + self.clear, + ) + + @property + def plain_callbacks(self) -> tuple[PlainCallback, ...]: + return ( + self.lists, + self.export, + ) + + +def process_cli(callbacks: Callbacks) -> None: + parser = build_parser(callbacks) + args = parser.parse_args() + if args.command: + args.callback( + **{ + name: getattr(args, name) + for name in signature(args.callback).parameters + if hasattr(args, name) + } + ) + else: + parser.print_help() + + +def build_parser(callbacks: Callbacks) -> ArgumentParser: + parser = ArgumentParser(description="A command-line task manager") + subparsers = parser.add_subparsers(title="commands", dest="command") + + for cb in callbacks.task_callbacks: + subparser = subparsers.add_parser(cb.__name__, help=cb.__doc__) + subparser.set_defaults(callback=cb) + add_tasks_positional(subparser) + add_list_option(subparser) + + # Rename + subparser = subparsers.add_parser("rename", help=callbacks.rename.__doc__) + subparser.add_argument("old", type=normalize, help="original task name") + subparser.add_argument("new", type=normalize, help="new task name") + subparser.set_defaults(callback=callbacks.rename) + add_list_option(subparser) + + for cb in callbacks.list_callbacks: + subparser = subparsers.add_parser(cb.__name__, help=cb.__doc__) + subparser.set_defaults(callback=cb) + add_list_option(subparser) + + for cb in callbacks.plain_callbacks: + subparser = subparsers.add_parser(cb.__name__, help=cb.__doc__) + subparser.set_defaults(callback=cb) + + return parser + + +def add_tasks_positional(parser: ArgumentParser) -> None: + parser.add_argument( + "tasks", + nargs="+", + type=normalize, + help="one or more tasks (e.g., 'eggs', 'bacon')", + ) + + +def add_list_option(parser: ArgumentParser) -> None: + parser.add_argument( + "-l", + "--list", + dest="list_name", + metavar="name", + help="optional name of the task list (e.g., 'shopping')", + default="default", + type=normalize, + ) + + +def normalize(name: str) -> str: + return name.strip().title() diff --git a/gemini-cli/todolist/src/todolist/database.py b/gemini-cli/todolist/src/todolist/database.py new file mode 100644 index 0000000000..3dc523bb20 --- /dev/null +++ b/gemini-cli/todolist/src/todolist/database.py @@ -0,0 +1,44 @@ +from functools import cached_property + +from peewee import ( + BooleanField, + ForeignKeyField, + Model, + SqliteDatabase, + TextField, +) +from platformdirs import user_cache_path + +db_file = user_cache_path() / __package__ / f"{__package__}.db" +db_file.parent.mkdir(parents=True, exist_ok=True) + +db = SqliteDatabase(db_file) + + +class TaskList(Model): + name = TextField(null=False, unique=True) + + class Meta: + database = db + table_name = "lists" + + +class Task(Model): + emoji = TextField(null=True) + name = TextField(null=False) + done = BooleanField(null=False, default=False) + task_list = ForeignKeyField(TaskList, backref="tasks", on_delete="CASCADE") + + class Meta: + database = db + table_name = "tasks" + + @cached_property + def pretty_name(self) -> str: + if self.emoji: + return f"{self.emoji} {self.name}" + else: + return str(self.name) + + +db.create_tables([TaskList, Task], safe=True) diff --git a/gemini-cli/todolist/src/todolist/emojis.py b/gemini-cli/todolist/src/todolist/emojis.py new file mode 100644 index 0000000000..dec5fde5df --- /dev/null +++ b/gemini-cli/todolist/src/todolist/emojis.py @@ -0,0 +1,73 @@ +import os +import typing +from collections.abc import Sequence +from functools import cache + +if typing.TYPE_CHECKING: + from openai import OpenAI + +MODEL = "gpt-4o-mini" +TEMPERATURE = 0.3 +MAX_TOKENS = 10 + +SYSTEM_PROMPT = ( + "You are an emoji expert. When given a phrase, you respond with only " + "a single emoji that best matches it. Never include explanations or " + "multiple emojis." +) + +BATCH_USER_PROMPT = ( + "Given the list of PHRASES below (each on a separate line), return ONLY " + "a list of emojis (one per line) that best represents each phrase. " + "Return ONLY the emoji characters themselves in the same order, with no " + "explanation or additional text. One emoji per line." +) + + +def has_emoji_support() -> bool: + if "NO_EMOJI" in os.environ: + return False + return os.environ.get("OPENAI_API_KEY") is not None + + +def find_matching_emojis(phrases: Sequence[str]) -> tuple[str | None, ...]: + if not phrases: + return tuple() + + if client := _get_client(): + phrases_text = "\n".join( + f"{i + 1}. {phrase}" for i, phrase in enumerate(phrases) + ) + user_prompt = BATCH_USER_PROMPT + f"\n\nPHRASES:\n{phrases_text}" + try: + response = client.chat.completions.create( + model=MODEL, + max_tokens=MAX_TOKENS * len(phrases), + temperature=TEMPERATURE, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + ) + emojis = response.choices[0].message.content.strip().split("\n") + result = [] + for i in range(len(phrases)): + if i < len(emojis): + emoji = emojis[i].strip() + if emoji and emoji[0].isdigit(): + emoji = emoji.split(".", 1)[-1].strip() + result.append(emoji[0] if emoji else None) + else: + result.append(None) + return tuple(result) + except Exception: + return (None,) * len(phrases) + else: + return (None,) * len(phrases) + + +@cache +def _get_client() -> OpenAI | None: + from openai import OpenAI + + return OpenAI() if has_emoji_support() else None diff --git a/gemini-cli/todolist/src/todolist/exporter.py b/gemini-cli/todolist/src/todolist/exporter.py new file mode 100644 index 0000000000..0e0b182ff3 --- /dev/null +++ b/gemini-cli/todolist/src/todolist/exporter.py @@ -0,0 +1,73 @@ +import json +import sys +from dataclasses import asdict +from datetime import datetime +from typing import Any, Iterator, Protocol, TypedDict + +from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel + +from todolist.status import TaskListStatus + + +class FormatOptions(TypedDict, total=False): + indent: int | str | None + separators: tuple[str, str] | None + sort_keys: bool + + +class SupportsWrite[T](Protocol): + def write(self, data: T, /) -> object: ... + + +class JSONExporter: + def __init__( + self, output: SupportsWrite[str], options: FormatOptions = {} + ) -> None: + self.output = output + self.options = options + + def export(self, content: Any) -> None: + json.dump(content, self.output, **self.options) + + +def export_database_to_json() -> None: + content = list(map(asdict, TaskListStatus.find_all())) + for exporter in exporters(): + exporter.export(content) + + +def exporters() -> Iterator[JSONExporter]: + file = None + try: + stdout_exporter = JSONExporter(sys.stdout) + stdout_exporter.options["indent"] = 2 + stdout_exporter.options["sort_keys"] = False + stdout_exporter.options["separators"] = (", ", ": ") + + file = open(f"todo_{timestamp()}.json", mode="w+", encoding="utf-8") + file_exporter = JSONExporter(file) + file_exporter.options["indent"] = None + file_exporter.options["sort_keys"] = True + file_exporter.options["separators"] = (",", ":") + + yield stdout_exporter + yield file_exporter + + file.seek(0) + Console(stderr=True).print( + Panel( + Markdown(file.read()), + title=file.name, + width=80, + border_style="bold", + ) + ) + finally: + if file: + file.close() + + +def timestamp() -> str: + return datetime.now().strftime("%Y%m%d%H%M%S") diff --git a/gemini-cli/todolist/src/todolist/renderer.py b/gemini-cli/todolist/src/todolist/renderer.py new file mode 100644 index 0000000000..447ae36606 --- /dev/null +++ b/gemini-cli/todolist/src/todolist/renderer.py @@ -0,0 +1,33 @@ +from rich.console import Console, Group +from rich.panel import Panel + +from todolist.status import TaskListStatus + +console = Console() + + +def render_short(status: TaskListStatus) -> None: + if len(status) == 0: + return + console.print(f"{status.name} ({len(status.done)}/{len(status)})") + + +def render_long(status: TaskListStatus) -> None: + if len(status) == 0: + return + + lines = [] + if status.pending: + lines.append("[b]Pending:[/]") + lines.extend([f"☐ {name}" for name in status.pending]) + + if status.done: + if status.pending: + lines.append("") + lines.append("[b]Completed:[/]") + lines.extend([f"[dim]🗹 [strike]{name}[/]" for name in status.done]) + + panel = Panel( + Group(*lines), title=status.name, width=60, border_style="bold" + ) + console.print(panel) diff --git a/gemini-cli/todolist/src/todolist/status.py b/gemini-cli/todolist/src/todolist/status.py new file mode 100644 index 0000000000..f091cba872 --- /dev/null +++ b/gemini-cli/todolist/src/todolist/status.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass +from typing import Self + +from todolist.database import Task, TaskList + + +@dataclass(frozen=True) +class TaskListStatus: + name: str + done: tuple[str, ...] + pending: tuple[str, ...] + + @classmethod + def find_all(cls) -> tuple[Self, ...]: + return tuple( + map(cls.from_model, TaskList.select().order_by(TaskList.name)) + ) + + @classmethod + def find_one(cls, list_name: str) -> Self | None: + if task_list := TaskList.get_or_none(TaskList.name == list_name): + return cls.from_model(task_list) + else: + return None + + @classmethod + def from_model(cls, task_list: TaskList) -> Self: + done, pending = [], [] + for task in task_list.tasks.order_by(Task.name): + if task.done: + done.append(task.pretty_name) + else: + pending.append(task.pretty_name) + return cls( + str(task_list.name), + tuple(done), + tuple(pending), + ) + + def __len__(self) -> int: + return len(self.done) + len(self.pending)