raw-llm 1.0.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.
- raw_llm-1.0.0/LICENSE +21 -0
- raw_llm-1.0.0/MANIFEST.in +3 -0
- raw_llm-1.0.0/Makefile +38 -0
- raw_llm-1.0.0/PKG-INFO +183 -0
- raw_llm-1.0.0/README.md +148 -0
- raw_llm-1.0.0/pyproject.toml +75 -0
- raw_llm-1.0.0/setup.cfg +4 -0
- raw_llm-1.0.0/src/raw_llm/__init__.py +0 -0
- raw_llm-1.0.0/src/raw_llm/claude.py +135 -0
- raw_llm-1.0.0/src/raw_llm/common.py +287 -0
- raw_llm-1.0.0/src/raw_llm/gemini.py +148 -0
- raw_llm-1.0.0/src/raw_llm.egg-info/PKG-INFO +183 -0
- raw_llm-1.0.0/src/raw_llm.egg-info/SOURCES.txt +15 -0
- raw_llm-1.0.0/src/raw_llm.egg-info/dependency_links.txt +1 -0
- raw_llm-1.0.0/src/raw_llm.egg-info/entry_points.txt +3 -0
- raw_llm-1.0.0/src/raw_llm.egg-info/requires.txt +12 -0
- raw_llm-1.0.0/src/raw_llm.egg-info/top_level.txt +1 -0
raw_llm-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rodolfo Villaruz
|
|
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.
|
raw_llm-1.0.0/Makefile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
SRC = .
|
|
2
|
+
|
|
3
|
+
.PHONY: all format format-check lint typecheck test check
|
|
4
|
+
|
|
5
|
+
all: check
|
|
6
|
+
|
|
7
|
+
check: format-check lint typecheck test
|
|
8
|
+
|
|
9
|
+
format:
|
|
10
|
+
isort --profile black $(SRC)
|
|
11
|
+
black --line-length 79 $(SRC)
|
|
12
|
+
|
|
13
|
+
format-check:
|
|
14
|
+
isort --profile black --check-only $(SRC)
|
|
15
|
+
black --line-length 79 --check $(SRC)
|
|
16
|
+
|
|
17
|
+
lint:
|
|
18
|
+
flake8 $(SRC)
|
|
19
|
+
|
|
20
|
+
typecheck:
|
|
21
|
+
mypy $(SRC)
|
|
22
|
+
|
|
23
|
+
test:
|
|
24
|
+
pytest $(SRC)
|
|
25
|
+
|
|
26
|
+
clean:
|
|
27
|
+
find . -type f -name "*.pyc" -delete
|
|
28
|
+
find . -type d -name "__pycache__" -delete
|
|
29
|
+
rm -rf build/ dist/ *.egg-info
|
|
30
|
+
|
|
31
|
+
build: clean
|
|
32
|
+
python -m build
|
|
33
|
+
|
|
34
|
+
publish: build
|
|
35
|
+
twine upload dist/*
|
|
36
|
+
|
|
37
|
+
publish-test: build
|
|
38
|
+
twine upload --repository testpypi dist/*
|
raw_llm-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: raw-llm
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The simplest way to context engineer. Minimal streaming CLI clients for Claude and Gemini.
|
|
5
|
+
Author-email: Rodolfo Villaruz <rodolfo@yes.ph>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rodolfovillaruz/simple
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/rodolfovillaruz/simple/issues
|
|
9
|
+
Project-URL: Repository, https://github.com/rodolfovillaruz/simple.git
|
|
10
|
+
Keywords: llm,claude,gemini,cli,streaming,context-engineering
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Environment :: Console
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: anthropic>=0.25.0
|
|
24
|
+
Requires-Dist: google-genai>=0.3.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: isort>=5.12.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pylint>=2.17.0; extra == "dev"
|
|
29
|
+
Requires-Dist: flake8>=6.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest>=7.3.0; extra == "dev"
|
|
32
|
+
Requires-Dist: build>=0.10.0; extra == "dev"
|
|
33
|
+
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# Simple
|
|
37
|
+
|
|
38
|
+
**The simplest way to context engineer.**
|
|
39
|
+
|
|
40
|
+
Minimal, streaming CLI clients for Claude and Gemini that keep your conversations in plain JSON files.
|
|
41
|
+
|
|
42
|
+
## What is this?
|
|
43
|
+
|
|
44
|
+
Simple is a pair of thin Python scripts that talk to the Anthropic and Google GenAI APIs. No frameworks, no agents, no abstractions you don't need. Just a prompt, a streaming response, and a JSON file you can version, diff, edit, and pipe.
|
|
45
|
+
|
|
46
|
+
The entire idea: your conversation _is_ a file. You build context by editing that file. That's it. That's the context engineering.
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- **Streaming output** — responses print token-by-token as they arrive
|
|
51
|
+
- **Conversation persistence** — every exchange is saved to a plain JSON file you own
|
|
52
|
+
- **Resume any conversation** — pass the JSON file back in to continue where you left off
|
|
53
|
+
- **Pipe-friendly** — reads from stdin, writes content to stdout, writes diagnostics to stderr
|
|
54
|
+
- **Colored output** — reasoning in gray (stderr), content in cyan (stdout), auto-disabled when piped
|
|
55
|
+
- **Conflict detection** — refuses to overwrite a conversation file modified by another process
|
|
56
|
+
- **Symlink to switch models** — symlink `claude.py` as `opus` or `haiku` to change the default model
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone https://github.com/rodolfovillaruz/simple.git
|
|
62
|
+
cd simple
|
|
63
|
+
pip install anthropic google-genai
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Set your API keys:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
70
|
+
export GEMINI_API_KEY="..." # or GOOGLE_API_KEY, per google-genai docs
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Usage
|
|
74
|
+
|
|
75
|
+
### Start a new conversation
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python claude.py
|
|
79
|
+
# Type your prompt, then press Ctrl+D to submit
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
echo "Explain monads in one paragraph" | python claude.py
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
python gemini.py
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Resume an existing conversation
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
python claude.py .prompt/some-conversation.json
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The JSON file contains the full message history. Edit it with any text editor to reshape context before your next turn.
|
|
97
|
+
|
|
98
|
+
### Pipe a file as context
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
cat code.py | python claude.py conversation.json
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Switch models
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# By flag
|
|
108
|
+
python claude.py -m claude-opus-4-6
|
|
109
|
+
|
|
110
|
+
# By symlink
|
|
111
|
+
ln -s claude.py opus
|
|
112
|
+
./opus
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| Symlink name | Default model |
|
|
116
|
+
| -------------------- | ---------------------- |
|
|
117
|
+
| `claude.py` (default)| `claude-sonnet-4-6` |
|
|
118
|
+
| `claude-opus` / `opus`| `claude-opus-4-6` |
|
|
119
|
+
| `claude-haiku` / `haiku`| `claude-haiku-4-5` |
|
|
120
|
+
| `gemini.py` (default)| `gemini-3.1-pro-preview` |
|
|
121
|
+
|
|
122
|
+
### Options
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
usage: claude.py [-h] [-n] [-v] [-m MODEL] [-t MAX_TOKENS] [-i] [conversation_file]
|
|
126
|
+
|
|
127
|
+
positional arguments:
|
|
128
|
+
conversation_file JSON file to resume (omit to start fresh)
|
|
129
|
+
|
|
130
|
+
options:
|
|
131
|
+
-n, --dry-run Build the prompt but don't send it
|
|
132
|
+
-v, --verbose Show model name and prompt preview
|
|
133
|
+
-m, --model MODEL Override the default model
|
|
134
|
+
-t, --max-tokens TOKENS Cap the response length
|
|
135
|
+
-i, --interactive Interactive REPL mode
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Conversation format
|
|
139
|
+
|
|
140
|
+
Conversations are stored as a JSON array of message objects, the same shape both APIs understand:
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
[
|
|
144
|
+
{
|
|
145
|
+
"role": "user",
|
|
146
|
+
"content": "What is context engineering?"
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"role": "assistant",
|
|
150
|
+
"content": "Context engineering is the practice of ..."
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
You can create these files by hand, merge them, truncate them, or generate them with other tools. Simple doesn't care. It reads the array, appends your new message, streams the response, and appends that too.
|
|
156
|
+
|
|
157
|
+
## Project structure
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
.
|
|
161
|
+
├── claude.py # Claude CLI client
|
|
162
|
+
├── gemini.py # Gemini CLI client
|
|
163
|
+
├── common.py # Shared utilities (streaming, I/O, conversation management)
|
|
164
|
+
├── Makefile # Formatting, linting, typing
|
|
165
|
+
└── .prompt/ # Default directory for conversation files (auto-used if present)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
make fmt # Format with black/isort
|
|
172
|
+
make lint # Lint with pylint/flake8
|
|
173
|
+
make type # Type-check with mypy
|
|
174
|
+
make all # All of the above
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Why?
|
|
178
|
+
|
|
179
|
+
Most LLM tools add layers between you and the model. Simple removes them. The conversation is a file. The prompt is stdin. The response is stdout. Everything else is up to you.
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT
|
raw_llm-1.0.0/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Simple
|
|
2
|
+
|
|
3
|
+
**The simplest way to context engineer.**
|
|
4
|
+
|
|
5
|
+
Minimal, streaming CLI clients for Claude and Gemini that keep your conversations in plain JSON files.
|
|
6
|
+
|
|
7
|
+
## What is this?
|
|
8
|
+
|
|
9
|
+
Simple is a pair of thin Python scripts that talk to the Anthropic and Google GenAI APIs. No frameworks, no agents, no abstractions you don't need. Just a prompt, a streaming response, and a JSON file you can version, diff, edit, and pipe.
|
|
10
|
+
|
|
11
|
+
The entire idea: your conversation _is_ a file. You build context by editing that file. That's it. That's the context engineering.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
- **Streaming output** — responses print token-by-token as they arrive
|
|
16
|
+
- **Conversation persistence** — every exchange is saved to a plain JSON file you own
|
|
17
|
+
- **Resume any conversation** — pass the JSON file back in to continue where you left off
|
|
18
|
+
- **Pipe-friendly** — reads from stdin, writes content to stdout, writes diagnostics to stderr
|
|
19
|
+
- **Colored output** — reasoning in gray (stderr), content in cyan (stdout), auto-disabled when piped
|
|
20
|
+
- **Conflict detection** — refuses to overwrite a conversation file modified by another process
|
|
21
|
+
- **Symlink to switch models** — symlink `claude.py` as `opus` or `haiku` to change the default model
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/rodolfovillaruz/simple.git
|
|
27
|
+
cd simple
|
|
28
|
+
pip install anthropic google-genai
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Set your API keys:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
35
|
+
export GEMINI_API_KEY="..." # or GOOGLE_API_KEY, per google-genai docs
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
### Start a new conversation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
python claude.py
|
|
44
|
+
# Type your prompt, then press Ctrl+D to submit
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
echo "Explain monads in one paragraph" | python claude.py
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python gemini.py
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Resume an existing conversation
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
python claude.py .prompt/some-conversation.json
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The JSON file contains the full message history. Edit it with any text editor to reshape context before your next turn.
|
|
62
|
+
|
|
63
|
+
### Pipe a file as context
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
cat code.py | python claude.py conversation.json
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Switch models
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# By flag
|
|
73
|
+
python claude.py -m claude-opus-4-6
|
|
74
|
+
|
|
75
|
+
# By symlink
|
|
76
|
+
ln -s claude.py opus
|
|
77
|
+
./opus
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
| Symlink name | Default model |
|
|
81
|
+
| -------------------- | ---------------------- |
|
|
82
|
+
| `claude.py` (default)| `claude-sonnet-4-6` |
|
|
83
|
+
| `claude-opus` / `opus`| `claude-opus-4-6` |
|
|
84
|
+
| `claude-haiku` / `haiku`| `claude-haiku-4-5` |
|
|
85
|
+
| `gemini.py` (default)| `gemini-3.1-pro-preview` |
|
|
86
|
+
|
|
87
|
+
### Options
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
usage: claude.py [-h] [-n] [-v] [-m MODEL] [-t MAX_TOKENS] [-i] [conversation_file]
|
|
91
|
+
|
|
92
|
+
positional arguments:
|
|
93
|
+
conversation_file JSON file to resume (omit to start fresh)
|
|
94
|
+
|
|
95
|
+
options:
|
|
96
|
+
-n, --dry-run Build the prompt but don't send it
|
|
97
|
+
-v, --verbose Show model name and prompt preview
|
|
98
|
+
-m, --model MODEL Override the default model
|
|
99
|
+
-t, --max-tokens TOKENS Cap the response length
|
|
100
|
+
-i, --interactive Interactive REPL mode
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Conversation format
|
|
104
|
+
|
|
105
|
+
Conversations are stored as a JSON array of message objects, the same shape both APIs understand:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
[
|
|
109
|
+
{
|
|
110
|
+
"role": "user",
|
|
111
|
+
"content": "What is context engineering?"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"role": "assistant",
|
|
115
|
+
"content": "Context engineering is the practice of ..."
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
You can create these files by hand, merge them, truncate them, or generate them with other tools. Simple doesn't care. It reads the array, appends your new message, streams the response, and appends that too.
|
|
121
|
+
|
|
122
|
+
## Project structure
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
.
|
|
126
|
+
├── claude.py # Claude CLI client
|
|
127
|
+
├── gemini.py # Gemini CLI client
|
|
128
|
+
├── common.py # Shared utilities (streaming, I/O, conversation management)
|
|
129
|
+
├── Makefile # Formatting, linting, typing
|
|
130
|
+
└── .prompt/ # Default directory for conversation files (auto-used if present)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
make fmt # Format with black/isort
|
|
137
|
+
make lint # Lint with pylint/flake8
|
|
138
|
+
make type # Type-check with mypy
|
|
139
|
+
make all # All of the above
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Why?
|
|
143
|
+
|
|
144
|
+
Most LLM tools add layers between you and the model. Simple removes them. The conversation is a file. The prompt is stdin. The response is stdout. Everything else is up to you.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=65.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "raw-llm"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "The simplest way to context engineer. Minimal streaming CLI clients for Claude and Gemini."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Rodolfo Villaruz", email = "rodolfo@yes.ph"}
|
|
14
|
+
]
|
|
15
|
+
keywords = ["llm", "claude", "gemini", "cli", "streaming", "context-engineering"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.9",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Environment :: Console",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
dependencies = [
|
|
29
|
+
"anthropic>=0.25.0",
|
|
30
|
+
"google-genai>=0.3.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"black>=23.0.0",
|
|
36
|
+
"isort>=5.12.0",
|
|
37
|
+
"pylint>=2.17.0",
|
|
38
|
+
"flake8>=6.0.0",
|
|
39
|
+
"mypy>=1.0.0",
|
|
40
|
+
"pytest>=7.3.0",
|
|
41
|
+
"build>=0.10.0",
|
|
42
|
+
"twine>=4.0.0",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
raw-claude = "raw_llm.claude:main"
|
|
47
|
+
raw-gemini = "raw_llm.gemini:main"
|
|
48
|
+
|
|
49
|
+
[project.urls]
|
|
50
|
+
Homepage = "https://github.com/rodolfovillaruz/simple"
|
|
51
|
+
"Bug Tracker" = "https://github.com/rodolfovillaruz/simple/issues"
|
|
52
|
+
Repository = "https://github.com/rodolfovillaruz/simple.git"
|
|
53
|
+
|
|
54
|
+
[tool.setuptools]
|
|
55
|
+
packages = ["raw_llm"]
|
|
56
|
+
|
|
57
|
+
[tool.setuptools.package-dir]
|
|
58
|
+
"" = "src"
|
|
59
|
+
|
|
60
|
+
[tool.black]
|
|
61
|
+
line-length = 88
|
|
62
|
+
target-version = ["py39", "py310", "py311", "py312"]
|
|
63
|
+
|
|
64
|
+
[tool.isort]
|
|
65
|
+
profile = "black"
|
|
66
|
+
line_length = 88
|
|
67
|
+
|
|
68
|
+
[tool.mypy]
|
|
69
|
+
python_version = "3.9"
|
|
70
|
+
warn_return_any = true
|
|
71
|
+
warn_unused_configs = true
|
|
72
|
+
disallow_untyped_defs = false
|
|
73
|
+
|
|
74
|
+
[tool.pylint.messages_control]
|
|
75
|
+
disable = ["C0111", "R0903"]
|
raw_llm-1.0.0/setup.cfg
ADDED
|
File without changes
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""
|
|
3
|
+
Claude CLI Client.
|
|
4
|
+
|
|
5
|
+
This script interacts with the Anthropic API to generate content based on
|
|
6
|
+
user input or existing conversation files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterable
|
|
12
|
+
|
|
13
|
+
import anthropic
|
|
14
|
+
from anthropic.types import MessageParam
|
|
15
|
+
|
|
16
|
+
from common import (
|
|
17
|
+
StreamPrinter,
|
|
18
|
+
create_parser,
|
|
19
|
+
get_question,
|
|
20
|
+
load_conversation,
|
|
21
|
+
prompt_preview,
|
|
22
|
+
save_conversation_safely,
|
|
23
|
+
spinning,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def stream_claude_response(
|
|
28
|
+
client: anthropic.Anthropic,
|
|
29
|
+
model: str,
|
|
30
|
+
messages: Iterable[MessageParam],
|
|
31
|
+
max_tokens: int,
|
|
32
|
+
) -> str:
|
|
33
|
+
"""
|
|
34
|
+
Stream the response from the Claude API with extended thinking.
|
|
35
|
+
Returns the full assistant content.
|
|
36
|
+
"""
|
|
37
|
+
printer = StreamPrinter()
|
|
38
|
+
assistant_content = []
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
actual_max_tokens = int(max_tokens) if max_tokens else 20000
|
|
42
|
+
budget_tokens = max(actual_max_tokens - 1024, 1024)
|
|
43
|
+
|
|
44
|
+
with client.messages.stream(
|
|
45
|
+
max_tokens=actual_max_tokens,
|
|
46
|
+
messages=messages,
|
|
47
|
+
model=model,
|
|
48
|
+
thinking={
|
|
49
|
+
"type": "enabled",
|
|
50
|
+
"budget_tokens": budget_tokens,
|
|
51
|
+
},
|
|
52
|
+
) as stream:
|
|
53
|
+
for event in stream:
|
|
54
|
+
if event.type == "content_block_start":
|
|
55
|
+
if event.content_block.type == "thinking":
|
|
56
|
+
printer.write_reasoning("") # activate reasoning color
|
|
57
|
+
elif event.content_block.type == "text":
|
|
58
|
+
pass
|
|
59
|
+
elif event.type == "content_block_delta":
|
|
60
|
+
if event.delta.type == "thinking_delta":
|
|
61
|
+
printer.write_reasoning(event.delta.thinking)
|
|
62
|
+
elif event.delta.type == "text_delta":
|
|
63
|
+
printer.write_content(event.delta.text)
|
|
64
|
+
assistant_content.append(event.delta.text)
|
|
65
|
+
|
|
66
|
+
except ConnectionError as e:
|
|
67
|
+
printer.close()
|
|
68
|
+
sys.stderr.write(f"\nError during streaming: {e}\n")
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
printer.close()
|
|
72
|
+
return "".join(assistant_content)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main() -> None:
|
|
76
|
+
"Main function"
|
|
77
|
+
|
|
78
|
+
match Path(__file__).name:
|
|
79
|
+
case "claude-opus" | "opus":
|
|
80
|
+
model = "claude-opus-4-6"
|
|
81
|
+
case "claude-haiku" | "haiku":
|
|
82
|
+
model = "claude-haiku-4-5"
|
|
83
|
+
case _:
|
|
84
|
+
model = "claude-sonnet-4-6"
|
|
85
|
+
|
|
86
|
+
parser = create_parser(
|
|
87
|
+
description="Resume a conversation with Claude",
|
|
88
|
+
model=model,
|
|
89
|
+
)
|
|
90
|
+
args = parser.parse_args()
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
client = anthropic.Anthropic()
|
|
94
|
+
except ConnectionError as e:
|
|
95
|
+
sys.stderr.write(f"Error initializing Claude client: {e}\n")
|
|
96
|
+
sys.stderr.write(
|
|
97
|
+
"Ensure ANTHROPIC_API_KEY environment variable is set.\n"
|
|
98
|
+
)
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
|
|
101
|
+
filename, messages, file_hash = load_conversation(args.conversation_file)
|
|
102
|
+
|
|
103
|
+
if args.verbose > 0:
|
|
104
|
+
sys.stderr.write(f"Model: {args.model}\n\n")
|
|
105
|
+
sys.stderr.flush()
|
|
106
|
+
|
|
107
|
+
question = get_question()
|
|
108
|
+
if not question:
|
|
109
|
+
raise ValueError("No messages to send")
|
|
110
|
+
|
|
111
|
+
sys.stderr.write("\n")
|
|
112
|
+
sys.stderr.flush()
|
|
113
|
+
|
|
114
|
+
if args.verbose > 0:
|
|
115
|
+
prompt_preview(question)
|
|
116
|
+
|
|
117
|
+
messages.append({"role": "user", "content": question})
|
|
118
|
+
|
|
119
|
+
if args.dry_run:
|
|
120
|
+
sys.exit(0)
|
|
121
|
+
|
|
122
|
+
assistant_content = stream_claude_response(
|
|
123
|
+
client, args.model, messages, args.max_tokens
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
messages.append({"role": "assistant", "content": assistant_content})
|
|
127
|
+
|
|
128
|
+
sys.stderr.write("\n")
|
|
129
|
+
sys.stderr.flush()
|
|
130
|
+
|
|
131
|
+
save_conversation_safely(messages, filename, file_hash)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
main()
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Common utilities for AI conversation tools."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import hashlib
|
|
6
|
+
import itertools
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
from anthropic.types import MessageParam
|
|
17
|
+
|
|
18
|
+
# Try to import readline for better input line editing (Unix only)
|
|
19
|
+
try:
|
|
20
|
+
import readline # noqa: F401 # pylint: disable=unused-import
|
|
21
|
+
except ImportError:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
PROMPT_FOLDER = ".prompt"
|
|
25
|
+
EMPTY_HASH = hashlib.sha256(b"").hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def spinner_task(
|
|
29
|
+
spinner_chars: itertools.cycle, done: threading.Event, label: str
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Show a spinner animation on stderr until done event is set."""
|
|
32
|
+
start = time.perf_counter()
|
|
33
|
+
for char in spinner_chars:
|
|
34
|
+
elapsed = time.perf_counter() - start
|
|
35
|
+
sys.stderr.write(f"\r\033[K{label} {char} ({elapsed:.1f}s)")
|
|
36
|
+
sys.stderr.flush()
|
|
37
|
+
if done.wait(0.1):
|
|
38
|
+
break
|
|
39
|
+
elapsed = time.perf_counter() - start
|
|
40
|
+
sys.stderr.write(f"\r\033[K{label} done ({elapsed:.1f}s)\n")
|
|
41
|
+
sys.stderr.flush()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@contextlib.contextmanager
|
|
45
|
+
def spinning(label: str = "Working"):
|
|
46
|
+
"""Context manager that displays a spinner while code executes."""
|
|
47
|
+
spinner = itertools.cycle("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
|
|
48
|
+
done_flag = threading.Event()
|
|
49
|
+
thread = threading.Thread(
|
|
50
|
+
target=spinner_task, args=(spinner, done_flag, label), daemon=True
|
|
51
|
+
)
|
|
52
|
+
thread.start()
|
|
53
|
+
try:
|
|
54
|
+
yield
|
|
55
|
+
finally:
|
|
56
|
+
done_flag.set()
|
|
57
|
+
thread.join()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ask_yes_no(prompt: str) -> bool:
|
|
61
|
+
"""Return True if the user answers 'y' or 'yes' (case-insensitive)."""
|
|
62
|
+
sys.stderr.write(f"{prompt} [y/N] ")
|
|
63
|
+
sys.stderr.flush()
|
|
64
|
+
answer = input().strip().lower()
|
|
65
|
+
return answer.startswith("y")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ask_filename(default: str) -> Path:
|
|
69
|
+
"""
|
|
70
|
+
Ask for a filename.
|
|
71
|
+
If the file already exists the user is asked whether to overwrite it.
|
|
72
|
+
The question is repeated until a valid answer is given.
|
|
73
|
+
"""
|
|
74
|
+
while True:
|
|
75
|
+
sys.stderr.write(f"\nFilename [{default}]: ")
|
|
76
|
+
sys.stderr.flush()
|
|
77
|
+
name = input().strip() or default
|
|
78
|
+
|
|
79
|
+
if not os.path.exists(name):
|
|
80
|
+
return Path(name)
|
|
81
|
+
|
|
82
|
+
sys.stderr.write(f'File "{name}" exists. Overwrite? [y/N]: ')
|
|
83
|
+
sys.stderr.flush()
|
|
84
|
+
choice = input().strip().lower()
|
|
85
|
+
if choice in {"y", "yes"}:
|
|
86
|
+
return Path(name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def same_hash(path: Path, old_hash: str) -> bool:
|
|
90
|
+
"""True -> file still has the same sha256 we saw when we loaded it."""
|
|
91
|
+
return old_hash == hashlib.sha256(path.read_bytes()).hexdigest()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_question() -> str:
|
|
95
|
+
"""Read question from stdin without stripping."""
|
|
96
|
+
if sys.stdin.isatty():
|
|
97
|
+
sys.stderr.write("Press Ctrl+D to submit\n\n")
|
|
98
|
+
sys.stderr.flush()
|
|
99
|
+
lines = []
|
|
100
|
+
while True:
|
|
101
|
+
try:
|
|
102
|
+
line = input()
|
|
103
|
+
lines.append(line)
|
|
104
|
+
except EOFError:
|
|
105
|
+
break
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
# Non-interactive: read entire stdin
|
|
109
|
+
return sys.stdin.read()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_width() -> int:
|
|
113
|
+
"""Get terminal width"""
|
|
114
|
+
try:
|
|
115
|
+
return os.get_terminal_size().columns
|
|
116
|
+
except OSError:
|
|
117
|
+
return 80
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def prompt_preview(prompt: str):
|
|
121
|
+
"""Preview prompt with visual markers"""
|
|
122
|
+
width = get_width()
|
|
123
|
+
start = "[ PROMPT ] "
|
|
124
|
+
end = "[ / PROMPT ] "
|
|
125
|
+
asterisks_start = "*" * (width - len(start))
|
|
126
|
+
asterisks_end = "*" * (width - len(end))
|
|
127
|
+
sys.stderr.write(
|
|
128
|
+
"\n".join(
|
|
129
|
+
[
|
|
130
|
+
start + asterisks_start,
|
|
131
|
+
prompt.rstrip(),
|
|
132
|
+
end + asterisks_end + "\n\n",
|
|
133
|
+
]
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
sys.stderr.flush()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def create_parser(description: str, model: str) -> argparse.ArgumentParser:
|
|
140
|
+
"""Create an argument parser with common arguments."""
|
|
141
|
+
parser = argparse.ArgumentParser(description=description)
|
|
142
|
+
parser.add_argument(
|
|
143
|
+
"conversation_file", nargs="?", default=None, help="Conversation file"
|
|
144
|
+
)
|
|
145
|
+
parser.add_argument(
|
|
146
|
+
"-n", "--dry-run", action="store_true", help="Run without submitting"
|
|
147
|
+
)
|
|
148
|
+
parser.add_argument(
|
|
149
|
+
"-v",
|
|
150
|
+
"--verbose",
|
|
151
|
+
action="count",
|
|
152
|
+
default=0,
|
|
153
|
+
help="Increase output verbosity (-v = INFO, -vv = DEBUG)",
|
|
154
|
+
)
|
|
155
|
+
parser.add_argument(
|
|
156
|
+
"-m",
|
|
157
|
+
"--model",
|
|
158
|
+
type=str,
|
|
159
|
+
default=model,
|
|
160
|
+
help="Name or identifier of the model to use",
|
|
161
|
+
)
|
|
162
|
+
parser.add_argument(
|
|
163
|
+
"-t",
|
|
164
|
+
"--max-tokens",
|
|
165
|
+
type=str,
|
|
166
|
+
help="Maximum number of tokens that can be generated in the response.",
|
|
167
|
+
)
|
|
168
|
+
parser.add_argument(
|
|
169
|
+
"-i",
|
|
170
|
+
"--interactive",
|
|
171
|
+
action="store_true",
|
|
172
|
+
help="Interactive REPL mode (each line is a separate message)",
|
|
173
|
+
)
|
|
174
|
+
return parser
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_conversation(
|
|
178
|
+
filepath_arg: Optional[str],
|
|
179
|
+
) -> Tuple[Path, List[MessageParam], str]:
|
|
180
|
+
"Load conversation from file or create new file path if it does not exist."
|
|
181
|
+
|
|
182
|
+
if not filepath_arg:
|
|
183
|
+
if os.path.isdir(PROMPT_FOLDER):
|
|
184
|
+
filename = (Path(PROMPT_FOLDER) / str(uuid.uuid1())).with_suffix(
|
|
185
|
+
".json"
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
filename = Path(str(uuid.uuid1())).with_suffix(".json")
|
|
189
|
+
else:
|
|
190
|
+
filename = Path(filepath_arg)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
with filename.open(encoding="utf-8") as fh:
|
|
194
|
+
content_str = fh.read()
|
|
195
|
+
json_content = json.loads(content_str)
|
|
196
|
+
file_hash = hashlib.sha256(content_str.encode("utf-8")).hexdigest()
|
|
197
|
+
except FileNotFoundError:
|
|
198
|
+
file_hash = EMPTY_HASH
|
|
199
|
+
json_content = []
|
|
200
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
201
|
+
raise AssertionError(
|
|
202
|
+
f"Content of '{filename}' is not valid JSON: {exc}"
|
|
203
|
+
) from exc
|
|
204
|
+
|
|
205
|
+
return filename, json_content, file_hash
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def save_to_file(messages: list[MessageParam], filename: Path) -> Path:
|
|
209
|
+
"""Save messages to JSON file."""
|
|
210
|
+
with filename.open("w", encoding="utf-8") as f:
|
|
211
|
+
json.dump(messages, f, indent=2, ensure_ascii=False)
|
|
212
|
+
return filename
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def save_conversation_safely(
|
|
216
|
+
messages: List[MessageParam], filename: Path, original_hash: str
|
|
217
|
+
) -> None:
|
|
218
|
+
"Save conversation to file if it hasn't been modified elsewhere."
|
|
219
|
+
|
|
220
|
+
if original_hash == EMPTY_HASH:
|
|
221
|
+
save_to_file(messages, filename)
|
|
222
|
+
sys.stderr.write(f"\nSaved to {filename}\n")
|
|
223
|
+
elif same_hash(filename, original_hash):
|
|
224
|
+
save_to_file(messages, filename)
|
|
225
|
+
sys.stderr.write(f"\nSaved to {filename}\n")
|
|
226
|
+
else:
|
|
227
|
+
sys.stderr.write(
|
|
228
|
+
f"\nError: “{filename}” has been modified by another process.\n"
|
|
229
|
+
)
|
|
230
|
+
sys.exit(2)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def get_colors() -> Dict[str, str]:
|
|
234
|
+
"""
|
|
235
|
+
Return color escape sequences for reasoning and content output,
|
|
236
|
+
empty strings if the corresponding stream is not a terminal.
|
|
237
|
+
"""
|
|
238
|
+
colors = {}
|
|
239
|
+
if sys.stderr.isatty():
|
|
240
|
+
colors["reasoning"] = "\033[90m"
|
|
241
|
+
colors["reasoning_reset"] = "\033[0m"
|
|
242
|
+
else:
|
|
243
|
+
colors["reasoning"] = colors["reasoning_reset"] = ""
|
|
244
|
+
if sys.stdout.isatty():
|
|
245
|
+
colors["content"] = "\033[36m"
|
|
246
|
+
colors["content_reset"] = "\033[0m"
|
|
247
|
+
else:
|
|
248
|
+
colors["content"] = colors["content_reset"] = ""
|
|
249
|
+
return colors
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class StreamPrinter:
|
|
253
|
+
"""Handles colored output of reasoning and content streams."""
|
|
254
|
+
|
|
255
|
+
def __init__(self):
|
|
256
|
+
self.colors = get_colors()
|
|
257
|
+
self.reasoning_active = False
|
|
258
|
+
self.content_active = False
|
|
259
|
+
|
|
260
|
+
def write_reasoning(self, text: str) -> None:
|
|
261
|
+
"""Write reasoning text to stderr with appropriate coloring."""
|
|
262
|
+
if not self.reasoning_active:
|
|
263
|
+
sys.stderr.write(self.colors["reasoning"])
|
|
264
|
+
self.reasoning_active = True
|
|
265
|
+
sys.stderr.write(text)
|
|
266
|
+
sys.stderr.flush()
|
|
267
|
+
|
|
268
|
+
def write_content(self, text: str) -> None:
|
|
269
|
+
"""Write content text to stdout with appropriate coloring."""
|
|
270
|
+
if self.reasoning_active:
|
|
271
|
+
sys.stderr.write(self.colors["reasoning_reset"])
|
|
272
|
+
sys.stderr.flush()
|
|
273
|
+
self.reasoning_active = False
|
|
274
|
+
if not self.content_active:
|
|
275
|
+
sys.stdout.write(self.colors["content"])
|
|
276
|
+
self.content_active = True
|
|
277
|
+
sys.stdout.write(text)
|
|
278
|
+
sys.stdout.flush()
|
|
279
|
+
|
|
280
|
+
def close(self) -> None:
|
|
281
|
+
"""Reset colors if any were active."""
|
|
282
|
+
if self.reasoning_active:
|
|
283
|
+
sys.stderr.write(self.colors["reasoning_reset"])
|
|
284
|
+
sys.stderr.flush()
|
|
285
|
+
if self.content_active:
|
|
286
|
+
sys.stdout.write(self.colors["content_reset"])
|
|
287
|
+
sys.stdout.flush()
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""
|
|
3
|
+
Gemini CLI Client.
|
|
4
|
+
|
|
5
|
+
This script interacts with the Google GenAI API to generate content based on
|
|
6
|
+
user input or existing conversation files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any, Dict, List
|
|
11
|
+
|
|
12
|
+
from google import genai
|
|
13
|
+
from google.genai.types import (
|
|
14
|
+
Content,
|
|
15
|
+
GenerateContentConfig,
|
|
16
|
+
Part,
|
|
17
|
+
ThinkingConfig,
|
|
18
|
+
ThinkingLevel,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from common import (
|
|
22
|
+
StreamPrinter,
|
|
23
|
+
create_parser,
|
|
24
|
+
get_question,
|
|
25
|
+
load_conversation,
|
|
26
|
+
prompt_preview,
|
|
27
|
+
save_conversation_safely,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def stream_gemini_response(
|
|
32
|
+
client: genai.Client,
|
|
33
|
+
model: str,
|
|
34
|
+
contents: list[Content], # Changed from Sequence[Content]
|
|
35
|
+
config: GenerateContentConfig,
|
|
36
|
+
) -> str:
|
|
37
|
+
"""
|
|
38
|
+
Stream the response from the Gemini API, printing reasoning to stderr
|
|
39
|
+
and content to stdout. Returns the full assistant content.
|
|
40
|
+
"""
|
|
41
|
+
printer = StreamPrinter()
|
|
42
|
+
assistant_parts = []
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
stream = client.models.generate_content_stream(
|
|
46
|
+
contents=contents, # type: ignore[arg-type]
|
|
47
|
+
model=model,
|
|
48
|
+
config=config,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
for chunk in stream:
|
|
52
|
+
if not chunk.candidates:
|
|
53
|
+
continue
|
|
54
|
+
for candidate in chunk.candidates:
|
|
55
|
+
if not candidate.content or not candidate.content.parts:
|
|
56
|
+
continue
|
|
57
|
+
for part in candidate.content.parts:
|
|
58
|
+
text = part.text
|
|
59
|
+
if not text:
|
|
60
|
+
continue
|
|
61
|
+
# Gemini marks reasoning with the 'thought' attribute
|
|
62
|
+
if getattr(part, "thought", False):
|
|
63
|
+
printer.write_reasoning(text)
|
|
64
|
+
else:
|
|
65
|
+
printer.write_content(text)
|
|
66
|
+
assistant_parts.append(text)
|
|
67
|
+
|
|
68
|
+
except ConnectionError as e:
|
|
69
|
+
printer.close()
|
|
70
|
+
sys.stderr.write(f"\nError during streaming: {e}\n")
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
printer.close()
|
|
74
|
+
return "".join(assistant_parts)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def main() -> None:
|
|
78
|
+
"Main function"
|
|
79
|
+
|
|
80
|
+
parser = create_parser(
|
|
81
|
+
description="Resume a file specified filename",
|
|
82
|
+
model="gemini-3.1-pro-preview",
|
|
83
|
+
)
|
|
84
|
+
args = parser.parse_args()
|
|
85
|
+
|
|
86
|
+
# Initialize Gemini client
|
|
87
|
+
client = genai.Client()
|
|
88
|
+
|
|
89
|
+
filename, messages, file_hash = load_conversation(args.conversation_file)
|
|
90
|
+
|
|
91
|
+
if args.verbose > 0:
|
|
92
|
+
sys.stderr.write(f"Model: {args.model}\n\n")
|
|
93
|
+
sys.stderr.flush()
|
|
94
|
+
|
|
95
|
+
question = get_question()
|
|
96
|
+
if not question:
|
|
97
|
+
raise ValueError("No messages to send")
|
|
98
|
+
|
|
99
|
+
sys.stderr.write("\n")
|
|
100
|
+
sys.stderr.flush()
|
|
101
|
+
|
|
102
|
+
if args.verbose > 0:
|
|
103
|
+
prompt_preview(question)
|
|
104
|
+
|
|
105
|
+
messages.append({"role": "user", "content": question})
|
|
106
|
+
|
|
107
|
+
if args.dry_run:
|
|
108
|
+
sys.exit(0)
|
|
109
|
+
|
|
110
|
+
# Build Gemini Content objects
|
|
111
|
+
contents: List[Content] = []
|
|
112
|
+
for msg in messages:
|
|
113
|
+
role_str: str = "model" if msg["role"] == "assistant" else msg["role"]
|
|
114
|
+
|
|
115
|
+
content = msg["content"]
|
|
116
|
+
if isinstance(content, str):
|
|
117
|
+
text_content = content
|
|
118
|
+
else:
|
|
119
|
+
text_content = str(content)
|
|
120
|
+
|
|
121
|
+
part = Part.from_text(text=text_content)
|
|
122
|
+
contents.append(Content(role=role_str, parts=[part]))
|
|
123
|
+
|
|
124
|
+
config_kwargs: Dict[str, Any] = {
|
|
125
|
+
"thinking_config": ThinkingConfig(
|
|
126
|
+
thinking_level=ThinkingLevel.HIGH,
|
|
127
|
+
include_thoughts=True,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
if args.max_tokens:
|
|
131
|
+
config_kwargs["max_output_tokens"] = int(args.max_tokens)
|
|
132
|
+
|
|
133
|
+
config = GenerateContentConfig(**config_kwargs)
|
|
134
|
+
|
|
135
|
+
assistant_content = stream_gemini_response(
|
|
136
|
+
client, args.model, contents, config
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
messages.append({"role": "assistant", "content": assistant_content})
|
|
140
|
+
|
|
141
|
+
sys.stderr.write("\n")
|
|
142
|
+
sys.stderr.flush()
|
|
143
|
+
|
|
144
|
+
save_conversation_safely(messages, filename, file_hash)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
main()
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: raw-llm
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The simplest way to context engineer. Minimal streaming CLI clients for Claude and Gemini.
|
|
5
|
+
Author-email: Rodolfo Villaruz <rodolfo@yes.ph>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rodolfovillaruz/simple
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/rodolfovillaruz/simple/issues
|
|
9
|
+
Project-URL: Repository, https://github.com/rodolfovillaruz/simple.git
|
|
10
|
+
Keywords: llm,claude,gemini,cli,streaming,context-engineering
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Environment :: Console
|
|
18
|
+
Classifier: Intended Audience :: Developers
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: anthropic>=0.25.0
|
|
24
|
+
Requires-Dist: google-genai>=0.3.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: isort>=5.12.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pylint>=2.17.0; extra == "dev"
|
|
29
|
+
Requires-Dist: flake8>=6.0.0; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest>=7.3.0; extra == "dev"
|
|
32
|
+
Requires-Dist: build>=0.10.0; extra == "dev"
|
|
33
|
+
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# Simple
|
|
37
|
+
|
|
38
|
+
**The simplest way to context engineer.**
|
|
39
|
+
|
|
40
|
+
Minimal, streaming CLI clients for Claude and Gemini that keep your conversations in plain JSON files.
|
|
41
|
+
|
|
42
|
+
## What is this?
|
|
43
|
+
|
|
44
|
+
Simple is a pair of thin Python scripts that talk to the Anthropic and Google GenAI APIs. No frameworks, no agents, no abstractions you don't need. Just a prompt, a streaming response, and a JSON file you can version, diff, edit, and pipe.
|
|
45
|
+
|
|
46
|
+
The entire idea: your conversation _is_ a file. You build context by editing that file. That's it. That's the context engineering.
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- **Streaming output** — responses print token-by-token as they arrive
|
|
51
|
+
- **Conversation persistence** — every exchange is saved to a plain JSON file you own
|
|
52
|
+
- **Resume any conversation** — pass the JSON file back in to continue where you left off
|
|
53
|
+
- **Pipe-friendly** — reads from stdin, writes content to stdout, writes diagnostics to stderr
|
|
54
|
+
- **Colored output** — reasoning in gray (stderr), content in cyan (stdout), auto-disabled when piped
|
|
55
|
+
- **Conflict detection** — refuses to overwrite a conversation file modified by another process
|
|
56
|
+
- **Symlink to switch models** — symlink `claude.py` as `opus` or `haiku` to change the default model
|
|
57
|
+
|
|
58
|
+
## Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone https://github.com/rodolfovillaruz/simple.git
|
|
62
|
+
cd simple
|
|
63
|
+
pip install anthropic google-genai
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Set your API keys:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
|
70
|
+
export GEMINI_API_KEY="..." # or GOOGLE_API_KEY, per google-genai docs
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Usage
|
|
74
|
+
|
|
75
|
+
### Start a new conversation
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python claude.py
|
|
79
|
+
# Type your prompt, then press Ctrl+D to submit
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
echo "Explain monads in one paragraph" | python claude.py
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
python gemini.py
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Resume an existing conversation
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
python claude.py .prompt/some-conversation.json
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The JSON file contains the full message history. Edit it with any text editor to reshape context before your next turn.
|
|
97
|
+
|
|
98
|
+
### Pipe a file as context
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
cat code.py | python claude.py conversation.json
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Switch models
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# By flag
|
|
108
|
+
python claude.py -m claude-opus-4-6
|
|
109
|
+
|
|
110
|
+
# By symlink
|
|
111
|
+
ln -s claude.py opus
|
|
112
|
+
./opus
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| Symlink name | Default model |
|
|
116
|
+
| -------------------- | ---------------------- |
|
|
117
|
+
| `claude.py` (default)| `claude-sonnet-4-6` |
|
|
118
|
+
| `claude-opus` / `opus`| `claude-opus-4-6` |
|
|
119
|
+
| `claude-haiku` / `haiku`| `claude-haiku-4-5` |
|
|
120
|
+
| `gemini.py` (default)| `gemini-3.1-pro-preview` |
|
|
121
|
+
|
|
122
|
+
### Options
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
usage: claude.py [-h] [-n] [-v] [-m MODEL] [-t MAX_TOKENS] [-i] [conversation_file]
|
|
126
|
+
|
|
127
|
+
positional arguments:
|
|
128
|
+
conversation_file JSON file to resume (omit to start fresh)
|
|
129
|
+
|
|
130
|
+
options:
|
|
131
|
+
-n, --dry-run Build the prompt but don't send it
|
|
132
|
+
-v, --verbose Show model name and prompt preview
|
|
133
|
+
-m, --model MODEL Override the default model
|
|
134
|
+
-t, --max-tokens TOKENS Cap the response length
|
|
135
|
+
-i, --interactive Interactive REPL mode
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Conversation format
|
|
139
|
+
|
|
140
|
+
Conversations are stored as a JSON array of message objects, the same shape both APIs understand:
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
[
|
|
144
|
+
{
|
|
145
|
+
"role": "user",
|
|
146
|
+
"content": "What is context engineering?"
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
"role": "assistant",
|
|
150
|
+
"content": "Context engineering is the practice of ..."
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
You can create these files by hand, merge them, truncate them, or generate them with other tools. Simple doesn't care. It reads the array, appends your new message, streams the response, and appends that too.
|
|
156
|
+
|
|
157
|
+
## Project structure
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
.
|
|
161
|
+
├── claude.py # Claude CLI client
|
|
162
|
+
├── gemini.py # Gemini CLI client
|
|
163
|
+
├── common.py # Shared utilities (streaming, I/O, conversation management)
|
|
164
|
+
├── Makefile # Formatting, linting, typing
|
|
165
|
+
└── .prompt/ # Default directory for conversation files (auto-used if present)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Development
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
make fmt # Format with black/isort
|
|
172
|
+
make lint # Lint with pylint/flake8
|
|
173
|
+
make type # Type-check with mypy
|
|
174
|
+
make all # All of the above
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Why?
|
|
178
|
+
|
|
179
|
+
Most LLM tools add layers between you and the model. Simple removes them. The conversation is a file. The prompt is stdin. The response is stdout. Everything else is up to you.
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
Makefile
|
|
4
|
+
README.md
|
|
5
|
+
pyproject.toml
|
|
6
|
+
src/raw_llm/__init__.py
|
|
7
|
+
src/raw_llm/claude.py
|
|
8
|
+
src/raw_llm/common.py
|
|
9
|
+
src/raw_llm/gemini.py
|
|
10
|
+
src/raw_llm.egg-info/PKG-INFO
|
|
11
|
+
src/raw_llm.egg-info/SOURCES.txt
|
|
12
|
+
src/raw_llm.egg-info/dependency_links.txt
|
|
13
|
+
src/raw_llm.egg-info/entry_points.txt
|
|
14
|
+
src/raw_llm.egg-info/requires.txt
|
|
15
|
+
src/raw_llm.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
raw_llm
|