ai-flow-cli 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ai_flow_cli-0.8.0/.gitignore +8 -0
- ai_flow_cli-0.8.0/LICENSE +21 -0
- ai_flow_cli-0.8.0/PKG-INFO +216 -0
- ai_flow_cli-0.8.0/README.md +199 -0
- ai_flow_cli-0.8.0/pyproject.toml +38 -0
- ai_flow_cli-0.8.0/src/ai_cli/__init__.py +3 -0
- ai_flow_cli-0.8.0/src/ai_cli/main.py +824 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Denis (Codder13)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ai-flow-cli
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: ⚡ Zero-dependency, ultra-fast streaming AI CLI for Unix pipelines
|
|
5
|
+
Project-URL: Homepage, https://github.com/Codder13/ai-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/Codder13/ai-cli.git
|
|
7
|
+
Project-URL: Issues, https://github.com/Codder13/ai-cli/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai,cli,llm,openai,streaming,terminal,unix-pipeline
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Dist: rich>=13.0.0
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# ai-cli
|
|
19
|
+
|
|
20
|
+
> **Sub-5ms streaming AI assistant & autonomous agent for Unix terminals with Rich markdown rendering and session memory.**
|
|
21
|
+
|
|
22
|
+
Compatible with any OpenAI-compatible provider: **Ollama, vLLM, LM Studio, Groq, OpenRouter, Together AI, OpenAI, and Antigravity / OMP**.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## ⚡ Why `ai-cli`?
|
|
27
|
+
|
|
28
|
+
Most AI command-line tools (`sgpt`, `llm`, `open-interpreter`) pull in dozens of heavy Python packages (`requests`, `pydantic`, `openai`, `click`), resulting in **200ms–400ms startup latency** before sending a single byte over the network.
|
|
29
|
+
|
|
30
|
+
`ai-cli` is engineered for speed, minimalism, and true Unix ergonomics:
|
|
31
|
+
- ⚡ **Instant Startup (< 5ms)** — starts and begins execution immediately.
|
|
32
|
+
- 🤖 **Autonomous Agent Mode (Default)** — equipped with 5 essential tools (`bash`, `read_file`, `write_file`, `search_web`, `fetch_web_page`).
|
|
33
|
+
- 🎨 **Rich Terminal Markdown** — syntax-highlighted code blocks, auto-aligned tables, blockquotes, and task checkboxes rendered cleanly in your terminal.
|
|
34
|
+
- 🧠 **Per-Terminal Session Memory** — preserves conversation context automatically within the same terminal tab/session (scoped by shell Process ID).
|
|
35
|
+
- 🌐 **Zero-Key Web Search** — live Google/Startpage + DuckDuckGo search integration without requiring third-party search API keys.
|
|
36
|
+
- 🚀 **Heavy Harness Handoff (`-H`)** — hand off active conversation context to `omp`, `claude`, `codex`, or `pi` when a task outgrows a lightweight CLI.
|
|
37
|
+
- 🚰 **Pure Unix Pipelines** — seamlessly handles `stdin`, trailing instructions, and outputs clean raw text when piped to files or other CLI tools.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 📦 Installation
|
|
42
|
+
|
|
43
|
+
### 1. One-Line Install (Recommended)
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
curl -sSL https://raw.githubusercontent.com/Codder13/ai-cli/main/install.sh | bash
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 2. Manual / Git Clone
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
git clone https://github.com/Codder13/ai-cli.git ~/Projects/ai-cli
|
|
53
|
+
ln -sf ~/Projects/ai-cli/src/ai_cli/main.py ~/.local/bin/ai
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 3. Dependencies
|
|
57
|
+
Requires Python 3.8+ and `rich` for terminal markdown formatting:
|
|
58
|
+
```bash
|
|
59
|
+
pip install rich
|
|
60
|
+
# or on Arch Linux:
|
|
61
|
+
sudo pacman -S python-rich
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 🛠️ Usage
|
|
67
|
+
|
|
68
|
+
> 💡 **No quotes required:** You can type your prompts directly without quotation marks across all flags and commands!
|
|
69
|
+
|
|
70
|
+
### 1. Direct Asking & Autonomous Tasks (Default)
|
|
71
|
+
`ai` runs in agent mode by default, choosing when to search the web, read files, or execute commands to give you an accurate, grounded answer:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Autonomous research & web searching
|
|
75
|
+
ai search the latest zig release and summarize changes
|
|
76
|
+
|
|
77
|
+
# Local codebase inspection & execution
|
|
78
|
+
ai inspect package.json and run the build script
|
|
79
|
+
|
|
80
|
+
# General knowledge
|
|
81
|
+
ai what is the biggest crater on the moon
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 2. Sandbox Security & Modification Bypass (`-s / --no-sandbox`)
|
|
85
|
+
By default, all command executions and file operations are protected in a **strict read-only Bubblewrap (`bwrap`) sandbox**:
|
|
86
|
+
* 🔒 **Read-Only System**: Root (`/`), `/etc`, `/usr`, and project files cannot be accidentally modified.
|
|
87
|
+
* 🔒 **Isolated Secrets**: `~/.ssh`, `~/.gnupg`, and `~/.aws` are masked with empty filesystems.
|
|
88
|
+
|
|
89
|
+
To allow creating files, modifying code, or running system commands, pass **`-s`** (unquoted):
|
|
90
|
+
```bash
|
|
91
|
+
# Create files without quotes:
|
|
92
|
+
ai -s create this and that
|
|
93
|
+
|
|
94
|
+
# Write code and scripts:
|
|
95
|
+
ai -s create a python script that tests database connections
|
|
96
|
+
|
|
97
|
+
# Install system packages:
|
|
98
|
+
ai -s install ripgrep with pacman
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 3. Pure No-Tools Streaming (`-n / --no-tools`)
|
|
102
|
+
If you want instant, direct token streaming without tool execution:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
ai -n explain quantum computing in 3 bullets
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 4. Unix Pipelines & Stdin
|
|
109
|
+
Pipe terminal output directly into `ai` with or without trailing instructions:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# Summarize git changes
|
|
113
|
+
git diff | ai summarize these changes in 3 bullet points
|
|
114
|
+
|
|
115
|
+
# Explain log errors
|
|
116
|
+
cat /var/log/nginx/error.log | tail -n 20 | ai explain these errors
|
|
117
|
+
|
|
118
|
+
# Pipe output cleanly to file (automatically strips ANSI formatting)
|
|
119
|
+
ai -n generate a json array of 5 fruits > fruits.json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### 5. Terminal Session Memory (Automatic)
|
|
123
|
+
`ai` automatically preserves conversation history within the same terminal tab/session:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
❯ ai what is the largest file in this project
|
|
127
|
+
# ... Inspects directory and lists the largest file ...
|
|
128
|
+
|
|
129
|
+
❯ ai how can I optimize it
|
|
130
|
+
# ... Automatically remembers the file from the previous turn!
|
|
131
|
+
|
|
132
|
+
# Clear conversation memory for the current tab:
|
|
133
|
+
❯ ai -C # or ai --clear
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### 6. Handoff to Heavy Harnesses (`-H / --handoff`)
|
|
137
|
+
When a simple task evolves into a large-scale refactor, codebase overhaul, or multi-file debugging session, hand off your entire conversation session context to a full workspace agent:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
# Handoff conversation to Oh My Pi / Hermes (default):
|
|
141
|
+
❯ ai -H Refactor all affected modules and run test suite
|
|
142
|
+
|
|
143
|
+
# Handoff to Claude Code:
|
|
144
|
+
❯ ai -H claude Fix all broken unit tests across the whole workspace
|
|
145
|
+
|
|
146
|
+
# Supported harnesses: omp, claude, codex, pi
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### 7. Interactive Tool Approvals (`-c / --confirm`)
|
|
150
|
+
By default, `ai` executes safe tools autonomously. If you prefer human-in-the-loop confirmation before each action:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
ai -c clean up temp and cache files in this directory
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
## ⚙️ Configuration Cascade
|
|
158
|
+
|
|
159
|
+
`ai-cli` resolves configuration in the following order:
|
|
160
|
+
1. **CLI Flags**: `-m`, `-u`, `-k`, `-s`
|
|
161
|
+
2. **Environment Variables**: `AI_MODEL`, `AI_BASE_URL`, `AI_API_KEY`
|
|
162
|
+
3. **Config File**: `~/.config/ai/config.json`
|
|
163
|
+
4. **OMP / AGM Config**: `~/.omp/agent/models.yml` (auto-detected)
|
|
164
|
+
|
|
165
|
+
### Initialize a config template:
|
|
166
|
+
```bash
|
|
167
|
+
ai --init-config
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
This generates `~/.config/ai/config.json`:
|
|
171
|
+
```json
|
|
172
|
+
{
|
|
173
|
+
"base_url": "https://api.openai.com/v1",
|
|
174
|
+
"api_key": "your-api-key",
|
|
175
|
+
"model": "gpt-4o-mini",
|
|
176
|
+
"system_prompt": "You are a concise, helpful terminal assistant.",
|
|
177
|
+
"temperature": 0.7,
|
|
178
|
+
"require_approval": false
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## 📖 CLI Reference
|
|
185
|
+
|
|
186
|
+
```text
|
|
187
|
+
usage: ai [-h] [-n] [-c] [-y] [-m MODEL] [-u BASE_URL] [-k API_KEY]
|
|
188
|
+
[-s SYSTEM] [-t TEMPERATURE] [--max-turns MAX_TURNS] [--init-config]
|
|
189
|
+
[-v] [-C] [-H [HARNESS]]
|
|
190
|
+
[prompt ...]
|
|
191
|
+
|
|
192
|
+
positional arguments:
|
|
193
|
+
prompt User prompt or instruction
|
|
194
|
+
|
|
195
|
+
options:
|
|
196
|
+
-h, --help Show this help message and exit
|
|
197
|
+
-n, --no-tools Disable tools and run in fast direct streaming completion mode
|
|
198
|
+
-c, --confirm Require manual confirmation for each tool action
|
|
199
|
+
-y, --yes Bypass confirmation and auto-approve all tool actions
|
|
200
|
+
-m, --model MODEL Model identifier (default: gemini-3.7-flash-high or config)
|
|
201
|
+
-u, --base-url BASE OpenAI-compatible API base URL
|
|
202
|
+
-k, --api-key KEY API authorization key
|
|
203
|
+
-s, --system SYSTEM Custom system prompt
|
|
204
|
+
-t, --temperature T Sampling temperature (0.0 - 2.0)
|
|
205
|
+
--max-turns N Maximum tool execution turns in agent mode (default: 25)
|
|
206
|
+
--init-config Create a default config template at ~/.config/ai/config.json
|
|
207
|
+
-v, --version Show version
|
|
208
|
+
-C, --clear Clear conversation memory for the current terminal session
|
|
209
|
+
-H, --handoff [NAME] Handoff session context to a heavy agent harness (omp, claude, codex, pi; default: omp)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 📄 License
|
|
215
|
+
|
|
216
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# ai-cli
|
|
2
|
+
|
|
3
|
+
> **Sub-5ms streaming AI assistant & autonomous agent for Unix terminals with Rich markdown rendering and session memory.**
|
|
4
|
+
|
|
5
|
+
Compatible with any OpenAI-compatible provider: **Ollama, vLLM, LM Studio, Groq, OpenRouter, Together AI, OpenAI, and Antigravity / OMP**.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## ⚡ Why `ai-cli`?
|
|
10
|
+
|
|
11
|
+
Most AI command-line tools (`sgpt`, `llm`, `open-interpreter`) pull in dozens of heavy Python packages (`requests`, `pydantic`, `openai`, `click`), resulting in **200ms–400ms startup latency** before sending a single byte over the network.
|
|
12
|
+
|
|
13
|
+
`ai-cli` is engineered for speed, minimalism, and true Unix ergonomics:
|
|
14
|
+
- ⚡ **Instant Startup (< 5ms)** — starts and begins execution immediately.
|
|
15
|
+
- 🤖 **Autonomous Agent Mode (Default)** — equipped with 5 essential tools (`bash`, `read_file`, `write_file`, `search_web`, `fetch_web_page`).
|
|
16
|
+
- 🎨 **Rich Terminal Markdown** — syntax-highlighted code blocks, auto-aligned tables, blockquotes, and task checkboxes rendered cleanly in your terminal.
|
|
17
|
+
- 🧠 **Per-Terminal Session Memory** — preserves conversation context automatically within the same terminal tab/session (scoped by shell Process ID).
|
|
18
|
+
- 🌐 **Zero-Key Web Search** — live Google/Startpage + DuckDuckGo search integration without requiring third-party search API keys.
|
|
19
|
+
- 🚀 **Heavy Harness Handoff (`-H`)** — hand off active conversation context to `omp`, `claude`, `codex`, or `pi` when a task outgrows a lightweight CLI.
|
|
20
|
+
- 🚰 **Pure Unix Pipelines** — seamlessly handles `stdin`, trailing instructions, and outputs clean raw text when piped to files or other CLI tools.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 📦 Installation
|
|
25
|
+
|
|
26
|
+
### 1. One-Line Install (Recommended)
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
curl -sSL https://raw.githubusercontent.com/Codder13/ai-cli/main/install.sh | bash
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 2. Manual / Git Clone
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
git clone https://github.com/Codder13/ai-cli.git ~/Projects/ai-cli
|
|
36
|
+
ln -sf ~/Projects/ai-cli/src/ai_cli/main.py ~/.local/bin/ai
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 3. Dependencies
|
|
40
|
+
Requires Python 3.8+ and `rich` for terminal markdown formatting:
|
|
41
|
+
```bash
|
|
42
|
+
pip install rich
|
|
43
|
+
# or on Arch Linux:
|
|
44
|
+
sudo pacman -S python-rich
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 🛠️ Usage
|
|
50
|
+
|
|
51
|
+
> 💡 **No quotes required:** You can type your prompts directly without quotation marks across all flags and commands!
|
|
52
|
+
|
|
53
|
+
### 1. Direct Asking & Autonomous Tasks (Default)
|
|
54
|
+
`ai` runs in agent mode by default, choosing when to search the web, read files, or execute commands to give you an accurate, grounded answer:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Autonomous research & web searching
|
|
58
|
+
ai search the latest zig release and summarize changes
|
|
59
|
+
|
|
60
|
+
# Local codebase inspection & execution
|
|
61
|
+
ai inspect package.json and run the build script
|
|
62
|
+
|
|
63
|
+
# General knowledge
|
|
64
|
+
ai what is the biggest crater on the moon
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 2. Sandbox Security & Modification Bypass (`-s / --no-sandbox`)
|
|
68
|
+
By default, all command executions and file operations are protected in a **strict read-only Bubblewrap (`bwrap`) sandbox**:
|
|
69
|
+
* 🔒 **Read-Only System**: Root (`/`), `/etc`, `/usr`, and project files cannot be accidentally modified.
|
|
70
|
+
* 🔒 **Isolated Secrets**: `~/.ssh`, `~/.gnupg`, and `~/.aws` are masked with empty filesystems.
|
|
71
|
+
|
|
72
|
+
To allow creating files, modifying code, or running system commands, pass **`-s`** (unquoted):
|
|
73
|
+
```bash
|
|
74
|
+
# Create files without quotes:
|
|
75
|
+
ai -s create this and that
|
|
76
|
+
|
|
77
|
+
# Write code and scripts:
|
|
78
|
+
ai -s create a python script that tests database connections
|
|
79
|
+
|
|
80
|
+
# Install system packages:
|
|
81
|
+
ai -s install ripgrep with pacman
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 3. Pure No-Tools Streaming (`-n / --no-tools`)
|
|
85
|
+
If you want instant, direct token streaming without tool execution:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
ai -n explain quantum computing in 3 bullets
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 4. Unix Pipelines & Stdin
|
|
92
|
+
Pipe terminal output directly into `ai` with or without trailing instructions:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
# Summarize git changes
|
|
96
|
+
git diff | ai summarize these changes in 3 bullet points
|
|
97
|
+
|
|
98
|
+
# Explain log errors
|
|
99
|
+
cat /var/log/nginx/error.log | tail -n 20 | ai explain these errors
|
|
100
|
+
|
|
101
|
+
# Pipe output cleanly to file (automatically strips ANSI formatting)
|
|
102
|
+
ai -n generate a json array of 5 fruits > fruits.json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### 5. Terminal Session Memory (Automatic)
|
|
106
|
+
`ai` automatically preserves conversation history within the same terminal tab/session:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
❯ ai what is the largest file in this project
|
|
110
|
+
# ... Inspects directory and lists the largest file ...
|
|
111
|
+
|
|
112
|
+
❯ ai how can I optimize it
|
|
113
|
+
# ... Automatically remembers the file from the previous turn!
|
|
114
|
+
|
|
115
|
+
# Clear conversation memory for the current tab:
|
|
116
|
+
❯ ai -C # or ai --clear
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 6. Handoff to Heavy Harnesses (`-H / --handoff`)
|
|
120
|
+
When a simple task evolves into a large-scale refactor, codebase overhaul, or multi-file debugging session, hand off your entire conversation session context to a full workspace agent:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
# Handoff conversation to Oh My Pi / Hermes (default):
|
|
124
|
+
❯ ai -H Refactor all affected modules and run test suite
|
|
125
|
+
|
|
126
|
+
# Handoff to Claude Code:
|
|
127
|
+
❯ ai -H claude Fix all broken unit tests across the whole workspace
|
|
128
|
+
|
|
129
|
+
# Supported harnesses: omp, claude, codex, pi
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### 7. Interactive Tool Approvals (`-c / --confirm`)
|
|
133
|
+
By default, `ai` executes safe tools autonomously. If you prefer human-in-the-loop confirmation before each action:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
ai -c clean up temp and cache files in this directory
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
## ⚙️ Configuration Cascade
|
|
141
|
+
|
|
142
|
+
`ai-cli` resolves configuration in the following order:
|
|
143
|
+
1. **CLI Flags**: `-m`, `-u`, `-k`, `-s`
|
|
144
|
+
2. **Environment Variables**: `AI_MODEL`, `AI_BASE_URL`, `AI_API_KEY`
|
|
145
|
+
3. **Config File**: `~/.config/ai/config.json`
|
|
146
|
+
4. **OMP / AGM Config**: `~/.omp/agent/models.yml` (auto-detected)
|
|
147
|
+
|
|
148
|
+
### Initialize a config template:
|
|
149
|
+
```bash
|
|
150
|
+
ai --init-config
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
This generates `~/.config/ai/config.json`:
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"base_url": "https://api.openai.com/v1",
|
|
157
|
+
"api_key": "your-api-key",
|
|
158
|
+
"model": "gpt-4o-mini",
|
|
159
|
+
"system_prompt": "You are a concise, helpful terminal assistant.",
|
|
160
|
+
"temperature": 0.7,
|
|
161
|
+
"require_approval": false
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## 📖 CLI Reference
|
|
168
|
+
|
|
169
|
+
```text
|
|
170
|
+
usage: ai [-h] [-n] [-c] [-y] [-m MODEL] [-u BASE_URL] [-k API_KEY]
|
|
171
|
+
[-s SYSTEM] [-t TEMPERATURE] [--max-turns MAX_TURNS] [--init-config]
|
|
172
|
+
[-v] [-C] [-H [HARNESS]]
|
|
173
|
+
[prompt ...]
|
|
174
|
+
|
|
175
|
+
positional arguments:
|
|
176
|
+
prompt User prompt or instruction
|
|
177
|
+
|
|
178
|
+
options:
|
|
179
|
+
-h, --help Show this help message and exit
|
|
180
|
+
-n, --no-tools Disable tools and run in fast direct streaming completion mode
|
|
181
|
+
-c, --confirm Require manual confirmation for each tool action
|
|
182
|
+
-y, --yes Bypass confirmation and auto-approve all tool actions
|
|
183
|
+
-m, --model MODEL Model identifier (default: gemini-3.7-flash-high or config)
|
|
184
|
+
-u, --base-url BASE OpenAI-compatible API base URL
|
|
185
|
+
-k, --api-key KEY API authorization key
|
|
186
|
+
-s, --system SYSTEM Custom system prompt
|
|
187
|
+
-t, --temperature T Sampling temperature (0.0 - 2.0)
|
|
188
|
+
--max-turns N Maximum tool execution turns in agent mode (default: 25)
|
|
189
|
+
--init-config Create a default config template at ~/.config/ai/config.json
|
|
190
|
+
-v, --version Show version
|
|
191
|
+
-C, --clear Clear conversation memory for the current terminal session
|
|
192
|
+
-H, --handoff [NAME] Handoff session context to a heavy agent harness (omp, claude, codex, pi; default: omp)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## 📄 License
|
|
198
|
+
|
|
199
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ai-flow-cli"
|
|
7
|
+
version = "0.8.0"
|
|
8
|
+
description = "⚡ Zero-dependency, ultra-fast streaming AI CLI for Unix pipelines"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"rich>=13.0.0",
|
|
12
|
+
]
|
|
13
|
+
license = { text = "MIT" }
|
|
14
|
+
keywords = ["ai", "cli", "openai", "streaming", "llm", "terminal", "unix-pipeline"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
"Topic :: Utilities",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
ai = "ai_cli.main:main"
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/Codder13/ai-cli"
|
|
27
|
+
Repository = "https://github.com/Codder13/ai-cli.git"
|
|
28
|
+
Issues = "https://github.com/Codder13/ai-cli/issues"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/ai_cli"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.sdist]
|
|
34
|
+
include = [
|
|
35
|
+
"/src",
|
|
36
|
+
"/README.md",
|
|
37
|
+
"/LICENSE",
|
|
38
|
+
]
|
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ai-cli: Zero-dependency, ultra-fast streaming AI CLI for Unix pipelines & agentic task solving.
|
|
4
|
+
Compatible with any OpenAI-compatible endpoint (OpenAI, Groq, Ollama, vLLM, OpenRouter, Together, AGM).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import html
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
__version__ = "0.8.0"
|
|
21
|
+
CONFIG_DIR = Path.home() / ".config" / "ai"
|
|
22
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
23
|
+
SESSION_MAX_MESSAGES = 20
|
|
24
|
+
|
|
25
|
+
def get_session_file() -> Path:
|
|
26
|
+
"""Return session history file path for the parent shell PID."""
|
|
27
|
+
ppid = os.getppid()
|
|
28
|
+
return Path(f"/tmp/ai_session_{ppid}.json")
|
|
29
|
+
|
|
30
|
+
def load_session_history() -> list:
|
|
31
|
+
"""Load conversation messages from current terminal session."""
|
|
32
|
+
session_file = get_session_file()
|
|
33
|
+
if session_file.is_file():
|
|
34
|
+
try:
|
|
35
|
+
with open(session_file, "r", encoding="utf-8") as f:
|
|
36
|
+
history = json.load(f)
|
|
37
|
+
return history[-SESSION_MAX_MESSAGES:]
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
def save_session_history(messages: list) -> None:
|
|
43
|
+
"""Save conversation messages to current terminal session."""
|
|
44
|
+
session_file = get_session_file()
|
|
45
|
+
try:
|
|
46
|
+
with open(session_file, "w", encoding="utf-8") as f:
|
|
47
|
+
# Persist user and assistant messages only to keep context clean
|
|
48
|
+
filtered = [
|
|
49
|
+
m for m in messages
|
|
50
|
+
if m.get("role") in ("user", "assistant") and m.get("content")
|
|
51
|
+
]
|
|
52
|
+
json.dump(filtered[-SESSION_MAX_MESSAGES:], f)
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
def clear_session_history() -> None:
|
|
57
|
+
"""Clear conversation history for the current terminal session."""
|
|
58
|
+
session_file = get_session_file()
|
|
59
|
+
if session_file.is_file():
|
|
60
|
+
try:
|
|
61
|
+
session_file.unlink(missing_ok=True)
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
def format_session_for_handoff() -> str:
|
|
65
|
+
"""Format conversation messages from current shell session into a unified context prompt."""
|
|
66
|
+
history = load_session_history()
|
|
67
|
+
if not history:
|
|
68
|
+
return ""
|
|
69
|
+
formatted = ["=== Conversation Context from ai-cli Terminal Session ==="]
|
|
70
|
+
for msg in history:
|
|
71
|
+
role = msg.get("role", "user").capitalize()
|
|
72
|
+
content = msg.get("content", "").strip()
|
|
73
|
+
formatted.append(f"\n[{role}]:\n{content}")
|
|
74
|
+
formatted.append("\n=== End Context ===\nPlease continue and solve the task based on the context above.")
|
|
75
|
+
return "\n".join(formatted)
|
|
76
|
+
|
|
77
|
+
def execute_handoff(target_harness: str = "omp", extra_instruction: str = "") -> None:
|
|
78
|
+
"""Handoff session context and launch the target agent harness (omp, claude, codex, pi)."""
|
|
79
|
+
import shutil
|
|
80
|
+
|
|
81
|
+
context = format_session_for_handoff()
|
|
82
|
+
full_prompt = context
|
|
83
|
+
if extra_instruction:
|
|
84
|
+
full_prompt = f"{context}\n\nAdditional Instruction: {extra_instruction}" if context else extra_instruction
|
|
85
|
+
|
|
86
|
+
harness = (target_harness or "omp").lower().strip()
|
|
87
|
+
|
|
88
|
+
# Find executable binary
|
|
89
|
+
bin_name = harness
|
|
90
|
+
if harness in ("omp", "hermes"):
|
|
91
|
+
bin_path = shutil.which("omp") or shutil.which("hermes")
|
|
92
|
+
label = "Oh My Pi (OMP)"
|
|
93
|
+
elif harness in ("claude", "claude-code"):
|
|
94
|
+
bin_path = shutil.which("claude")
|
|
95
|
+
label = "Claude Code"
|
|
96
|
+
elif harness in ("codex",):
|
|
97
|
+
bin_path = shutil.which("codex")
|
|
98
|
+
label = "Codex"
|
|
99
|
+
elif harness in ("pi",):
|
|
100
|
+
bin_path = shutil.which("pi")
|
|
101
|
+
label = "Pi"
|
|
102
|
+
else:
|
|
103
|
+
bin_path = shutil.which(harness)
|
|
104
|
+
label = harness
|
|
105
|
+
|
|
106
|
+
if not bin_path:
|
|
107
|
+
print(f"\033[31mError:\033[0m Harness binary for '{harness}' not found in PATH.", file=sys.stderr)
|
|
108
|
+
print("Supported harnesses: omp, claude, codex, pi", file=sys.stderr)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
print(f"\033[1;35m🚀 Handing off session to {label} ({bin_path})...\033[0m")
|
|
112
|
+
if full_prompt:
|
|
113
|
+
os.execvp(bin_path, [bin_path, full_prompt])
|
|
114
|
+
else:
|
|
115
|
+
os.execvp(bin_path, [bin_path])
|
|
116
|
+
|
|
117
|
+
def print_markdown(text: str) -> None:
|
|
118
|
+
"""Render markdown using rich into clean terminal output, or plain text if piped."""
|
|
119
|
+
if not sys.stdout.isatty():
|
|
120
|
+
print(text)
|
|
121
|
+
return
|
|
122
|
+
try:
|
|
123
|
+
from rich.console import Console
|
|
124
|
+
from rich.markdown import Markdown
|
|
125
|
+
console = Console()
|
|
126
|
+
console.print(Markdown(text))
|
|
127
|
+
except Exception:
|
|
128
|
+
print(text)
|
|
129
|
+
|
|
130
|
+
TOOLS = [
|
|
131
|
+
{
|
|
132
|
+
"type": "function",
|
|
133
|
+
"function": {
|
|
134
|
+
"name": "bash",
|
|
135
|
+
"description": "Execute a local shell command (e.g. tests, git, compilers, local processes). Do NOT use bash to curl or scrape the web unless the user explicitly requests a curl/shell command.",
|
|
136
|
+
"parameters": {
|
|
137
|
+
"type": "object",
|
|
138
|
+
"properties": {
|
|
139
|
+
"command": {"type": "string", "description": "The shell command to run"}
|
|
140
|
+
},
|
|
141
|
+
"required": ["command"],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"type": "function",
|
|
147
|
+
"function": {
|
|
148
|
+
"name": "read_file",
|
|
149
|
+
"description": "Read file contents from disk.",
|
|
150
|
+
"parameters": {
|
|
151
|
+
"type": "object",
|
|
152
|
+
"properties": {
|
|
153
|
+
"path": {"type": "string", "description": "Path to the file"},
|
|
154
|
+
"limit": {"type": "integer", "description": "Max number of lines to read"},
|
|
155
|
+
},
|
|
156
|
+
"required": ["path"],
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"type": "function",
|
|
162
|
+
"function": {
|
|
163
|
+
"name": "write_file",
|
|
164
|
+
"description": "Write or overwrite file contents.",
|
|
165
|
+
"parameters": {
|
|
166
|
+
"type": "object",
|
|
167
|
+
"properties": {
|
|
168
|
+
"path": {"type": "string", "description": "Path to the file"},
|
|
169
|
+
"content": {"type": "string", "description": "Content to write"},
|
|
170
|
+
},
|
|
171
|
+
"required": ["path", "content"],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"type": "function",
|
|
177
|
+
"function": {
|
|
178
|
+
"name": "search_web",
|
|
179
|
+
"description": "Search the web for information, documentation, or news. Always use this tool when web information is needed.",
|
|
180
|
+
"parameters": {
|
|
181
|
+
"type": "object",
|
|
182
|
+
"properties": {
|
|
183
|
+
"query": {"type": "string", "description": "Search query"}
|
|
184
|
+
},
|
|
185
|
+
"required": ["query"],
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
"type": "function",
|
|
191
|
+
"function": {
|
|
192
|
+
"name": "fetch_web_page",
|
|
193
|
+
"description": "Fetch and read the text content of a web page URL.",
|
|
194
|
+
"parameters": {
|
|
195
|
+
"type": "object",
|
|
196
|
+
"properties": {
|
|
197
|
+
"url": {"type": "string", "description": "The HTTP or HTTPS URL to read"},
|
|
198
|
+
"max_chars": {"type": "integer", "description": "Maximum characters to return (default: 4000)"},
|
|
199
|
+
},
|
|
200
|
+
"required": ["url"],
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def load_config() -> dict:
|
|
208
|
+
"""Load configuration from ~/.config/ai/config.json with fallback to ~/.omp/agent/models.yml if present."""
|
|
209
|
+
config = {}
|
|
210
|
+
|
|
211
|
+
if CONFIG_FILE.is_file():
|
|
212
|
+
try:
|
|
213
|
+
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
|
214
|
+
config = json.load(f)
|
|
215
|
+
except Exception:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
# Fallback to OMP models.yml if available
|
|
219
|
+
if not config.get("base_url") or not config.get("api_key"):
|
|
220
|
+
omp_models = Path.home() / ".omp" / "agent" / "models.yml"
|
|
221
|
+
if omp_models.is_file():
|
|
222
|
+
try:
|
|
223
|
+
text = omp_models.read_text(encoding="utf-8")
|
|
224
|
+
agm_match = re.search(r"agm:\s*\n((?:\s+.*\n)+)", text)
|
|
225
|
+
section = agm_match.group(1) if agm_match else text
|
|
226
|
+
b = re.search(r'(?:baseUrl|base_url):\s*["\']?([^"\'\s\n]+)', section)
|
|
227
|
+
k = re.search(r'(?:apiKey|api_key):\s*["\']?([^"\'\s\n]+)', section)
|
|
228
|
+
if b and not config.get("base_url"):
|
|
229
|
+
config["base_url"] = b.group(1)
|
|
230
|
+
if k and not config.get("api_key"):
|
|
231
|
+
config["api_key"] = k.group(1)
|
|
232
|
+
if not config.get("model"):
|
|
233
|
+
config["model"] = "gemini-3.7-flash-high"
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
|
|
237
|
+
return config
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def execute_bash(command: str, max_chars: int = 12000, sandbox: bool = True) -> str:
|
|
241
|
+
"""Execute command in bash (optionally sandboxed via bwrap) and return combined stdout/stderr."""
|
|
242
|
+
bwrap_path = shutil.which("bwrap")
|
|
243
|
+
use_sandbox = sandbox and bool(bwrap_path)
|
|
244
|
+
|
|
245
|
+
if use_sandbox:
|
|
246
|
+
pwd = os.getcwd()
|
|
247
|
+
home = os.path.expanduser("~")
|
|
248
|
+
cmd_args = [
|
|
249
|
+
bwrap_path,
|
|
250
|
+
"--ro-bind", "/", "/",
|
|
251
|
+
"--dev", "/dev",
|
|
252
|
+
"--proc", "/proc",
|
|
253
|
+
"--tmpfs", "/tmp",
|
|
254
|
+
"--tmpfs", f"{home}/.ssh",
|
|
255
|
+
"--tmpfs", f"{home}/.gnupg",
|
|
256
|
+
"--share-net",
|
|
257
|
+
"--",
|
|
258
|
+
"/bin/bash", "-c", command,
|
|
259
|
+
]
|
|
260
|
+
else:
|
|
261
|
+
cmd_args = ["/bin/bash", "-c", command]
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
res = subprocess.run(
|
|
265
|
+
cmd_args,
|
|
266
|
+
capture_output=True,
|
|
267
|
+
text=True,
|
|
268
|
+
timeout=60,
|
|
269
|
+
)
|
|
270
|
+
out = res.stdout + res.stderr
|
|
271
|
+
if not out.strip():
|
|
272
|
+
out = f"[Command exited with status {res.returncode} and produced no output]"
|
|
273
|
+
if len(out) > max_chars:
|
|
274
|
+
out = out[:max_chars] + f"\n... [Output truncated to {max_chars} characters]"
|
|
275
|
+
return out
|
|
276
|
+
except subprocess.TimeoutExpired:
|
|
277
|
+
return "[Error: Command timed out after 60 seconds]"
|
|
278
|
+
except Exception as e:
|
|
279
|
+
return f"[Execution Error: {e}]"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def execute_read_file(path_str: str, limit: int = None) -> str:
|
|
283
|
+
"""Read file content safely."""
|
|
284
|
+
path = Path(path_str).expanduser()
|
|
285
|
+
if not path.is_file():
|
|
286
|
+
return f"[Error: File not found at '{path_str}']"
|
|
287
|
+
try:
|
|
288
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
289
|
+
if limit and limit > 0:
|
|
290
|
+
lines = [f.readline() for _ in range(limit)]
|
|
291
|
+
return "".join(lines)
|
|
292
|
+
return f.read()
|
|
293
|
+
except Exception as e:
|
|
294
|
+
return f"[Error reading file: {e}]"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def execute_write_file(path_str: str, content: str, sandbox: bool = True) -> str:
|
|
298
|
+
"""Write file content safely (blocked in read-only sandbox mode)."""
|
|
299
|
+
if sandbox:
|
|
300
|
+
return f"[Sandbox Violation: Cannot write to '{path_str}' because the sandbox is in read-only mode. Use -s / --no-sandbox to allow file modifications.]"
|
|
301
|
+
path = Path(path_str).expanduser()
|
|
302
|
+
try:
|
|
303
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
304
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
305
|
+
f.write(content)
|
|
306
|
+
return f"[Successfully wrote {len(content.encode('utf-8'))} bytes to '{path_str}']"
|
|
307
|
+
except Exception as e:
|
|
308
|
+
return f"[Error writing file: {e}]"
|
|
309
|
+
|
|
310
|
+
def execute_web_search(query: str, max_results: int = 8) -> str:
|
|
311
|
+
"""Search the web with automatic multi-engine fallbacks (Startpage, DuckDuckGo, Bing)."""
|
|
312
|
+
# 1. Primary Engine: Startpage (Google-powered, unblocked, fast)
|
|
313
|
+
try:
|
|
314
|
+
url = "https://www.startpage.com/sp/search"
|
|
315
|
+
data = urllib.parse.urlencode({"query": query}).encode("utf-8")
|
|
316
|
+
headers = {
|
|
317
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
|
318
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
319
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
320
|
+
}
|
|
321
|
+
req = urllib.request.Request(url, data=data, headers=headers)
|
|
322
|
+
with urllib.request.urlopen(req, timeout=6) as resp:
|
|
323
|
+
page = resp.read().decode("utf-8", errors="ignore")
|
|
324
|
+
|
|
325
|
+
results = []
|
|
326
|
+
for m in re.finditer(
|
|
327
|
+
r"<a[^>]*class=[\x27\"][^\x27\"]*result-title[^\x27\"]*[\x27\"][^>]*href=[\x27\"]([^\x27\"]+)[\x27\"][^>]*>(.*?)</a>",
|
|
328
|
+
page,
|
|
329
|
+
):
|
|
330
|
+
link = m.group(1)
|
|
331
|
+
raw_title = html.unescape(re.sub(r"<[^>]+>", "", m.group(2)).strip())
|
|
332
|
+
# Strip inline CSS if injected
|
|
333
|
+
title = re.sub(r"^[^{]+\{[^}]+\}", "", raw_title).strip()
|
|
334
|
+
if not title:
|
|
335
|
+
title = raw_title
|
|
336
|
+
results.append(f"- **Title:** {title}\n **URL:** {link}")
|
|
337
|
+
if len(results) >= max_results:
|
|
338
|
+
break
|
|
339
|
+
if results:
|
|
340
|
+
return "\n\n".join(results)
|
|
341
|
+
except Exception:
|
|
342
|
+
pass
|
|
343
|
+
|
|
344
|
+
# 2. Secondary Engine: DuckDuckGo HTML POST
|
|
345
|
+
try:
|
|
346
|
+
url = "https://html.duckduckgo.com/html/"
|
|
347
|
+
data = urllib.parse.urlencode({"q": query}).encode("utf-8")
|
|
348
|
+
headers = {
|
|
349
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
|
350
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
351
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
352
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
353
|
+
"Referer": "https://html.duckduckgo.com/",
|
|
354
|
+
"Origin": "https://html.duckduckgo.com",
|
|
355
|
+
}
|
|
356
|
+
req = urllib.request.Request(url, data=data, headers=headers)
|
|
357
|
+
with urllib.request.urlopen(req, timeout=6) as resp:
|
|
358
|
+
page = resp.read().decode("utf-8", errors="ignore")
|
|
359
|
+
|
|
360
|
+
results = []
|
|
361
|
+
for m in re.finditer(
|
|
362
|
+
r"<a[^>]*class=\"result__snippet[^\"]*\"[^>]*href=\"([^\"]+)\"[^>]*>(.*?)</a>",
|
|
363
|
+
page,
|
|
364
|
+
re.DOTALL,
|
|
365
|
+
):
|
|
366
|
+
raw_url = m.group(1)
|
|
367
|
+
u_match = re.search(r"uddg=([^&]+)", raw_url)
|
|
368
|
+
link = urllib.parse.unquote(u_match.group(1)) if u_match else raw_url
|
|
369
|
+
snippet = html.unescape(re.sub(r"<[^>]+>", "", m.group(2)).strip())
|
|
370
|
+
results.append(f"- **URL:** {link}\n **Snippet:** {snippet}")
|
|
371
|
+
if len(results) >= max_results:
|
|
372
|
+
break
|
|
373
|
+
if results:
|
|
374
|
+
return "\n\n".join(results)
|
|
375
|
+
except Exception:
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
return "No results found."
|
|
379
|
+
|
|
380
|
+
def execute_fetch_web_page(url_str: str, max_chars: int = 4000) -> str:
|
|
381
|
+
"""Fetch clean, readable text from a web page URL."""
|
|
382
|
+
if not url_str.startswith("http://") and not url_str.startswith("https://"):
|
|
383
|
+
url_str = "https://" + url_str
|
|
384
|
+
headers = {
|
|
385
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
386
|
+
}
|
|
387
|
+
req = urllib.request.Request(url_str, headers=headers)
|
|
388
|
+
try:
|
|
389
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
390
|
+
raw_html = resp.read().decode("utf-8", errors="ignore")
|
|
391
|
+
# Strip scripts, styles, and tags
|
|
392
|
+
clean = re.sub(r"<(script|style|nav|footer|header)[^>]*>.*?</\1>", "", raw_html, flags=re.DOTALL | re.IGNORECASE)
|
|
393
|
+
clean = re.sub(r"<[^>]+>", " ", clean)
|
|
394
|
+
clean = html.unescape(clean)
|
|
395
|
+
clean = " ".join(clean.split())
|
|
396
|
+
if len(clean) > max_chars:
|
|
397
|
+
clean = clean[:max_chars] + f"\n... [Truncated to {max_chars} chars]"
|
|
398
|
+
return clean if clean.strip() else "[Web page contained no readable text]"
|
|
399
|
+
except Exception as e:
|
|
400
|
+
return f"[Error fetching web page: {e}]"
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def run_agent_loop(
|
|
404
|
+
base_url: str,
|
|
405
|
+
api_key: str,
|
|
406
|
+
model: str,
|
|
407
|
+
user_content: str,
|
|
408
|
+
system_prompt: str = None,
|
|
409
|
+
temperature: float = None,
|
|
410
|
+
max_turns: int = 15,
|
|
411
|
+
auto_approve: bool = False,
|
|
412
|
+
sandbox: bool = True,
|
|
413
|
+
) -> None:
|
|
414
|
+
"""Run an agentic loop with bash, read_file, write_file, search_web, and fetch_web_page tools."""
|
|
415
|
+
base_instructions = (
|
|
416
|
+
"You are a fast, lightweight terminal assistant. "
|
|
417
|
+
"Your primary job is to resolve simple requests quickly, accurately, and concisely.\n\n"
|
|
418
|
+
"Tool Usage Policy:\n"
|
|
419
|
+
"- Efficiency & Quality: Use the minimum number of tool calls needed, but NEVER sacrifice answer quality, accuracy, or completeness. Always be confident that you have fully answered the user's intent with grounded facts before finishing.\n"
|
|
420
|
+
"- Internet Requests: When searching or retrieving information from the internet, ALWAYS use `search_web` (and `fetch_web_page` to read specific URLs).\n"
|
|
421
|
+
"- Shell & Commands: Only use `bash` to fetch web content if the user explicitly asks to run a curl/bash/script command. Use `bash` for local system tasks.\n"
|
|
422
|
+
"- Directness: Provide direct, clear answers without unnecessary fluff or excessive commentary."
|
|
423
|
+
)
|
|
424
|
+
history = load_session_history()
|
|
425
|
+
messages = []
|
|
426
|
+
sys_content = f"{base_instructions}\n\n{system_prompt}" if system_prompt else base_instructions
|
|
427
|
+
messages.append({"role": "system", "content": sys_content})
|
|
428
|
+
if history:
|
|
429
|
+
messages.extend(history)
|
|
430
|
+
messages.append({"role": "user", "content": user_content})
|
|
431
|
+
for turn in range(max_turns):
|
|
432
|
+
payload = {
|
|
433
|
+
"model": model,
|
|
434
|
+
"messages": messages,
|
|
435
|
+
"tools": TOOLS,
|
|
436
|
+
"tool_choice": "auto",
|
|
437
|
+
}
|
|
438
|
+
if temperature is not None:
|
|
439
|
+
payload["temperature"] = temperature
|
|
440
|
+
|
|
441
|
+
req = urllib.request.Request(
|
|
442
|
+
f"{base_url.rstrip('/')}/chat/completions",
|
|
443
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
444
|
+
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
|
|
445
|
+
method="POST",
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
with urllib.request.urlopen(req) as resp:
|
|
450
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
451
|
+
except urllib.error.HTTPError as e:
|
|
452
|
+
print(f"\n\033[31mAPI Error ({e.code}):\033[0m {e.read().decode('utf-8')}", file=sys.stderr)
|
|
453
|
+
sys.exit(1)
|
|
454
|
+
except Exception as e:
|
|
455
|
+
print(f"\n\033[31mError:\033[0m {e}", file=sys.stderr)
|
|
456
|
+
sys.exit(1)
|
|
457
|
+
|
|
458
|
+
choice = data.get("choices", [{}])[0]
|
|
459
|
+
message = choice.get("message", {})
|
|
460
|
+
messages.append(message)
|
|
461
|
+
|
|
462
|
+
tool_calls = message.get("tool_calls")
|
|
463
|
+
if not tool_calls:
|
|
464
|
+
# Final output text from the model
|
|
465
|
+
content = message.get("content", "")
|
|
466
|
+
if content:
|
|
467
|
+
print_markdown(content)
|
|
468
|
+
# Persist updated session history
|
|
469
|
+
updated_history = list(history)
|
|
470
|
+
updated_history.append({"role": "user", "content": user_content})
|
|
471
|
+
updated_history.append({"role": "assistant", "content": content})
|
|
472
|
+
save_session_history(updated_history)
|
|
473
|
+
break
|
|
474
|
+
|
|
475
|
+
# Execute each requested tool call
|
|
476
|
+
for tool in tool_calls:
|
|
477
|
+
func = tool.get("function", {})
|
|
478
|
+
fname = func.get("name")
|
|
479
|
+
tool_id = tool.get("id")
|
|
480
|
+
raw_args = func.get("arguments", "{}")
|
|
481
|
+
|
|
482
|
+
try:
|
|
483
|
+
args = json.loads(raw_args)
|
|
484
|
+
except Exception:
|
|
485
|
+
args = {}
|
|
486
|
+
|
|
487
|
+
if fname == "bash":
|
|
488
|
+
cmd = args.get("command", "")
|
|
489
|
+
print(f"\033[1;33m⚡ Action (bash):\033[0m \033[36m{cmd}\033[0m")
|
|
490
|
+
if not auto_approve:
|
|
491
|
+
try:
|
|
492
|
+
ans = input(" Execute? [Y/n/a(lways)] ").strip().lower()
|
|
493
|
+
except (KeyboardInterrupt, EOFError):
|
|
494
|
+
print("\nAborted.")
|
|
495
|
+
sys.exit(130)
|
|
496
|
+
if ans == "a":
|
|
497
|
+
auto_approve = True
|
|
498
|
+
elif ans == "n":
|
|
499
|
+
messages.append({"role": "tool", "tool_call_id": tool_id, "content": "[Command rejected by user]"})
|
|
500
|
+
continue
|
|
501
|
+
output = execute_bash(cmd, sandbox=sandbox)
|
|
502
|
+
lines = output.strip().split("\n")
|
|
503
|
+
preview = "\n".join(lines[:6]) + (f"\n... ({len(lines)-6} more lines)" if len(lines) > 6 else "")
|
|
504
|
+
print(f"\033[90m{preview}\033[0m")
|
|
505
|
+
messages.append({"role": "tool", "tool_call_id": tool_id, "content": output})
|
|
506
|
+
|
|
507
|
+
elif fname == "read_file":
|
|
508
|
+
p = args.get("path", "")
|
|
509
|
+
lim = args.get("limit")
|
|
510
|
+
print(f"\033[1;34m📖 Read File:\033[0m \033[36m{p}\033[0m")
|
|
511
|
+
output = execute_read_file(p, lim)
|
|
512
|
+
messages.append({"role": "tool", "tool_call_id": tool_id, "content": output})
|
|
513
|
+
|
|
514
|
+
elif fname == "write_file":
|
|
515
|
+
p = args.get("path", "")
|
|
516
|
+
c = args.get("content", "")
|
|
517
|
+
print(f"\033[1;32m📝 Write File:\033[0m \033[36m{p}\033[0m ({len(c.encode('utf-8'))} bytes)")
|
|
518
|
+
if not auto_approve:
|
|
519
|
+
try:
|
|
520
|
+
ans = input(f" Write to '{p}'? [Y/n/a(lways)] ").strip().lower()
|
|
521
|
+
except (KeyboardInterrupt, EOFError):
|
|
522
|
+
print("\nAborted.")
|
|
523
|
+
sys.exit(130)
|
|
524
|
+
if ans == "a":
|
|
525
|
+
auto_approve = True
|
|
526
|
+
elif ans == "n":
|
|
527
|
+
messages.append({"role": "tool", "tool_call_id": tool_id, "content": "[File write rejected by user]"})
|
|
528
|
+
continue
|
|
529
|
+
output = execute_write_file(p, c, sandbox=sandbox)
|
|
530
|
+
print(f"\033[90m{output}\033[0m")
|
|
531
|
+
messages.append({"role": "tool", "tool_call_id": tool_id, "content": output})
|
|
532
|
+
|
|
533
|
+
elif fname in ("search_web", "web_search", "search", "internet_search"):
|
|
534
|
+
q = args.get("query") or args.get("q") or args.get("search_terms") or ""
|
|
535
|
+
print(f"\033[1;35m🌐 Search Web:\033[0m \033[36m{q}\033[0m")
|
|
536
|
+
output = execute_web_search(q)
|
|
537
|
+
if output.startswith("[Web Search Error") or output == "No results found.":
|
|
538
|
+
print(f" \033[31m✗ {output}\033[0m")
|
|
539
|
+
else:
|
|
540
|
+
# Count found results
|
|
541
|
+
items = [line for line in output.split("\n\n") if line.strip().startswith("- **")]
|
|
542
|
+
count = len(items) if items else 1
|
|
543
|
+
print(f" \033[32m✓ Found {count} result{'s' if count != 1 else ''}\033[0m")
|
|
544
|
+
for item in items[:3]:
|
|
545
|
+
first_line = item.strip().split("\n")[0]
|
|
546
|
+
clean_first = first_line.replace("- **Title:**", "•").replace("- **URL:**", "•")
|
|
547
|
+
print(f" \033[90m{clean_first[:85]}\033[0m")
|
|
548
|
+
if len(items) > 3:
|
|
549
|
+
print(f" \033[90m... and {len(items)-3} more\033[0m")
|
|
550
|
+
messages.append({"role": "tool", "tool_call_id": tool_id, "content": output})
|
|
551
|
+
|
|
552
|
+
elif fname in ("fetch_web_page", "read_url", "fetch_url"):
|
|
553
|
+
u = args.get("url") or args.get("link") or ""
|
|
554
|
+
mc = args.get("max_chars", 4000)
|
|
555
|
+
print(f"\033[1;35m📄 Fetch Page:\033[0m \033[36m{u}\033[0m")
|
|
556
|
+
output = execute_fetch_web_page(u, max_chars=mc)
|
|
557
|
+
if output.startswith("[Error"):
|
|
558
|
+
print(f" \033[31m✗ {output}\033[0m")
|
|
559
|
+
else:
|
|
560
|
+
print(f" \033[32m✓ Fetched {len(output)} chars\033[0m")
|
|
561
|
+
messages.append({"role": "tool", "tool_call_id": tool_id, "content": output})
|
|
562
|
+
else:
|
|
563
|
+
messages.append({"role": "tool", "tool_call_id": tool_id, "content": f"[Unknown tool: {fname}]"})
|
|
564
|
+
|
|
565
|
+
def run_stream_completion(
|
|
566
|
+
base_url: str,
|
|
567
|
+
api_key: str,
|
|
568
|
+
model: str,
|
|
569
|
+
user_content: str,
|
|
570
|
+
system_prompt: str = None,
|
|
571
|
+
temperature: float = None,
|
|
572
|
+
) -> None:
|
|
573
|
+
"""Stream completions with sub-5ms latency and zero dependencies."""
|
|
574
|
+
history = load_session_history()
|
|
575
|
+
messages = []
|
|
576
|
+
default_no_tool_system = (
|
|
577
|
+
"You are a fast, lightweight terminal assistant. "
|
|
578
|
+
"Your job is to resolve simple requests quickly, accurately, and concisely."
|
|
579
|
+
)
|
|
580
|
+
sys_content = f"{default_no_tool_system}\n\n{system_prompt}" if system_prompt else default_no_tool_system
|
|
581
|
+
messages.append({"role": "system", "content": sys_content})
|
|
582
|
+
if history:
|
|
583
|
+
messages.extend(history)
|
|
584
|
+
messages.append({"role": "user", "content": user_content})
|
|
585
|
+
url = f"{base_url.rstrip('/')}/chat/completions"
|
|
586
|
+
headers = {
|
|
587
|
+
"Content-Type": "application/json",
|
|
588
|
+
"Authorization": f"Bearer {api_key}",
|
|
589
|
+
}
|
|
590
|
+
payload = {
|
|
591
|
+
"model": model,
|
|
592
|
+
"messages": messages,
|
|
593
|
+
"stream": True,
|
|
594
|
+
}
|
|
595
|
+
if temperature is not None:
|
|
596
|
+
payload["temperature"] = temperature
|
|
597
|
+
|
|
598
|
+
req = urllib.request.Request(
|
|
599
|
+
url,
|
|
600
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
601
|
+
headers=headers,
|
|
602
|
+
method="POST",
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
full_response = []
|
|
606
|
+
try:
|
|
607
|
+
with urllib.request.urlopen(req) as response:
|
|
608
|
+
for raw_line in response:
|
|
609
|
+
line = raw_line.decode("utf-8").strip()
|
|
610
|
+
if line.startswith("data: ") and line != "data: [DONE]":
|
|
611
|
+
try:
|
|
612
|
+
chunk = json.loads(line[6:])
|
|
613
|
+
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
|
614
|
+
content = delta.get("content", "")
|
|
615
|
+
if content:
|
|
616
|
+
full_response.append(content)
|
|
617
|
+
except json.JSONDecodeError:
|
|
618
|
+
continue
|
|
619
|
+
print_markdown("".join(full_response))
|
|
620
|
+
if full_response:
|
|
621
|
+
updated_history = list(history)
|
|
622
|
+
updated_history.append({"role": "user", "content": user_content})
|
|
623
|
+
updated_history.append({"role": "assistant", "content": "".join(full_response)})
|
|
624
|
+
save_session_history(updated_history)
|
|
625
|
+
except KeyboardInterrupt:
|
|
626
|
+
sys.exit(130)
|
|
627
|
+
except urllib.error.HTTPError as e:
|
|
628
|
+
print(f"\n\033[31mAPI Error ({e.code}):\033[0m {e.read().decode('utf-8')}", file=sys.stderr)
|
|
629
|
+
sys.exit(1)
|
|
630
|
+
except Exception as e:
|
|
631
|
+
print(f"\n\033[31mError:\033[0m {e}", file=sys.stderr)
|
|
632
|
+
sys.exit(1)
|
|
633
|
+
|
|
634
|
+
def main() -> None:
|
|
635
|
+
config = load_config()
|
|
636
|
+
|
|
637
|
+
parser = argparse.ArgumentParser(
|
|
638
|
+
prog="ai",
|
|
639
|
+
description="Lightning-fast streaming AI CLI with zero external dependencies and agentic tool mode.",
|
|
640
|
+
epilog=(
|
|
641
|
+
"Examples:\n"
|
|
642
|
+
" ai what is the biggest thing on the moon\n"
|
|
643
|
+
" ai search the latest zig release and summarize changes\n"
|
|
644
|
+
" git diff | ai summarize changes in 3 bullets\n"
|
|
645
|
+
" ai inspect package.json and run the test script\n"
|
|
646
|
+
" ai -n 'Reply in haiku' explain rust (pure prompt mode, no tools)\n"
|
|
647
|
+
" ai -m claude-3-5-sonnet 'explain quantum entanglement'"
|
|
648
|
+
),
|
|
649
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
650
|
+
)
|
|
651
|
+
parser.add_argument("prompt", nargs="*", help="User prompt or instruction")
|
|
652
|
+
parser.add_argument(
|
|
653
|
+
"-n",
|
|
654
|
+
"--no-tools",
|
|
655
|
+
"--no-agent",
|
|
656
|
+
action="store_true",
|
|
657
|
+
help="Disable tools and run in fast direct streaming completion mode",
|
|
658
|
+
)
|
|
659
|
+
parser.add_argument(
|
|
660
|
+
"-c",
|
|
661
|
+
"--confirm",
|
|
662
|
+
action="store_true",
|
|
663
|
+
default=None,
|
|
664
|
+
help="Require manual confirmation for each tool action (default: auto-executes unless set in config)",
|
|
665
|
+
)
|
|
666
|
+
parser.add_argument(
|
|
667
|
+
"-y",
|
|
668
|
+
"--yes",
|
|
669
|
+
action="store_true",
|
|
670
|
+
default=None,
|
|
671
|
+
help="Bypass confirmation and auto-approve all tool actions",
|
|
672
|
+
)
|
|
673
|
+
parser.add_argument(
|
|
674
|
+
"-s",
|
|
675
|
+
"--no-sandbox",
|
|
676
|
+
action="store_true",
|
|
677
|
+
help="Disable Bubblewrap sandbox and run commands directly on the host",
|
|
678
|
+
)
|
|
679
|
+
parser.add_argument(
|
|
680
|
+
"-m",
|
|
681
|
+
"--model",
|
|
682
|
+
default=os.getenv("AI_MODEL", config.get("model", "gemini-3.7-flash-high")),
|
|
683
|
+
help="Model identifier (default: gemini-3.7-flash-high or from config)",
|
|
684
|
+
)
|
|
685
|
+
parser.add_argument(
|
|
686
|
+
"-u",
|
|
687
|
+
"--base-url",
|
|
688
|
+
default=os.getenv("AI_BASE_URL", config.get("base_url", "https://api.openai.com/v1")),
|
|
689
|
+
help="OpenAI-compatible API base URL",
|
|
690
|
+
)
|
|
691
|
+
parser.add_argument(
|
|
692
|
+
"-k",
|
|
693
|
+
"--api-key",
|
|
694
|
+
default=os.getenv("AI_API_KEY", config.get("api_key", "dummy")),
|
|
695
|
+
help="API authorization key",
|
|
696
|
+
)
|
|
697
|
+
parser.add_argument(
|
|
698
|
+
"--system",
|
|
699
|
+
default=config.get("system_prompt"),
|
|
700
|
+
help="Custom system prompt",
|
|
701
|
+
)
|
|
702
|
+
parser.add_argument(
|
|
703
|
+
"-t",
|
|
704
|
+
"--temperature",
|
|
705
|
+
type=float,
|
|
706
|
+
default=config.get("temperature"),
|
|
707
|
+
help="Sampling temperature (0.0 - 2.0)",
|
|
708
|
+
)
|
|
709
|
+
parser.add_argument(
|
|
710
|
+
"--max-turns",
|
|
711
|
+
type=int,
|
|
712
|
+
default=25,
|
|
713
|
+
help="Maximum tool execution turns in agent mode (default: 25)",
|
|
714
|
+
)
|
|
715
|
+
parser.add_argument(
|
|
716
|
+
"--init-config",
|
|
717
|
+
action="store_true",
|
|
718
|
+
help="Create a default config template at ~/.config/ai/config.json",
|
|
719
|
+
)
|
|
720
|
+
parser.add_argument(
|
|
721
|
+
"-v",
|
|
722
|
+
"--version",
|
|
723
|
+
action="version",
|
|
724
|
+
version=f"%(prog)s {__version__}",
|
|
725
|
+
)
|
|
726
|
+
parser.add_argument(
|
|
727
|
+
"-C",
|
|
728
|
+
"--clear",
|
|
729
|
+
action="store_true",
|
|
730
|
+
help="Clear conversation memory for the current terminal session",
|
|
731
|
+
)
|
|
732
|
+
parser.add_argument(
|
|
733
|
+
"-H",
|
|
734
|
+
"--handoff",
|
|
735
|
+
nargs="?",
|
|
736
|
+
const="omp",
|
|
737
|
+
default=None,
|
|
738
|
+
metavar="HARNESS",
|
|
739
|
+
help="Handoff session context and launch a heavy agent harness (omp, claude, codex, pi; default: omp)",
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
args = parser.parse_args()
|
|
743
|
+
|
|
744
|
+
if args.init_config:
|
|
745
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
746
|
+
sample = {
|
|
747
|
+
"base_url": args.base_url,
|
|
748
|
+
"api_key": args.api_key,
|
|
749
|
+
"model": args.model,
|
|
750
|
+
"system_prompt": "You are a concise, helpful terminal assistant.",
|
|
751
|
+
"temperature": 0.7,
|
|
752
|
+
"require_approval": False,
|
|
753
|
+
}
|
|
754
|
+
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
|
755
|
+
json.dump(sample, f, indent=2)
|
|
756
|
+
print(f"Created configuration file at {CONFIG_FILE}")
|
|
757
|
+
sys.exit(0)
|
|
758
|
+
if args.clear:
|
|
759
|
+
clear_session_history()
|
|
760
|
+
print("✨ Cleared conversation session memory.")
|
|
761
|
+
sys.exit(0)
|
|
762
|
+
if args.handoff is not None:
|
|
763
|
+
extra_inst = " ".join(args.prompt).strip()
|
|
764
|
+
execute_handoff(args.handoff, extra_inst)
|
|
765
|
+
sys.exit(0)
|
|
766
|
+
|
|
767
|
+
# Ingest stdin if piped
|
|
768
|
+
stdin_text = ""
|
|
769
|
+
if not sys.stdin.isatty():
|
|
770
|
+
try:
|
|
771
|
+
stdin_text = sys.stdin.read().strip()
|
|
772
|
+
except Exception:
|
|
773
|
+
pass
|
|
774
|
+
|
|
775
|
+
args_text = " ".join(args.prompt).strip()
|
|
776
|
+
|
|
777
|
+
if args_text and stdin_text:
|
|
778
|
+
user_content = f"{args_text}\n\nInput Context:\n```\n{stdin_text}\n```"
|
|
779
|
+
elif args_text:
|
|
780
|
+
user_content = args_text
|
|
781
|
+
elif stdin_text:
|
|
782
|
+
user_content = stdin_text
|
|
783
|
+
else:
|
|
784
|
+
parser.print_help(sys.stderr)
|
|
785
|
+
sys.exit(1)
|
|
786
|
+
|
|
787
|
+
# Resolve approval preference
|
|
788
|
+
require_approval = config.get("require_approval", False)
|
|
789
|
+
auto_approve = True
|
|
790
|
+
if args.confirm:
|
|
791
|
+
auto_approve = False
|
|
792
|
+
elif args.yes:
|
|
793
|
+
auto_approve = True
|
|
794
|
+
elif require_approval:
|
|
795
|
+
auto_approve = False
|
|
796
|
+
if args.no_tools:
|
|
797
|
+
run_stream_completion(
|
|
798
|
+
base_url=args.base_url,
|
|
799
|
+
api_key=args.api_key,
|
|
800
|
+
model=args.model,
|
|
801
|
+
user_content=user_content,
|
|
802
|
+
system_prompt=args.system,
|
|
803
|
+
temperature=args.temperature,
|
|
804
|
+
)
|
|
805
|
+
else:
|
|
806
|
+
use_sandbox = not args.no_sandbox and config.get("sandbox", True)
|
|
807
|
+
try:
|
|
808
|
+
run_agent_loop(
|
|
809
|
+
base_url=args.base_url,
|
|
810
|
+
api_key=args.api_key,
|
|
811
|
+
model=args.model,
|
|
812
|
+
user_content=user_content,
|
|
813
|
+
system_prompt=args.system,
|
|
814
|
+
temperature=args.temperature,
|
|
815
|
+
max_turns=args.max_turns,
|
|
816
|
+
auto_approve=auto_approve,
|
|
817
|
+
sandbox=use_sandbox,
|
|
818
|
+
)
|
|
819
|
+
except KeyboardInterrupt:
|
|
820
|
+
print("\nAborted.")
|
|
821
|
+
sys.exit(130)
|
|
822
|
+
|
|
823
|
+
if __name__ == "__main__":
|
|
824
|
+
main()
|