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 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.
@@ -0,0 +1,3 @@
1
+ include README.md
2
+ include LICENSE
3
+ include Makefile
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
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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,3 @@
1
+ [console_scripts]
2
+ raw-claude = raw_llm.claude:main
3
+ raw-gemini = raw_llm.gemini:main
@@ -0,0 +1,12 @@
1
+ anthropic>=0.25.0
2
+ google-genai>=0.3.0
3
+
4
+ [dev]
5
+ black>=23.0.0
6
+ isort>=5.12.0
7
+ pylint>=2.17.0
8
+ flake8>=6.0.0
9
+ mypy>=1.0.0
10
+ pytest>=7.3.0
11
+ build>=0.10.0
12
+ twine>=4.0.0
@@ -0,0 +1 @@
1
+ raw_llm