├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── requirements └── linting.txt ├── .gitignore ├── Makefile ├── .pre-commit-config.yaml ├── README.md ├── LICENSE ├── pyproject.toml └── samuelcolvin_aicli.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: samuelcolvin 2 | -------------------------------------------------------------------------------- /requirements/linting.txt: -------------------------------------------------------------------------------- 1 | black==23.9.1 2 | pre-commit==3.4.0 3 | ruff==0.0.289 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | .idea/ 3 | env*/ 4 | .coverage 5 | .cache/ 6 | htmlcov/ 7 | *.egg-info/ 8 | /build/ 9 | /dist/ 10 | /.ruff_cache/ 11 | /scratch/ 12 | /site/ 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := lint 2 | sources = samuelcolvin_aicli.py 3 | 4 | .PHONY: install 5 | install: 6 | pip install -r requirements/linting.txt 7 | pre-commit install 8 | 9 | .PHONY: format 10 | format: 11 | black $(sources) 12 | ruff --fix-only $(sources) 13 | 14 | .PHONY: lint 15 | lint: 16 | ruff $(sources) 17 | black $(sources) --check --diff 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.0.1 4 | hooks: 5 | - id: check-yaml 6 | - id: check-toml 7 | - id: end-of-file-fixer 8 | - id: trailing-whitespace 9 | 10 | - repo: local 11 | hooks: 12 | - id: format 13 | name: Format Python 14 | entry: make format 15 | types: [python] 16 | language: system 17 | pass_filenames: false 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aicli 2 | 3 | [![pypi](https://img.shields.io/pypi/v/samuelcolvin-aicli.svg)](https://pypi.python.org/pypi/samuelcolvin-aicli) 4 | [![versions](https://img.shields.io/pypi/pyversions/samuelcolvin-aicli.svg)](https://github.com/samuelcolvin/aicli) 5 | [![license](https://img.shields.io/github/license/samuelcolvin/aicli.svg)](https://github.com/samuelcolvin/aicli/blob/main/LICENSE) 6 | 7 | ## Installation 8 | 9 | ```bash 10 | pip install samuelcolvin-aicli 11 | ``` 12 | 13 | ## Usage 14 | 15 | You'll need to set the `OPENAI_API_KEY` environment variable to use `aicli` which you can generate from 16 | [platform.openai.com](https://platform.openai.com/), you'll have to add some credit to use the API. 17 | 18 | ```bash 19 | export OPENAI_API_KEY='...' 20 | ``` 21 | 22 | Then usage is as simple as: 23 | 24 | ```bash 25 | aicli 26 | ``` 27 | 28 | ## Example 29 | 30 | https://github.com/samuelcolvin/aicli/assets/4039449/53a30d7f-79ae-452e-ac81-e94c8cd4032b 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 - present Samuel Colvin. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '**' 9 | pull_request: 10 | types: [opened, synchronize] 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - uses: actions/setup-python@v4 20 | with: 21 | python-version: '3.10' 22 | 23 | - run: pip install -r requirements/linting.txt 24 | 25 | - uses: pre-commit/action@v3.0.0 26 | with: 27 | extra_args: --all-files 28 | 29 | check: # This job does nothing and is only used for the branch protection 30 | if: always() 31 | needs: [lint] 32 | runs-on: ubuntu-latest 33 | 34 | steps: 35 | - name: Decide whether the needed jobs succeeded or failed 36 | uses: re-actors/alls-green@release/v1 37 | id: all-green 38 | with: 39 | jobs: ${{ toJSON(needs) }} 40 | 41 | release: 42 | needs: [check] 43 | if: "success() && startsWith(github.ref, 'refs/tags/')" 44 | runs-on: ubuntu-latest 45 | environment: release 46 | 47 | permissions: 48 | id-token: write 49 | 50 | steps: 51 | - uses: actions/checkout@v3 52 | 53 | - name: set up python 54 | uses: actions/setup-python@v4 55 | with: 56 | python-version: '3.11' 57 | 58 | - name: install 59 | run: pip install -U build 60 | 61 | - name: check version 62 | id: check-version 63 | uses: samuelcolvin/check-python-version@v4.1 64 | with: 65 | version_file_path: 'samuelcolvin_aicli.py' 66 | 67 | - name: build 68 | run: python -m build 69 | 70 | - name: Upload package to PyPI 71 | uses: pypa/gh-action-pypi-publish@release/v1 72 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [tool.hatch.version] 6 | path = "samuelcolvin_aicli.py" 7 | 8 | [tool.hatch.build.targets.sdist] 9 | exclude = ["/.github", "/example.mov"] 10 | 11 | [project] 12 | name = "samuelcolvin-aicli" 13 | description = "OpenAI powered AI CLI in just a few lines of code." 14 | authors = [{ name = "Samuel Colvin", email = "s@muelcolvin.com" }] 15 | license = "MIT" 16 | readme = "README.md" 17 | classifiers = [ 18 | "Development Status :: 4 - Beta", 19 | "Topic :: Internet", 20 | "Topic :: Communications :: Chat", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3 :: Only", 24 | "Programming Language :: Python :: 3.7", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Programming Language :: Python :: 3.12", 30 | "Intended Audience :: Developers", 31 | "Intended Audience :: Information Technology", 32 | "Environment :: Console", 33 | ] 34 | requires-python = ">=3.7" 35 | dependencies = [ 36 | "openai>=1,<2", 37 | "rich>=13", 38 | "prompt-toolkit>=3", 39 | ] 40 | dynamic = ["version"] 41 | 42 | [project.urls] 43 | Homepage = "https://github.com/samuelcolvin/aicli" 44 | Funding = "https://github.com/sponsors/samuelcolvin" 45 | Source = "https://github.com/samuelcolvin/aicli" 46 | 47 | [project.scripts] 48 | aicli = "samuelcolvin_aicli:cli" 49 | 50 | [tool.ruff] 51 | line-length = 120 52 | extend-select = ["Q", "RUF100", "UP", "I"] 53 | flake8-quotes = {inline-quotes = "single", multiline-quotes = "double"} 54 | target-version = "py37" 55 | 56 | [tool.black] 57 | color = true 58 | line-length = 120 59 | target-version = ["py37"] 60 | skip-string-normalization = true 61 | -------------------------------------------------------------------------------- /samuelcolvin_aicli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import argparse 3 | import os 4 | import sys 5 | from datetime import datetime, timezone 6 | from pathlib import Path 7 | 8 | import openai 9 | from prompt_toolkit import PromptSession 10 | from prompt_toolkit.auto_suggest import AutoSuggestFromHistory 11 | from prompt_toolkit.history import FileHistory 12 | from rich.console import Console, ConsoleOptions, RenderResult 13 | from rich.live import Live 14 | from rich.markdown import CodeBlock, Markdown 15 | from rich.status import Status 16 | from rich.syntax import Syntax 17 | from rich.text import Text 18 | 19 | __version__ = '0.8.0' 20 | 21 | 22 | class SimpleCodeBlock(CodeBlock): 23 | def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: 24 | code = str(self.text).rstrip() 25 | yield Text(self.lexer_name, style='dim') 26 | yield Syntax(code, self.lexer_name, theme=self.theme, background_color='default', word_wrap=True) 27 | yield Text(f'/{self.lexer_name}', style='dim') 28 | 29 | 30 | Markdown.elements['fence'] = SimpleCodeBlock 31 | 32 | 33 | def cli() -> int: 34 | parser = argparse.ArgumentParser( 35 | prog='aicli', 36 | description=f"""\ 37 | OpenAI powered AI CLI v{__version__} 38 | 39 | Special prompts: 40 | * `show-markdown` - show the markdown output from the previous response 41 | * `multiline` - toggle multiline mode 42 | """, 43 | ) 44 | parser.add_argument('prompt', nargs='?', help='AI Prompt, if omitted fall into interactive mode') 45 | 46 | # allows you to disable streaming responses if they get annoying or are more expensive. 47 | parser.add_argument('--no-stream', action='store_true', help='Whether to stream responses from OpenAI') 48 | 49 | parser.add_argument('--version', action='store_true', help='Show version and exit') 50 | 51 | args = parser.parse_args() 52 | 53 | console = Console() 54 | console.print(f'aicli - OpenAI powered AI CLI v{__version__}', style='green bold', highlight=False) 55 | if args.version: 56 | return 0 57 | 58 | try: 59 | openai_api_key = os.environ['OPENAI_API_KEY'] 60 | except KeyError: 61 | console.print('You must set the OPENAI_API_KEY environment variable', style='red') 62 | return 1 63 | 64 | client = openai.OpenAI(api_key=openai_api_key) 65 | 66 | now_utc = datetime.now(timezone.utc) 67 | setup = f"""\ 68 | Help the user by responding to their request, the output should be concise and always written in markdown. 69 | The current date and time is {datetime.now()} {now_utc.astimezone().tzinfo.tzname(now_utc)}. 70 | The user is running {sys.platform}.""" 71 | 72 | stream = not args.no_stream 73 | messages = [{'role': 'system', 'content': setup}] 74 | 75 | if args.prompt: 76 | messages.append({'role': 'user', 'content': args.prompt}) 77 | try: 78 | ask_openai(client, messages, stream, console) 79 | except KeyboardInterrupt: 80 | pass 81 | return 0 82 | 83 | history = Path().home() / '.openai-prompt-history.txt' 84 | session = PromptSession(history=FileHistory(str(history))) 85 | multiline = False 86 | 87 | while True: 88 | try: 89 | text = session.prompt('aicli ➤ ', auto_suggest=AutoSuggestFromHistory(), multiline=multiline) 90 | except (KeyboardInterrupt, EOFError): 91 | return 0 92 | 93 | if not text.strip(): 94 | continue 95 | 96 | ident_prompt = text.lower().strip(' ').replace(' ', '-') 97 | if ident_prompt == 'show-markdown': 98 | last_content = messages[-1]['content'] 99 | console.print('[dim]Last markdown output of last question:[/dim]\n') 100 | console.print(Syntax(last_content, lexer='markdown', background_color='default')) 101 | continue 102 | elif ident_prompt == 'multiline': 103 | multiline = not multiline 104 | if multiline: 105 | console.print( 106 | 'Enabling multiline mode. ' 107 | '[dim]Press [Meta+Enter] or [Esc] followed by [Enter] to accept input.[/dim]' 108 | ) 109 | else: 110 | console.print('Disabling multiline mode.') 111 | continue 112 | 113 | messages.append({'role': 'user', 'content': text}) 114 | 115 | try: 116 | content = ask_openai(client, messages, stream, console) 117 | except KeyboardInterrupt: 118 | return 0 119 | messages.append({'role': 'assistant', 'content': content}) 120 | 121 | 122 | def ask_openai(client: openai.OpenAI, messages: list[dict[str, str]], stream: bool, console: Console) -> str: 123 | with Status('[dim]Working on it…[/dim]', console=console): 124 | response = client.chat.completions.create(model='gpt-4o', messages=messages, stream=stream) 125 | 126 | console.print('\nResponse:', style='green') 127 | if stream: 128 | content = '' 129 | interrupted = False 130 | with Live('', refresh_per_second=15, console=console) as live: 131 | try: 132 | for chunk in response: 133 | if chunk.choices[0].finish_reason is not None: 134 | break 135 | chunk_text = chunk.choices[0].delta.content 136 | content += chunk_text 137 | live.update(Markdown(content)) 138 | except KeyboardInterrupt: 139 | interrupted = True 140 | 141 | if interrupted: 142 | console.print('[dim]Interrupted[/dim]') 143 | else: 144 | content = response.choices[0].message.content 145 | console.print(Markdown(content)) 146 | 147 | return content 148 | 149 | 150 | if __name__ == '__main__': 151 | sys.exit(cli()) 152 | --------------------------------------------------------------------------------