diff --git a/src/llm_cli/assets/prompts/commit.md b/src/llm_cli/assets/prompts/commit.md
index 56132f2..129c265 100644
--- a/src/llm_cli/assets/prompts/commit.md
+++ b/src/llm_cli/assets/prompts/commit.md
@@ -8,7 +8,7 @@ ${GIT_DIFF}
Now, if provided, use this context to understand the motivation behind the changes and any relevant background information:
-${FILES}
+${GIT_FILES}
@@ -45,6 +45,7 @@ The commit message should be structured as follows:
```
- lines must not be longer than 74 characters
+- use a markdown list for the optional body if it contains multiple items
- choose only 1 type from the type-to-description below:
- feat: Introduce new features
- fix: Fix a bug
diff --git a/src/llm_cli/cmd/__init__.pyi b/src/llm_cli/cmd/__init__.pyi
index cdbd2ac..bb5eadd 100644
--- a/src/llm_cli/cmd/__init__.pyi
+++ b/src/llm_cli/cmd/__init__.pyi
@@ -1,4 +1,4 @@
-from . import repo
+from . import commit, repo
from ._app import app
-__all__ = ["app", "repo"]
+__all__ = ["app", "commit", "repo"]
diff --git a/src/llm_cli/cmd/_app.py b/src/llm_cli/cmd/_app.py
index be8531b..ef5cb06 100644
--- a/src/llm_cli/cmd/_app.py
+++ b/src/llm_cli/cmd/_app.py
@@ -1,7 +1,8 @@
import typer
-import llm_cli.cmd as lc
import llm_cli.utils as lu
+from llm_cli import cmd
app: typer.Typer = typer.Typer(name="llm-cli", no_args_is_help=True)
-lu.add_command(app, lc.repo.app)
+lu.add_command(app, cmd.repo.app)
+lu.add_command(app, cmd.commit.app)
diff --git a/src/llm_cli/cmd/commit/__init__.py b/src/llm_cli/cmd/commit/__init__.py
new file mode 100644
index 0000000..62d86f7
--- /dev/null
+++ b/src/llm_cli/cmd/commit/__init__.py
@@ -0,0 +1,3 @@
+import lazy_loader as lazy
+
+__getattr__, __dir__, __all__ = lazy.attach_stub(__name__, __file__)
diff --git a/src/llm_cli/cmd/commit/__init__.pyi b/src/llm_cli/cmd/commit/__init__.pyi
new file mode 100644
index 0000000..f35f969
--- /dev/null
+++ b/src/llm_cli/cmd/commit/__init__.pyi
@@ -0,0 +1,4 @@
+from ._app import app
+from ._main import main
+
+__all__ = ["app", "main"]
diff --git a/src/llm_cli/cmd/commit/_app.py b/src/llm_cli/cmd/commit/_app.py
new file mode 100644
index 0000000..1c0e955
--- /dev/null
+++ b/src/llm_cli/cmd/commit/_app.py
@@ -0,0 +1,12 @@
+import asyncio
+
+import typer
+
+app = typer.Typer(name="commit")
+
+
+@app.command()
+def main() -> None:
+ from ._main import main
+
+ asyncio.run(main())
diff --git a/src/llm_cli/cmd/commit/_main.py b/src/llm_cli/cmd/commit/_main.py
new file mode 100644
index 0000000..4d594b5
--- /dev/null
+++ b/src/llm_cli/cmd/commit/_main.py
@@ -0,0 +1,30 @@
+import asyncio
+import string
+
+import git
+import litellm
+import typer
+
+import llm_cli as lc
+import llm_cli.utils as lu
+
+
+async def main(*, verify: bool = True) -> None:
+ prompt_template = string.Template(lu.get_prompt("commit"))
+ repo = git.Repo(search_parent_directories=True)
+ diff: str = repo.git.diff("--cached", "--no-ext-diff")
+ files: str = repo.git.ls_files()
+ prompt: str = prompt_template.substitute({"GIT_DIFF": diff, "GIT_FILES": files})
+ resp: litellm.ModelResponse = await lc.output(prompt, prefix="")
+ choices: litellm.Choices = resp.choices[0] # pyright: ignore [reportAssignmentType]
+ message: str = lu.extract_between_tags(choices.message.content)
+ proc: asyncio.subprocess.Process = await lu.run(
+ "git",
+ "commit",
+ f"--message={message}",
+ "--verify" if verify else "--no-verify",
+ "--edit",
+ check=False,
+ )
+ if proc.returncode:
+ raise typer.Exit(proc.returncode)
diff --git a/src/llm_cli/cmd/repo/description/__init__.pyi b/src/llm_cli/cmd/repo/description/__init__.pyi
index 2a9db04..f35f969 100644
--- a/src/llm_cli/cmd/repo/description/__init__.pyi
+++ b/src/llm_cli/cmd/repo/description/__init__.pyi
@@ -1,3 +1,4 @@
from ._app import app
+from ._main import main
-__all__ = ["app"]
+__all__ = ["app", "main"]
diff --git a/src/llm_cli/cmd/repo/topics/__init__.pyi b/src/llm_cli/cmd/repo/topics/__init__.pyi
index 2a9db04..f35f969 100644
--- a/src/llm_cli/cmd/repo/topics/__init__.pyi
+++ b/src/llm_cli/cmd/repo/topics/__init__.pyi
@@ -1,3 +1,4 @@
from ._app import app
+from ._main import main
-__all__ = ["app"]
+__all__ = ["app", "main"]
diff --git a/src/llm_cli/interactive/_output.py b/src/llm_cli/interactive/_output.py
index 27b4a2f..5185cac 100644
--- a/src/llm_cli/interactive/_output.py
+++ b/src/llm_cli/interactive/_output.py
@@ -1,4 +1,4 @@
-from collections.abc import Sequence
+from collections.abc import Callable, Sequence
from typing import Any
import litellm
@@ -7,12 +7,14 @@
from rich.panel import Panel
import llm_cli as lc
+import llm_cli.utils as lu
async def output(
prompt: str,
*,
prefix: str | None = None,
+ sanitize: Callable[[str], str] | None = lu.extract_between_tags,
stop: str | Sequence[str] | None = None,
title: str | None = None,
) -> litellm.ModelResponse:
@@ -36,7 +38,8 @@ async def output(
response = litellm.stream_chunk_builder(chunks) # pyright: ignore [reportAssignmentType]
choices: litellm.Choices = response.choices[0] # pyright: ignore [reportAssignmentType]
content: str = choices.message.content or ""
- content = content.strip()
+ if sanitize:
+ content = sanitize(content)
live.update(
Group(
Panel(content, title=title, title_align="left"),
diff --git a/src/llm_cli/utils/__init__.pyi b/src/llm_cli/utils/__init__.pyi
index 9b36ef5..95bf94b 100644
--- a/src/llm_cli/utils/__init__.pyi
+++ b/src/llm_cli/utils/__init__.pyi
@@ -1,8 +1,17 @@
from . import git
from ._add_command import add_command
+from ._extract_between_tags import extract_between_tags
from ._get_app_dir import get_app_dir
from ._get_prompt import get_prompt
from ._repomix import repomix
from ._run import run
-__all__ = ["add_command", "get_app_dir", "get_prompt", "git", "repomix", "run"]
+__all__ = [
+ "add_command",
+ "extract_between_tags",
+ "get_app_dir",
+ "get_prompt",
+ "git",
+ "repomix",
+ "run",
+]
diff --git a/src/llm_cli/utils/_extract_between_tags.py b/src/llm_cli/utils/_extract_between_tags.py
new file mode 100644
index 0000000..c1289e4
--- /dev/null
+++ b/src/llm_cli/utils/_extract_between_tags.py
@@ -0,0 +1,11 @@
+def extract_between_tags(content: str | None, tag: str = "Answer") -> str:
+ if content is None:
+ return ""
+ start: int = content.find("<" + tag + ">")
+ if start >= 0:
+ start += len(tag) + 2
+ content = content[start:]
+ end: int = content.find("" + tag + ">")
+ if end >= 0:
+ content = content[:end]
+ return content.strip()
diff --git a/src/llm_cli/utils/_run.py b/src/llm_cli/utils/_run.py
index dedebb3..f1df1fa 100644
--- a/src/llm_cli/utils/_run.py
+++ b/src/llm_cli/utils/_run.py
@@ -7,8 +7,9 @@
async def run(
program: _StrOrBytesPath, *args: _StrOrBytesPath, check: bool = True
-) -> None:
+) -> asp.Process:
proc: asp.Process = await asp.create_subprocess_exec(program, *args)
returncode: int = await proc.wait()
if check and returncode != 0:
raise subprocess.CalledProcessError(returncode, [program, *args])
+ return proc