shell-prompter 0.2.0__tar.gz → 0.6.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.
- {shell_prompter-0.2.0/shell_prompter.egg-info → shell_prompter-0.6.0}/PKG-INFO +25 -6
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/README.md +24 -5
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/__init__.py +2 -1
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/agent.py +57 -8
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/cli.py +42 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/colors.py +2 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/completion.py +7 -1
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/config.py +53 -0
- shell_prompter-0.6.0/prompter/cost.py +31 -0
- shell_prompter-0.6.0/prompter/history.py +65 -0
- shell_prompter-0.6.0/prompter/markdown.py +383 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/prompts.py +24 -2
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/providers/anthropic_provider.py +48 -1
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/providers/base.py +71 -7
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/providers/gemini_provider.py +80 -20
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/providers/openai_provider.py +31 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/risk.py +17 -7
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/ui.py +42 -7
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/pyproject.toml +1 -1
- {shell_prompter-0.2.0 → shell_prompter-0.6.0/shell_prompter.egg-info}/PKG-INFO +25 -6
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/shell_prompter.egg-info/SOURCES.txt +7 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_agent.py +107 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_cli.py +65 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_config.py +23 -0
- shell_prompter-0.6.0/tests/test_cost.py +22 -0
- shell_prompter-0.6.0/tests/test_history.py +92 -0
- shell_prompter-0.6.0/tests/test_markdown.py +125 -0
- shell_prompter-0.6.0/tests/test_prompts.py +22 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_providers.py +188 -5
- shell_prompter-0.6.0/tests/test_risk.py +85 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_ui.py +42 -0
- shell_prompter-0.2.0/tests/test_risk.py +0 -49
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/LICENSE +0 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/__main__.py +0 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/constants.py +0 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/keys.py +0 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/providers/__init__.py +0 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/shell.py +0 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/setup.cfg +0 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/shell_prompter.egg-info/dependency_links.txt +0 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/shell_prompter.egg-info/entry_points.txt +0 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/shell_prompter.egg-info/requires.txt +0 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/shell_prompter.egg-info/top_level.txt +0 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_completion.py +0 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_keys.py +0 -0
- {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_shell.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: shell-prompter
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: A natural-language shell agent powered by Claude, OpenAI, or Gemini.
|
|
5
5
|
Author-email: radinkv <26397342+Radinkv@users.noreply.github.com>
|
|
6
6
|
License: MIT
|
|
@@ -38,6 +38,8 @@ endpoint such as Groq or OpenRouter), or Gemini.
|
|
|
38
38
|
```
|
|
39
39
|
$ prompter "make a folder called scratch, cd into it, then run claude"
|
|
40
40
|
$ prompter "download uv if it isn't installed, then print its version"
|
|
41
|
+
$ prompter "write a C++ linked list, compile it with clang++, and run it"
|
|
42
|
+
$ prompter "make a venv in ~/Documents/ml, install scikit-learn, open it in VS Code"
|
|
41
43
|
$ prompter
|
|
42
44
|
```
|
|
43
45
|
|
|
@@ -110,6 +112,10 @@ On first run prompter writes `~/.prompter/config.json`:
|
|
|
110
112
|
| `api_key_env` | The environment variable that holds the API key. Defaults to `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GEMINI_API_KEY`. |
|
|
111
113
|
| `max_fix_attempts` | How many commands may fail in a row before prompter stops. See [Self-repair](#self-repair). |
|
|
112
114
|
| `auto_approve_safe` | Set to `false` to confirm every command, including safe ones. |
|
|
115
|
+
| `concise` | Set to `true` to make the model write code and scripts with no comments and skip the closing explanation. Saves tokens. Off by default; override per run with `--concise` / `--verbose`. |
|
|
116
|
+
| `show_usage` | Set to `true` to print token usage after each run. Off by default; enable per run with `--usage`. |
|
|
117
|
+
| `render_markdown` | Render the model's markdown (bold, headings, lists, code) as styled terminal text. On by default; disable per run with `--raw`. Always off when output isn't a TTY, so pipes stay plain. |
|
|
118
|
+
| `pricing` | Optional per-model price map for a cost estimate, e.g. `{"claude-sonnet-4-6": {"input": 3.0, "output": 15.0}}` (USD per million tokens). Without it the usage line shows tokens only. |
|
|
113
119
|
| `preferences` | Free-form lines passed straight to the model, such as `"Use pnpm, not npm."` |
|
|
114
120
|
|
|
115
121
|
Run `prompter config` to print the path, or `prompter status` to see the
|
|
@@ -174,9 +180,9 @@ decides whether it runs on its own or asks first.
|
|
|
174
180
|
|
|
175
181
|
| Tier | Examples | Default |
|
|
176
182
|
|------|----------|---------|
|
|
177
|
-
| 🟢 SAFE | `ls`, `cd`, `mkdir`, `git status`, `cat` | runs automatically |
|
|
178
|
-
| 🟡 CONFIRM | `brew install`, `pip install`, `git clone`, `mv`, `rm`, `curl` | asks first |
|
|
179
|
-
| 🔴 DANGER | `rm -rf`, `sudo`, `curl ... \| sh`, force-push, `dd` | asks first, shown in red |
|
|
183
|
+
| 🟢 SAFE | `ls`, `cd`, `mkdir`, `git status`, `cat`, `... 2>/dev/null` | runs automatically |
|
|
184
|
+
| 🟡 CONFIRM | `brew install`, `pip install`, `git clone`, `mv`, `rm`, `curl`, `git clean -f` | asks first |
|
|
185
|
+
| 🔴 DANGER | `rm -rf`, `sudo`/`su`/`pkexec`, `curl ... \| sh`, force-push, `dd`, `shutdown`, `shred` | asks first, shown in red |
|
|
180
186
|
|
|
181
187
|
When prompter asks, you answer:
|
|
182
188
|
|
|
@@ -185,8 +191,10 @@ When prompter asks, you answer:
|
|
|
185
191
|
- `a`: run it and auto-approve the rest of this run
|
|
186
192
|
- `q`: quit
|
|
187
193
|
|
|
188
|
-
|
|
189
|
-
|
|
194
|
+
Piping a download into an interpreter (`curl ... | sh`, and likewise `bash`,
|
|
195
|
+
`python`, `perl`, `ruby`, `node`) is always DANGER, because it runs code you
|
|
196
|
+
have not seen. `--yolo` removes the gate entirely. Use it only when you trust
|
|
197
|
+
the task.
|
|
190
198
|
|
|
191
199
|
## Self-repair
|
|
192
200
|
|
|
@@ -199,6 +207,12 @@ commands that fail in a row. At the limit, prompter tells the model to stop and
|
|
|
199
207
|
explain what went wrong. A command you decline does not count. Only commands
|
|
200
208
|
that ran and failed do.
|
|
201
209
|
|
|
210
|
+
This is separate from network resilience. When a *model request* fails
|
|
211
|
+
transiently (a rate limit, timeout, or 5xx), prompter retries it automatically
|
|
212
|
+
with exponential backoff before giving up; a bad request or auth failure is not
|
|
213
|
+
retried. A retry only happens before any of the turn's response has streamed, so
|
|
214
|
+
output is never printed twice.
|
|
215
|
+
|
|
202
216
|
## How it works
|
|
203
217
|
|
|
204
218
|
1. Your request, plus your OS, shell, current directory, default workspace, and
|
|
@@ -234,6 +248,7 @@ opens a prompt.
|
|
|
234
248
|
| `prompter status` | Show the current provider, model, workspace, and keys. |
|
|
235
249
|
| `prompter config` | Print the config file path. |
|
|
236
250
|
| `prompter completions <shell>` | Print a tab-completion script (zsh, bash). See [Tab completion](#tab-completion). |
|
|
251
|
+
| `prompter version` | Print the installed version (also `--version`, `-V`). |
|
|
237
252
|
| `prompter help` | Show usage. |
|
|
238
253
|
|
|
239
254
|
## Tab completion
|
|
@@ -281,6 +296,10 @@ Flags modify a single run and sit alongside the goal (`prompter --yolo "..."`).
|
|
|
281
296
|
| `--max-fix N` | Override `max_fix_attempts`. |
|
|
282
297
|
| `--ask-all` | Confirm every command, including safe ones. |
|
|
283
298
|
| `--yolo` | Run everything with no confirmation. Dangerous. |
|
|
299
|
+
| `--concise` | No code comments, minimal explanation, for this run. |
|
|
300
|
+
| `--verbose` | Full comments and explanation, even if `concise` is set in config. |
|
|
301
|
+
| `--usage` | Print token usage (and cost, if `pricing` is set) after the run. |
|
|
302
|
+
| `--raw` | Print the model's text as-is, without rendering markdown. |
|
|
284
303
|
|
|
285
304
|
## Project layout
|
|
286
305
|
|
|
@@ -11,6 +11,8 @@ endpoint such as Groq or OpenRouter), or Gemini.
|
|
|
11
11
|
```
|
|
12
12
|
$ prompter "make a folder called scratch, cd into it, then run claude"
|
|
13
13
|
$ prompter "download uv if it isn't installed, then print its version"
|
|
14
|
+
$ prompter "write a C++ linked list, compile it with clang++, and run it"
|
|
15
|
+
$ prompter "make a venv in ~/Documents/ml, install scikit-learn, open it in VS Code"
|
|
14
16
|
$ prompter
|
|
15
17
|
```
|
|
16
18
|
|
|
@@ -83,6 +85,10 @@ On first run prompter writes `~/.prompter/config.json`:
|
|
|
83
85
|
| `api_key_env` | The environment variable that holds the API key. Defaults to `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, or `GEMINI_API_KEY`. |
|
|
84
86
|
| `max_fix_attempts` | How many commands may fail in a row before prompter stops. See [Self-repair](#self-repair). |
|
|
85
87
|
| `auto_approve_safe` | Set to `false` to confirm every command, including safe ones. |
|
|
88
|
+
| `concise` | Set to `true` to make the model write code and scripts with no comments and skip the closing explanation. Saves tokens. Off by default; override per run with `--concise` / `--verbose`. |
|
|
89
|
+
| `show_usage` | Set to `true` to print token usage after each run. Off by default; enable per run with `--usage`. |
|
|
90
|
+
| `render_markdown` | Render the model's markdown (bold, headings, lists, code) as styled terminal text. On by default; disable per run with `--raw`. Always off when output isn't a TTY, so pipes stay plain. |
|
|
91
|
+
| `pricing` | Optional per-model price map for a cost estimate, e.g. `{"claude-sonnet-4-6": {"input": 3.0, "output": 15.0}}` (USD per million tokens). Without it the usage line shows tokens only. |
|
|
86
92
|
| `preferences` | Free-form lines passed straight to the model, such as `"Use pnpm, not npm."` |
|
|
87
93
|
|
|
88
94
|
Run `prompter config` to print the path, or `prompter status` to see the
|
|
@@ -147,9 +153,9 @@ decides whether it runs on its own or asks first.
|
|
|
147
153
|
|
|
148
154
|
| Tier | Examples | Default |
|
|
149
155
|
|------|----------|---------|
|
|
150
|
-
| 🟢 SAFE | `ls`, `cd`, `mkdir`, `git status`, `cat` | runs automatically |
|
|
151
|
-
| 🟡 CONFIRM | `brew install`, `pip install`, `git clone`, `mv`, `rm`, `curl` | asks first |
|
|
152
|
-
| 🔴 DANGER | `rm -rf`, `sudo`, `curl ... \| sh`, force-push, `dd` | asks first, shown in red |
|
|
156
|
+
| 🟢 SAFE | `ls`, `cd`, `mkdir`, `git status`, `cat`, `... 2>/dev/null` | runs automatically |
|
|
157
|
+
| 🟡 CONFIRM | `brew install`, `pip install`, `git clone`, `mv`, `rm`, `curl`, `git clean -f` | asks first |
|
|
158
|
+
| 🔴 DANGER | `rm -rf`, `sudo`/`su`/`pkexec`, `curl ... \| sh`, force-push, `dd`, `shutdown`, `shred` | asks first, shown in red |
|
|
153
159
|
|
|
154
160
|
When prompter asks, you answer:
|
|
155
161
|
|
|
@@ -158,8 +164,10 @@ When prompter asks, you answer:
|
|
|
158
164
|
- `a`: run it and auto-approve the rest of this run
|
|
159
165
|
- `q`: quit
|
|
160
166
|
|
|
161
|
-
|
|
162
|
-
|
|
167
|
+
Piping a download into an interpreter (`curl ... | sh`, and likewise `bash`,
|
|
168
|
+
`python`, `perl`, `ruby`, `node`) is always DANGER, because it runs code you
|
|
169
|
+
have not seen. `--yolo` removes the gate entirely. Use it only when you trust
|
|
170
|
+
the task.
|
|
163
171
|
|
|
164
172
|
## Self-repair
|
|
165
173
|
|
|
@@ -172,6 +180,12 @@ commands that fail in a row. At the limit, prompter tells the model to stop and
|
|
|
172
180
|
explain what went wrong. A command you decline does not count. Only commands
|
|
173
181
|
that ran and failed do.
|
|
174
182
|
|
|
183
|
+
This is separate from network resilience. When a *model request* fails
|
|
184
|
+
transiently (a rate limit, timeout, or 5xx), prompter retries it automatically
|
|
185
|
+
with exponential backoff before giving up; a bad request or auth failure is not
|
|
186
|
+
retried. A retry only happens before any of the turn's response has streamed, so
|
|
187
|
+
output is never printed twice.
|
|
188
|
+
|
|
175
189
|
## How it works
|
|
176
190
|
|
|
177
191
|
1. Your request, plus your OS, shell, current directory, default workspace, and
|
|
@@ -207,6 +221,7 @@ opens a prompt.
|
|
|
207
221
|
| `prompter status` | Show the current provider, model, workspace, and keys. |
|
|
208
222
|
| `prompter config` | Print the config file path. |
|
|
209
223
|
| `prompter completions <shell>` | Print a tab-completion script (zsh, bash). See [Tab completion](#tab-completion). |
|
|
224
|
+
| `prompter version` | Print the installed version (also `--version`, `-V`). |
|
|
210
225
|
| `prompter help` | Show usage. |
|
|
211
226
|
|
|
212
227
|
## Tab completion
|
|
@@ -254,6 +269,10 @@ Flags modify a single run and sit alongside the goal (`prompter --yolo "..."`).
|
|
|
254
269
|
| `--max-fix N` | Override `max_fix_attempts`. |
|
|
255
270
|
| `--ask-all` | Confirm every command, including safe ones. |
|
|
256
271
|
| `--yolo` | Run everything with no confirmation. Dangerous. |
|
|
272
|
+
| `--concise` | No code comments, minimal explanation, for this run. |
|
|
273
|
+
| `--verbose` | Full comments and explanation, even if `concise` is set in config. |
|
|
274
|
+
| `--usage` | Print token usage (and cost, if `pricing` is set) after the run. |
|
|
275
|
+
| `--raw` | Print the model's text as-is, without rendering markdown. |
|
|
257
276
|
|
|
258
277
|
## Project layout
|
|
259
278
|
|
|
@@ -8,17 +8,22 @@ changes nothing here.
|
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import time
|
|
11
12
|
from dataclasses import dataclass
|
|
12
13
|
|
|
13
14
|
from .config import ApprovalMode, Config
|
|
15
|
+
from .cost import estimate as estimate_cost
|
|
16
|
+
from .history import compact
|
|
14
17
|
from .prompts import build_system_texts
|
|
15
18
|
from .providers.base import (
|
|
16
19
|
AssistantMessage,
|
|
17
20
|
AssistantTurn,
|
|
18
21
|
ModelProvider,
|
|
22
|
+
ProviderError,
|
|
19
23
|
ToolInvocation,
|
|
20
24
|
ToolResult,
|
|
21
25
|
ToolResultsMessage,
|
|
26
|
+
Usage,
|
|
22
27
|
UserMessage,
|
|
23
28
|
)
|
|
24
29
|
from .risk import RiskAssessment, RiskTier, classify
|
|
@@ -41,6 +46,9 @@ _PAYLOAD_TEMPLATE = (
|
|
|
41
46
|
"stderr:\n{stderr}"
|
|
42
47
|
)
|
|
43
48
|
|
|
49
|
+
MAX_RETRIES = 3
|
|
50
|
+
_RETRY_BASE_DELAY_SECONDS = 2
|
|
51
|
+
|
|
44
52
|
|
|
45
53
|
class QuitRequested(Exception):
|
|
46
54
|
"""Raised when the user chooses 'quit' at a confirmation prompt."""
|
|
@@ -102,14 +110,25 @@ class Agent:
|
|
|
102
110
|
self._approve_all = False
|
|
103
111
|
self.consecutive_failures = 0
|
|
104
112
|
self._force_stop = False
|
|
113
|
+
self._run_usage = Usage()
|
|
105
114
|
|
|
106
115
|
def run_turn(self, user_text: str) -> None:
|
|
107
116
|
self.consecutive_failures = 0
|
|
108
117
|
self._force_stop = False
|
|
118
|
+
self._run_usage = Usage()
|
|
109
119
|
self.conversation.add_user(user_text)
|
|
110
120
|
another_round = True
|
|
111
121
|
while another_round:
|
|
112
122
|
another_round = self._run_round()
|
|
123
|
+
self._report_usage()
|
|
124
|
+
|
|
125
|
+
def _report_usage(self) -> None:
|
|
126
|
+
if not self.config.show_usage:
|
|
127
|
+
return
|
|
128
|
+
usage = self._run_usage
|
|
129
|
+
cost = estimate_cost(self.config.resolved_model, usage, self.config.pricing)
|
|
130
|
+
self.console.usage(
|
|
131
|
+
usage.input_tokens, usage.output_tokens, usage.cached_tokens, cost)
|
|
113
132
|
|
|
114
133
|
def _run_round(self) -> bool:
|
|
115
134
|
"""Run one model turn and return whether another round is needed."""
|
|
@@ -124,16 +143,46 @@ class Agent:
|
|
|
124
143
|
|
|
125
144
|
def _complete(self) -> AssistantTurn:
|
|
126
145
|
system_texts = build_system_texts(self.config, self.shell.cwd)
|
|
127
|
-
self.
|
|
128
|
-
turn = self.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
self._force_stop,
|
|
132
|
-
self.console.stream_text,
|
|
133
|
-
)
|
|
134
|
-
self.console.end_stream()
|
|
146
|
+
history = compact(self.conversation.history)
|
|
147
|
+
turn = self._complete_with_retry(history, system_texts)
|
|
148
|
+
if turn.usage is not None:
|
|
149
|
+
self._run_usage = self._run_usage + turn.usage
|
|
135
150
|
return turn
|
|
136
151
|
|
|
152
|
+
def _complete_with_retry(self, history, system_texts) -> AssistantTurn:
|
|
153
|
+
"""Call the provider, retrying transient failures with backoff.
|
|
154
|
+
|
|
155
|
+
A retry replays the whole turn, so it is only safe before any text has
|
|
156
|
+
streamed to the console; once output has started, a transient failure
|
|
157
|
+
propagates rather than risk printing the turn twice.
|
|
158
|
+
"""
|
|
159
|
+
streamed = [False]
|
|
160
|
+
|
|
161
|
+
def on_text(text: str) -> None:
|
|
162
|
+
streamed[0] = True
|
|
163
|
+
self.console.stream_text(text)
|
|
164
|
+
|
|
165
|
+
attempt = 0
|
|
166
|
+
while True:
|
|
167
|
+
streamed[0] = False
|
|
168
|
+
self.console.begin_stream()
|
|
169
|
+
try:
|
|
170
|
+
turn = self.provider.complete(
|
|
171
|
+
history, system_texts, self._force_stop, on_text)
|
|
172
|
+
self.console.end_stream()
|
|
173
|
+
return turn
|
|
174
|
+
except ProviderError as e:
|
|
175
|
+
self.console.end_stream()
|
|
176
|
+
if not self._may_retry(e, attempt, streamed[0]):
|
|
177
|
+
raise
|
|
178
|
+
delay = _RETRY_BASE_DELAY_SECONDS * (2 ** attempt)
|
|
179
|
+
self.console.retry_notice(str(e), attempt + 1, MAX_RETRIES, delay)
|
|
180
|
+
time.sleep(delay)
|
|
181
|
+
attempt += 1
|
|
182
|
+
|
|
183
|
+
def _may_retry(self, error: ProviderError, attempt: int, streamed: bool) -> bool:
|
|
184
|
+
return error.retryable and attempt < MAX_RETRIES and not streamed
|
|
185
|
+
|
|
137
186
|
def _process_tool_calls(self, tool_calls: list[ToolInvocation]) -> list[ToolResult]:
|
|
138
187
|
results = []
|
|
139
188
|
for invocation in tool_calls:
|
|
@@ -24,7 +24,9 @@ from .config import (
|
|
|
24
24
|
default_model_for,
|
|
25
25
|
load_config,
|
|
26
26
|
normalize_provider,
|
|
27
|
+
program_version,
|
|
27
28
|
save_config,
|
|
29
|
+
source_version,
|
|
28
30
|
)
|
|
29
31
|
from .completion import (
|
|
30
32
|
CommandSurface,
|
|
@@ -54,7 +56,14 @@ _CMD_CONFIG = "config"
|
|
|
54
56
|
_CMD_RUN = "run"
|
|
55
57
|
_CMD_HELP = "help"
|
|
56
58
|
_CMD_COMPLETIONS = "completions"
|
|
59
|
+
_CMD_VERSION = "version"
|
|
57
60
|
_HELP_FLAGS = ("-h", "--help")
|
|
61
|
+
_VERSION_FLAGS = ("-V", "--version")
|
|
62
|
+
_VERSION_LINE = "{program} {version}"
|
|
63
|
+
_VERSION_DRIFT = (
|
|
64
|
+
"note: pyproject.toml declares {source}, but the installed package is "
|
|
65
|
+
"{installed}. Run `pip install -e .` to refresh."
|
|
66
|
+
)
|
|
58
67
|
|
|
59
68
|
_KEYS_LIST = "list"
|
|
60
69
|
_KEYS_ADD = "add"
|
|
@@ -71,6 +80,10 @@ _FLAG_WORKSPACE = "--workspace"
|
|
|
71
80
|
_FLAG_MAX_FIX = "--max-fix"
|
|
72
81
|
_FLAG_ASK_ALL = "--ask-all"
|
|
73
82
|
_FLAG_YOLO = "--yolo"
|
|
83
|
+
_FLAG_CONCISE = "--concise"
|
|
84
|
+
_FLAG_VERBOSE = "--verbose"
|
|
85
|
+
_FLAG_USAGE = "--usage"
|
|
86
|
+
_FLAG_RAW = "--raw"
|
|
74
87
|
_ACTION_STORE_TRUE = "store_true"
|
|
75
88
|
|
|
76
89
|
_LABEL_ADD_IT = "add it"
|
|
@@ -156,6 +169,10 @@ Run flags (modify one run):
|
|
|
156
169
|
--max-fix N failures in a row before stopping
|
|
157
170
|
--ask-all confirm every command
|
|
158
171
|
--yolo run everything with no confirmation
|
|
172
|
+
--concise no code comments, minimal explanation
|
|
173
|
+
--verbose full comments and explanation (overrides --concise/config)
|
|
174
|
+
--usage print token usage (and cost if priced) per run
|
|
175
|
+
--raw don't render markdown; print the model's text as-is
|
|
159
176
|
|
|
160
177
|
Manage:
|
|
161
178
|
prompter keys add <provider> <key> store an API key
|
|
@@ -165,6 +182,7 @@ Manage:
|
|
|
165
182
|
prompter status show the current setup
|
|
166
183
|
prompter config print the config file path
|
|
167
184
|
prompter completions <shell> print a tab-completion script (zsh, bash)
|
|
185
|
+
prompter version print the installed version
|
|
168
186
|
prompter help show this help
|
|
169
187
|
|
|
170
188
|
Providers: anthropic, openai, gemini
|
|
@@ -251,6 +269,10 @@ def _run_parser() -> argparse.ArgumentParser:
|
|
|
251
269
|
parser.add_argument(_FLAG_MAX_FIX, type=int, default=None)
|
|
252
270
|
parser.add_argument(_FLAG_ASK_ALL, action=_ACTION_STORE_TRUE)
|
|
253
271
|
parser.add_argument(_FLAG_YOLO, action=_ACTION_STORE_TRUE)
|
|
272
|
+
parser.add_argument(_FLAG_CONCISE, action=_ACTION_STORE_TRUE)
|
|
273
|
+
parser.add_argument(_FLAG_VERBOSE, action=_ACTION_STORE_TRUE)
|
|
274
|
+
parser.add_argument(_FLAG_USAGE, action=_ACTION_STORE_TRUE)
|
|
275
|
+
parser.add_argument(_FLAG_RAW, action=_ACTION_STORE_TRUE)
|
|
254
276
|
return parser
|
|
255
277
|
|
|
256
278
|
|
|
@@ -278,6 +300,14 @@ def _apply_overrides(args, config: Config) -> None:
|
|
|
278
300
|
config.default_workspace = args.workspace
|
|
279
301
|
if args.max_fix is not None:
|
|
280
302
|
config.max_fix_attempts = args.max_fix
|
|
303
|
+
if args.concise:
|
|
304
|
+
config.concise = True
|
|
305
|
+
if args.verbose: # an explicit --verbose wins over --concise and config
|
|
306
|
+
config.concise = False
|
|
307
|
+
if args.usage:
|
|
308
|
+
config.show_usage = True
|
|
309
|
+
if args.raw:
|
|
310
|
+
config.render_markdown = False
|
|
281
311
|
pinned = config.model if config_provider == config.provider else EMPTY
|
|
282
312
|
config.model = args.model or pinned or default_model_for(config.provider)
|
|
283
313
|
|
|
@@ -357,6 +387,7 @@ def cmd_run(argv: list[str], console: Console) -> int:
|
|
|
357
387
|
agent, config, mode = _prepare(args, console)
|
|
358
388
|
except _CommandError as e:
|
|
359
389
|
return _fail(console, e.title, e.hints, e.detail)
|
|
390
|
+
console.set_markdown(config.render_markdown)
|
|
360
391
|
console.banner(config, mode)
|
|
361
392
|
return _run_agent(agent, config, console, SPACE.join(args.prompt).strip())
|
|
362
393
|
|
|
@@ -458,6 +489,15 @@ def cmd_help(console: Console) -> int:
|
|
|
458
489
|
return OK_EXIT_CODE
|
|
459
490
|
|
|
460
491
|
|
|
492
|
+
def cmd_version(console: Console) -> int:
|
|
493
|
+
installed = program_version()
|
|
494
|
+
console.info(_VERSION_LINE.format(program=PROGRAM_NAME, version=installed))
|
|
495
|
+
source = source_version()
|
|
496
|
+
if source is not None and source != installed:
|
|
497
|
+
console.note(_VERSION_DRIFT.format(source=source, installed=installed))
|
|
498
|
+
return OK_EXIT_CODE
|
|
499
|
+
|
|
500
|
+
|
|
461
501
|
_COMMANDS = {
|
|
462
502
|
_CMD_KEYS: cmd_keys,
|
|
463
503
|
_CMD_USE: cmd_use,
|
|
@@ -499,6 +539,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
499
539
|
head = argv[0]
|
|
500
540
|
if head in _HELP_FLAGS or head == _CMD_HELP:
|
|
501
541
|
return cmd_help(console)
|
|
542
|
+
if head in _VERSION_FLAGS or head == _CMD_VERSION:
|
|
543
|
+
return cmd_version(console)
|
|
502
544
|
if head == _CMD_RUN:
|
|
503
545
|
return cmd_run(argv[1:], console)
|
|
504
546
|
handler = _COMMANDS.get(head)
|
|
@@ -12,6 +12,7 @@ ENV_NO_COLOR = "NO_COLOR"
|
|
|
12
12
|
_RESET = "\033[0m"
|
|
13
13
|
_BOLD = "\033[1m"
|
|
14
14
|
_DIM = "\033[2m"
|
|
15
|
+
_ITALIC = "\033[3m"
|
|
15
16
|
_RED = "\033[31m"
|
|
16
17
|
_GREEN = "\033[32m"
|
|
17
18
|
_YELLOW = "\033[33m"
|
|
@@ -36,6 +37,7 @@ class Palette:
|
|
|
36
37
|
self.RESET = pick(_RESET)
|
|
37
38
|
self.BOLD = pick(_BOLD)
|
|
38
39
|
self.DIM = pick(_DIM)
|
|
40
|
+
self.ITALIC = pick(_ITALIC)
|
|
39
41
|
self.RED = pick(_RED)
|
|
40
42
|
self.GREEN = pick(_GREEN)
|
|
41
43
|
self.YELLOW = pick(_YELLOW)
|
|
@@ -182,7 +182,13 @@ def _value_flag_cases(shell: str, surface: CommandSurface) -> str:
|
|
|
182
182
|
|
|
183
183
|
|
|
184
184
|
def render(shell: str, surface: CommandSurface) -> str:
|
|
185
|
-
"""Return the completion script for ``shell`` (one of SUPPORTED_SHELLS).
|
|
185
|
+
"""Return the completion script for ``shell`` (one of SUPPORTED_SHELLS).
|
|
186
|
+
|
|
187
|
+
The surface's commands, flags, and provider names are interpolated into the
|
|
188
|
+
script unquoted. They are code-defined identifiers (dispatch keys, argparse
|
|
189
|
+
flag strings, registry names), never external input, so they are assumed
|
|
190
|
+
shell-safe -- keep them that way (plain ``[\\w-]`` tokens) if you add any.
|
|
191
|
+
"""
|
|
186
192
|
template = _ZSH_TEMPLATE if shell == ZSH else _BASH_TEMPLATE
|
|
187
193
|
return template.format(
|
|
188
194
|
program=PROGRAM_NAME,
|
|
@@ -13,14 +13,63 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import json
|
|
15
15
|
import os
|
|
16
|
+
import re
|
|
16
17
|
import sys
|
|
17
18
|
from dataclasses import asdict, dataclass, field, fields
|
|
18
19
|
from enum import Enum
|
|
20
|
+
from importlib.metadata import PackageNotFoundError, version as _distribution_version
|
|
21
|
+
from pathlib import Path
|
|
19
22
|
|
|
20
23
|
from .colors import palette
|
|
21
24
|
from .constants import EMPTY, FILE_WRITE_MODE, JSON_INDENT
|
|
22
25
|
|
|
23
26
|
PROGRAM_NAME = "prompter"
|
|
27
|
+
DISTRIBUTION_NAME = "shell-prompter"
|
|
28
|
+
_UNKNOWN_VERSION = "0.0.0+unknown"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def program_version() -> str:
|
|
32
|
+
"""The installed package version, read from distribution metadata.
|
|
33
|
+
|
|
34
|
+
Single source of truth is pyproject.toml; the metadata carries it into the
|
|
35
|
+
built package. Falls back when running from a tree that was never installed.
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
return _distribution_version(DISTRIBUTION_NAME)
|
|
39
|
+
except PackageNotFoundError:
|
|
40
|
+
return _UNKNOWN_VERSION
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_PYPROJECT_NAME = "pyproject.toml"
|
|
44
|
+
_PROJECT_HEADER_RE = re.compile(r"^\[project\]\s*$", re.MULTILINE)
|
|
45
|
+
_TABLE_HEADER_RE = re.compile(r"^\[", re.MULTILINE)
|
|
46
|
+
_PYPROJECT_VERSION_RE = re.compile(r'^version\s*=\s*["\']([^"\']+)["\']', re.MULTILINE)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _version_from_pyproject(text: str) -> str | None:
|
|
50
|
+
section = _PROJECT_HEADER_RE.search(text)
|
|
51
|
+
if section is None:
|
|
52
|
+
return None
|
|
53
|
+
next_header = _TABLE_HEADER_RE.search(text, section.end())
|
|
54
|
+
project_body = text[section.end():next_header.start()] if next_header else text[section.end():]
|
|
55
|
+
match = _PYPROJECT_VERSION_RE.search(project_body)
|
|
56
|
+
return match.group(1) if match else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def source_version() -> str | None:
|
|
60
|
+
"""The version declared in a sibling pyproject.toml's [project] table, or None.
|
|
61
|
+
|
|
62
|
+
Only a source checkout has pyproject.toml next to the package; an installed
|
|
63
|
+
wheel does not ship it. So a non-None result that differs from
|
|
64
|
+
program_version() means the editable install's metadata is stale -- the dev
|
|
65
|
+
bumped the version but has not reinstalled. End users never hit this path.
|
|
66
|
+
"""
|
|
67
|
+
pyproject = Path(__file__).resolve().parent.parent / _PYPROJECT_NAME
|
|
68
|
+
try:
|
|
69
|
+
text = pyproject.read_text()
|
|
70
|
+
except OSError:
|
|
71
|
+
return None
|
|
72
|
+
return _version_from_pyproject(text)
|
|
24
73
|
|
|
25
74
|
PROVIDER_ANTHROPIC = "anthropic"
|
|
26
75
|
PROVIDER_OPENAI = "openai"
|
|
@@ -88,6 +137,10 @@ class Config:
|
|
|
88
137
|
api_key_env: str | None = None
|
|
89
138
|
max_fix_attempts: int = DEFAULT_MAX_FIX_ATTEMPTS
|
|
90
139
|
auto_approve_safe: bool = True
|
|
140
|
+
concise: bool = False
|
|
141
|
+
show_usage: bool = False
|
|
142
|
+
render_markdown: bool = True
|
|
143
|
+
pricing: dict = field(default_factory=dict)
|
|
91
144
|
preferences: list[str] = field(
|
|
92
145
|
default_factory=lambda: list(DEFAULT_PREFERENCES)
|
|
93
146
|
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Estimate the dollar cost of a run from token usage and configured prices.
|
|
2
|
+
|
|
3
|
+
Prices are not hard-coded: they move, and they differ per model and provider.
|
|
4
|
+
The user supplies them in config under ``pricing``, keyed by model id, as USD
|
|
5
|
+
per million input and output tokens::
|
|
6
|
+
|
|
7
|
+
"pricing": {"claude-sonnet-4-6": {"input": 3.0, "output": 15.0}}
|
|
8
|
+
|
|
9
|
+
With no price for the active model, estimate() returns None and the usage line
|
|
10
|
+
simply shows token counts. The estimate charges the whole prompt at the input
|
|
11
|
+
rate (it does not discount the cached portion, which providers bill cheaper), so
|
|
12
|
+
it is a slight upper bound -- hence "est".
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .providers.base import Usage
|
|
18
|
+
|
|
19
|
+
PRICE_INPUT = "input"
|
|
20
|
+
PRICE_OUTPUT = "output"
|
|
21
|
+
_PER_MILLION = 1_000_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def estimate(model: str, usage: Usage, pricing: dict) -> float | None:
|
|
25
|
+
"""USD estimate for one run, or None when the model has no configured price."""
|
|
26
|
+
rates = pricing.get(model)
|
|
27
|
+
if not rates:
|
|
28
|
+
return None
|
|
29
|
+
in_rate = rates.get(PRICE_INPUT, 0) or 0
|
|
30
|
+
out_rate = rates.get(PRICE_OUTPUT, 0) or 0
|
|
31
|
+
return (usage.input_tokens * in_rate + usage.output_tokens * out_rate) / _PER_MILLION
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Context compaction: shrink the history re-sent to the model each turn.
|
|
2
|
+
|
|
3
|
+
A multi-step task resends the whole conversation on every turn, so old command
|
|
4
|
+
outputs pile up as input tokens. compact() keeps the goal, the most recent tool
|
|
5
|
+
results, and any failures verbatim, and collapses older *successful* outputs to
|
|
6
|
+
their first line (the exit-code header) with the body elided. The model
|
|
7
|
+
already reacted to the full output when the command ran; a later turn only needs
|
|
8
|
+
to know the step succeeded.
|
|
9
|
+
|
|
10
|
+
It is a pure transform applied just before a provider call. The stored
|
|
11
|
+
conversation is never mutated, so nothing is lost from the program's own record;
|
|
12
|
+
only the copy handed to the model is slimmed. Failures survive untouched because
|
|
13
|
+
self-repair reads their error text, and short outputs survive because collapsing
|
|
14
|
+
them would save nothing.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .constants import NEWLINE
|
|
20
|
+
from .providers.base import HistoryItem, ToolResult, ToolResultsMessage
|
|
21
|
+
|
|
22
|
+
# Tool-result messages kept verbatim, counting back from the latest turn.
|
|
23
|
+
DEFAULT_KEEP_RECENT = 2
|
|
24
|
+
# Successful outputs at or under this length are left alone; collapsing a short
|
|
25
|
+
# output would replace it with a marker of about the same size.
|
|
26
|
+
COLLAPSE_THRESHOLD = 240
|
|
27
|
+
_ELISION = "... [output elided to save context] ..."
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compact(history: list[HistoryItem],
|
|
31
|
+
keep_recent: int = DEFAULT_KEEP_RECENT) -> list[HistoryItem]:
|
|
32
|
+
"""Return a copy of history with old, large, successful outputs collapsed."""
|
|
33
|
+
total = sum(isinstance(item, ToolResultsMessage) for item in history)
|
|
34
|
+
seen = 0
|
|
35
|
+
out: list[HistoryItem] = []
|
|
36
|
+
for item in history:
|
|
37
|
+
if isinstance(item, ToolResultsMessage):
|
|
38
|
+
seen += 1
|
|
39
|
+
recent = seen > total - keep_recent
|
|
40
|
+
out.append(item if recent else _collapse_message(item))
|
|
41
|
+
else:
|
|
42
|
+
out.append(item)
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _collapse_message(message: ToolResultsMessage) -> ToolResultsMessage:
|
|
47
|
+
return ToolResultsMessage([_collapse_result(r) for r in message.results])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _collapse_result(result: ToolResult) -> ToolResult:
|
|
51
|
+
if result.is_error or len(result.content) <= COLLAPSE_THRESHOLD:
|
|
52
|
+
return result
|
|
53
|
+
return ToolResult(result.call_id, _shorten(result.content), result.is_error)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _shorten(content: str) -> str:
|
|
57
|
+
"""Keep the first line (the exit-code header) and elide the rest.
|
|
58
|
+
|
|
59
|
+
A single-line payload (no header/body split) has no body to elide, so cap it
|
|
60
|
+
by character budget instead of growing it with a marker.
|
|
61
|
+
"""
|
|
62
|
+
head, separator, _body = content.partition(NEWLINE)
|
|
63
|
+
if not separator:
|
|
64
|
+
return content[:COLLAPSE_THRESHOLD]
|
|
65
|
+
return head + NEWLINE + _ELISION
|