├── .flake8 ├── .github └── workflows │ ├── import_smart_workflow.yaml │ ├── initialize_project.yaml │ ├── publish.yaml │ └── unit_tests.yml ├── .gitignore ├── .idea ├── .gitignore ├── inspectionProfiles │ └── Project_Default.xml ├── misc.xml ├── modules.xml ├── runConfigurations │ ├── Grab_Commands.xml │ ├── Nil_Run.xml │ └── Tests.xml └── vcs.xml ├── .pilot-commands.yaml ├── .pilot-hints.md ├── .pilot-skills.yaml ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── cli ├── __init__.py ├── cli.py ├── command_index.py ├── commands │ ├── __init__.py │ ├── chat.py │ ├── config.py │ ├── edit.py │ ├── grab.py │ ├── history.py │ ├── plan.py │ ├── pr.py │ ├── run.py │ ├── task.py │ └── upgrade.py ├── constants.py ├── detect_repository.py ├── models.py ├── plan_executor.py ├── prompt_template.py ├── skill_index.py ├── status_indicator.py ├── task_handler.py ├── task_runner.py ├── user_config.py └── util.py ├── clitest.py ├── examples ├── extract-project-template │ ├── Makefile │ ├── extract_template.md.jinja2 │ ├── format_instructions.md.jinja2 │ ├── new_project_from_template.md.jinja2 │ ├── template.md │ ├── understand_build_system.md.jinja2 │ ├── understand_project_structure.md.jinja2 │ └── understand_readme.md.jinja2 ├── implementation-plan │ ├── README.md │ └── quicksort.yaml ├── recursive-magic │ └── research.md.jinja2 └── spec-and-build-feature │ ├── feature_description.md │ ├── plan.yaml │ └── write_spec.md.jinja2 ├── homebrew_formula.rb ├── poetry.lock ├── pr-pilot-cli.iml ├── prompts ├── README.md ├── README.md.jinja2 ├── analyze_test_results.md.jinja2 ├── calculate_pi.md.jinja2 ├── fix-tests.md.jinja2 ├── generate_pr_description.md.jinja2 ├── homebrew.md.jinja2 ├── house-keeping.md.jinja2 ├── investigate-pod.jinja2 ├── make_improvements.yaml.jinja2 ├── slack-report.md.jinja2 └── test.yaml ├── pyproject.toml └── tests ├── __init__.py ├── conftest.py ├── test_cli.py ├── test_command_grabbing.py ├── test_command_index.py ├── test_prompt_template.py └── test_util.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | -------------------------------------------------------------------------------- /.github/workflows/import_smart_workflow.yaml: -------------------------------------------------------------------------------- 1 | name: 🔄 Import Smart Workflow 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | workflow-id: 7 | description: ID of the workflow to import 8 | required: true 9 | 10 | jobs: 11 | setup: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout PR-Pilot-AI/smart-workflows 15 | uses: actions/checkout@v2 16 | with: 17 | repository: PR-Pilot-AI/smart-workflows 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | - name: Determine workflow file path 20 | id: filepath 21 | run: | 22 | if [ -f tools/${{ github.event.inputs.workflow-id }}/workflow.yaml ]; then 23 | echo "::set-output name=path::tools/${{ github.event.inputs.workflow-id }}/workflow.yaml" 24 | elif [ -f automations/${{ github.event.inputs.workflow-id }}/workflow.yaml ]; then 25 | echo "::set-output name=path::automations/${{ github.event.inputs.workflow-id }}/workflow.yaml" 26 | else 27 | echo "Path not found" && exit 1 28 | fi 29 | - name: Copy workflow file 30 | id: print-workflow 31 | run: | 32 | workflow=$(cat ${{ steps.filepath.outputs.path }}) 33 | echo "WORKFLOW<> "$GITHUB_OUTPUT" 34 | 35 | - name: Import Workflow 36 | uses: PR-Pilot-AI/smart-actions/quick-task@v1 37 | with: 38 | api-key: ${{ secrets.PR_PILOT_API_KEY }} 39 | agent-instructions: | 40 | Create a new file in `.github/workflows/` for the following workflow: 41 | 42 | --- 43 | 44 | ${{ steps.print-workflow.outputs.WORKFLOW }} 45 | 46 | --- 47 | 48 | Respond with suggestions on how the user may customize the workflow to fit their project. 49 | -------------------------------------------------------------------------------- /.github/workflows/initialize_project.yaml: -------------------------------------------------------------------------------- 1 | name: "🚀 Initialize Project" 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | programming-language: 7 | description: 'The programming language of the project.' 8 | required: true 9 | framework: 10 | description: 'The framework to be used (optional).' 11 | required: false 12 | project-description: 13 | description: 'A brief description of the project.' 14 | required: true 15 | 16 | jobs: 17 | setup_project: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Setup Project Instructions 21 | uses: PR-Pilot-AI/smart-actions/quick-task@v1 22 | with: 23 | api-key: ${{ secrets.PR_PILOT_API_KEY }} 24 | gpt-model: gpt-4 25 | agent-instructions: | 26 | This is an empty project and we want to initialize it. 27 | 28 | Programming language: ${{ github.event.inputs.programming-language }} 29 | Framework (optional): ${{ github.event.inputs.framework }} 30 | Project description: 31 | ${{ github.event.inputs.project-description }} 32 | 33 | 1. Use the programming language and framework (if provided) to search the internet for information on how to start a project with these specifications. 34 | 2. Compile the information into concise, actionable instructions for starting the project, including a list of initial files that need to be created. 35 | 3. Create a Github issue in the repository with the compiled instructions and detailed list of files to create. Label the issue `needs-refinement`. 36 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'pyproject.toml' 7 | branches: 8 | - main 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v3 20 | 21 | - name: Set up Python 22 | uses: actions/setup-python@v3 23 | with: 24 | python-version: '3.10' 25 | 26 | - name: Install Poetry 27 | run: | 28 | curl -sSL https://install.python-poetry.org | python3 - 29 | # Add Poetry to PATH 30 | echo "${HOME}/.local/bin" >> $GITHUB_PATH 31 | 32 | - name: Install dependencies 33 | run: poetry install --no-dev 34 | 35 | - name: Build package 36 | run: poetry build 37 | 38 | - name: Publish package 39 | env: 40 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} 41 | run: poetry publish --username __token__ --password $POETRY_PYPI_TOKEN_PYPI 42 | -------------------------------------------------------------------------------- /.github/workflows/unit_tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Set up Python 3.10 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: '3.10' 17 | - name: Cache Python dependencies 18 | uses: actions/cache@v2 19 | with: 20 | path: ~/.cache/pip 21 | key: ${{ runner.os }}-pip-${{ hashFiles('**/pyroject.toml') }} 22 | restore-keys: | 23 | ${{ runner.os }}-pip- 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade poetry 27 | poetry install 28 | - name: Run Unit Tests 29 | run: poetry run pytest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | venv -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Grab_Commands.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Nil_Run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Tests.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.pilot-commands.yaml: -------------------------------------------------------------------------------- 1 | commands: 2 | - description: Writes a Haiku about this project 3 | name: haiku 4 | params: 5 | cheap: true 6 | code: false 7 | debug: false 8 | direct: false 9 | model: gpt-3.5-turbo 10 | prompt: write a haiku about this project 11 | repo: arc-eng/cli 12 | snap: false 13 | spinner: true 14 | sync: false 15 | verbose: true 16 | wait: true 17 | - description: Run the tests, analyze the output and provide suggestions 18 | name: test-analysis 19 | params: 20 | cheap: false 21 | code: false 22 | debug: false 23 | direct: false 24 | file: prompts/analyze_test_results.md.jinja2 25 | model: gpt-4o 26 | snap: false 27 | spinner: true 28 | sync: true 29 | verbose: true 30 | wait: true 31 | - description: Assemble a comprehensive daily report and send it to Slack 32 | name: daily-report 33 | params: 34 | cheap: false 35 | code: false 36 | debug: false 37 | direct: false 38 | file: prompts/slack-report.md.jinja2 39 | model: gpt-4o 40 | snap: false 41 | spinner: true 42 | sync: false 43 | verbose: true 44 | wait: true 45 | - description: Generate title and description for a pull request 46 | name: pr-description 47 | params: 48 | cheap: false 49 | code: false 50 | debug: false 51 | direct: false 52 | file: prompts/generate_pr_description.md.jinja2 53 | model: gpt-4o 54 | snap: false 55 | spinner: true 56 | sync: false 57 | verbose: false 58 | wait: false 59 | -------------------------------------------------------------------------------- /.pilot-hints.md: -------------------------------------------------------------------------------- 1 | Arcane Engine is a tool that lets developers delegate small tasks to AI in natural language to save time and avoid context switching. 2 | Every interaction between the user and Arcane Engine is a task. Tasks are created using prompts. 3 | This project: 4 | - Uses `click` to implement CLI in Python 5 | - Main entry point is the `pilot` command in `cli/cli.py` 6 | - Sub-commands implemented in `cli/commands/` directory 7 | - Uses `poetry` for dependency management 8 | - Uses `rich` for printing to the console -------------------------------------------------------------------------------- /.pilot-skills.yaml: -------------------------------------------------------------------------------- 1 | - args: 2 | expected_output: Expected output of the command 3 | name: Name of the command 4 | purpose: What the command does 5 | instructions: | 6 | Add a new CLI command by doing the following: 7 | 1. Read `cli/cli.py` and `cli/commands/task.py` to understand how the CLI commands are implemented 8 | 2. Implement the new command in `cli/commands/.py` 9 | 3. Integrate the new command in `cli/cli.py` 10 | result: A short summary of your actions 11 | title: Add new CLI command 12 | - args: 13 | expected_change: How the command implementation needs to change 14 | name: Name of the command 15 | instructions: | 16 | Modify an existing CLI command by doing the following: 17 | 1. List the `cli/commands` directory to find the file where the command is implemented 18 | 2. Read the file and understand how the command is implemented 19 | 3. Modify the command implementation to meet the new requirements 20 | 4. Write the changes back to the file 21 | result: A short summary of your actions 22 | title: Modify a CLI command 23 | - title: Run a skills test 24 | args: 25 | param: The word 'test' 26 | instructions: | 27 | 1. Find all open Github issues 28 | 2. Read one of them 29 | 3. Read `README.md` and write a Haiku about everything 30 | result: Only the Haiku -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.10 3 | repos: 4 | - repo: https://github.com/psf/black 5 | rev: 24.4.2 6 | hooks: 7 | - id: black 8 | args: ["--config=pyproject.toml"] 9 | language_version: python3 10 | - repo: https://github.com/pycqa/flake8 11 | rev: '7.1.0' 12 | hooks: 13 | - id: flake8 14 | language_version: python3 -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | readme: 2 | poetry run pilot task --direct -f prompts/README.md.jinja2 -o README.md 3 | sed -i '' 's/python -m cli.cli/pilot/g' README.md 4 | git add README.md 5 | git add prompts/README.md.jinja2 6 | git commit -m "docs: update README.md" 7 | git push 8 | homebrew: 9 | # Generate a homebrew formula and copy it to the clipboard 10 | poetry homebrew-formula --quiet --template=homebrew_formula.rb --output=- | pbcopy 11 | commit-hooks: 12 | # Install pre-commit hooks 13 | poetry install 14 | poetry run pre-commit install -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Arcane Engine Logo 3 |
4 | 5 |

6 | Install | 7 | Documentation | 8 | Blog | 9 | Website 10 |

