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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ .env
@@ -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,3 @@
1
+ """Zero-dependency, instant streaming AI CLI for Unix pipelines."""
2
+
3
+ __version__ = "0.8.0"
@@ -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()