11 | 12 | 13 | # Arcane Engine Command-Line Interface 14 | [![Unit Tests](https://github.com/arc-eng/cli/actions/workflows/unit_tests.yml/badge.svg)][tests] 15 | [![PyPI](https://img.shields.io/pypi/v/arcane-cli.svg)][pypi status] 16 | [![Python Version](https://img.shields.io/pypi/pyversions/arcane-cli)][pypi status] 17 | [![License](https://img.shields.io/pypi/l/arcane-cli)][license] 18 | [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)][black] 19 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)][pre-commit] 20 |
21 | 22 | [pypi status]: https://pypi.org/project/arcane-cli/ 23 | [tests]: https://github.com/arc-eng/cli/actions/workflows/unit_tests.yml 24 | [codecov]: https://app.codecov.io/gh/magmax/python-inquirer 25 | [pre-commit]: https://github.com/pre-commit/pre-commit 26 | [black]: https://github.com/psf/black 27 | [license]: https://github.com/arc-eng/cli/blob/main/LICENSE 28 | 29 | [Arcane Engine](https://docs.arcane.engineer) is **simple and intuitive CLI** that assists you in your daily work: 30 | 31 | ```bash 32 | pilot edit main.py "Add docstrings to all functions and classes" 33 | ``` 34 | 35 | **It works with [the dev tools you trust and love](https://docs.arcane.engineer/integrations.html)** - exactly when and where you want it. 36 | 37 | ```bash 38 | pilot task "Find all bug issues on Github and Linear opened yesterday, post them to #bugs-daily on Slack." 39 | ``` 40 | 41 | [Prompt templates](https://github.com/arc-eng/cli/tree/main/prompts) let you can create powerful, 42 | **executable prompt-based commands**, defined as Jinja templates: 43 | 44 | ```markdown 45 | I've made some changes and opened a new PR: #{{ env('PR_NUMBER') }}. 46 | 47 | I need a PR title and a description that summarizes these changes in short, concise bullet points. 48 | The PR description will also be used as merge commit message, so it should be clear and informative. 49 | 50 | Use the following guidelines: 51 | 52 | - Start title with a verb in the imperative mood (e.g., "Add", "Fix", "Update"). 53 | - At the very top, provide 1-sentence summary of the changes and their impact. 54 | - Below, list the changes made in bullet points. 55 | 56 | # Your task 57 | Edit PR #{{ env('PR_NUMBER') }} title and description to reflect the changes made in this PR. 58 | ``` 59 | 60 | Send Arcane Engine off to give any PR a title and description **according to your guidelines**: 61 | 62 | ```bash 63 | ➜ PR_NUMBER=153 pilot task -f generate-pr-description.md.jinja2 --save-command 64 | ✔ Task created: 7d5573d2-2717-4a96-8bae-035886420c74 (0:00:00.00) 65 | ✔ Update PR #153 title and description to reflect changes made (0:00:17.87) 66 | ╭──────────────────────────── Result ──────────────────────────────────────────╮ 67 | │ The PR title and description have been updated. You can view the PR here. │ 68 | ╰──────────────────────────────────────────────────────────────────────────────╯ 69 | ``` 70 | 71 | The `--save-command` parameter makes this call **re-usable**: 72 | 73 | ```bash 74 | ➜ pilot task -f generate-pr-description.md.jinja2 --save-command 75 | 76 | Save the task parameters as a command: 77 | 78 | Name (e.g. generate-pr-desc): pr-description 79 | Short description: Generate title and description for a pull request 80 | 81 | Command saved to .pilot-commands.yaml 82 | ``` 83 | 84 | You can now run this command **for any PR** with `pilot run pr-description`: 85 | 86 | ```bash 87 | ➜ pilot run pr-description 88 | Enter value for PR_NUMBER: 83 89 | ╭──────────── Result ─────────────╮ 90 | │ Here is the link to the PR #83 │ 91 | ╰─────────────────────────────────╯ 92 | ``` 93 | 94 | To learn more, please visit our **[User Guide](https://docs.arcane.engineer/user_guide.html)** and **[demo repository](https://github.com/PR-Pilot-AI/demo/tree/main)**. 95 | 96 | ## 📦 Installation 97 | First, make sure you have [installed](https://github.com/apps/pr-pilot-ai/installations/new) Arcane Engine in your repository. 98 | 99 | Then, install the CLI using one of the following methods: 100 | 101 | ### pip 102 | ``` 103 | pip install --upgrade arcane-cli 104 | ``` 105 | 106 | ### Homebrew: 107 | ``` 108 | brew tap pr-pilot-ai/homebrew-tap 109 | brew install arcane-cli 110 | ``` 111 | 112 | 113 | ### ⚙️ Options and Parameters 114 | 115 | The CLI has global parameters and options that can be used to customize its behavior. 116 | 117 | 118 | ```bash 119 | Usage: pilot [OPTIONS] COMMAND [ARGS]... 120 | 121 | Arcane Engine CLI - https://docs.arcane.engineer 122 | 123 | Delegate routine work to AI with confidence and predictability. 124 | 125 | Options: 126 | --wait / --no-wait Wait for Arcane Engine to finish the task. 127 | --repo TEXT Github repository in the format owner/repo. 128 | --spinner / --no-spinner Display a loading indicator. 129 | --verbose Display status messages 130 | -m, --model TEXT GPT model to use. 131 | -b, --branch TEXT Run the task on a specific branch. 132 | --sync / --no-sync Run task on your current branch and pull Arcane Engines 133 | changes when done. 134 | --debug Display debug information. 135 | --help Show this message and exit. 136 | 137 | Commands: 138 | chat 💬 Chat with Arcane Engine. 139 | config 🔧 Customize Arcane Engines behavior. 140 | edit ✍️ Let Arcane Engine edit a file for you. 141 | grab 🤲 Grab commands, prompts and plans from other repositories. 142 | history 📜 Access recent tasks. 143 | plan 📋 Let Arcane Engine execute a plan for you. 144 | run 🚀 Run a saved command. 145 | task ➕ Create a new task for Arcane Engine. 146 | upgrade ⬆️ Upgrade arcane-cli to the latest version. 147 | ``` 148 | 149 | ## 🛠️ Usage 150 | 151 | In your repository, use the `pilot` command: 152 | 153 | ```bash 154 | pilot task "Tell me about this project!" 155 | # 📝 Ask Arcane Engine to edit a local file for you: 156 | pilot edit cli/cli.py "Make sure all functions and classes have docstrings." 157 | # ⚡ Generate code quickly and save it as a file: 158 | pilot task -o test_utils.py --code "Write some unit tests for the utils.py file." 159 | # 🔍 Capture part of your screen and add it to a prompt: 160 | pilot task -o component.html --code --snap "Write a Bootstrap5 component that looks like this." 161 | # 📊 Get an organized view of your Github issues: 162 | pilot task "Find all open Github issues labeled as 'bug', categorize and prioritize them" 163 | # 📝 Ask Arcane Engine to analyze your test results using prompt templates: 164 | pilot task -f prompts/analyze-test-results.md.jinja2 165 | ``` 166 | 167 | For more detailed examples, please visit our **[demo repository](https://github.com/PR-Pilot-AI/demo/tree/main)**. 168 | 169 | 170 | ### ⬇️ Grab commands from other repositories 171 | 172 | Once saved in a repository, commands can be grabbed from anywhere: 173 | 174 | ```bash 175 | ➜ code pilot grab commands pr-pilot-ai/core 176 | 177 | pr-pilot-ai/core 178 | haiku Writes a Haiku about your project 179 | test-analysis Run unit tests, analyze the output & provide suggestions 180 | daily-report Assemble a comprehensive daily report & send it to Slack 181 | pr-description Generate PR Title & Description 182 | house-keeping Organize & clean up cfg files (package.json, pom.xml, etc) 183 | readme-badges Generate badges for your README file 184 | 185 | [?] Grab: 186 | [ ] haiku 187 | [X] test-analysis 188 | [ ] daily-report 189 | > [X] pr-description 190 | [ ] house-keeping 191 | [ ] readme-badges 192 | 193 | 194 | You can now use the following commands: 195 | 196 | pilot run test-analysis Run unit tests, analyze the output & provide suggestions 197 | pilot run pr-description Generate PR Title & Description 198 | ``` 199 | 200 | Our **[core repository](https://github.com/PR-Pilot-AI/core)** contains an ever-growing, curated list of commands 201 | that we tested and handcrafted for you. You can grab them and use them in your own repositories. 202 | 203 | ### 📝 Advanced Usage: Execute a step-by-step plan 204 | 205 | Break down more complex tasks into smaller steps with a plan: 206 | 207 | ```yaml 208 | # add_page.yaml 209 | 210 | name: Add a TODO Page 211 | prompt: | 212 | We are adding a TODO page to the application. 213 | Users should be able to: 214 | - See a list of their TODOs 215 | - Cross of TODO items / mark them as done 216 | - Add new TODO items 217 | 218 | steps: 219 | - name: Create HTML template 220 | prompt: | 221 | 1. Look at templates/users.html to understand the basic structure 222 | 2. Create templates/todo.html based on the example 223 | - name: Create view controller 224 | prompt: | 225 | The controller should handle all actions/calls from the UI. 226 | 1. Look at views/users.py to understand the basic structure 227 | 2. Create views/todo.py based on the example 228 | - name: Integrate the page 229 | prompt: | 230 | Integrate the new page into the application: 231 | 1. Add a new route in urls.py, referencing the new view controller 232 | 2. Add a new tab to the navigation in templates/base.html 233 | - name: Generate PR description 234 | template: prompts/generate-pr-description.md.jinja2 235 | ``` 236 | You can run this plan with: 237 | ```bash 238 | pilot plan add_page.yaml 239 | ``` 240 | 241 | Arcane Engine will then autonomously: 242 | * Create a new branch and open a PR 243 | * Implement the HTML template and view controller 244 | * Integrate the new page into the navigation 245 | * Look at all changes and create a PR description based on your preferences defined in `prompts/generate-pr-description.md.jinja2` 246 | 247 | Save this as part of your code base. Next time you need a new page, simply adjust the plan and run it again. 248 | If you don't like the result, simply close the PR and delete the branch. 249 | 250 | You can iterate on the plan until you are satisfied with the result. 251 | 252 | 253 | ## ⚙️ Configuration 254 | The configuration file is located at `~/.pr-pilot.yaml`. 255 | 256 | ```yaml 257 | # Your API Key from https://arcane.engineer/dashboard/api-keys/ 258 | api_key: YOUR_API_KEY 259 | 260 | # Default Github repository if not running CLI in a repository directory 261 | default_repo: owner/repo 262 | 263 | # Enabled --sync by default 264 | auto_sync: true 265 | 266 | # Suppress status messages by default 267 | verbose: false 268 | ``` 269 | 270 | ## 🤝 Contributing 271 | Contributors are welcome to improve the CLI by submitting pull requests or reporting issues. For more details, check the project's GitHub repository. 272 | 273 | ## 📜 License 274 | The Arcane Engine CLI is open-source software licensed under the GPL-3 license. -------------------------------------------------------------------------------- /cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arc-eng/cli/dbca0af986e3928579ffa6f8f39a52a368d60d8b/cli/__init__.py -------------------------------------------------------------------------------- /cli/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | from rich import print 3 | 4 | from cli.commands.chat import chat 5 | from cli.commands.config import config 6 | from cli.commands.edit import edit 7 | from cli.commands.grab import grab 8 | from cli.commands.history import history 9 | from cli.commands.plan import plan 10 | from cli.commands.run import RunCommand 11 | from cli.commands.task import task 12 | from cli.commands.upgrade import upgrade 13 | from cli.commands.pr import pr 14 | from cli.constants import DEFAULT_MODEL 15 | from cli.user_config import UserConfig 16 | 17 | 18 | @click.group() 19 | @click.option( 20 | "--wait/--no-wait", 21 | is_flag=True, 22 | default=True, 23 | help="Wait for Arcane Engine to finish the task.", 24 | ) 25 | @click.option("--repo", help="Github repository in the format owner/repo.", required=False) 26 | @click.option( 27 | "--spinner/--no-spinner", 28 | is_flag=True, 29 | default=True, 30 | help="Display a loading indicator.", 31 | ) 32 | @click.option("--verbose", is_flag=True, default=None, help="Display status messages") 33 | @click.option("--model", "-m", help="GPT model to use.", default=DEFAULT_MODEL) 34 | @click.option( 35 | "--branch", 36 | "-b", 37 | help="Run the task on a specific branch.", 38 | required=False, 39 | default=None, 40 | ) 41 | @click.option( 42 | "--sync/--no-sync", 43 | is_flag=True, 44 | default=None, 45 | help="Run task on your current branch and pull Arcane Engines changes when done.", 46 | ) 47 | @click.option("--debug", is_flag=True, default=False, help="Display debug information.") 48 | @click.pass_context 49 | 50 | def main(ctx, wait, repo, spinner, verbose, model, branch, sync, debug): 51 | """Arcane Engine CLI - https://docs.arcane.engineer 52 | 53 | Delegate routine work to AI with confidence and predictability. 54 | """ 55 | 56 | user_config = UserConfig() 57 | user_config.set_api_key_env_var() 58 | 59 | # If repo is set manually, don't auto sync 60 | if repo: 61 | sync = False 62 | else: 63 | if sync is None: 64 | # Sync is not set, so let's see if it's set in user config 65 | sync = user_config.auto_sync_enabled 66 | 67 | ctx.ensure_object(dict) 68 | ctx.obj["wait"] = wait 69 | ctx.obj["repo"] = repo 70 | ctx.obj["spinner"] = spinner 71 | ctx.obj["model"] = model 72 | ctx.obj["branch"] = branch 73 | ctx.obj["sync"] = sync 74 | ctx.obj["debug"] = debug 75 | 76 | if verbose is None: 77 | # Verbose is not set, so let's see if it's set in user config 78 | ctx.obj["verbose"] = user_config.verbose 79 | else: 80 | ctx.obj["verbose"] = verbose 81 | 82 | if debug: 83 | print(ctx.obj) 84 | 85 | 86 | main.add_command(task) 87 | main.add_command(edit) 88 | main.add_command(plan) 89 | main.add_command(grab) 90 | main.add_command(history) 91 | main.add_command(config) 92 | main.add_command(upgrade) 93 | main.add_command(chat) 94 | main.add_command(pr) 95 | 96 | run_command_help = """ 97 | 🚀 Run a saved command. 98 | 99 | Create new commands by using the --save-command flag when running a task. 100 | """ 101 | 102 | main.add_command(RunCommand(name="run", help=run_command_help)) 103 | 104 | 105 | if __name__ == "__main__": 106 | main() 107 | -------------------------------------------------------------------------------- /cli/command_index.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List, Optional 3 | 4 | import click 5 | import yaml 6 | from click import Command 7 | from pydantic import BaseModel, Field 8 | from rich.console import Console 9 | 10 | from cli.models import TaskParameters 11 | from cli.status_indicator import StatusIndicator 12 | from cli.task_runner import TaskRunner 13 | from cli.util import is_git_repo, get_git_root 14 | 15 | COMMAND_FILE_PATH = ".pilot-commands.yaml" 16 | 17 | 18 | class PilotCommand(BaseModel): 19 | """Implementation of 'pilot run' command.""" 20 | 21 | name: str = Field(..., description="Name of the command", pattern="^[a-z0-9-]+$") 22 | description: str = Field(..., description="Description of the command") 23 | params: TaskParameters = Field(..., description="CLI parameters for the command") 24 | 25 | def callback(self, *args, **kwargs): 26 | console = Console() 27 | 28 | # Overwrite parameters 29 | self.params.output = kwargs.get("output", self.params.output) 30 | self.params.model = kwargs.get("model", self.params.model) 31 | self.params.verbose = kwargs.get("verbose", self.params.verbose) 32 | self.params.debug = kwargs.get("debug", self.params.debug) 33 | self.params.spinner = kwargs.get("spinner", self.params.spinner) 34 | self.params.sync = kwargs.get("sync", self.params.sync) 35 | self.params.wait = kwargs.get("wait", self.params.wait) 36 | 37 | if self.params.sync: 38 | # Get current branch from git 39 | current_branch = os.popen("git rev-parse --abbrev-ref HEAD").read().strip() 40 | if current_branch not in ["master", "main"]: 41 | self.params.branch = current_branch 42 | status_indicator = StatusIndicator( 43 | spinner=self.params.spinner, 44 | display_log_messages=self.params.verbose, 45 | console=console, 46 | ) 47 | runner = TaskRunner(status_indicator) 48 | runner.run_task(self.params) 49 | status_indicator.stop() 50 | 51 | def to_click_command(self) -> Command: 52 | cmd = Command(self.name, callback=self.callback, help=self.description) 53 | 54 | cmd.params.append( 55 | click.Option( 56 | ["--output", "-o"], 57 | help="💾 Overwrite the output file path.", 58 | default=self.params.output, 59 | show_default=True, 60 | ) 61 | ) 62 | cmd.params.append( 63 | click.Option( 64 | ["--model", "-m"], 65 | help="🧠 Overwrite the model to use.", 66 | default=self.params.model, 67 | show_default=True, 68 | ) 69 | ) 70 | cmd.params.append( 71 | click.Option( 72 | ["--verbose"], 73 | help="🔊 Print more status messages.", 74 | is_flag=True, 75 | default=self.params.verbose, 76 | ) 77 | ) 78 | cmd.params.append( 79 | click.Option( 80 | ["--debug"], 81 | help="🐞 Run in debug mode.", 82 | is_flag=True, 83 | default=self.params.debug, 84 | ) 85 | ) 86 | cmd.params.append( 87 | click.Option( 88 | ["--spinner/--no-spinner"], 89 | help="🔄 Display a loading indicator.", 90 | is_flag=True, 91 | default=self.params.spinner, 92 | ) 93 | ) 94 | cmd.params.append( 95 | click.Option( 96 | ["--sync/--no-sync"], 97 | help="🔄 Sync local repository state with Arcane Engine changes.", 98 | is_flag=True, 99 | default=self.params.sync, 100 | ) 101 | ) 102 | cmd.params.append( 103 | click.Option( 104 | ["--wait/--no-wait"], 105 | help="⏳ Wait for the task to complete.", 106 | is_flag=True, 107 | default=self.params.wait, 108 | ) 109 | ) 110 | return cmd 111 | 112 | 113 | def find_pilot_commands_file() -> Optional[str]: 114 | """Discover the location of the .pilot-commands.yaml file. 115 | 116 | The file is searched for in the following order: 117 | 1. The root of the current Git repository 118 | 2. The home directory 119 | 120 | :return: The absolute path to the file, or None if not found. 121 | """ 122 | # Check if the current directory is part of a Git repository 123 | if is_git_repo(): 124 | git_root = get_git_root() 125 | if git_root: 126 | git_repo_file_path = os.path.join(git_root, ".pilot-commands.yaml") 127 | if os.path.isfile(git_repo_file_path): 128 | return os.path.abspath(git_repo_file_path) 129 | return None 130 | 131 | 132 | class CommandIndex: 133 | """ 134 | A class to manage the index of commands stored in a YAML file. 135 | """ 136 | 137 | def __init__(self, file_path: str = None): 138 | """ 139 | Initialize the CommandIndex with the given file path. 140 | 141 | :param file_path: Path to the YAML file containing commands. 142 | """ 143 | self.file_path = file_path 144 | if not self.file_path: 145 | self.file_path = find_pilot_commands_file() 146 | self.commands = self._load_commands() if self.file_path else [] 147 | 148 | def _load_commands(self) -> List[PilotCommand]: 149 | """ 150 | Load commands from the YAML file. 151 | 152 | :return: A list of Command instances. 153 | """ 154 | try: 155 | with open(self.file_path, "r") as file: 156 | data = yaml.safe_load(file) 157 | return [PilotCommand(**cmd) for cmd in data.get("commands", [])] 158 | except FileNotFoundError: 159 | return [] 160 | 161 | def save_commands(self) -> None: 162 | """ 163 | Save the current list of commands to the YAML file. 164 | """ 165 | with open(self.file_path, "w") as file: 166 | yaml.dump( 167 | {"commands": [cmd.model_dump(exclude_none=True) for cmd in self.commands]}, 168 | file, 169 | ) 170 | 171 | def add_command(self, new_command: PilotCommand) -> None: 172 | """ 173 | Add a new command to the list and save it. 174 | 175 | :param new_command: The Command instance to add. 176 | 177 | :raises ValueError: If a command with the same name already exists. 178 | """ 179 | for cmd in self.commands: 180 | if cmd.name == new_command.name: 181 | raise ValueError(f"Command with name '{new_command.name}' already exists") 182 | new_command.params.branch = None 183 | new_command.params.pr_number = None 184 | if new_command.params.file: 185 | new_command.params.prompt = None 186 | self.commands.append(new_command) 187 | self.save_commands() 188 | 189 | def get_commands(self) -> List[PilotCommand]: 190 | """ 191 | Get the list of commands. 192 | 193 | :return: A list of Command instances. 194 | """ 195 | return self.commands 196 | 197 | def get_command(self, command_name) -> Optional[PilotCommand]: 198 | """ 199 | Get a command by name. 200 | 201 | :param command_name: 202 | :return: 203 | """ 204 | for cmd in self.commands: 205 | if cmd.name == command_name: 206 | return cmd 207 | return None 208 | 209 | def remove_command(self, command_name) -> None: 210 | """ 211 | Remove a command by name. 212 | 213 | :param command_name: 214 | """ 215 | self.commands = [cmd for cmd in self.commands if cmd.name != command_name] 216 | self.save_commands() 217 | -------------------------------------------------------------------------------- /cli/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arc-eng/cli/dbca0af986e3928579ffa6f8f39a52a368d60d8b/cli/commands/__init__.py -------------------------------------------------------------------------------- /cli/commands/chat.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from typing import List, Optional 4 | 5 | import click 6 | from pydantic import BaseModel, Field 7 | from rich import print 8 | from rich.console import Console 9 | from rich.panel import Panel 10 | from rich.prompt import Confirm 11 | 12 | from cli.detect_repository import detect_repository 13 | from cli.models import TaskParameters 14 | from cli.status_indicator import StatusIndicator 15 | from cli.task_runner import TaskRunner 16 | from cli.util import get_branch_if_pushed, markdown_panel 17 | 18 | 19 | class ChatMessage(BaseModel): 20 | role: str 21 | content: str 22 | 23 | def print(self): 24 | """Print the chat message""" 25 | if self.role == "user": 26 | print(Panel(f"[blue]{self.content}[/blue]", expand=False, title="You")) 27 | elif self.role == "assistant": 28 | print(markdown_panel(None, self.content, hide_frame=True)) 29 | 30 | 31 | class ChatHistory(BaseModel): 32 | messages: List[ChatMessage] = Field(default=[]) 33 | file: Optional[str] = Field(default=None) 34 | 35 | def print(self): 36 | """Print the chat history""" 37 | for msg in self.messages: 38 | msg.print() 39 | 40 | def dump(self): 41 | """Dump chat history to JSON file""" 42 | json.dump([msg.dict() for msg in self.messages], open(self.file, "w")) 43 | 44 | def append(self, message: ChatMessage): 45 | """Append a message to the chat history""" 46 | self.messages.append(message) 47 | 48 | def load(self): 49 | """Load chat history from JSON file. Create it if it doesn't exist.""" 50 | if not self.file: 51 | raise ValueError("No file path provided.") 52 | if not os.path.exists(self.file): 53 | with open(self.file, "w") as f: 54 | json.dump([], f) 55 | return 56 | self.messages = [ChatMessage(**msg) for msg in json.load(open(self.file, "r"))] 57 | 58 | def to_prompt(self): 59 | """Convert chat history to a prompt""" 60 | history = "\n\n---\n\n".join( 61 | [f"{msg.role.upper()}: {msg.content}" for msg in self.messages] 62 | ) 63 | prompt = "We're having a conversation. Here's the chat history:\n\n" + history 64 | return prompt + "\n\n---\nRespond to the last message above." 65 | 66 | 67 | @click.command() 68 | @click.option( 69 | "--branch", 70 | "-b", 71 | help="Chat in the context of a specific branch.", 72 | required=False, 73 | default=None, 74 | ) 75 | @click.option( 76 | "--history", 77 | "-h", 78 | type=click.Path(exists=True), 79 | help="Chat history file to load.", 80 | required=False, 81 | default=None, 82 | ) 83 | @click.pass_context 84 | def chat(ctx, branch, history): 85 | """💬 Chat with Arcane Engine.""" 86 | console = Console() 87 | status_indicator = StatusIndicator( 88 | display_log_messages=True, spinner=True, console=console, display_spinner_text=False 89 | ) 90 | task_runner = TaskRunner(status_indicator) 91 | chat_history = ChatHistory(file=history, messages=[]) 92 | 93 | if chat_history.file: 94 | # There is an existing conversation. Load and print it. 95 | welcome_message = ( 96 | f"Continuing conversation from [code][yellow]{chat_history.file}[/yellow][/code]" 97 | ) 98 | chat_history.load() 99 | chat_history.print() 100 | else: 101 | welcome_message = "Starting a new conversation" 102 | 103 | if not ctx.obj["repo"]: 104 | ctx.obj["repo"] = detect_repository() 105 | 106 | welcome_message += f" on [code][bold]{ctx.obj['repo']}[/bold][/code]" 107 | if ctx.obj["sync"]: 108 | ctx.obj["branch"] = get_branch_if_pushed() 109 | if not branch and ctx.obj["branch"]: 110 | branch = ctx.obj["branch"] 111 | 112 | if branch: 113 | welcome_message += f" in branch [code]{branch}[/code]" 114 | print("[dim]" + welcome_message + "[/dim]\n") 115 | 116 | run_chat(branch, chat_history, console, ctx, task_runner) 117 | 118 | # If we have a file, automatically save the chat history 119 | if chat_history.file: 120 | chat_history.dump() 121 | print(f"Chat history saved to [code][yellow]{chat_history.file}[/yellow][/code]") 122 | # Otherwise, ask the user if they want to save the chat history 123 | elif Confirm.ask("Do you want to save the chat history as a JSON file?", default=False): 124 | file_path = console.input("Enter the file path to save the chat history: ") 125 | chat_history.file = file_path 126 | chat_history.dump() 127 | print(f"Chat history saved to [code]{file_path}[/code]") 128 | 129 | 130 | def run_chat(branch, chat_history, console, ctx, task_runner): 131 | while True: 132 | user_input = console.input("[bold blue]You:[/bold blue] ") 133 | if user_input.strip() == "": 134 | break 135 | chat_history.append(ChatMessage(role="user", content=user_input)) 136 | params = TaskParameters( 137 | verbose=True, 138 | prompt=chat_history.to_prompt(), 139 | wait=True, 140 | sync=ctx.obj["sync"], 141 | branch=branch, 142 | repo=ctx.obj["repo"], 143 | model=ctx.obj["model"], 144 | ) 145 | task = task_runner.run_task(params, print_result=False, print_task_id=False) 146 | if task: 147 | response = ChatMessage(role="assistant", content=task.result) 148 | chat_history.append(response) 149 | response.print() 150 | -------------------------------------------------------------------------------- /cli/commands/config.py: -------------------------------------------------------------------------------- 1 | import click 2 | import os 3 | import subprocess 4 | from rich import print 5 | from rich.prompt import Confirm 6 | 7 | from cli.constants import CONFIG_LOCATION 8 | 9 | 10 | @click.group() 11 | def config(): 12 | """🔧 Customize Arcane Engines behavior.""" 13 | pass 14 | 15 | 16 | @config.command() 17 | def edit(): 18 | """Edit the configuration file.""" 19 | config_file = os.path.expanduser(CONFIG_LOCATION) 20 | click.edit(filename=config_file) 21 | 22 | 23 | @config.command() 24 | def shell_completion(): 25 | """Add shell completions.""" 26 | shell = os.getenv("SHELL") 27 | if not shell: 28 | print( 29 | "[red]Could not determine the shell. Please set the SHELL environment variable.[/red]" 30 | ) 31 | return 32 | 33 | shell_name = os.path.basename(shell) 34 | 35 | if shell_name in ["bash", "zsh", "fish"]: 36 | if shell_name == "bash": 37 | print("[blue]Add the following line to your ~/.bashrc or ~/.profile:[/blue]") 38 | print('[cyan]eval "$(_PILOT_COMPLETE=source_bash pilot)"[/cyan]') 39 | if Confirm.ask( 40 | "Do you want to add this line to your ~/.bashrc or ~/.profile?", default=True 41 | ): 42 | subprocess.run( 43 | [ 44 | "bash", 45 | "-c", 46 | "echo 'eval \"$(_PILOT_COMPLETE=source_bash pilot)\"' >> ~/.bashrc", 47 | ] 48 | ) 49 | elif shell_name == "zsh": 50 | print("[blue]Add the following line to your ~/.zshrc:[/blue]") 51 | print('[cyan]eval "$(_PILOT_COMPLETE=source_zsh pilot)"[/cyan]') 52 | if Confirm.ask("Do you want to add this line to your ~/.zshrc?", default=True): 53 | subprocess.run( 54 | ["zsh", "-c", "echo 'eval \"$(_PILOT_COMPLETE=source_zsh pilot)\"' >> ~/.zshrc"] 55 | ) 56 | elif shell_name == "fish": 57 | print("[blue]Add the following line to your ~/.config/fish/config.fish:[/blue]") 58 | print("[cyan]eval (env _PILOT_COMPLETE=source_fish pilot)[/cyan]") 59 | if Confirm.ask( 60 | "Do you want to add this line to your ~/.config/fish/config.fish?", default=True 61 | ): 62 | subprocess.run( 63 | [ 64 | "fish", 65 | "-c", 66 | "echo 'eval (env _PILOT_COMPLETE=source_fish pilot)' " 67 | ">> ~/.config/fish/config.fish", 68 | ] 69 | ) 70 | else: 71 | print(f"[red]Shell {shell_name} is not supported for automatic completions.[/red]") 72 | -------------------------------------------------------------------------------- /cli/commands/edit.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import click 5 | from rich.console import Console 6 | 7 | from cli.models import TaskParameters 8 | from cli.status_indicator import StatusIndicator 9 | from cli.task_runner import TaskRunner 10 | from cli.util import pull_branch_changes 11 | 12 | 13 | @click.command() 14 | @click.option("--snap", is_flag=True, help="📸 Add a screenshot to your prompt.") 15 | @click.argument("file_path", type=click.Path(exists=True)) 16 | @click.argument("prompt", required=False, default=None, type=str) 17 | @click.pass_context 18 | def edit(ctx, snap, file_path, prompt): 19 | """✍️ Let Arcane Engine edit a file for you. 20 | 21 | Examples: 22 | 23 | \b 24 | - ✍️ Quickly add docstrings to a Python file: 25 | pilot edit main.py "Add docstrings for all classes, functions and parameters" 26 | 27 | \b 28 | - ♻️ Refactor and clean up code: 29 | pilot edit main.js "Break up large functions, organize the file and add comments" 30 | 31 | \b 32 | - 🧩 Implement placeholders: 33 | pilot edit "I left placeholder comments in the file. Please replace them with the actual code" 34 | 35 | """ 36 | console = Console() 37 | status_indicator = StatusIndicator( 38 | spinner=ctx.obj["spinner"], display_log_messages=ctx.obj["verbose"], console=console 39 | ) 40 | 41 | if not prompt: 42 | prompt = click.edit("", extension=".md") 43 | 44 | file_content = Path(file_path).read_text() 45 | user_prompt = prompt 46 | prompt = f"I have the following file content:\n\n---\n{file_content}\n---\n\n" 47 | prompt += f"Please edit the file content above in the following way:\n\n{user_prompt}" 48 | 49 | try: 50 | if ctx.obj["sync"] and not ctx.obj["branch"]: 51 | # Get current branch from git 52 | current_branch = os.popen("git rev-parse --abbrev-ref HEAD").read().strip() 53 | if current_branch not in ["master", "main"]: 54 | ctx.obj["branch"] = current_branch 55 | 56 | task_params = TaskParameters( 57 | snap=snap, 58 | wait=ctx.obj["wait"], 59 | repo=ctx.obj["repo"], 60 | verbose=ctx.obj["verbose"], 61 | output=file_path, 62 | code=True, 63 | model=ctx.obj["model"], 64 | debug=ctx.obj["debug"], 65 | prompt=prompt, 66 | branch=ctx.obj["branch"], 67 | ) 68 | 69 | runner = TaskRunner(status_indicator) 70 | finished_task = runner.run_task(task_params) 71 | if ctx.obj["sync"]: 72 | pull_branch_changes(status_indicator, console, finished_task.branch, ctx.obj["debug"]) 73 | 74 | except Exception as e: 75 | status_indicator.fail() 76 | raise click.ClickException(f"An error occurred: {type(e)} {str(e)}") 77 | 78 | finally: 79 | status_indicator.stop() 80 | -------------------------------------------------------------------------------- /cli/commands/grab.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import tempfile 4 | 5 | import click 6 | import inquirer 7 | from rich.console import Console 8 | from rich.padding import Padding 9 | from rich.prompt import Confirm 10 | from rich.table import Table 11 | from rich.text import Text 12 | 13 | from cli.command_index import COMMAND_FILE_PATH, CommandIndex 14 | from cli.skill_index import SkillIndex, SKILL_FILE_PATH 15 | from cli.status_indicator import StatusIndicator 16 | 17 | 18 | @click.group() 19 | @click.pass_context 20 | def grab(ctx): 21 | """🤲 Grab commands, prompts, plans, and skills from other repositories.""" 22 | pass 23 | 24 | 25 | @grab.command("commands") 26 | @click.argument("repo") 27 | @click.pass_context 28 | def grab_commands(ctx, repo): 29 | """🤲 Grab commands from a Github repository (owner/repo). 30 | 31 | Example: pilot grab commands arc-eng/cli 32 | """ 33 | console = Console() 34 | status_indicator = StatusIndicator( 35 | spinner=ctx.obj["spinner"], display_log_messages=ctx.obj["verbose"], console=console 36 | ) 37 | status_indicator.start() 38 | full_repo_url = f"git@github.com:{repo}.git" 39 | with tempfile.TemporaryDirectory() as tmp_dir: 40 | clone_repository(status_indicator, full_repo_url, tmp_dir) 41 | full_path = os.path.join(tmp_dir, COMMAND_FILE_PATH) 42 | if not os.path.exists(full_path): 43 | click.ClickException( 44 | f"Repository {full_repo_url} does not contain a {COMMAND_FILE_PATH} file." 45 | ) 46 | status_indicator.stop() 47 | remote_index = CommandIndex(full_path) 48 | local_index = CommandIndex() 49 | display_commands(console, repo, local_index, remote_index) 50 | answers = prompt_user_for_commands(local_index, remote_index) 51 | if not answers: 52 | return 53 | 54 | commands_imported, files_imported = import_commands( 55 | answers, remote_index, local_index, tmp_dir 56 | ) 57 | local_index.save_commands() 58 | display_imported_commands(console, commands_imported) 59 | 60 | 61 | @grab.command("skills") 62 | @click.argument("repo") 63 | @click.pass_context 64 | def grab_skills(ctx, repo): 65 | """🤲 Grab skills from a Github repository (owner/repo). 66 | 67 | Example: pilot grab skills arc-eng/cli 68 | """ 69 | console = Console() 70 | status_indicator = StatusIndicator( 71 | spinner=ctx.obj["spinner"], display_log_messages=ctx.obj["verbose"], console=console 72 | ) 73 | status_indicator.start() 74 | full_repo_url = f"git@github.com:{repo}.git" 75 | with tempfile.TemporaryDirectory() as tmp_dir: 76 | clone_repository(status_indicator, full_repo_url, tmp_dir) 77 | full_path = os.path.join(tmp_dir, SKILL_FILE_PATH) 78 | if not os.path.exists(full_path): 79 | click.ClickException( 80 | f"Repository {full_repo_url} does not contain a {SKILL_FILE_PATH} file." 81 | ) 82 | status_indicator.stop() 83 | remote_index = SkillIndex(full_path) 84 | local_index = SkillIndex() 85 | display_skills(console, repo, local_index, remote_index) 86 | answers = prompt_user_for_skills(local_index, remote_index) 87 | if not answers: 88 | return 89 | 90 | skills_imported = import_skills(answers, remote_index, local_index, tmp_dir) 91 | local_index.save_skills() 92 | display_imported_skills(console, skills_imported) 93 | 94 | 95 | def clone_repository(status_indicator, full_repo_url, tmp_dir): 96 | """Clone the repository to a temporary directory.""" 97 | status_indicator.update_spinner_message(f"Loading from {full_repo_url}") 98 | subprocess.run( 99 | ["git", "clone", "--depth", "1", full_repo_url, tmp_dir], 100 | check=True, 101 | capture_output=True, 102 | ) 103 | 104 | 105 | def display_commands(console, repo, local_index, remote_index): 106 | """Display the commands found in the repository.""" 107 | table = Table(box=None, show_header=True) 108 | table.add_column(repo) 109 | table.add_column("") 110 | local_command_names = [cmd.name for cmd in local_index.get_commands()] 111 | for command in remote_index.get_commands(): 112 | if command.name in local_command_names: 113 | # give name grey color if already exists in local index 114 | table.add_row( 115 | Text(command.name, style="bright_black"), 116 | Text(command.description, style="bright_black"), 117 | ) 118 | else: 119 | table.add_row( 120 | Text(command.name, style="bold blue"), Text(command.description, style="bold") 121 | ) 122 | console.print(Padding(table, (1, 6))) 123 | 124 | 125 | def display_skills(console, repo, local_index, remote_index): 126 | """Display the skills found in the repository.""" 127 | table = Table(box=None, show_header=True) 128 | table.add_column(repo) 129 | table.add_column("") 130 | local_skill_titles = [skill.title for skill in local_index.get_skills()] 131 | for skill in remote_index.get_skills(): 132 | if skill.title in local_skill_titles: 133 | # give title grey color if already exists in local index 134 | table.add_row( 135 | Text(skill.title, style="bright_black"), 136 | Text(skill.instructions, style="bright_black"), 137 | ) 138 | else: 139 | table.add_row( 140 | Text(skill.title, style="bold blue"), Text(skill.instructions, style="bold") 141 | ) 142 | console.print(Padding(table, (1, 6))) 143 | 144 | 145 | def prompt_user_for_commands(local_index, remote_index): 146 | """Prompt the user to select commands to import.""" 147 | local_command_names = [cmd.name for cmd in local_index.get_commands()] 148 | choices = [ 149 | cmd.name for cmd in remote_index.get_commands() if cmd.name not in local_command_names 150 | ] 151 | questions = [ 152 | inquirer.Checkbox( 153 | "commands", 154 | message="Grab", 155 | choices=choices, 156 | ), 157 | ] 158 | return inquirer.prompt(questions) 159 | 160 | 161 | def prompt_user_for_skills(local_index, remote_index): 162 | """Prompt the user to select skills to import.""" 163 | local_skill_titles = [skill.title for skill in local_index.get_skills()] 164 | choices = [ 165 | skill.title for skill in remote_index.get_skills() if skill.title not in local_skill_titles 166 | ] 167 | if not choices: 168 | console = Console() 169 | console.print("No new skills found in the repository.") 170 | return [] 171 | questions = [ 172 | inquirer.Checkbox( 173 | "skills", 174 | message="Grab", 175 | choices=choices, 176 | ), 177 | ] 178 | return inquirer.prompt(questions) 179 | 180 | 181 | def import_commands(answers, remote_index, local_index, tmp_dir): 182 | """Import the selected commands into the local index.""" 183 | files_imported = [] 184 | commands_imported = [] 185 | for command_name in answers["commands"]: 186 | remote_command = remote_index.get_command(command_name) 187 | if local_index.get_command(command_name): 188 | overwrite = Confirm.ask(f"Command {command_name} already exists. Overwrite?") 189 | if not overwrite: 190 | continue 191 | local_index.remove_command(command_name) 192 | local_index.add_command(remote_command) 193 | if remote_command.params.file: 194 | full_path = os.path.join(tmp_dir, remote_command.params.file) 195 | copy_file_to_local_directory(full_path, remote_command.params.file) 196 | files_imported.append(remote_command.params.file) 197 | commands_imported.append(remote_command) 198 | return commands_imported, files_imported 199 | 200 | 201 | def import_skills(answers, remote_index, local_index, tmp_dir): 202 | """Import the selected skills into the local index.""" 203 | skills_imported = [] 204 | for skill_name in answers["skills"]: 205 | remote_skill = remote_index.get_skill(skill_name) 206 | if local_index.get_skill(skill_name): 207 | overwrite = Confirm.ask(f"Skill {skill_name} already exists. Overwrite?") 208 | if not overwrite: 209 | continue 210 | local_index.remove_skill(skill_name) 211 | local_index.add_skill(remote_skill) 212 | skills_imported.append(remote_skill) 213 | return skills_imported 214 | 215 | 216 | def copy_file_to_local_directory(source_path, destination_path): 217 | """Copy a file from the source path to the local directory.""" 218 | with open(source_path, "r") as f: 219 | content = f.read() 220 | os.makedirs(os.path.dirname(destination_path), exist_ok=True) 221 | with open(destination_path, "w") as f: 222 | f.write(content) 223 | 224 | 225 | def display_imported_commands(console, commands_imported): 226 | """Display the imported commands.""" 227 | console.line() 228 | if commands_imported: 229 | console.print("You can now use the following commands:") 230 | table = Table(box=None, show_header=False) 231 | table.add_column("Command", style="bold") 232 | table.add_column("Description", style="magenta") 233 | for command in commands_imported: 234 | table.add_row( 235 | f"[code]pilot run [green]{command.name}[/green][code]", 236 | command.description, 237 | ) 238 | console.print(Padding(table, (1, 1))) 239 | else: 240 | console.print("No commands imported.") 241 | 242 | 243 | def display_imported_skills(console, skills_imported): 244 | """Display the imported skills.""" 245 | console.line() 246 | if skills_imported: 247 | console.print("I now have the following skill(s) in this repository:") 248 | table = Table(box=None, show_header=False) 249 | table.add_column("Skill", style="bold") 250 | for skill in skills_imported: 251 | table.add_row( 252 | f"[green]{skill.title}[/green]", 253 | ) 254 | console.print(Padding(table, (1, 1))) 255 | console.print( 256 | "You can now refer to these skills in your prompts and I will use them to assist you." 257 | ) 258 | else: 259 | console.print("No skills imported.") 260 | -------------------------------------------------------------------------------- /cli/commands/history.py: -------------------------------------------------------------------------------- 1 | import click 2 | from arcane.engine import ArcaneEngine 3 | 4 | from rich.console import Console 5 | from rich.markdown import Markdown 6 | from rich.padding import Padding 7 | from rich.table import Table 8 | 9 | from cli.util import TaskFormatter, markdown_panel 10 | 11 | NO_TASKS_MESSAGE = """ 12 | You have no tasks yet. Run a task with `pilot task` to create one. 13 | """ 14 | 15 | 16 | @click.group(invoke_without_command=True) 17 | @click.pass_context 18 | def history(ctx): 19 | """📜 Access recent tasks.""" 20 | engine = ArcaneEngine() 21 | tasks = engine.list_tasks() 22 | ctx.obj["tasks"] = tasks 23 | 24 | if ctx.invoked_subcommand is None: 25 | # Default behavior when no sub-command is invoked 26 | console = Console() 27 | if not tasks: 28 | console.print(Padding(Markdown(NO_TASKS_MESSAGE), (1, 1))) 29 | return 30 | task_number = 1 31 | 32 | table = Table(box=None) 33 | 34 | table.add_column("#", justify="left", style="bold yellow", no_wrap=True) 35 | table.add_column("Timestamp", justify="left", style="cyan", no_wrap=True) 36 | table.add_column("Project", justify="left", style="magenta", no_wrap=True) 37 | table.add_column("PR") 38 | table.add_column("Status") 39 | table.add_column("Title", style="blue") 40 | 41 | for task in tasks: 42 | task_formatter = TaskFormatter(task) 43 | table.add_row( 44 | str(task_number), 45 | task_formatter.format_created_at(), 46 | task_formatter.format_github_project(), 47 | task_formatter.format_pr_link(), 48 | task_formatter.format_status(), 49 | task_formatter.format_title(), 50 | ) 51 | task_number += 1 52 | console.print(Padding(table, (1, 1))) 53 | return 54 | 55 | 56 | @history.group(invoke_without_command=True) 57 | @click.argument("task_number", required=False, type=int, default=1) 58 | @click.pass_context 59 | def last(ctx, task_number): 60 | """Show the n-th latest task. Default is the last task.""" 61 | console = Console() 62 | tasks = ctx.obj["tasks"] 63 | if task_number > len(tasks): 64 | console.print(f"[bold red]There are less than {task_number} tasks.[/bold red]") 65 | raise click.Abort() 66 | ctx.obj["selected_task"] = tasks[task_number - 1] 67 | if ctx.invoked_subcommand is None: 68 | # Pretty print task properties using rich 69 | task = tasks[task_number - 1] 70 | # Print header using table grid 71 | table = Table(box=None, show_header=False) 72 | task_formatter = TaskFormatter(task) 73 | table.add_column("Property", justify="left", style="bold yellow", no_wrap=True) 74 | table.add_column("Value", justify="left", style="cyan") 75 | table.add_row("Title", task_formatter.format_title()) 76 | table.add_row("Status", task_formatter.format_status()) 77 | table.add_row("Created", task_formatter.format_created_at()) 78 | table.add_row("Project", task_formatter.format_github_project()) 79 | if task.pr_number: 80 | table.add_row("PR", task_formatter.format_pr_link()) 81 | if task.branch: 82 | table.add_row("Branch", task_formatter.format_branch()) 83 | console.print(Padding(table, (1, 1))) 84 | console.print(markdown_panel("Prompt", task.user_request)) 85 | console.print(markdown_panel("Result", task.result)) 86 | 87 | 88 | @last.command() 89 | @click.option( 90 | "--markdown", 91 | is_flag=True, 92 | default=False, 93 | help="Return the prompt in markdown format.", 94 | ) 95 | @click.pass_context 96 | def prompt(ctx, markdown): 97 | """Show the n-th latest task's prompt.""" 98 | console = Console() 99 | task = ctx.obj["selected_task"] 100 | if markdown: 101 | console.print(task.user_request) 102 | else: 103 | console.print(Markdown(task.user_request)) 104 | 105 | 106 | @last.command() 107 | @click.option( 108 | "--markdown", 109 | is_flag=True, 110 | default=False, 111 | help="Return the result in markdown format.", 112 | ) 113 | @click.pass_context 114 | def result(ctx, markdown): 115 | """Show the n-th latest task's result.""" 116 | console = Console() 117 | task = ctx.obj["selected_task"] 118 | if markdown: 119 | console.print(task.result) 120 | else: 121 | console.print(Markdown(task.result)) 122 | -------------------------------------------------------------------------------- /cli/commands/plan.py: -------------------------------------------------------------------------------- 1 | import click 2 | from rich.console import Console 3 | 4 | from cli.plan_executor import PlanExecutor 5 | from cli.status_indicator import StatusIndicator 6 | from cli.util import pull_branch_changes, get_branch_if_pushed 7 | 8 | 9 | @click.command() 10 | @click.argument("file_path", type=click.Path(exists=True)) 11 | @click.pass_context 12 | def plan(ctx, file_path): 13 | """📋 Let Arcane Engine execute a plan for you. 14 | 15 | Learn more: https://docs.arcane.engineer/user_guide.html 16 | """ 17 | console = Console() 18 | status_indicator = StatusIndicator( 19 | spinner=ctx.obj["spinner"], display_log_messages=ctx.obj["verbose"], console=console 20 | ) 21 | if ctx.obj["sync"]: 22 | ctx.obj["branch"] = get_branch_if_pushed() 23 | 24 | runner = PlanExecutor(file_path, status_indicator) 25 | runner.run( 26 | ctx.obj["wait"], 27 | ctx.obj["repo"], 28 | ctx.obj["verbose"], 29 | ctx.obj["model"], 30 | ctx.obj["debug"], 31 | ) 32 | if ctx.obj["sync"]: 33 | pull_branch_changes(status_indicator, console, ctx.obj["branch"], ctx.obj["debug"]) 34 | -------------------------------------------------------------------------------- /cli/commands/pr.py: -------------------------------------------------------------------------------- 1 | import webbrowser 2 | 3 | import arcane 4 | import click 5 | from arcane import RepoBranchInput 6 | from arcane.exceptions import NotFoundException 7 | from arcane.util import _get_config_from_env 8 | 9 | from rich.console import Console 10 | 11 | from cli.detect_repository import detect_repository 12 | from cli.status_indicator import StatusIndicator 13 | from cli.util import get_current_branch 14 | 15 | 16 | @click.command() 17 | @click.option("--no-browser", "-nb", is_flag=True, help="Do not open the PR in a browser.") 18 | @click.pass_context 19 | def pr(ctx, no_browser): 20 | """🌐 Find and open the pull request for the current branch.""" 21 | # Identify the current Github repo 22 | if not ctx.obj["repo"]: 23 | ctx.obj["repo"] = detect_repository() 24 | repo = ctx.obj["repo"] 25 | 26 | # Identify the current branch 27 | branch = get_current_branch() 28 | status_indicator = StatusIndicator( 29 | spinner=ctx.obj["spinner"], display_log_messages=ctx.obj["verbose"], console=Console() 30 | ) 31 | status_indicator.update_spinner_message( 32 | f"Looking for PR number for {repo} on branch {branch}..." 33 | ) 34 | status_indicator.start() 35 | 36 | # Retrieve the PR number 37 | with arcane.ApiClient(_get_config_from_env()) as api_client: 38 | api_instance = arcane.PRRetrievalApi(api_client) 39 | if not repo: 40 | raise Exception("Repository not found.") 41 | try: 42 | response = api_instance.resolve_pr_create( 43 | RepoBranchInput(github_repo=repo, branch=branch) 44 | ) 45 | except NotFoundException: 46 | status_indicator.stop() 47 | status_indicator.log_message( 48 | f"No PR found for branch `{branch}` on repository `{repo}`." 49 | ) 50 | return 51 | status_indicator.stop() 52 | pr_link = f"https://github.com/{repo}/pull/{response.pr_number}" 53 | status_indicator.log_message(f"Branch `{branch}` has PR [#{response.pr_number}]({pr_link})") 54 | if not no_browser: 55 | webbrowser.open(pr_link) 56 | -------------------------------------------------------------------------------- /cli/commands/run.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from cli.command_index import CommandIndex 4 | 5 | 6 | class RunCommand(click.Group): 7 | 8 | def __init__(self, **kwargs): 9 | super().__init__(**kwargs) 10 | self.command_index = CommandIndex() 11 | 12 | def list_commands(self, ctx): 13 | rv = [] 14 | for pilot_command in self.command_index.get_commands(): 15 | rv.append(pilot_command.name) 16 | rv.sort() 17 | return rv 18 | 19 | def get_command(self, ctx, name): 20 | for pilot_command in self.command_index.get_commands(): 21 | if pilot_command.name == name: 22 | return pilot_command.to_click_command() 23 | raise click.UsageError(f"Command '{name}' not found.") 24 | -------------------------------------------------------------------------------- /cli/commands/task.py: -------------------------------------------------------------------------------- 1 | import click 2 | from rich.console import Console 3 | from rich.markdown import Markdown 4 | from rich.padding import Padding 5 | import sys 6 | 7 | from cli.command_index import CommandIndex, PilotCommand 8 | from cli.constants import CHEAP_MODEL 9 | from cli.models import TaskParameters 10 | from cli.status_indicator import StatusIndicator 11 | from cli.task_runner import TaskRunner 12 | from cli.util import get_branch_if_pushed 13 | 14 | 15 | @click.command() 16 | @click.option( 17 | "--snap", 18 | is_flag=True, 19 | help="📸 Select a portion of your screen to add as an image to the task.", 20 | ) 21 | @click.option( 22 | "--cheap", 23 | is_flag=True, 24 | default=False, 25 | help=f"💸 Use the cheapest GPT model ({CHEAP_MODEL})", 26 | ) 27 | @click.option( 28 | "--code", 29 | is_flag=True, 30 | default=False, 31 | help="💻 Optimize prompt and settings for generating code", 32 | ) 33 | @click.option( 34 | "--file", 35 | "-f", 36 | type=click.Path(exists=True), 37 | help="📂 Generate prompt from a template file.", 38 | ) 39 | @click.option( 40 | "--direct", 41 | is_flag=True, 42 | default=False, 43 | help="🔄 Do not feed the rendered template as a prompt into Arcane Engine, " 44 | "but render it directly as output.", 45 | ) 46 | @click.option( 47 | "--output", 48 | "-o", 49 | type=click.Path(exists=False), 50 | help="💾 Output file for the result.", 51 | ) 52 | @click.option( 53 | "--save-command", 54 | is_flag=True, 55 | help="💾 Save the task parameters as a command for later use.", 56 | ) 57 | @click.argument("prompt", required=False, default=None, type=str) 58 | @click.pass_context 59 | def task(ctx, snap, cheap, code, file, direct, output, save_command, prompt): 60 | """➕ Create a new task for Arcane Engine. 61 | 62 | Examples: https://github.com/arc-eng/cli 63 | """ 64 | console = Console() 65 | status_indicator = StatusIndicator( 66 | spinner=ctx.obj["spinner"], display_log_messages=ctx.obj["verbose"], console=console 67 | ) 68 | 69 | try: 70 | if ctx.obj["sync"]: 71 | ctx.obj["branch"] = get_branch_if_pushed() 72 | 73 | task_params = TaskParameters( 74 | wait=ctx.obj["wait"], 75 | repo=ctx.obj["repo"], 76 | snap=snap, 77 | verbose=ctx.obj["verbose"], 78 | cheap=cheap, 79 | code=code, 80 | file=file, 81 | direct=direct, 82 | output=output, 83 | model=ctx.obj["model"], 84 | debug=ctx.obj["debug"], 85 | prompt=prompt, 86 | branch=ctx.obj["branch"], 87 | spinner=ctx.obj["spinner"], 88 | sync=ctx.obj["sync"], 89 | ) 90 | 91 | if save_command: 92 | command_index = CommandIndex() 93 | console.print( 94 | Padding( 95 | "[green bold]Save the task parameters as a command:[/green bold]", 96 | (1, 1), 97 | ) 98 | ) 99 | name = click.prompt(" Name (e.g. my-new-cmd)", type=str) 100 | description = click.prompt(" Short description", type=str) 101 | command = PilotCommand(name=name, description=description, params=task_params) 102 | command_index.add_command(command) 103 | console.print( 104 | Padding(f"Command saved to [code]{command_index.file_path}[/code]", (1, 1)) 105 | ) 106 | console.print( 107 | Padding( 108 | Markdown(f"You can now run this command with `pilot run {name}`."), 109 | (1, 1), 110 | ) 111 | ) 112 | return 113 | 114 | runner = TaskRunner(status_indicator) 115 | 116 | if not sys.stdin.isatty(): 117 | # Read from stdin if stdin is not a tty (i.e., data is piped) 118 | runner.run_task(task_params, piped_data=sys.stdin.read().strip()) 119 | else: 120 | runner.run_task(task_params) 121 | 122 | finally: 123 | status_indicator.stop() 124 | -------------------------------------------------------------------------------- /cli/commands/upgrade.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | 4 | import click 5 | from rich.prompt import Confirm 6 | 7 | 8 | @click.command() 9 | def upgrade(): 10 | """⬆️ Upgrade arcane-cli to the latest version.""" 11 | if is_installed_via_homebrew(): 12 | if Confirm.ask( 13 | "Found homebrew installation. Upgrade the [code]arcane-cli[/code] package?" 14 | ): 15 | subprocess.run(["brew", "update"], check=True) 16 | subprocess.run(["brew", "upgrade", "arcane-cli"], check=True) 17 | else: 18 | if Confirm.ask("Upgrade the [code]arcane-cli[/code] package with pip?"): 19 | subprocess.run( 20 | [sys.executable, "-m", "pip", "install", "--upgrade", "arcane-cli"], check=True 21 | ) 22 | 23 | 24 | def is_installed_via_homebrew() -> bool: 25 | """Check if arcane-cli is installed via Homebrew.""" 26 | result = subprocess.run(["brew", "list", "arcane-cli"], capture_output=True) 27 | return result.returncode == 0 28 | -------------------------------------------------------------------------------- /cli/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | CONFIG_LOCATION = os.path.expanduser("~/.pr-pilot.yaml") 4 | CONFIG_API_KEY = "api_key" 5 | CODE_MODEL = "gpt-4o" 6 | CHEAP_MODEL = "gpt-3.5-turbo" 7 | POLL_INTERVAL = 2 8 | CODE_PRIMER = ( 9 | "Do not write anything to file, but ONLY respond with the code/content, no other text. " 10 | "Do not wrap it in triple backticks." 11 | ) 12 | DEFAULT_MODEL = "gpt-4o" 13 | -------------------------------------------------------------------------------- /cli/detect_repository.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import re 3 | from typing import Optional 4 | 5 | 6 | def is_git_repo(): 7 | """Check if the current directory is a git repository.""" 8 | try: 9 | subprocess.run( 10 | ["git", "rev-parse", "--is-inside-work-tree"], 11 | check=True, 12 | stdout=subprocess.PIPE, 13 | stderr=subprocess.PIPE, 14 | ) 15 | return True 16 | except subprocess.CalledProcessError: 17 | return False 18 | 19 | 20 | def get_remote_origin_url(): 21 | """Get the remote origin URL of the git repository.""" 22 | try: 23 | result = subprocess.run( 24 | ["git", "config", "--get", "remote.origin.url"], 25 | check=True, 26 | stdout=subprocess.PIPE, 27 | stderr=subprocess.PIPE, 28 | ) 29 | return result.stdout.decode("utf-8").strip() 30 | except subprocess.CalledProcessError: 31 | return None 32 | 33 | 34 | def extract_owner_repo(url): 35 | """Extract the owner/repo from a GitHub URL.""" 36 | match = re.match(r"(?:git@github\.com:|https://github\.com/)([^/]+)/([^/]+)(?:\.git)?", url) 37 | if match: 38 | return f"{match.group(1)}/{match.group(2)}".replace(".git", "") 39 | return None 40 | 41 | 42 | def detect_repository() -> Optional[str]: 43 | """Detect the Github repository of the current directory, if any.""" 44 | if is_git_repo(): 45 | repo_url = get_remote_origin_url() 46 | if repo_url: 47 | owner_repo = extract_owner_repo(repo_url) 48 | if owner_repo: 49 | return owner_repo 50 | return None 51 | 52 | 53 | if __name__ == "__main__": 54 | if is_git_repo(): 55 | repo_url = get_remote_origin_url() 56 | if repo_url: 57 | owner_repo = extract_owner_repo(repo_url) 58 | if owner_repo: 59 | print(f"This directory is a clone of the repository: {owner_repo}") 60 | else: 61 | print("Could not extract owner/repo from the remote origin URL.") 62 | else: 63 | print( 64 | "This directory is a git repository, " 65 | "but the remote origin URL could not be determined." 66 | ) 67 | else: 68 | print("This directory is not a git repository.") 69 | -------------------------------------------------------------------------------- /cli/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | from typing import Optional 3 | 4 | 5 | class TaskParameters(BaseModel): 6 | """ 7 | Model representing the parameters for a task. 8 | """ 9 | 10 | wait: bool = Field(default=False, description="Wait for the task to complete") 11 | repo: Optional[str] = Field(default=None, description="Repository to run the task on") 12 | snap: bool = Field(default=False, description="Take a screenshot") 13 | verbose: bool = Field(default=True, description="Print status messages") 14 | cheap: bool = Field(default=False, description="Use the cheap model") 15 | code: bool = Field(default=False, description="Include code primer") 16 | file: Optional[str] = Field(default=None, description="File to use for the prompt template") 17 | direct: bool = Field(default=False, description="Directly output the prompt") 18 | output: Optional[str] = Field(default=None, description="Output file for the prompt") 19 | model: Optional[str] = Field(default=None, description="Model to use for the task") 20 | debug: bool = Field(default=False, description="Run in debug mode") 21 | prompt: Optional[str] = Field(default=None, description="Prompt to use for the task") 22 | branch: Optional[str] = Field(default=None, description="Branch to use for the task") 23 | pr_number: Optional[int] = Field(default=None, description="Pull request number") 24 | spinner: bool = Field(default=True, description="Display spinners") 25 | sync: bool = Field( 26 | default=False, description="Sync local repository state with Arcane Engine changes" 27 | ) 28 | -------------------------------------------------------------------------------- /cli/plan_executor.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | from rich.console import Console 3 | from rich.markdown import Markdown 4 | 5 | from cli.status_indicator import StatusIndicator 6 | from cli.task_runner import TaskRunner 7 | from cli.models import TaskParameters 8 | 9 | 10 | class PlanExecutor: 11 | def __init__(self, plan: str, status_indicator: StatusIndicator): 12 | """Initializes the PlanExecutor class 13 | 14 | :param plan: File path to a plan YAML file 15 | :param status_indicator: Status indicator 16 | """ 17 | with open(plan, "r") as f: 18 | self.plan = yaml.safe_load(f) 19 | 20 | if "name" not in self.plan: 21 | raise ValueError("Plan must have a name") 22 | if "steps" not in self.plan: 23 | raise ValueError("Plan must have steps") 24 | self.name = self.plan.get("name") 25 | self.tasks = self.plan.get("steps") 26 | self.status_indicator = status_indicator 27 | self.pr_number = None 28 | self.responses = [] 29 | 30 | def run(self, wait, repo, verbose, model, debug): 31 | """Run all steps in a given plan 32 | 33 | :param wait: Wait for Arcane Engine to finish the plan 34 | :param repo: Github repository in the format owner/repo 35 | :param verbose: Display more status messages 36 | :param model: GPT model to use 37 | :param debug: Display debug information 38 | 39 | """ 40 | console = Console() 41 | num_tasks = len(self.tasks) 42 | current_task = 0 43 | if verbose: 44 | console.line() 45 | console.print(f"Running [bold]{self.name}[/bold] with {num_tasks} sub-tasks.") 46 | for task in self.tasks: 47 | if verbose: 48 | console.line() 49 | console.print(f"( {current_task + 1}/{num_tasks} ) {task.get('name')}") 50 | current_task += 1 51 | # Collect template_file_path, repo, model, output_file, prompt 52 | template_file_path = task.get("template", None) 53 | repo = task.get("repo", repo) 54 | model = task.get("model", model) 55 | output_file = task.get("output_file", None) 56 | cheap = task.get("cheap", False) 57 | code = task.get("code", False) 58 | direct = task.get("direct", False) 59 | branch = task.get("branch", None) 60 | snap = False 61 | 62 | previous_responses = "" 63 | for i, response in enumerate(self.responses): 64 | previous_responses += f"## Result of Sub-task {i + 1}\n\n{response}\n\n" 65 | wrapped_prompt = ( 66 | "We are working on a main task that contains a list of sub-tTasks. " 67 | f"This is sub-task {current_task} / {num_tasks}\n\n---\n\n" 68 | f"# Main Task {self.name}\n\n{self.plan.get('prompt')}\n\n" 69 | f"# Results of previous sub-tasks\n\n{previous_responses}\n\n" 70 | f"# Current Sub-task: {task.get('name')}\n\n{task.get('prompt')}\n\n---\n\n" 71 | f"Follow the instructions of the current sub-task! " 72 | f"Respond with a compact bullet list of your actions." 73 | ) 74 | if debug: 75 | console.line() 76 | console.print(Markdown(wrapped_prompt)) 77 | console.line() 78 | 79 | params = TaskParameters( 80 | wait=wait, 81 | repo=repo, 82 | snap=snap, 83 | verbose=verbose, 84 | cheap=cheap, 85 | code=code, 86 | template_file_path=template_file_path, 87 | direct=direct, 88 | output_file=output_file, 89 | model=model, 90 | debug=debug, 91 | prompt=wrapped_prompt, 92 | branch=branch, 93 | pr_number=self.pr_number, 94 | ) 95 | 96 | task_runner = TaskRunner(self.status_indicator) 97 | finished_task = task_runner.run_task(params) 98 | if not finished_task: 99 | raise ValueError("Task failed") 100 | self.responses.append(finished_task.result) 101 | if self.pr_number is None and finished_task.pr_number and verbose: 102 | console.print( 103 | f"Found new pull request! " 104 | f"All subsequent tasks will run on PR #{finished_task.pr_number}" 105 | ) 106 | self.pr_number = int(finished_task.pr_number) if finished_task.pr_number else None 107 | -------------------------------------------------------------------------------- /cli/prompt_template.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | import click 5 | import inquirer 6 | import jinja2 7 | from arcane.engine import ArcaneEngine 8 | from rich.console import Console 9 | from rich.padding import Padding 10 | from rich.prompt import Prompt 11 | 12 | from cli.task_handler import TaskHandler 13 | from cli.util import is_git_repo, get_git_root 14 | 15 | MAX_RECURSION_LEVEL = 3 16 | 17 | 18 | def select(prompt: str, choices: list[str]): 19 | """Prompt the user to select one of the choices.""" 20 | questions = [ 21 | inquirer.List( 22 | "choices", 23 | message=prompt, 24 | choices=choices, 25 | ), 26 | ] 27 | response = inquirer.prompt(questions) 28 | if not response: 29 | raise click.Abort() 30 | return response["choices"] 31 | 32 | 33 | def sh(shell_command, status): 34 | """Run a shell command and return the output""" 35 | status.start() 36 | if isinstance(shell_command, str): 37 | shell_command = shell_command.split() 38 | 39 | status.update_spinner_message(f"Running shell command: {' '.join(shell_command)}") 40 | subprocess_params = dict( 41 | stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=os.environ.copy() 42 | ) 43 | result = subprocess.run(shell_command, **subprocess_params) 44 | if result.stderr: 45 | status.fail() 46 | status.stop() 47 | console = Console() 48 | console.print(Padding(result.stderr, (1, 1))) 49 | else: 50 | status.stop() 51 | status.log_message(f"Run shell command `{' '.join(shell_command)}`") 52 | result = (result.stdout + result.stderr).strip() 53 | return result 54 | 55 | 56 | def read_env_var(variable, default=None): 57 | """Get the value of an environment variable, with a default value.""" 58 | if variable not in os.environ and default is None: 59 | # Ask for Variable input with click 60 | prompt = variable.lower().replace("_", " ") 61 | first_letter_capitalized = prompt[0].upper() + prompt[1:] 62 | os.environ[variable] = Prompt.ask("> " + first_letter_capitalized) 63 | return os.environ.get(variable, default) 64 | 65 | 66 | def wrap_function_with_status(func, status): 67 | def wrapper(*args, **kwargs): 68 | kwargs["status"] = status 69 | return func(*args, **kwargs) 70 | 71 | return wrapper 72 | 73 | 74 | class PromptTemplate: 75 | 76 | def __init__( 77 | self, template_file_path, repo, model, status, recursion_level=0, home=None, **kwargs 78 | ): 79 | self.template_file_path = template_file_path 80 | self.repo = repo 81 | self.model = model 82 | self.status = status 83 | self.variables = kwargs 84 | self.recursion_level = recursion_level 85 | self.home = home 86 | if not self.home: 87 | self.home = self.determine_template_home() 88 | 89 | def determine_template_home(self): 90 | if is_git_repo(): 91 | # If the template is in a git repo, use the root of the git repo as the home directory 92 | return get_git_root() 93 | # Otherwise, use the current working 94 | return os.getcwd() 95 | 96 | def get_template_file_path(self): 97 | """ 98 | The template file path is relative. If it exists relative to the current working directory 99 | AND the current working directory is a sub-dir of self.home, the assemble a path that 100 | is relative to self.home. 101 | Otherwise, return the template file path as is. 102 | """ 103 | full_template_path = os.path.join(os.getcwd(), self.template_file_path) 104 | current_template_path = os.path.dirname(full_template_path) 105 | if current_template_path.startswith(self.home) and os.path.exists(full_template_path): 106 | # Templates relative to the cwd have priority 107 | return os.path.relpath(full_template_path, self.home) 108 | return self.template_file_path 109 | 110 | def render(self): 111 | 112 | def subtask(prompt, status, **kwargs): 113 | # Treat prompt as a file path and read the content if the file exists 114 | # The file name will be relative to the current jinja template 115 | full_template_path = os.path.join(os.getcwd(), self.template_file_path) 116 | current_template_path = os.path.dirname(full_template_path) 117 | potential_file_path = os.path.join(current_template_path, prompt) 118 | status.start() 119 | if os.path.exists(potential_file_path): 120 | 121 | if self.recursion_level >= MAX_RECURSION_LEVEL: 122 | status.update_spinner_message( 123 | f"Abort loading {prompt}. Maximum recursion level reached." 124 | ) 125 | status.fail() 126 | return "" 127 | sub_template = PromptTemplate( 128 | potential_file_path, 129 | self.repo, 130 | self.model, 131 | status, 132 | self.recursion_level - 1, 133 | **kwargs, 134 | ) 135 | prompt = sub_template.render() 136 | 137 | try: 138 | status.update_spinner_message("Creating sub-task ...") 139 | engine = ArcaneEngine() 140 | task = engine.create_task(self.repo, prompt, log=False, gpt_model=self.model) 141 | task_handler = TaskHandler(task, status) 142 | return task_handler.wait_for_result(log_messages=False, print_result=False) 143 | except Exception as e: 144 | raise click.ClickException(f"Error creating sub-task: {e}") 145 | finally: 146 | status.stop() 147 | 148 | env = jinja2.Environment(loader=jinja2.FileSystemLoader(self.home)) 149 | env.globals.update(env=read_env_var) 150 | env.globals.update(select=select) 151 | env.globals.update(subtask=wrap_function_with_status(subtask, self.status)) 152 | env.globals.update(sh=wrap_function_with_status(sh, self.status)) 153 | template = env.get_template(self.get_template_file_path()) 154 | return template.render(self.variables) 155 | -------------------------------------------------------------------------------- /cli/skill_index.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List, Optional 3 | 4 | import yaml 5 | from pydantic import BaseModel, Field 6 | 7 | from cli.util import is_git_repo, get_git_root 8 | 9 | SKILL_FILE_PATH = ".pilot-skills.yaml" 10 | 11 | 12 | def str_presenter(dumper, data): 13 | if "\n" in data: # Check if the string contains newlines 14 | return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") 15 | return dumper.represent_scalar("tag:yaml.org,2002:str", data) 16 | 17 | 18 | # Add the custom representer to handle multiline strings 19 | yaml.add_representer(str, str_presenter) 20 | 21 | 22 | class AgentSkill(BaseModel): 23 | """User-defined skill for the Arcane Engine agent.""" 24 | 25 | title: str = Field(..., title="Short title of the skill") 26 | args: Optional[dict] = Field(None, title="Arguments required to perform the skill") 27 | instructions: str = Field(..., title="Instructions for the agent") 28 | result: Optional[str] = Field( 29 | "A short summary of your actions", title="Expected result of the skill" 30 | ) 31 | 32 | def dict(self, *args, **kwargs): 33 | original_dict = super().dict(*args, **kwargs) 34 | # Reorder the dictionary as desired 35 | ordered_dict = { 36 | "title": original_dict["title"], 37 | "args": original_dict["args"], 38 | "instructions": original_dict["instructions"], 39 | "result": original_dict["result"], 40 | } 41 | return ordered_dict 42 | 43 | 44 | def find_pilot_skills_file() -> Optional[str]: 45 | """Discover the location of the .pilot-skills.yaml file. 46 | 47 | The file is searched for in the following order: 48 | 1. The root of the current Git repository 49 | 2. The home directory 50 | 51 | :return: The absolute path to the file, or None if not found. 52 | """ 53 | # Check if the current directory is part of a Git repository 54 | if is_git_repo(): 55 | git_root = get_git_root() 56 | if git_root: 57 | git_repo_file_path = os.path.join(git_root, SKILL_FILE_PATH) 58 | if os.path.isfile(git_repo_file_path): 59 | return os.path.abspath(git_repo_file_path) 60 | return None 61 | 62 | 63 | class SkillIndex: 64 | """ 65 | A class to manage the index of skills stored in a YAML file. 66 | """ 67 | 68 | def __init__(self, file_path: str = None): 69 | """ 70 | Initialize the SkillIndex with the given file path. 71 | 72 | :param file_path: Path to the YAML file containing skills. 73 | """ 74 | self.file_path = file_path 75 | if not self.file_path: 76 | self.file_path = find_pilot_skills_file() 77 | 78 | self.skills = self._load_skills() if self.file_path else [] 79 | 80 | def _load_skills(self) -> List[AgentSkill]: 81 | """ 82 | Load skills from the YAML file. 83 | 84 | :return: A list of AgentSkill objects. 85 | """ 86 | try: 87 | with open(self.file_path, "r") as file: 88 | data = yaml.safe_load(file) 89 | return [AgentSkill(**skill) for skill in data] 90 | except FileNotFoundError: 91 | return [] 92 | 93 | def save_skills(self) -> None: 94 | """ 95 | Save the current list of skills to the YAML file. 96 | """ 97 | with open(self.file_path, "w") as file: 98 | yaml.dump( 99 | [skill.dict() for skill in self.skills], 100 | file, 101 | default_flow_style=False, 102 | allow_unicode=True, 103 | ) 104 | 105 | def add_skill(self, new_skill: AgentSkill) -> None: 106 | """ 107 | Add a new skill to the list and save it. 108 | 109 | :param new_skill: The AgentSkill object to add. 110 | 111 | :raises ValueError: If a skill with the same name already exists. 112 | """ 113 | for skill in self.skills: 114 | if skill.title == new_skill.title: 115 | raise ValueError(f"Skill with title '{new_skill.title}' already exists") 116 | self.skills.append(new_skill) 117 | self.save_skills() 118 | 119 | def get_skills(self) -> List[AgentSkill]: 120 | """ 121 | Get the list of skills. 122 | 123 | :return: A list of AgentSkill objects. 124 | """ 125 | return self.skills 126 | 127 | def get_skill(self, skill_title: str) -> Optional[AgentSkill]: 128 | """ 129 | Get a skill by title. 130 | 131 | :param skill_title: The title of the skill. 132 | :return: The AgentSkill object, or None if not found. 133 | """ 134 | for skill in self.skills: 135 | if skill.title == skill_title: 136 | return skill 137 | return None 138 | 139 | def remove_skill(self, skill_title: str) -> None: 140 | """ 141 | Remove a skill by title. 142 | 143 | :param skill_title: The title of the skill to remove. 144 | """ 145 | self.skills = [skill for skill in self.skills if skill.title != skill_title] 146 | self.save_skills() 147 | -------------------------------------------------------------------------------- /cli/status_indicator.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | from rich.markdown import Markdown 3 | from yaspin import yaspin 4 | 5 | 6 | MAX_MSG_LEN = 100 7 | 8 | 9 | class StatusIndicator: 10 | 11 | def __init__( 12 | self, 13 | spinner=True, 14 | display_log_messages=True, 15 | console=None, 16 | display_spinner_text=True, 17 | indent=0, 18 | ): 19 | self.spinner = yaspin("Let's go", timer=True) 20 | self.visible = spinner 21 | self.display_spinner_text = display_spinner_text 22 | self.display_log_messages = display_log_messages 23 | self.console = console if console else Console() 24 | self.indent = indent 25 | 26 | def update_spinner_message(self, text): 27 | if self.visible and self.display_spinner_text: 28 | self.spinner.text = text 29 | 30 | def hide(self): 31 | if self.visible: 32 | self.spinner.hide() 33 | 34 | def show(self): 35 | if self.visible: 36 | self.spinner.show() 37 | 38 | def log_message(self, text, character="✔", character_color="green", dim_text=False): 39 | if self.display_log_messages: 40 | self.hide() 41 | markdown_txt = Markdown(text) 42 | if dim_text: 43 | character_color = f"dim {character_color}" 44 | markdown_txt = Markdown(text, style="dim") 45 | indent_space = " " * self.indent 46 | self.console.print( 47 | f"{indent_space}[{character_color}]{character}[/{character_color}]", 48 | markdown_txt, 49 | sep=" ", 50 | end=" ", 51 | ) 52 | self.show() 53 | 54 | def warning(self, message): 55 | return self.log_message(message, "!", "yellow") 56 | 57 | def success(self, start_again=False, message="SUCCESS"): 58 | if self.visible and self.display_log_messages: 59 | self.log_message(self.spinner.text) 60 | self.spinner.text = "" 61 | self.spinner.ok(f"✔ {message}") 62 | if start_again: 63 | self.spinner.start() 64 | 65 | def fail(self, error=""): 66 | if self.visible: 67 | self.log_message(self.spinner.text) 68 | self.spinner.text = error 69 | self.spinner.fail("❌ FAILURE") 70 | self.spinner.stop() 71 | 72 | def start(self): 73 | if self.visible: 74 | self.spinner.start() 75 | 76 | def stop(self): 77 | if self.visible: 78 | self.spinner.stop() 79 | -------------------------------------------------------------------------------- /cli/task_handler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | 4 | import click.exceptions 5 | import websockets 6 | from arcane import Task 7 | from rich.console import Console 8 | from websockets.frames import CloseCode 9 | 10 | from cli.status_indicator import StatusIndicator 11 | from cli.user_config import UserConfig 12 | from cli.util import clean_code_block_with_language_specifier, markdown_panel, get_api_host 13 | 14 | STATUS_FAILED = "failed" 15 | STATUS_COMPLETED = "completed" 16 | MSG_EVENT = "event" 17 | MSG_STATUS_UPDATE = "status_update" 18 | MSG_TITLE_UPDATE = "title_update" 19 | MAX_TITLE_LENGTH = 100 20 | IGNORED_EVENT_ACTIONS = ["clone_repo"] 21 | 22 | 23 | class TaskHandler: 24 | def __init__(self, task: Task, status_indicator: StatusIndicator): 25 | self.task = task 26 | self.dashboard_url = f"{get_api_host()}/dashboard/tasks/{task.id}" 27 | self.console = Console() 28 | self.status = status_indicator 29 | self.task_runs_on_pr = self.task.pr_number is not None 30 | self.action_character_map = { 31 | "invoke_skill": "└─┐", 32 | "finish_skill": "┌─┘", 33 | "push_branch": "●", 34 | "checkout_branch": "○", 35 | "write_file": "✎", 36 | "list_directory": "▸", 37 | "search_code": "∷", 38 | "search": "⌕", 39 | "search_issues": "⌕", 40 | "read_github_issue": "≡", 41 | "read_pull_request": "≡", 42 | "read_files": "≡", 43 | # Add more mappings as needed 44 | } 45 | 46 | async def stream_task_events( 47 | self, task_id, output_file=None, log_messages=True, code=False, print_result=True 48 | ): 49 | """ 50 | Connect to the websocket and stream task events until the task is completed or failed. 51 | :param task_id: The ID of the task to stream events for. 52 | :param output_file: Optional file to save the result. 53 | :param log_messages: Print status messages. 54 | :param code: If True, the result will be treated as code 55 | :param print_result: If True, the result will be printed on the command line. 56 | """ 57 | max_retries = 3 58 | retry_count = 0 59 | self.status.start() 60 | api_key = UserConfig().api_key 61 | websocket_host = get_api_host().replace("https://", "wss://").replace("http://", "ws://") 62 | websocket_url = f"{websocket_host}/ws/tasks/{task_id}/events/" 63 | headers = {"X-Api-Key": api_key} 64 | 65 | while retry_count < max_retries: 66 | try: 67 | async with websockets.connect(websocket_url, extra_headers=headers) as websocket: 68 | async for message in websocket: 69 | json_message = json.loads(message) 70 | msg_type = json_message.get("type") 71 | if msg_type == MSG_TITLE_UPDATE: 72 | title = json_message.get("data") 73 | self.task.title = title 74 | self.status.update_spinner_message(title) 75 | if msg_type == MSG_STATUS_UPDATE: 76 | new_status = json_message.get("data").get("status") 77 | message = json_message.get("data").get("message", "") 78 | self.task.result = message 79 | if new_status == STATUS_COMPLETED: 80 | self.status.hide() 81 | if output_file: 82 | await self.write_result_to_file(code, message, output_file) 83 | elif print_result: 84 | self.console.print( 85 | markdown_panel(None, message, hide_frame=True) 86 | ) 87 | self.status.show() 88 | return message 89 | elif new_status == STATUS_FAILED: 90 | self.status.fail() 91 | raise click.ClickException(f"Task failed: {self.task.result}") 92 | if msg_type == MSG_EVENT: 93 | event = json_message.get("data") 94 | action = event.get("action") 95 | target = event.get("target") 96 | if action not in IGNORED_EVENT_ACTIONS and log_messages: 97 | character = self.action_character_map.get(action, "✔") 98 | if action == "invoke_skill": 99 | self.status.log_message( 100 | event.get("message"), 101 | character=character, 102 | character_color="dim", 103 | ) 104 | self.status.indent = 2 105 | elif action == "finish_skill": 106 | self.status.indent = 0 107 | self.status.log_message( 108 | "Skill finished", 109 | character=character, 110 | character_color="dim", 111 | dim_text=True, 112 | ) 113 | elif action == "push_branch" or action == "checkout_branch": 114 | self.status.log_message( 115 | event.get("message"), character=character, dim_text=True 116 | ) 117 | else: 118 | self.status.log_message( 119 | event.get("message"), character=character 120 | ) 121 | if str(action).replace("_", "-") == "push-branch": 122 | # The agent created a new branch, let's save it to the task object 123 | self.task.branch = target.strip() 124 | except websockets.exceptions.ConnectionClosedError as e: 125 | if e.code == CloseCode.ABNORMAL_CLOSURE: 126 | retry_count += 1 127 | self.status.warning("Connection was interrupted, reconnecting...") 128 | else: 129 | self.status.warning( 130 | f"Unexpected Connection Error: {str(e.code)} - " 131 | f"{str(e)}. Retry {retry_count} of {max_retries}." 132 | ) 133 | await asyncio.sleep(1) 134 | 135 | async def write_result_to_file(self, code, message, output_file): 136 | """Write the result to a file. 137 | :param code: If True, the result will be treated as code 138 | :param message: The message to write to the file 139 | :param output_file: The file to write the message to 140 | """ 141 | with open(output_file, "w") as f: 142 | if code: 143 | self.status.log_message(f"Save code to `{output_file}`") 144 | f.write(clean_code_block_with_language_specifier(message)) 145 | else: 146 | self.status.log_message(f"Save result to `{output_file}`") 147 | f.write(message) 148 | 149 | def wait_for_result(self, output_file=None, log_messages=True, code=False, print_result=True): 150 | """ 151 | Start the asyncio event loop to stream task events. 152 | :param output_file: Optional file to save the result. 153 | :param log_messages: Print status messages. 154 | :param code: If True, the result will be treated as code 155 | :param print_result: If True, the result will be printed on the command line. 156 | """ 157 | asyncio.run( 158 | self.stream_task_events(self.task.id, output_file, log_messages, code, print_result) 159 | ) 160 | -------------------------------------------------------------------------------- /cli/task_runner.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | import click 6 | from arcane import Task, ApiException 7 | from arcane.engine import ArcaneEngine 8 | from rich.console import Console 9 | from rich.markdown import Markdown 10 | from rich.padding import Padding 11 | 12 | from cli.constants import CODE_PRIMER, CHEAP_MODEL, CODE_MODEL, CONFIG_LOCATION 13 | from cli.detect_repository import detect_repository 14 | from cli.models import TaskParameters 15 | from cli.prompt_template import PromptTemplate 16 | from cli.status_indicator import StatusIndicator 17 | from cli.task_handler import TaskHandler 18 | from cli.user_config import UserConfig 19 | from cli.util import pull_branch_changes 20 | 21 | 22 | class TaskRunner: 23 | def __init__(self, status_indicator: StatusIndicator): 24 | self.config = UserConfig() 25 | self.status_indicator = status_indicator 26 | 27 | def take_screenshot(self): 28 | screenshot_command = "screencapture -i /tmp/screenshot.png" 29 | os.system(screenshot_command) 30 | return Path("/tmp/screenshot.png") 31 | 32 | def run_task( 33 | self, params: TaskParameters, print_result=True, print_task_id=True, piped_data=None 34 | ) -> Optional[Task]: 35 | 36 | console = Console() 37 | screenshot = self.take_screenshot() if params.snap else None 38 | 39 | if not params.repo: 40 | params.repo = detect_repository() 41 | if not params.repo: 42 | params.repo = self.config.get("default_repo") 43 | if not params.repo: 44 | console.print( 45 | f"No Github repository provided. " 46 | f"Use --repo or set 'default_repo' in {CONFIG_LOCATION}." 47 | ) 48 | return None 49 | if params.file: 50 | renderer = PromptTemplate(params.file, params.repo, params.model, self.status_indicator) 51 | params.prompt = renderer.render() 52 | if not params.prompt: 53 | params.prompt = click.edit("", extension=".md") 54 | if not params.prompt: 55 | console.print("No prompt provided.") 56 | return None 57 | 58 | if params.pr_number: 59 | params.prompt = ( 60 | f"We are working on PR #{params.pr_number}. " 61 | "Read the PR first before doing anything else.\n\n---\n\n" + params.prompt 62 | ) 63 | 64 | if params.cheap: 65 | params.model = CHEAP_MODEL 66 | if params.code: 67 | params.prompt += "\n\n" + CODE_PRIMER 68 | if not params.model: 69 | params.model = CODE_MODEL 70 | 71 | if params.direct: 72 | if params.output: 73 | with open(params.output, "w") as f: 74 | f.write(params.prompt) 75 | self.status_indicator.stop() 76 | if not params.verbose: 77 | console.line() 78 | console.print( 79 | Markdown(f"Rendered template `{params.file}` into `{params.output}`") 80 | ) 81 | console.line() 82 | return 83 | 84 | if piped_data: 85 | params.prompt = f"```\n{piped_data}\n```\n\n" + params.prompt 86 | 87 | branch_str = f" on branch [code]{params.branch}[/code]" if params.branch else "" 88 | pr_link = ( 89 | ( 90 | f"[link=https://github.com/{params.repo}/pull/{params.pr_number}]" 91 | f"PR #{params.pr_number}[/link]" 92 | ) 93 | if params.pr_number 94 | else "" 95 | ) 96 | try: 97 | engine = ArcaneEngine() 98 | task = engine.create_task( 99 | params.repo, 100 | params.prompt, 101 | log=False, 102 | gpt_model=params.model, 103 | image=screenshot, 104 | branch=params.branch, 105 | pr_number=params.pr_number, 106 | ) 107 | except ApiException as e: 108 | if e.data: 109 | console.print(e.data) 110 | elif e.body: 111 | console.print(e.body) 112 | else: 113 | console.print(f"An error occurred: {e}") 114 | raise click.Abort() 115 | 116 | if not params.verbose: 117 | # Status messages are only visible in verbose mode, so let's print the new task ID 118 | message = ( 119 | f"✔ [bold][green]Task created[/green]: " 120 | f"[link=https://arcane.engineer/dashboard/tasks/{task.id}/]{task.id}[/link][/bold]" 121 | f"{branch_str}{pr_link}" 122 | ) 123 | if print_task_id: 124 | console.print(Padding(message, (0, 0))) 125 | self.status_indicator.update_spinner_message("") 126 | self.status_indicator.start() 127 | 128 | if params.debug: 129 | console.print(task) 130 | task_handler = None 131 | if params.wait: 132 | task_handler = TaskHandler(task, self.status_indicator) 133 | task_handler.wait_for_result( 134 | params.output, params.verbose, code=params.code, print_result=print_result 135 | ) 136 | if params.sync and task_handler.task.branch: 137 | pull_branch_changes( 138 | self.status_indicator, console, task_handler.task.branch, params.debug 139 | ) 140 | 141 | self.status_indicator.stop() 142 | if params.debug: 143 | console.print(task) 144 | return task_handler.task if task_handler else task 145 | -------------------------------------------------------------------------------- /cli/user_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import socket 3 | import socketserver 4 | import webbrowser 5 | from http.server import BaseHTTPRequestHandler 6 | 7 | import click 8 | import yaml 9 | from rich.console import Console 10 | from rich.prompt import Confirm 11 | 12 | from cli.constants import CONFIG_LOCATION, CONFIG_API_KEY 13 | from cli.util import get_api_host 14 | 15 | PORT = 8043 16 | API_KEY_PARAM = "key" 17 | console = Console() 18 | 19 | 20 | class AuthHandler(BaseHTTPRequestHandler): 21 | 22 | api_key = None 23 | 24 | def log_message(self, format, *args): 25 | # Override to mute the server output 26 | pass 27 | 28 | def do_GET(self): 29 | if self.path.startswith("/callback?"): 30 | query = self.path.split("?")[1] 31 | params = dict(qc.split("=") for qc in query.split("&")) 32 | api_key = params.get(API_KEY_PARAM) 33 | if api_key: 34 | AuthHandler.api_key = api_key 35 | console.print("[green]Authentication successful[/green]") 36 | self.send_response(200) 37 | self.end_headers() 38 | self.wfile.write(b"API key saved. You can close this window.") 39 | else: 40 | self.send_response(400) 41 | self.end_headers() 42 | self.wfile.write(b"API key not found in the request.") 43 | else: 44 | self.send_response(404) 45 | self.end_headers() 46 | 47 | 48 | class UserConfig: 49 | """Class to manage user configuration settings.""" 50 | 51 | def __init__(self, config_location: str = CONFIG_LOCATION): 52 | self.config_location = config_location 53 | self.config = {} 54 | self.load_config() 55 | 56 | def set(self, key, value): 57 | """Set a value in the configuration file.""" 58 | self.config[key] = value 59 | 60 | with open(self.config_location, "w") as f: 61 | f.write(yaml.dump(self.config)) 62 | console.print( 63 | f"Set [code]{key}[/code] to [code]{value}[/code] in [code]{CONFIG_LOCATION}[/code]" 64 | ) 65 | 66 | def get(self, param): 67 | return self.config.get(param) 68 | 69 | def load_config(self): 70 | """Load the configuration from the default location. If it doesn't exist, 71 | run through the auth process and save config.""" 72 | if os.path.exists(self.config_location): 73 | # Config file exists, load it 74 | with open(self.config_location) as f: 75 | self.config = yaml.safe_load(f) 76 | if os.getenv("PR_PILOT_API_KEY"): 77 | # Override the config file with the environment variable 78 | self.config[CONFIG_API_KEY] = os.getenv("PR_PILOT_API_KEY") 79 | return 80 | 81 | # Config file does not exist, create it 82 | if not os.getenv("PR_PILOT_API_KEY"): 83 | console.print( 84 | "[bold yellow]No configuration file found. " 85 | "Starting authentication ...[/bold yellow]" 86 | ) 87 | self.config = {CONFIG_API_KEY: self.authenticate()} 88 | self.collect_user_preferences() 89 | with open(self.config_location, "w") as f: 90 | f.write(yaml.dump(self.config)) 91 | else: 92 | console.print("[dim]Using API key from environment variable PR_PILOT_API_KEY[/dim]") 93 | self.config = {CONFIG_API_KEY: os.getenv("PR_PILOT_API_KEY")} 94 | 95 | def set_api_key_env_var(self): 96 | """Set the API key as an environment variable.""" 97 | os.environ["PR_PILOT_API_KEY"] = self.api_key 98 | 99 | def collect_user_preferences(self): 100 | console.print( 101 | "Since it's the first time you're using Arcane Engine, " "let's set some default values." 102 | ) 103 | auto_sync = Confirm.ask( 104 | "When a new PR/branch is created, do you want it checked out automatically?", 105 | default=True, 106 | ) 107 | self.set("auto_sync", auto_sync) 108 | verbose = Confirm.ask("Do you want to see detailed status messages?", default=True) 109 | self.set("verbose", verbose) 110 | console.print("Done! Change these values any time with [code]pilot config edit[/code]") 111 | 112 | @property 113 | def api_key(self): 114 | if CONFIG_API_KEY not in self.config: 115 | self.authenticate() 116 | return self.config[CONFIG_API_KEY] 117 | 118 | @property 119 | def auto_sync_enabled(self): 120 | return self.config.get("auto_sync", False) 121 | 122 | @property 123 | def verbose(self): 124 | return self.config.get("verbose", False) 125 | 126 | def authenticate(self) -> str: 127 | """Authenticate the CLI with Arcane Engine.""" 128 | key_name = f"CLI on {socket.gethostname()}" 129 | callback_url = f"http://localhost:{PORT}/callback" 130 | auth_url = f"{get_api_host()}/dashboard/cli-auth/?name={key_name}&callback={callback_url}" 131 | 132 | confirmed = Confirm.ask( 133 | "You'll login with your Github account and we'll create an API key for you. Continue?", 134 | default=True, 135 | ) 136 | if not confirmed: 137 | raise click.Abort() 138 | 139 | webbrowser.open(auth_url) 140 | with socketserver.TCPServer(("", PORT), AuthHandler) as httpd: 141 | console.print("Waiting for API key ...") 142 | httpd.handle_request() 143 | if AuthHandler.api_key: 144 | return AuthHandler.api_key 145 | else: 146 | console.print("[red]Authentication failed[/red]") 147 | raise click.Abort() 148 | -------------------------------------------------------------------------------- /cli/util.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import os 3 | import subprocess 4 | from datetime import datetime, timezone 5 | 6 | import humanize 7 | from arcane import Task 8 | from rich import box 9 | from rich.markdown import Markdown 10 | from rich.panel import Panel 11 | 12 | 13 | def clean_code_block_with_language_specifier(response): 14 | """ 15 | Clean the code block by removing the language specifier and the enclosing backticks. 16 | 17 | Args: 18 | response (str): The response containing the code block. 19 | 20 | Returns: 21 | str: The cleaned code block. 22 | """ 23 | lines = response.split("\n") 24 | 25 | # Check if the first line starts with ``` followed by a language specifier 26 | # and the last line is just ``` 27 | if lines[0].startswith("```") and lines[-1].strip() == "```": 28 | # Remove the first and last lines 29 | cleaned_lines = lines[1:-1] 30 | else: 31 | cleaned_lines = lines 32 | 33 | clean_response = "\n".join(cleaned_lines) 34 | return clean_response 35 | 36 | 37 | def pull_branch_changes(status_indicator, console, branch, debug=False): 38 | """ 39 | Pull the latest changes from the specified branch. 40 | 41 | Args: 42 | status_indicator: The status indicator object. 43 | console: The console object for printing messages. 44 | branch (str): The branch to pull changes from. 45 | debug (bool, optional): If True, print debug information. Defaults to False. 46 | """ 47 | status_indicator.start() 48 | status_indicator.update_spinner_message(f"Pulling changes from branch: {branch}") 49 | error = "" 50 | try: 51 | # Fetch origin and checkout branch 52 | subprocess_params = dict(stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) 53 | subprocess.run(["git", "fetch", "origin"], **subprocess_params) 54 | subprocess.run(["git", "checkout", branch], **subprocess_params) 55 | # Capture output of git pull 56 | result = subprocess.run(["git", "pull", "origin", branch], **subprocess_params) 57 | output = result.stdout 58 | error = result.stderr 59 | if debug: 60 | console.line() 61 | console.print(output) 62 | console.line() 63 | status_indicator.update_spinner_message("") 64 | status_indicator.log_message( 65 | f"Pull latest changes from `{branch}`", dim_text=True, character="↻" 66 | ) 67 | except Exception as e: 68 | status_indicator.fail() 69 | console.print("[bold red]An error occurred:" f"[/bold red] {type(e)} {str(e)}\n\n{error}") 70 | finally: 71 | status_indicator.stop() 72 | 73 | 74 | class TaskFormatter: 75 | """ 76 | A class to format task information for display. 77 | 78 | Attributes: 79 | task (Task): The task object to format. 80 | """ 81 | 82 | def __init__(self, task: Task): 83 | """ 84 | Initialize the TaskFormatter with a task. 85 | 86 | Args: 87 | task (Task): The task object to format. 88 | """ 89 | self.task = task 90 | 91 | def format_github_project(self): 92 | """ 93 | Format the GitHub project link. 94 | 95 | Returns: 96 | str: The formatted GitHub project link. 97 | """ 98 | return ( 99 | f"[link=https://github.com/{self.task.github_project}]{self.task.github_project}[/link]" 100 | ) 101 | 102 | def format_created_at(self): 103 | """ 104 | Format the creation time of the task. 105 | 106 | Returns: 107 | str: The formatted creation time. 108 | """ 109 | # If task was created less than 23 hours ago, show relative time 110 | now = datetime.now(timezone.utc) # Use timezone-aware datetime 111 | if (now - self.task.created).days == 0: 112 | return humanize.naturaltime(self.task.created) 113 | local_time = self.task.created.astimezone() 114 | return local_time.strftime("%Y-%m-%d %H:%M:%S") 115 | 116 | def format_pr_link(self): 117 | """ 118 | Format the pull request link. 119 | 120 | Returns: 121 | str: The formatted pull request link. 122 | """ 123 | if self.task.pr_number: 124 | return ( 125 | f"[link=https://github.com/{self.task.github_project}/pull/" 126 | f"{self.task.pr_number}]#{self.task.pr_number}[/link]" 127 | ) 128 | return "" 129 | 130 | def format_status(self): 131 | """ 132 | Format the status of the task. 133 | 134 | Returns: 135 | str: The formatted status. 136 | """ 137 | if self.task.status == "running": 138 | return f"[bold yellow]{self.task.status}[/bold yellow]" 139 | elif self.task.status == "completed": 140 | return f"[bold green]{self.task.status}[/bold green]" 141 | elif self.task.status == "failed": 142 | return f"[bold red]{self.task.status}[/bold red]" 143 | 144 | def format_title(self): 145 | """ 146 | Format the title of the task. 147 | 148 | Returns: 149 | str: The formatted title. 150 | """ 151 | task_title = self.task.title.replace("\n", " ")[0:80] 152 | dashboard_url = f"https://arcane.engineer/dashboard/tasks/{str(self.task.id)}/" 153 | return f"[link={dashboard_url}]{task_title}[/link]" 154 | 155 | def format_branch(self): 156 | """ 157 | Format the branch name. 158 | 159 | Returns: 160 | str: The formatted branch name. 161 | """ 162 | return Markdown(f"`{self.task.branch}`") 163 | 164 | 165 | def get_current_branch(): 166 | """ 167 | Get the current Git branch. 168 | 169 | Returns: 170 | str: The name of the current branch. 171 | """ 172 | return os.popen("git rev-parse --abbrev-ref HEAD").read().strip() 173 | 174 | 175 | def is_branch_pushed(branch): 176 | """ 177 | Check if the branch is pushed to the remote repository. 178 | 179 | Args: 180 | branch (str): The branch name to check. 181 | 182 | Returns: 183 | bool: True if the branch is pushed, False otherwise. 184 | """ 185 | return os.popen(f"git branch -r --list origin/{branch}").read().strip() != "" 186 | 187 | 188 | def get_branch_if_pushed(): 189 | """ 190 | Get the current branch if it is pushed to the remote repository. 191 | 192 | Returns: 193 | str or None: The name of the current branch if pushed, None otherwise. 194 | """ 195 | current_branch = get_current_branch() 196 | if current_branch not in ["master", "main"] and is_branch_pushed(current_branch): 197 | return current_branch 198 | return None 199 | 200 | 201 | def markdown_panel(title, content, hide_frame=False): 202 | """ 203 | Create a Rich panel with markdown content that automatically fits the width. 204 | 205 | Args: 206 | title (str): The title of the panel. 207 | content (str): The markdown content to display. 208 | hide_frame (bool, optional): If True, hide the frame of the panel. Defaults to False. 209 | 210 | Returns: 211 | Panel: The created Rich panel. 212 | """ 213 | # Calculate width based on the text content 214 | max_line_length = max(len(line) for line in content.split("\n")) 215 | padding = 4 # Adjust padding as necessary 216 | width = max_line_length + padding 217 | return Panel.fit( 218 | Markdown(content), title=title, width=width, box=box.MINIMAL if hide_frame else box.ROUNDED 219 | ) 220 | 221 | 222 | @functools.lru_cache() 223 | def is_git_repo(): 224 | """ 225 | Check if the current directory is part of a Git repository. 226 | 227 | Returns: 228 | bool: True if the current directory is part of a Git repository, False otherwise. 229 | """ 230 | try: 231 | subprocess.run( 232 | ["git", "rev-parse", "--is-inside-work-tree"], 233 | check=True, 234 | stdout=subprocess.PIPE, 235 | stderr=subprocess.PIPE, 236 | ) 237 | return True 238 | except subprocess.CalledProcessError: 239 | return False 240 | 241 | 242 | @functools.lru_cache() 243 | def get_git_root(): 244 | """ 245 | Get the root directory of the current Git repository. 246 | 247 | Returns: 248 | str or None: The root directory of the Git repository, or None if not in a Git repository. 249 | """ 250 | try: 251 | result = subprocess.run( 252 | ["git", "rev-parse", "--show-toplevel"], 253 | check=True, 254 | stdout=subprocess.PIPE, 255 | stderr=subprocess.PIPE, 256 | ) 257 | return result.stdout.decode("utf-8").strip() 258 | except subprocess.CalledProcessError: 259 | return None 260 | 261 | 262 | def get_api_host(): 263 | """ 264 | Get the API host URL. 265 | 266 | Returns: 267 | str: The API host URL. 268 | """ 269 | return os.getenv("PR_PILOT_HOST", "https://arcane.engineer") 270 | -------------------------------------------------------------------------------- /clitest.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | from rich.markdown import Markdown 3 | from rich.measure import measure_renderables 4 | from rich.panel import Panel 5 | 6 | console = Console() 7 | 8 | if __name__ == "__main__": 9 | markdown = Markdown("This is a test\n\n") 10 | width, _ = measure_renderables(console, console.options, [markdown]) 11 | console.print(Panel.fit(markdown, title="Result", width=60)) 12 | -------------------------------------------------------------------------------- /examples/extract-project-template/Makefile: -------------------------------------------------------------------------------- 1 | extract-template: 2 | pilot --model=gpt-4o --direct -f extract_template.md.jinja2 -o template.md 3 | initialize-project: 4 | pilot --model=gpt-4o -f new_project_from_template.md.jinja2 -------------------------------------------------------------------------------- /examples/extract-project-template/extract_template.md.jinja2: -------------------------------------------------------------------------------- 1 | ### Project Structure and Dependencies 2 | {{ subtask('understand_project_structure.md.jinja2') }} 3 | 4 | ### README File 5 | {{ subtask('understand_readme.md.jinja2') }} 6 | 7 | ### Build System & CI/CD 8 | {{ subtask('understand_build_system.md.jinja2') }} 9 | 10 | -------------------------------------------------------------------------------- /examples/extract-project-template/format_instructions.md.jinja2: -------------------------------------------------------------------------------- 1 | Your response will be used as a template for creating new projects from scratch. 2 | Respond in concise bullet points, no other text. -------------------------------------------------------------------------------- /examples/extract-project-template/new_project_from_template.md.jinja2: -------------------------------------------------------------------------------- 1 | We have an empty project and need to initialize it with files. The project will be similar to a project that 2 | already exists. We will use the existing project as a template to create the new project. 3 | 4 | Here is what the template project looks like: 5 | 6 | --- 7 | {{ sh('cat template.md') }} 8 | --- 9 | 10 | Here is what the new project will be about: 11 | 12 | --- 13 | A command-line interface for checking the local weather. 14 | --- 15 | 16 | Apply the new project information to the template project and 17 | open a new Github issue for initializing the project. 18 | The issue body should: 19 | - Explain the new project and its purpose 20 | - Provide a list of all files that need to be created 21 | - For each file, provide a brief description of its purpose, content and structure 22 | - List other requirements for the project 23 | 24 | Open the new issue! -------------------------------------------------------------------------------- /examples/extract-project-template/template.md: -------------------------------------------------------------------------------- 1 | ### Project Structure and Dependencies 2 | Here's the information you requested about the `arc-eng/cli` project: 3 | 4 | 1. **Primary Language and Framework:** 5 | - The project primarily uses Python. This is inferred from the presence of Python-specific files like `requirements.txt` and `setup.py`. 6 | 7 | 2. **License:** 8 | - The project is licensed under the GNU General Public License v3.0. 9 | 10 | 3. **Contents of `.gitignore`:** 11 | - The `.gitignore` file includes: 12 | ``` 13 | .idea 14 | *.iml 15 | __pycache__ 16 | venv 17 | ``` 18 | 19 | 4. **Path of the Dependency File:** 20 | - The dependency file is `requirements.txt`. 21 | 22 | 5. **Name of the Dependency Management System:** 23 | - The project uses `pip`, a package installer for Python, evident from the `requirements.txt` file. 24 | 25 | 6. **List of Main Dependencies:** 26 | - The main dependencies related to the project's framework and purpose include: 27 | - `click` (for creating command line interfaces) 28 | - `pr-pilot` (likely a core library for the project's functionality) 29 | - `pyyaml` (for YAML file parsing) 30 | - `yaspin` (for terminal spinners) 31 | - `rich` (for rich text and beautiful formatting in the terminal) 32 | - `jinja2` (for templating) 33 | 34 | These details provide a comprehensive overview of the project's technical and legal setup. 35 | 36 | ### README File 37 | The `README.md` file for the Arcane Engine CLI project is structured and organized as follows: 38 | 39 | ### Structure and Sections: 40 | 1. **Header**: Includes a centered logo and navigation links (Install, Documentation, Blog, Website). 41 | 2. **Introduction**: Briefly describes the purpose and functionality of Arcane Engine CLI. 42 | 3. **Usage**: Explains how to use the CLI with examples for various tasks. 43 | 4. **Options and Parameters**: Detailed list of command-line options and parameters. 44 | 5. **Installation**: Steps to install Arcane Engine CLI via pip and Homebrew. 45 | 6. **Configuration**: Information about the configuration file location. 46 | 7. **Contributing**: Encourages contributions and links to the GitHub repository. 47 | 8. **License**: Specifies the licensing information (GPL-3). 48 | 49 | ### References to Important Files or Directories: 50 | - References the `prompts` directory which contains Jinja templates for generating READMEs and other documents. 51 | - Mentions specific files like `prompts/README.md.jinja2` and `~/.pr-pilot.yaml` for configuration. 52 | 53 | ### Writing Style: 54 | - The writing style is formal and informative, aimed at providing clear and concise instructions to users. 55 | - It uses technical language appropriate for a developer audience, explaining commands and functionalities in detail. 56 | 57 | ### Use of Emojis: 58 | - The README does not use emojis within the text. It maintains a professional tone suitable for a technical document. 59 | 60 | This structure ensures that users can quickly find the information they need about installation, usage, and customization of the Arcane Engine CLI. 61 | 62 | ### Build System & CI/CD 63 | ### Build System Details of the Arcane Engine CLI Project 64 | 65 | **Build System Used:** 66 | - The project uses `Makefile` as its build system. 67 | 68 | **Build Commands:** 69 | - The specific build commands can be found within the `Makefile`. Common commands in a Makefile include `make build`, `make install`, `make test`, etc. 70 | 71 | **Path of the Main Build File:** 72 | - The main build file is located at the root of the project and is named `Makefile`. 73 | 74 | **Structure of the Build File:** 75 | - The `Makefile` typically contains targets that specify how to execute tasks like building the project, cleaning build files, running tests, etc. Each target lists the commands to be executed. 76 | 77 | **Manifests or Configuration Files Related to the Build System:** 78 | - `MANIFEST.in` and `setup.py` are related to the build system, particularly for Python projects. `MANIFEST.in` includes instructions on which files to include in the distribution (not directly part of the build process but related to packaging). 79 | - `setup.py` is a configuration file for `setuptools`, which manages the packaging and distribution of Python packages. 80 | 81 | **CI/CD System Used:** 82 | - The project uses GitHub Actions for CI/CD. 83 | 84 | **GitHub Actions in `.github/workflows`:** 85 | - The following GitHub Actions workflows are present: 86 | - `import_smart_workflow.yaml` 87 | - `initialize_project.yaml` 88 | - `publish.yaml` 89 | 90 | **Files in the '.' Directory Related to CI/CD:** 91 | - The `.github` directory contains the CI/CD workflows. 92 | - The `Makefile` can also be part of the CI/CD process, as it defines how to build and test the project. 93 | 94 | These details should give you a comprehensive understanding of the build system used in the Arcane Engine CLI project. If you need more specific details about any of the components, feel free to ask! 95 | -------------------------------------------------------------------------------- /examples/extract-project-template/understand_build_system.md.jinja2: -------------------------------------------------------------------------------- 1 | I want you to understand the following about the build system of the project: 2 | - What build system is used (e.g. Maven, Gradle, Makefile)? 3 | - What are the build commands? 4 | - What is the path of the main build file? 5 | - What is the structure of the build file? 6 | - Any manifests or configuration files related to the build system? 7 | - What CI/CD system is used? 8 | - Are there any Github actions in `.github/workflows`? 9 | - Are any files in the '.' directory related to CI/CD? 10 | 11 | {% include 'format_instructions.md.jinja2' %} -------------------------------------------------------------------------------- /examples/extract-project-template/understand_project_structure.md.jinja2: -------------------------------------------------------------------------------- 1 | I want you to collect the following information about the project 2 | - Identify the primary language and / or framework used in the project 3 | - What's the license? 4 | - What does `.gitignore` contain? 5 | - Path of the dependency file 6 | - Name of the dependency management system used (if any) 7 | - List of main dependencies related to the project's framework / main purpose 8 | 9 | 10 | {% include 'format_instructions.md.jinja2' %} -------------------------------------------------------------------------------- /examples/extract-project-template/understand_readme.md.jinja2: -------------------------------------------------------------------------------- 1 | I want you to understand the following about the README.md file: 2 | - What is the structure of the ile? 3 | - What sections are included? 4 | - Does it contain references to important files or directories in the repository? 5 | - What is the writing style? 6 | - Does it use Emojis and where? 7 | 8 | {% include 'format_instructions.md.jinja2' %} -------------------------------------------------------------------------------- /examples/implementation-plan/README.md: -------------------------------------------------------------------------------- 1 | # Example: Implementation Plan 2 | 3 | This directory contains a number of plans that can be executed with Pilot. Each plan is a YAML file that describes a series of steps to be executed. 4 | Plans can be used to break down more complex tasks into smaller steps. 5 | 6 | 7 | # Example 1: Quicksort 8 | 9 | This plan demonstrates how to implement the Quicksort algorithm in Python. 10 | 11 | ```shell 12 | pilot plan quicksort.yaml 13 | ``` -------------------------------------------------------------------------------- /examples/implementation-plan/quicksort.yaml: -------------------------------------------------------------------------------- 1 | name: Write Quicksort Implementation 2 | prompt: We want to implement quicksort TDD-Style. 3 | 4 | steps: 5 | - name: Write Quicksort unit tests 6 | prompt: | 7 | Write Python unit tests for a quicksort implementation. The tests should cover the following cases: 8 | - An empty list 9 | - A list with one element 10 | - A list with two elements 11 | Write the tests into `test_quicksort.py`. 12 | - name: Write Quicksort implementation 13 | prompt: | 14 | Write a Python function that implements the quicksort algorithm so that the tests pass. 15 | The function should take a list of integers as input and return a sorted list. 16 | Write the implementation into `quicksort.py`. 17 | - name: QA 18 | prompt: | 19 | 1. Look at all changes in this PR and make sure they make sense. 20 | 2. If anything needs to change, make the necessary changes. 21 | 3. Edit the PR title and description so they reflect all file changes of the PR coherently 22 | -------------------------------------------------------------------------------- /examples/recursive-magic/research.md.jinja2: -------------------------------------------------------------------------------- 1 | # Task 2 | 3 | We want to collect information about which GPT models are referenced / used in this project and how they are used. 4 | 5 | # Existing Research 6 | 7 | {{ subtask('research.md.jinja2') }} 8 | 9 | # Next Steps 10 | 11 | Search the code base for the keyword 'GPT' and document the results. 12 | 13 | # Your Job 14 | 1. Execute the next steps 15 | 2. Respond with a copy of "Existing Research" with the new information added -------------------------------------------------------------------------------- /examples/spec-and-build-feature/feature_description.md: -------------------------------------------------------------------------------- 1 | A Python module that provides a simple CLI for getting weather reports. 2 | 3 | Functional Requirements: 4 | - The user can get the weather report for a specific location. 5 | - If not specified, the CLI asks for the location 6 | - There should be options to get the weather report in Celsius or Fahrenheit 7 | 8 | Non-functional Requirements: 9 | - The CLI is implemented in Python using click and rich 10 | - The CLI should be easy to use. 11 | - It should use a free weather API. 12 | - The CLI should be able to handle errors gracefully. 13 | - There should be a file with pytests for the CLI's main functionality 14 | - There should be a README.md file with instructions on how to use the CLI -------------------------------------------------------------------------------- /examples/spec-and-build-feature/plan.yaml: -------------------------------------------------------------------------------- 1 | name: Weather CLI Feature 2 | prompt: | 3 | We are building a Python module that provides a simple CLI for getting weather reports. 4 | 5 | Functional Requirements: 6 | - The user can get the weather report for a specific location. 7 | - If not specified, the CLI asks for the location 8 | - There should be options to get the weather report in Celsius or Fahrenheit 9 | 10 | Non-functional Requirements: 11 | - The CLI is implemented in Python using click and rich 12 | - The CLI should be easy to use. 13 | - It should use a free weather API. 14 | - The CLI should be able to handle errors gracefully. 15 | - There should be a file with pytests for the CLI's main functionality 16 | - There should be a README.md file with instructions on how to use the CLI 17 | 18 | steps: 19 | - name: Implement CLI 20 | output_file: weather_cli.py 21 | code: true 22 | prompt: | 23 | We are building a Python module that provides a simple CLI for getting weather reports. 24 | 25 | You need to implement `weather_cli.py` to provide the main CLI functionality. 26 | Follow these steps: 27 | 1. Import necessary libraries: `click` for CLI commands, `requests` for API calls, and `rich` for rich text formatting. 28 | 2. Define a function `get_weather` that takes a location and a unit (Celsius or Fahrenheit) as arguments and returns the weather report using a free weather API (e.g., OpenWeatherMap). 29 | 3. Define a function `print_weather` that takes the weather data and prints it using `rich`. 30 | 4. Define the main CLI command using `click`. This command should: 31 | - Accept a location as an argument (optional). 32 | - Accept a unit option (Celsius or Fahrenheit). 33 | - If the location is not provided, prompt the user to enter it. 34 | - Call `get_weather` with the provided location and unit. 35 | - Call `print_weather` to display the weather report. 36 | 5. Add error handling to manage API errors, invalid locations, and other potential issues gracefully. 37 | 38 | - name: Implement Tests 39 | output_file: test_weather_cli.py 40 | code: true 41 | prompt: | 42 | We are building a Python module that provides a simple CLI for getting weather reports. 43 | 44 | You need to implement `test_weather_cli.py` to provide tests for the CLI's main functionality. 45 | Follow these steps: 46 | 1. Import necessary libraries: `pytest` for testing and `click.testing` for testing Click commands. 47 | 2. Write tests for the `get_weather` function to ensure it correctly fetches weather data for valid locations and handles errors for invalid locations. 48 | 3. Write tests for the main CLI command to ensure it correctly handles user input, calls `get_weather`, and prints the weather report. 49 | 4. Use fixtures to mock API responses for testing purposes. 50 | 51 | - name: Write README 52 | output_file: README.md 53 | code: true 54 | prompt: | 55 | We are building a Python module that provides a simple CLI for getting weather reports. 56 | 57 | You need to implement `README.md` to provide instructions on how to use the CLI. 58 | 59 | Follow these steps: 60 | 1. Write a brief introduction to the CLI and its functionality. 61 | 2. Provide installation instructions, including any necessary dependencies. 62 | 3. Provide usage instructions, including examples of how to get weather reports for specific locations and how to specify the unit (Celsius or Fahrenheit). 63 | 4. Include a section on error handling and how the CLI manages potential issues. 64 | 5. Provide instructions for running the tests. -------------------------------------------------------------------------------- /examples/spec-and-build-feature/write_spec.md.jinja2: -------------------------------------------------------------------------------- 1 | We want to build a new feature: 2 | 3 | --- 4 | {{ sh('cat feature_description.md') }} 5 | --- 6 | 7 | You need to create a technical specification for the feature. The specification should be in the form of a 8 | list of all files that need to be created. 9 | 10 | For each file: 11 | - Explain its purpose and how it fits into the feature. 12 | - Explain step-by-step how to implement the file. 13 | - Include any relevant context or information. 14 | 15 | Follow these guidelines: 16 | - Be as detailed and specific as possible without writing code 17 | - List all frameworks, APIs and libraries and how they will be used 18 | - consider edge cases and error handling 19 | 20 | Generate an technical specification as a YAML file: 21 | 22 | ```yaml 23 | name: 24 | prompt: | 25 | 26 | tasks: 27 | - name: 28 | output_file: 29 | code: true 30 | prompt: | 31 | We are building <... relevant context ...> 32 | 33 | You need to implement to <... description ...>. 34 | Follow these steps: 35 | 1. 36 | 2. 37 | n. 38 | - name: 39 | output_file: 40 | code: true 41 | prompt: | 42 | etc... 43 | ``` -------------------------------------------------------------------------------- /homebrew_formula.rb: -------------------------------------------------------------------------------- 1 | class PrPilotCli < Formula 2 | include Language::Python::Virtualenv 3 | 4 | desc "CLI for Arcane Engine, a text-to-task automation platform for Github." 5 | homepage "https://arcane.engineer" 6 | license "GPL-3.0" 7 | 8 | {{ PACKAGE_URL }} 9 | 10 | depends_on "python@3.10" 11 | depends_on "rust" => :build 12 | 13 | {{ RESOURCES }} 14 | 15 | def install 16 | virtualenv_create(libexec, "python3") 17 | virtualenv_install_with_resources 18 | end 19 | 20 | test do 21 | system "#{bin}/pilot", "--help" 22 | end 23 | end -------------------------------------------------------------------------------- /pr-pilot-cli.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /prompts/README.md: -------------------------------------------------------------------------------- 1 | # Prompt Templates 2 | 3 | ## What are Prompt Templates? 4 | 5 | Prompt templates in Arcane Engine are a superset of [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/), 6 | with three special functions available: 7 | 8 | - `sh`: Execute shell commands and capture the output. 9 | - `env`: Access environment variables. 10 | - `subtask`: Let RP Pilot run a task and capture the output. 11 | - `select`: Select an option from a list. 12 | 13 | ## Example of a Prompt Template 14 | 15 | An example of a prompt template is [analyze_test_results.md.jinja2](./analyze_test_results.md.jinja2). This template is designed to help analyze and 16 | report the results of unit tests. Here's a breakdown of its content: 17 | 18 | ```markdown 19 | We ran our unit tests, here's the output: 20 | 21 | --- 22 | {{ sh('pytest') }} 23 | --- 24 | 25 | If the tests are green, do nothing. Otherwise: 26 | 1. Identify the parts of the code that are not working. 27 | 2. Read the relevant files to get a better understanding of the problem. 28 | 3. Write a short, concise, structured analysis of the test results. 29 | {% if env('PR_NUMBER') %} 30 | 4. Comment on PR #{{ env('PR_NUMBER') }} with the analysis. 31 | {% endif %} 32 | ``` 33 | 34 | ### Explanation: 35 | 36 | The goal is to dynamically generate a prompt that includes the output of running unit tests and provides instructions 37 | for Arcane Engine on how to analyze the results. Here's what each part does: 38 | 39 | - `{{ sh('pytest') }}`: This line dynamically inserts the output of the `pytest` command, which runs unit tests. 40 | - `{% if env('PR_NUMBER') %}`: This conditional block checks if a PR number is set in the environment (e.g. when run as a Github Action). If it is, it includes a step to comment on the PR with the analysis of the test results. 41 | 42 | ## How to Use Prompt Templates 43 | 44 | To use a prompt template, you can pass it as a file to Arcane Engine using the `-f` or `--file` option. For example: 45 | 46 | ```bash 47 | pilot -f analyze_test_results.md.jinja2 48 | ``` 49 | 50 | This will trigger the following: 51 | 1. Run the tests in the local environment 52 | 2. Capture the output of the tests as part of the prompt 53 | 3. Send the prompt to Arcane Engine, where it will autonomously: 54 | 1. Read the test results and identify failing parts 55 | 2. Read the relevant files to understand the problem 56 | 3. Write a structured analysis of the test results 57 | 4. Optionally, comment on the PR with the analysis if a PR number is provided in the environment. 58 | 59 | ## Crafting Powerful Multi-Step Prompts with `subtask` 60 | 61 | The `subtask` function allows you to run a task within a prompt template and capture the output. This enables you 62 | to dynamically generate complex documents or instructions by combining smaller, well-defined tasks. 63 | 64 | ### Example: Generating Code Documentation 65 | 66 | Suppose you want to generate documentation for a codebase. You can create a document template that includes 67 | multiple subtasks for generating documentation for different parts of the codebase. 68 | 69 | Here's a simplified example: 70 | 71 | ```markdown 72 | # Task Processing in Arcane Engine 73 | 74 | The lifecycle of a task within Arcane Engine involves several key components: `TaskEngine`, `TaskScheduler`, and `TaskWorker`. 75 | 76 | ## Domain Model 77 | 78 | {% set domain_model_prompt %} 79 | Read the following files: 80 | - engine/task_engine.py 81 | - engine/task_scheduler.py 82 | - engine/task_worker.py 83 | - engine/task.py 84 | 85 | Generate a Mermaid class diagram to illustrate the domain model of the task processing in Arcane Engine. Add a clear and concise text description. 86 | {% endset %} 87 | 88 | {{ subtask(domain_model_prompt) }} 89 | 90 | ## Task Lifecycle 91 | 92 | {% set diagram_prompt %} 93 | Read the following files: 94 | - engine/task_engine.py 95 | - engine/task_scheduler.py 96 | - engine/task_worker.py 97 | 98 | Generate a Mermaid sequence diagram to illustrate how these components work together to execute a task. 99 | Add a clear and concise text describing the interplay of the components. 100 | {% endset %} 101 | 102 | {{ subtask(diagram_prompt) }} 103 | ``` 104 | 105 | In this example, the `subtask` function is used to generate a class diagram and a sequence diagram for the codebase. 106 | For better readability and maintainability, the prompts could even be stored in their own separate files and included in the main prompt template: 107 | 108 | ```markdown 109 | # Task Processing in Arcane Engine 110 | 111 | The lifecycle of a task within Arcane Engine involves several key components: `TaskEngine`, `TaskScheduler`, and `TaskWorker`. 112 | 113 | ## Domain Model 114 | 115 | {{ subtask('domain_model.md.jinja2') }} 116 | 117 | ## Task Lifecycle 118 | 119 | {{ subtask('task_lifecycle.md.jinja2') }} 120 | ``` 121 | You can then run this prompt template using the CLI: 122 | 123 | ```bash 124 | pilot --direct -f task_processing_docs.md.jinja2 -o docs/task_processing.md 125 | ``` 126 | 127 | This will autonomously generate the documentation for the codebase based on the defined subtasks in the prompt template: 128 | - `-f` specifies the prompt template file 129 | - `-o` specifies the output file where the generated documentation will be saved 130 | - `--direct` tells Arcane Engine to render the template directly as output (instead of using it as a prompt) 131 | 132 | 133 | ## Select values from a list with `select` 134 | 135 | Using the `select` function, you can present a list of options to the user and capture their selection. 136 | This is useful for creating interactive prompts that require user input: 137 | 138 | ```markdown 139 | {% set pod=select('Select a pod', sh('kubectl get pods -o custom-columns=:metadata.name').split('\n')) %} 140 | 141 | Here are the last 10 log lines of a Kubernetes pod named `{{ pod }}`: 142 | 143 | {{ sh(['kubectl', '--tail=10', 'logs', pod]) }} 144 | 145 | --- 146 | 147 | I want to know: 148 | {{ env('QUESTION_ABOUT_LOGS') }} 149 | ``` 150 | 151 | In this example, the `select` function presents a list of pods retrieved using `kubectl get pods` and captures the user's selection: 152 | 153 | ```shell 154 | ➜ pilot --verbose task -f prompts/investigate-pod.md.jinja2 155 | ✔ Running shell command: kubectl get pods -o custom-columns=:metadata.name (0:00:00.22) 156 | [?] Select a pod: 157 | nginx-static-cf7f8fd89-dv6pf 158 | nginx-static-cf7f8fd89-qqvg7 159 | pr-pilot-app-868489cdf6-5qrt8 160 | pr-pilot-app-868489cdf6-8qjrk 161 | pr-pilot-app-868489cdf6-g9lh9 162 | pr-pilot-db-postgresql-0 163 | pr-pilot-redis-master-0 164 | > pr-pilot-worker-0 165 | pr-pilot-worker-1 166 | 167 | ✔ Running shell command: kubectl --tail=10 logs pr-pilot-worker-0 (0:00:00.24) 168 | > Question about logs: Do you see any errors? 169 | ✔ Task created: a011fcbe-9069-4e34-892e-b89459da1ee1 (0:00:00.00) 170 | ✔ Investigate Errors in Kubernetes Pod `pr-pilot-worker-0` Logs (0:00:09.99) 171 | ╭────────────────────────────────────────────────────────────────────────────────────── Result ──────────────────────────────────────────────────────────────────────────────────────╮ 172 | │ Based on the provided log lines, there are no errors visible. All the log entries are marked with INFO level, indicating normal operation. Here is a summary of the log entries: │ 173 | │ │ 174 | │ 1 A new branch named investigate-the-output was created. │ 175 | │ 2 An HTTP POST request to the OpenAI API was successful (HTTP/1.1 200 OK). │ 176 | │ 3 A cost item for a conversation with the model gpt-4o was recorded. │ 177 | │ 4 The branch investigate-the-output was deleted because there were no changes. │ 178 | │ 5 The latest main branch was checked out. │ 179 | │ 6 The branch investigate-the-output was deleted. │ 180 | │ 7 The project PR-Pilot-AI/demo was checked for open source eligibility. │ 181 | │ 8 A discount of 0.0% was applied. │ 182 | │ 9 The total cost was recorded as 4.0 credits. │ 183 | │ 10 The remaining budget for the user mlamina was recorded as 291.82 credits. │ 184 | │ │ 185 | │ All these entries indicate normal operations without any errors. │ 186 | ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ 187 | 188 | ``` -------------------------------------------------------------------------------- /prompts/README.md.jinja2: -------------------------------------------------------------------------------- 1 |
2 | Arcane Engine Logo 3 |
4 | 5 |

6 | Install | 7 | Documentation | 8 | Blog | 9 | Website 10 |

11 | 12 | 13 | # Arcane Engine Command-Line Interface 14 | [![Unit Tests](https://github.com/arc-eng/cli/actions/workflows/unit_tests.yml/badge.svg)][tests] 15 | [![PyPI](https://img.shields.io/pypi/v/arcane-cli.svg)][pypi status] 16 | [![Python Version](https://img.shields.io/pypi/pyversions/arcane-cli)][pypi status] 17 | [![License](https://img.shields.io/pypi/l/arcane-cli)][license] 18 | [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)][black] 19 | [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)][pre-commit] 20 |
21 | 22 | [pypi status]: https://pypi.org/project/arcane-cli/ 23 | [tests]: https://github.com/arc-eng/cli/actions/workflows/unit_tests.yml 24 | [codecov]: https://app.codecov.io/gh/magmax/python-inquirer 25 | [pre-commit]: https://github.com/pre-commit/pre-commit 26 | [black]: https://github.com/psf/black 27 | [license]: https://github.com/arc-eng/cli/blob/main/LICENSE 28 | 29 | [Arcane Engine](https://docs.arcane.engineer) is **simple and intuitive CLI** that assists you in your daily work: 30 | 31 | ```bash 32 | pilot edit main.py "Add docstrings to all functions and classes" 33 | ``` 34 | 35 | **It works with [the dev tools you trust and love](https://docs.arcane.engineer/integrations.html)** - exactly when and where you want it. 36 | 37 | ```bash 38 | pilot task "Find all bug issues on Github and Linear opened yesterday, post them to #bugs-daily on Slack." 39 | ``` 40 | 41 | [Prompt templates](https://github.com/arc-eng/cli/tree/main/prompts) let you can create powerful, 42 | **executable prompt-based commands**, defined as Jinja templates: 43 | 44 | ```markdown 45 | I've made some changes and opened a new PR: #{% raw %}{{ env('PR_NUMBER') }}{% endraw %}. 46 | 47 | I need a PR title and a description that summarizes these changes in short, concise bullet points. 48 | The PR description will also be used as merge commit message, so it should be clear and informative. 49 | 50 | Use the following guidelines: 51 | 52 | - Start title with a verb in the imperative mood (e.g., "Add", "Fix", "Update"). 53 | - At the very top, provide 1-sentence summary of the changes and their impact. 54 | - Below, list the changes made in bullet points. 55 | 56 | # Your task 57 | Edit PR #{% raw %}{{ env('PR_NUMBER') }}{% endraw %} title and description to reflect the changes made in this PR. 58 | ``` 59 | 60 | Send Arcane Engine off to give any PR a title and description **according to your guidelines**: 61 | 62 | ```bash 63 | ➜ PR_NUMBER=153 pilot task -f generate-pr-description.md.jinja2 --save-command 64 | ✔ Task created: 7d5573d2-2717-4a96-8bae-035886420c74 (0:00:00.00) 65 | ✔ Update PR #153 title and description to reflect changes made (0:00:17.87) 66 | ╭──────────────────────────── Result ──────────────────────────────────────────╮ 67 | │ The PR title and description have been updated. You can view the PR here. │ 68 | ╰──────────────────────────────────────────────────────────────────────────────╯ 69 | ``` 70 | 71 | The `--save-command` parameter makes this call **re-usable**: 72 | 73 | ```bash 74 | ➜ pilot task -f generate-pr-description.md.jinja2 --save-command 75 | 76 | Save the task parameters as a command: 77 | 78 | Name (e.g. generate-pr-desc): pr-description 79 | Short description: Generate title and description for a pull request 80 | 81 | Command saved to .pilot-commands.yaml 82 | ``` 83 | 84 | You can now run this command **for any PR** with `pilot run pr-description`: 85 | 86 | ```bash 87 | ➜ pilot run pr-description 88 | Enter value for PR_NUMBER: 83 89 | ╭──────────── Result ─────────────╮ 90 | │ Here is the link to the PR #83 │ 91 | ╰─────────────────────────────────╯ 92 | ``` 93 | 94 | To learn more, please visit our **[User Guide](https://docs.arcane.engineer/user_guide.html)** and **[demo repository](https://github.com/PR-Pilot-AI/demo/tree/main)**. 95 | 96 | ## 📦 Installation 97 | First, make sure you have [installed](https://github.com/apps/pr-pilot-ai/installations/new) Arcane Engine in your repository. 98 | 99 | Then, install the CLI using one of the following methods: 100 | 101 | ### pip 102 | ``` 103 | pip install --upgrade arcane-cli 104 | ``` 105 | 106 | ### Homebrew: 107 | ``` 108 | brew tap pr-pilot-ai/homebrew-tap 109 | brew install arcane-cli 110 | ``` 111 | 112 | 113 | ### ⚙️ Options and Parameters 114 | 115 | The CLI has global parameters and options that can be used to customize its behavior. 116 | 117 | 118 | ```bash 119 | {{ sh('python -m cli.cli --help') }} 120 | ``` 121 | 122 | ## 🛠️ Usage 123 | 124 | In your repository, use the `pilot` command: 125 | 126 | ```bash 127 | pilot task "Tell me about this project!" 128 | # 📝 Ask Arcane Engine to edit a local file for you: 129 | pilot edit cli/cli.py "Make sure all functions and classes have docstrings." 130 | # ⚡ Generate code quickly and save it as a file: 131 | pilot task -o test_utils.py --code "Write some unit tests for the utils.py file." 132 | # 🔍 Capture part of your screen and add it to a prompt: 133 | pilot task -o component.html --code --snap "Write a Bootstrap5 component that looks like this." 134 | # 📊 Get an organized view of your Github issues: 135 | pilot task "Find all open Github issues labeled as 'bug', categorize and prioritize them" 136 | # 📝 Ask Arcane Engine to analyze your test results using prompt templates: 137 | pilot task -f prompts/analyze-test-results.md.jinja2 138 | ``` 139 | 140 | For more detailed examples, please visit our **[demo repository](https://github.com/PR-Pilot-AI/demo/tree/main)**. 141 | 142 | 143 | ### ⬇️ Grab commands from other repositories 144 | 145 | Once saved in a repository, commands can be grabbed from anywhere: 146 | 147 | ```bash 148 | ➜ code pilot grab commands pr-pilot-ai/core 149 | 150 | pr-pilot-ai/core 151 | haiku Writes a Haiku about your project 152 | test-analysis Run unit tests, analyze the output & provide suggestions 153 | daily-report Assemble a comprehensive daily report & send it to Slack 154 | pr-description Generate PR Title & Description 155 | house-keeping Organize & clean up cfg files (package.json, pom.xml, etc) 156 | readme-badges Generate badges for your README file 157 | 158 | [?] Grab: 159 | [ ] haiku 160 | [X] test-analysis 161 | [ ] daily-report 162 | > [X] pr-description 163 | [ ] house-keeping 164 | [ ] readme-badges 165 | 166 | 167 | You can now use the following commands: 168 | 169 | pilot run test-analysis Run unit tests, analyze the output & provide suggestions 170 | pilot run pr-description Generate PR Title & Description 171 | ``` 172 | 173 | Our **[core repository](https://github.com/PR-Pilot-AI/core)** contains an ever-growing, curated list of commands 174 | that we tested and handcrafted for you. You can grab them and use them in your own repositories. 175 | 176 | ### 📝 Advanced Usage: Execute a step-by-step plan 177 | 178 | Break down more complex tasks into smaller steps with a plan: 179 | 180 | ```yaml 181 | # add_page.yaml 182 | 183 | name: Add a TODO Page 184 | prompt: | 185 | We are adding a TODO page to the application. 186 | Users should be able to: 187 | - See a list of their TODOs 188 | - Cross of TODO items / mark them as done 189 | - Add new TODO items 190 | 191 | steps: 192 | - name: Create HTML template 193 | prompt: | 194 | 1. Look at templates/users.html to understand the basic structure 195 | 2. Create templates/todo.html based on the example 196 | - name: Create view controller 197 | prompt: | 198 | The controller should handle all actions/calls from the UI. 199 | 1. Look at views/users.py to understand the basic structure 200 | 2. Create views/todo.py based on the example 201 | - name: Integrate the page 202 | prompt: | 203 | Integrate the new page into the application: 204 | 1. Add a new route in urls.py, referencing the new view controller 205 | 2. Add a new tab to the navigation in templates/base.html 206 | - name: Generate PR description 207 | template: prompts/generate-pr-description.md.jinja2 208 | ``` 209 | You can run this plan with: 210 | ```bash 211 | pilot plan add_page.yaml 212 | ``` 213 | 214 | Arcane Engine will then autonomously: 215 | * Create a new branch and open a PR 216 | * Implement the HTML template and view controller 217 | * Integrate the new page into the navigation 218 | * Look at all changes and create a PR description based on your preferences defined in `prompts/generate-pr-description.md.jinja2` 219 | 220 | Save this as part of your code base. Next time you need a new page, simply adjust the plan and run it again. 221 | If you don't like the result, simply close the PR and delete the branch. 222 | 223 | You can iterate on the plan until you are satisfied with the result. 224 | 225 | 226 | ## ⚙️ Configuration 227 | The configuration file is located at `~/.pr-pilot.yaml`. 228 | 229 | ```yaml 230 | # Your API Key from https://arcane.engineer/dashboard/api-keys/ 231 | api_key: YOUR_API_KEY 232 | 233 | # Default Github repository if not running CLI in a repository directory 234 | default_repo: owner/repo 235 | 236 | # Enabled --sync by default 237 | auto_sync: true 238 | 239 | # Suppress status messages by default 240 | verbose: false 241 | ``` 242 | 243 | ## 🤝 Contributing 244 | Contributors are welcome to improve the CLI by submitting pull requests or reporting issues. For more details, check the project's GitHub repository. 245 | 246 | ## 📜 License 247 | The Arcane Engine CLI is open-source software licensed under the GPL-3 license. 248 | -------------------------------------------------------------------------------- /prompts/analyze_test_results.md.jinja2: -------------------------------------------------------------------------------- 1 | We ran our unit tests, here's the output: 2 | 3 | --- 4 | {{ sh('poetry run pytest') }} 5 | --- 6 | 7 | If the tests are green, do nothing. Otherwise: 8 | 1. Identify the parts of the code that are not working. 9 | 2. Read the relevant files to get a better understanding of the problem. 10 | 3. Respond with a short, concise explanation of the errors and a suggestion for how to fix them 11 | -------------------------------------------------------------------------------- /prompts/calculate_pi.md.jinja2: -------------------------------------------------------------------------------- 1 | Here is a Python function that calculates Pi: 2 | 3 | --- 4 | {{ subtask('Write me a Python function that calculates Pi. Return only the code, no other text.') }} 5 | --- 6 | 7 | Write a unit test for the function -------------------------------------------------------------------------------- /prompts/fix-tests.md.jinja2: -------------------------------------------------------------------------------- 1 | We ran our unit tests, here's the output: 2 | 3 | --- 4 | {{ sh('poetry run pytest') }} 5 | --- 6 | 7 | If the tests are green, do nothing. Otherwise: 8 | 1. Identify the parts of the code that are not working. 9 | 2. Read the relevant files to get a better understanding of the problem. 10 | 3. Fix the tests and/or the code. 11 | 12 | Additional Notes: 13 | {{ env('ADDITIONAL_NOTES') }} 14 | -------------------------------------------------------------------------------- /prompts/generate_pr_description.md.jinja2: -------------------------------------------------------------------------------- 1 | I've made some changes and opened a new PR: #{{ env('PR_NUMBER') }}. 2 | 3 | I need a PR title and a description that summarizes these changes in short, concise bullet points. 4 | The PR description will also be used as merge commit message, so it should be clear and informative. 5 | 6 | Use the following guidelines: 7 | 8 | ## PR Title 9 | - Start with a verb in the imperative mood (e.g., "Add", "Fix", "Update"). 10 | - Put an emoji at the beginning that reflects the nature of the changes 11 | 12 | ## PR Body 13 | 14 | - At the very top, provide short paragraph summarizing the changes and their impact. 15 | - Below, list the changes made in bullet points. 16 | - Use bold text instead of sections/sub-sections to separate the bullet points into topics 17 | - There should be no more than 3-4 topics 18 | - Use the present tense and the active voice. 19 | - Be specific - Include names, paths, identifiers, names, versions, etc 20 | - Where possible, make file names/paths clickable using Markdown links. Use this format for the URL: `https://github.com//blob//` 21 | 22 | 23 | # Your task 24 | Edit PR #{{ env('PR_NUMBER') }} title and description to reflect the changes made in this PR. 25 | Respond only with a link to the PR. -------------------------------------------------------------------------------- /prompts/homebrew.md.jinja2: -------------------------------------------------------------------------------- 1 | Here is the new homebrew formula: 2 | 3 | ```ruby 4 | {{ sh('poetry homebrew-formula --template=homebrew_formula.rb --output=-') }} 5 | ``` 6 | 7 | Write the content to `Formula/arcane-cli.rb`. -------------------------------------------------------------------------------- /prompts/house-keeping.md.jinja2: -------------------------------------------------------------------------------- 1 | I'd like to do some house-keeping in the repository, help me with it! 2 | 3 | 1. List the `.` directory 4 | 2. Identify the types of files that get cluttered easily, e.g.: package.json, pom.xml, Dockerfile, etc. 5 | 3. Read a maximum of four of these files to understand their contents 6 | 4. Clean them up so they meet the criteria below 7 | 5. Write each of them back to file 8 | 6. Respond with a one-sentence summary of your changes and the list of files cleaned up 9 | 10 | Criteria: 11 | - Remove any commented-out code (unless it explicitly says "DO NOT REMOVE") 12 | - Fix spacing / indentation issues 13 | - Add comments to parts of the file that are not self-explanatory 14 | - If possible and beneficial, re-structure the file to make it more readable 15 | 16 | GO! -------------------------------------------------------------------------------- /prompts/investigate-pod.jinja2: -------------------------------------------------------------------------------- 1 | {% set pod=select('Select a pod', sh('kubectl get pods -o custom-columns=:metadata.name').split('\n')) %} 2 | 3 | Here are the last 10 log lines of a Kubernetes pod named `{{ pod }}`: 4 | 5 | ```shell 6 | {{ sh(['kubectl', '--tail=10', 'logs', pod]) }} 7 | ``` 8 | 9 | I want to know: 10 | {{ env('QUESTION_ABOUT_LOGS') }} 11 | -------------------------------------------------------------------------------- /prompts/make_improvements.yaml.jinja2: -------------------------------------------------------------------------------- 1 | {# Generate improvement suggestions for each file in a directory and render them in YAML #} 2 | 3 | {% set directory = 'prompts/' %} 4 | 5 | {# Run shell command to list all files in the directory #} 6 | {% set files = sh("find " ~ directory ~ " -type f -maxdepth 1").split('\n') %} 7 | 8 | {# Generate YAML content #} 9 | --- 10 | suggested_improvements: 11 | {%- for file in files if file %} 12 | - name: {{ file }} 13 | task: | 14 | {{ subtask("Read the file `" ~ file ~ "` and identify one potential improvement that improves readability or architecture. Respond with short, high-level bullet points of what should be improved and why.") | indent(6) }} 15 | {%- endfor %} 16 | -------------------------------------------------------------------------------- /prompts/slack-report.md.jinja2: -------------------------------------------------------------------------------- 1 | We have collected the following information about our projects: 2 | 3 | # Git Activity of the past 24 hours 4 | {{ sh(['git', 'log', '--since="24 hours ago"', '--pretty=format:"%h - %an: %s%n%b"']) }} 5 | 6 | # New Github issues since yesterday 7 | {% set prompt %} 8 | 1. Find all Github issues created since yesterday 7am PST 9 | 2. Read and understand every issue you found 10 | 3. Identify common topics / parts of the code that are affected 11 | 4. Summarize the issues by topics, with in-line links for easy access 12 | Your summaries should be: 13 | - Designed to catch me up and give me the gist 14 | - Contain references to the original issues, files, and people involved 15 | {% endset %} 16 | {{ subtask(prompt) }} 17 | 18 | # New Linear issues since yesterday 19 | {% set prompt %} 20 | 1. Find all linear issues (title, description, url) created since yesterday 7am PST for team "Arcane Engine" 21 | 2. Read and understand every issue you found 22 | 3. Identify common topics / parts of the code that are affected 23 | 4. Summarize the issues by topics, with in-line links for easy access 24 | Your summaries should be: 25 | - Designed to catch me up and give me the gist 26 | - Contain references to the original issues, files, and people involved 27 | {% endset %} 28 | {{ subtask(prompt) }} 29 | 30 | # New Slack messages since yesterday 31 | {{ subtask('Find all Slack messages in channel #bot-testing from yesterday and today. Summarize them by topics, with inline-links to messages') }} 32 | 33 | --- 34 | 35 | Create a report for me to read in the morning, so that I can catch up on what happened yesterday. 36 | Use the following template to organize the content by topic and priority: 37 | 38 | ```markdown 39 | *Daily Report for * 40 | 41 | 42 | 43 | *_Highlights_* 44 | 45 | 46 | *_* 47 | 48 | 49 | *__* 50 | 51 | 52 | ... 53 | 54 | ``` 55 | 56 | Guidelines for writing the report: 57 | - Use personal, concise, informative language. 58 | - Topics should be content-oriented, e.g. not "Github issues" but "CLI history feature" or "Bug in dashboard" 59 | 60 | Do the following: 61 | 1. Send the report to #daily-reports on Slack 62 | 2. Respond only with a link to the Slack message. 63 | -------------------------------------------------------------------------------- /prompts/test.yaml: -------------------------------------------------------------------------------- 1 | name: Summarize a Github issue 2 | prompt: | 3 | Summarize a Github issue in a single sentence. The summary should be concise and capture the main point of the issue. 4 | 5 | steps: 6 | - name: Find the issue 7 | prompt: Look at all open issues and give me a link to one of the issues. 8 | 9 | - name: Summarize the issue 10 | prompt: | 11 | Read the issue and summarize it in a single sentence. The summary should be concise and capture the main point of the issue. 12 | Write the summary here. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "arcane-cli" 3 | version = "1.20.2" 4 | description = "CLI for Arcane Engine, a text-to-task automation platform." 5 | authors = ["Marco Lamina "] 6 | license = "GPL-3.0" 7 | readme = "README.md" 8 | packages = [{include = "cli"}] 9 | documentation = "https://docs.arcane.engineer" 10 | homepage = "https://arcane.engineer" 11 | repository = "https://github.com/arc-eng/cli" 12 | 13 | [tool.black] 14 | line-length = 100 15 | 16 | [tool.poetry.dependencies] 17 | python = "~3.10.0" 18 | click = "8.1.7" 19 | arcane-engine = "1.9.1" 20 | pyyaml = "6.0.1" 21 | yaspin = "3.0.2" 22 | rich = "13.7.1" 23 | jinja2 = "3.1.4" 24 | humanize = "^4.9.0" 25 | inquirer = "^3.2.5" 26 | websockets = "^12.0" 27 | 28 | [tool.poetry.dev-dependencies] 29 | pytest = "^8.2.0" 30 | black = "^24.4.2" 31 | flake8 = "^7.1.0" 32 | pre-commit = "^3.7.1" 33 | 34 | [tool.poetry.scripts] 35 | pilot = "cli.cli:main" 36 | 37 | 38 | [build-system] 39 | requires = ["poetry-core>=1.0.0"] 40 | build-backend = "poetry.core.masonry.api" 41 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arc-eng/cli/dbca0af986e3928579ffa6f8f39a52a368d60d8b/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, MagicMock 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture(autouse=True) 7 | def mock_default_config_location(tmp_path): 8 | with patch("cli.user_config.CONFIG_LOCATION", tmp_path / "config.yaml"): 9 | path = tmp_path / "config.yaml" 10 | path.touch() 11 | yield path 12 | 13 | 14 | @pytest.fixture(autouse=True) 15 | def mock_engine(): 16 | with patch("cli.task_runner.ArcaneEngine") as mock: 17 | mock.return_value = MagicMock() 18 | yield mock.return_value 19 | 20 | 21 | @pytest.fixture(autouse=True) 22 | def mock_user_config(mock_default_config_location): 23 | mock_instance = MagicMock(authenticate=MagicMock()) 24 | mock_instance.verbose = False 25 | mock_instance.auto_sync_enabled = False 26 | mock_instance.api_key = "test_api_key" 27 | mock_class = MagicMock(return_value=mock_instance) 28 | with patch("cli.task_runner.UserConfig", mock_class): 29 | with patch("cli.cli.UserConfig", mock_class): 30 | yield mock_class 31 | 32 | 33 | @pytest.fixture(autouse=True) 34 | def mock_engine_in_history_command(): 35 | with patch("cli.commands.history.ArcaneEngine") as mock: 36 | mock.return_value = MagicMock() 37 | yield mock.return_value 38 | 39 | 40 | @pytest.fixture(autouse=True) 41 | def mock_console(): 42 | with patch("cli.task_handler.Console") as mock: 43 | yield mock 44 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import patch, MagicMock 3 | 4 | import pytest 5 | from click.testing import CliRunner 6 | 7 | from cli.cli import main 8 | from cli.constants import CODE_PRIMER 9 | 10 | 11 | @pytest.fixture 12 | def runner(): 13 | return CliRunner() 14 | 15 | 16 | @pytest.fixture(autouse=True) 17 | def mock_task_handler(): 18 | with patch("cli.task_runner.TaskHandler") as mock: 19 | mock.return_value = MagicMock( 20 | start_streaming=MagicMock(), 21 | wait_for_result=MagicMock(), 22 | ) 23 | yield mock 24 | 25 | 26 | @patch.dict(os.environ, {}, clear=True) 27 | def test_main_loads_user_config(mock_user_config, runner): 28 | result = runner.invoke(main, ["history"]) 29 | mock_user_config.return_value.set_api_key_env_var.assert_called_once() 30 | assert result.exit_code == 0 31 | 32 | 33 | def test_code_param_includes_code_primer(runner, mock_engine): 34 | """The --code param should include the code primer in the prompt""" 35 | prompt = "This is a test prompt." 36 | result = runner.invoke(main, ["task", "--code", prompt]) 37 | 38 | mock_engine.create_task.assert_called_once() 39 | called_prompt = mock_engine.create_task.call_args[0][1] 40 | assert CODE_PRIMER in called_prompt 41 | assert prompt in called_prompt 42 | assert result.exit_code == 0 43 | 44 | 45 | @pytest.fixture 46 | def mock_command_index(): 47 | with patch("cli.commands.task.CommandIndex") as mock: 48 | yield mock.return_value 49 | 50 | 51 | @pytest.fixture(autouse=True) 52 | def mock_click_prompt(): 53 | with patch("cli.commands.task.click.prompt") as mock: 54 | mock.return_value = "test-value" 55 | yield mock 56 | 57 | 58 | def test_save_command_param_saves_command( 59 | runner, mock_engine, mock_command_index, mock_click_prompt 60 | ): 61 | """The --save-command param should save the task parameters as a command""" 62 | prompt = "This is a test prompt." 63 | result = runner.invoke(main, ["task", "--save-command", prompt]) 64 | mock_command_index.add_command.assert_called_once() 65 | assert result.exit_code == 0 66 | 67 | 68 | def test_save_command_param_does_not_run_task( 69 | runner, mock_engine, mock_command_index, mock_click_prompt 70 | ): 71 | """The --save-command param should not run the task""" 72 | result = runner.invoke(main, ["task", "--save-command", "test-prompt"]) 73 | mock_engine.assert_not_called() 74 | assert result.exit_code == 0 75 | 76 | 77 | @patch("cli.commands.task.get_branch_if_pushed") 78 | @patch("cli.task_runner.pull_branch_changes") 79 | def test_sync_option_syncs_correctly( 80 | mock_pull_branch_changes, 81 | mock_get_branch_if_pushed, 82 | runner, 83 | mock_engine, 84 | ): 85 | """The --sync option should set the branch to the current branch""" 86 | mock_get_branch_if_pushed.return_value = "test-value" 87 | result = runner.invoke(main, ["--wait", "--sync", "task", "test-prompt"]) 88 | mock_engine.create_task.assert_called_once() 89 | mock_pull_branch_changes.assert_called_once() 90 | assert mock_engine.create_task.call_args[1]["branch"] == "test-value" 91 | assert result.exit_code == 0 92 | 93 | 94 | @patch("cli.commands.task.get_branch_if_pushed") 95 | @patch("cli.task_runner.pull_branch_changes") 96 | def test_sync_option_syncs_only_pushed_branches( 97 | mock_pull_branch_changes, 98 | mock_get_branch_if_pushed, 99 | runner, 100 | mock_engine, 101 | ): 102 | mock_get_branch_if_pushed.return_value = None 103 | result = runner.invoke(main, ["--wait", "--sync", "task", "test-prompt"]) 104 | mock_engine.create_task.assert_called_once() 105 | assert mock_engine.create_task.call_args[1]["branch"] is None 106 | assert result.exit_code == 0 107 | -------------------------------------------------------------------------------- /tests/test_command_grabbing.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import MagicMock, patch 3 | 4 | import pytest 5 | 6 | from cli.commands.grab import ( 7 | clone_repository, 8 | prompt_user_for_commands, 9 | import_commands, 10 | copy_file_to_local_directory, 11 | ) 12 | 13 | 14 | @pytest.fixture 15 | def mock_console(): 16 | return MagicMock() 17 | 18 | 19 | @pytest.fixture 20 | def mock_status_indicator(): 21 | return MagicMock() 22 | 23 | 24 | @pytest.fixture 25 | def mock_command_index(): 26 | return MagicMock() 27 | 28 | 29 | def test_clone_repository(mock_status_indicator): 30 | with patch("subprocess.run") as mock_run: 31 | clone_repository(mock_status_indicator, "git@github.com:repo.git", "/tmp/dir") 32 | mock_run.assert_called_once_with( 33 | ["git", "clone", "--depth", "1", "git@github.com:repo.git", "/tmp/dir"], 34 | check=True, 35 | capture_output=True, 36 | ) 37 | 38 | 39 | def test_prompt_user_for_commands(mock_command_index): 40 | mock_command_index.get_commands.return_value = [ 41 | MagicMock(name="cmd1"), 42 | MagicMock(name="cmd2"), 43 | ] 44 | with patch("inquirer.prompt") as mock_prompt: 45 | mock_prompt.return_value = {"commands": ["cmd1"]} 46 | answers = prompt_user_for_commands(mock_command_index, MagicMock()) 47 | assert answers == {"commands": ["cmd1"]} 48 | mock_prompt.assert_called_once() 49 | 50 | 51 | def test_import_commands(mock_command_index): 52 | mock_remote_index = MagicMock() 53 | mock_local_index = MagicMock() 54 | mock_remote_command = MagicMock() 55 | mock_remote_command.params.file = "file.txt" 56 | mock_remote_index.get_command.return_value = mock_remote_command 57 | mock_local_index.get_command.return_value = None 58 | 59 | with patch("cli.commands.grab.copy_file_to_local_directory") as mock_copy: 60 | answers = {"commands": ["cmd1"]} 61 | commands_imported, files_imported = import_commands( 62 | answers, mock_remote_index, mock_local_index, "/tmp/dir" 63 | ) 64 | assert commands_imported == [mock_remote_command] 65 | assert files_imported == ["file.txt"] 66 | mock_copy.assert_called_once_with("/tmp/dir/file.txt", "file.txt") 67 | 68 | 69 | def test_copy_file_to_local_directory(): 70 | with patch("builtins.open", new_callable=MagicMock) as mock_open: 71 | mock_open.return_value.__enter__.return_value.read.return_value = "content" 72 | with patch("os.makedirs") as mock_makedirs: 73 | copy_file_to_local_directory("/source/path", "/dest/path") 74 | mock_open.assert_any_call("/source/path", "r") 75 | mock_open.assert_any_call("/dest/path", "w") 76 | mock_makedirs.assert_called_once_with(os.path.dirname("/dest/path"), exist_ok=True) 77 | -------------------------------------------------------------------------------- /tests/test_command_index.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from cli.command_index import CommandIndex, PilotCommand 3 | from cli.models import TaskParameters 4 | 5 | 6 | @pytest.fixture 7 | def command_index(tmp_path): 8 | file_path = tmp_path / ".pilot-commands.yaml" 9 | return CommandIndex(file_path=str(file_path)) 10 | 11 | 12 | def test_add_command(command_index): 13 | task_params = TaskParameters( 14 | wait=True, 15 | repo="test_repo", 16 | snap=False, 17 | verbose=True, 18 | cheap=False, 19 | code=False, 20 | file=None, 21 | direct=False, 22 | output=None, 23 | model="test_model", 24 | debug=False, 25 | prompt="test_prompt", 26 | branch=None, 27 | ) 28 | command = PilotCommand(name="test-command", description="Test command", params=task_params) 29 | command_index.add_command(command) 30 | 31 | commands = command_index.get_commands() 32 | assert len(commands) == 1 33 | assert commands[0].name == "test-command" 34 | assert commands[0].description == "Test command" 35 | assert commands[0].params.prompt == "test_prompt" 36 | 37 | 38 | def test_add_duplicate_command(command_index): 39 | task_params = TaskParameters( 40 | wait=True, 41 | repo="test_repo", 42 | snap=False, 43 | verbose=True, 44 | cheap=False, 45 | code=False, 46 | file=None, 47 | direct=False, 48 | output=None, 49 | model="test_model", 50 | debug=False, 51 | prompt="test_prompt", 52 | branch=None, 53 | ) 54 | command = PilotCommand(name="test-command", description="Test command", params=task_params) 55 | command_index.add_command(command) 56 | 57 | with pytest.raises(ValueError, match="Command with name 'test-command' already exists"): 58 | command_index.add_command(command) 59 | 60 | 61 | def test_load_commands_from_file(command_index): 62 | task_params = TaskParameters( 63 | wait=True, 64 | repo="test_repo", 65 | snap=False, 66 | verbose=True, 67 | cheap=False, 68 | code=False, 69 | file=None, 70 | direct=False, 71 | output=None, 72 | model="test_model", 73 | debug=False, 74 | prompt="test_prompt", 75 | branch=None, 76 | ) 77 | command = PilotCommand(name="test-command", description="Test command", params=task_params) 78 | command_index.add_command(command) 79 | 80 | new_index = CommandIndex(file_path=command_index.file_path) 81 | commands = new_index.get_commands() 82 | assert len(commands) == 1 83 | assert commands[0].name == "test-command" 84 | assert commands[0].description == "Test command" 85 | assert commands[0].params.prompt == "test_prompt" 86 | -------------------------------------------------------------------------------- /tests/test_prompt_template.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import tempfile 4 | from unittest.mock import Mock, patch 5 | 6 | from cli.prompt_template import sh, PromptTemplate 7 | 8 | 9 | @patch("subprocess.run") 10 | def test_shell_command_execution_with_success(mock_subprocess_run): 11 | mock_subprocess_run.return_value = subprocess.CompletedProcess( 12 | args=["echo", "test"], returncode=0, stdout="test", stderr="" 13 | ) 14 | status = Mock() 15 | assert sh("echo test", status) == "test" 16 | status.start.assert_called_once() 17 | status.update_spinner_message.assert_called() 18 | status.stop.assert_called_once() 19 | 20 | 21 | @patch("subprocess.run") 22 | def test_shell_command_execution_with_failure(mock_subprocess_run): 23 | mock_subprocess_run.return_value = subprocess.CompletedProcess( 24 | args=["echo", "test"], returncode=1, stdout="", stderr="error" 25 | ) 26 | status = Mock() 27 | assert sh("echo test", status) == "error" 28 | status.start.assert_called_once() 29 | status.update_spinner_message.assert_called() 30 | status.fail.assert_called_once() 31 | status.stop.assert_called_once() 32 | 33 | 34 | @patch("subprocess.run") 35 | def test_shell_command_execution_with_list_input(mock_subprocess_run): 36 | mock_subprocess_run.return_value = subprocess.CompletedProcess( 37 | args=["echo", "test"], returncode=0, stdout="test", stderr="" 38 | ) 39 | status = Mock() 40 | assert sh(["echo", "test"], status) == "test" 41 | status.start.assert_called_once() 42 | status.update_spinner_message.assert_called() 43 | status.stop.assert_called_once() 44 | 45 | 46 | @patch("cli.prompt_template.is_git_repo") 47 | @patch("cli.prompt_template.get_git_root") 48 | def test_prompt_template_render(mock_is_git_repo, mock_get_git_root): 49 | status = Mock() 50 | template_content = "Hello, {{ name }}!" 51 | 52 | with tempfile.TemporaryDirectory() as tmpdirname: 53 | os.mkdir(os.path.join(tmpdirname, "test_repo")) 54 | template_file_path = "test_repo/test_model.jinja2" 55 | 56 | with open(os.path.join(tmpdirname, template_file_path), "w") as f: 57 | f.write(template_content) 58 | 59 | prompt_template = PromptTemplate( 60 | template_file_path=template_file_path, 61 | repo="test_repo", 62 | model="test_model", 63 | status=status, 64 | name="World", 65 | home=tmpdirname, 66 | ) 67 | 68 | result = prompt_template.render() 69 | assert result == "Hello, World!" 70 | mock_is_git_repo.assert_not_called() 71 | mock_get_git_root.assert_not_called() 72 | 73 | 74 | @patch("cli.prompt_template.is_git_repo") 75 | @patch("cli.prompt_template.get_git_root") 76 | def test_determine_template_home(mock_get_git_root, mock_is_git_repo): 77 | status = Mock() 78 | mock_is_git_repo.return_value = True 79 | mock_get_git_root.return_value = "/path/to/git/root" 80 | 81 | prompt_template = PromptTemplate( 82 | template_file_path="test_model.jinja2", 83 | repo="test_repo", 84 | model="test_model", 85 | status=status, 86 | name="World", 87 | ) 88 | 89 | assert prompt_template.home == "/path/to/git/root" 90 | mock_is_git_repo.assert_called_once() 91 | mock_get_git_root.assert_called_once() 92 | 93 | 94 | @patch("cli.prompt_template.is_git_repo") 95 | @patch("cli.prompt_template.get_git_root") 96 | def test_determine_template_home_no_git_repo(mock_get_git_root, mock_is_git_repo): 97 | status = Mock() 98 | mock_is_git_repo.return_value = False 99 | 100 | prompt_template = PromptTemplate( 101 | template_file_path="test_model.jinja2", 102 | repo="test_repo", 103 | model="test_model", 104 | status=status, 105 | name="World", 106 | ) 107 | 108 | assert prompt_template.home == os.getcwd() 109 | mock_is_git_repo.assert_called_once() 110 | mock_get_git_root.assert_not_called() 111 | 112 | 113 | @patch("cli.prompt_template.is_git_repo") 114 | @patch("cli.prompt_template.get_git_root") 115 | @patch("cli.prompt_template.os.getcwd") 116 | def test_get_template_file_path__relative_to_cwd(mock_getcwd, mock_get_git_root, mock_is_git_repo): 117 | # Create temporary directory as "git repo" 118 | with tempfile.TemporaryDirectory() as tmpdirname: 119 | status = Mock() 120 | mock_is_git_repo.return_value = True 121 | mock_get_git_root.return_value = tmpdirname 122 | 123 | # Create a sub-directory and a file 124 | os.mkdir(os.path.join(tmpdirname, "test_repo")) 125 | template_file_path = "test_repo/test_model.jinja2" 126 | with open(os.path.join(tmpdirname, template_file_path), "w") as f: 127 | f.write("Hello, {{ name }}!") 128 | 129 | # Simulate "pilot run" command in sub-directory of the repo 130 | mock_getcwd.return_value = os.path.join(tmpdirname, "test_repo") 131 | prompt_template = PromptTemplate( 132 | template_file_path="test_model.jinja2", 133 | repo="test_repo", 134 | model="test_model", 135 | status=status, 136 | name="World", 137 | ) 138 | 139 | # Should return the template path relative to the git root 140 | assert prompt_template.get_template_file_path() == "test_repo/test_model.jinja2" 141 | mock_is_git_repo.assert_called_once() 142 | mock_get_git_root.assert_called_once() 143 | 144 | 145 | @patch("cli.prompt_template.is_git_repo") 146 | @patch("cli.prompt_template.get_git_root") 147 | @patch("cli.prompt_template.os.getcwd") 148 | def test_get_template_file_path__relative_to_git_dir( 149 | mock_getcwd, mock_get_git_root, mock_is_git_repo 150 | ): 151 | # Create temporary directory as "git repo" 152 | with tempfile.TemporaryDirectory() as tmpdirname: 153 | status = Mock() 154 | mock_is_git_repo.return_value = True 155 | mock_get_git_root.return_value = tmpdirname 156 | 157 | # Create a sub-directory and a file 158 | os.mkdir(os.path.join(tmpdirname, "test_repo")) 159 | template_file_path = "test_repo/test_model.jinja2" 160 | with open(os.path.join(tmpdirname, template_file_path), "w") as f: 161 | f.write("Hello, {{ name }}!") 162 | 163 | # Simulate "pilot run" command in root of the repo 164 | mock_getcwd.return_value = tmpdirname 165 | prompt_template = PromptTemplate( 166 | template_file_path="test_repo/test_model.jinja2", 167 | repo="test_repo", 168 | model="test_model", 169 | status=status, 170 | name="World", 171 | ) 172 | 173 | # Should return the template path relative to the git root 174 | assert prompt_template.get_template_file_path() == "test_repo/test_model.jinja2" 175 | mock_is_git_repo.assert_called_once() 176 | mock_get_git_root.assert_called_once() 177 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | from datetime import timezone, datetime 2 | from unittest.mock import patch 3 | 4 | import humanize 5 | import pytest 6 | from arcane import Task 7 | from rich.markdown import Markdown 8 | 9 | from cli.util import ( 10 | clean_code_block_with_language_specifier, 11 | TaskFormatter, 12 | ) 13 | 14 | 15 | @pytest.fixture 16 | @patch("cli.util.click.prompt") 17 | def mock_click_prompt(mock_prompt): 18 | return mock_prompt 19 | 20 | 21 | @pytest.fixture 22 | @patch("cli.util.click.confirm") 23 | def mock_click_confirm(mock_confirm): 24 | return mock_confirm 25 | 26 | 27 | def test_clean_code_block_with_language_specifier(): 28 | response = """```python 29 | def foo(): 30 | pass 31 | ```""" 32 | expected = "def foo():\n pass" 33 | assert clean_code_block_with_language_specifier(response) == expected 34 | 35 | response = "def foo():\n pass" 36 | expected = "def foo():\n pass" 37 | assert clean_code_block_with_language_specifier(response) == expected 38 | 39 | 40 | @pytest.fixture 41 | def sample_task(): 42 | return Task( 43 | id="1", 44 | github_project="test_project", 45 | github_user="test_user", 46 | created=datetime.now(timezone.utc), 47 | pr_number=1, 48 | status="running", 49 | title="Test Task", 50 | branch="test-branch", 51 | ) 52 | 53 | 54 | def test_task_formatter(sample_task): 55 | formatter = TaskFormatter(sample_task) 56 | assert ( 57 | formatter.format_github_project() 58 | == "[link=https://github.com/test_project]test_project[/link]" 59 | ) 60 | assert formatter.format_created_at() == humanize.naturaltime(sample_task.created) 61 | assert formatter.format_pr_link() == "[link=https://github.com/test_project/pull/1]#1[/link]" 62 | assert formatter.format_status() == "[bold yellow]running[/bold yellow]" 63 | assert ( 64 | formatter.format_title() 65 | == "[link=https://arcane.engineer/dashboard/tasks/1/]Test Task[/link]" 66 | ) 67 | assert formatter.format_branch().markup == Markdown("`test-branch`").markup 68 | --------------------------------------------------------------------------------