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.
Files changed (46) hide show
  1. {shell_prompter-0.2.0/shell_prompter.egg-info → shell_prompter-0.6.0}/PKG-INFO +25 -6
  2. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/README.md +24 -5
  3. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/__init__.py +2 -1
  4. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/agent.py +57 -8
  5. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/cli.py +42 -0
  6. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/colors.py +2 -0
  7. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/completion.py +7 -1
  8. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/config.py +53 -0
  9. shell_prompter-0.6.0/prompter/cost.py +31 -0
  10. shell_prompter-0.6.0/prompter/history.py +65 -0
  11. shell_prompter-0.6.0/prompter/markdown.py +383 -0
  12. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/prompts.py +24 -2
  13. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/providers/anthropic_provider.py +48 -1
  14. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/providers/base.py +71 -7
  15. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/providers/gemini_provider.py +80 -20
  16. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/providers/openai_provider.py +31 -0
  17. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/risk.py +17 -7
  18. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/ui.py +42 -7
  19. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/pyproject.toml +1 -1
  20. {shell_prompter-0.2.0 → shell_prompter-0.6.0/shell_prompter.egg-info}/PKG-INFO +25 -6
  21. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/shell_prompter.egg-info/SOURCES.txt +7 -0
  22. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_agent.py +107 -0
  23. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_cli.py +65 -0
  24. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_config.py +23 -0
  25. shell_prompter-0.6.0/tests/test_cost.py +22 -0
  26. shell_prompter-0.6.0/tests/test_history.py +92 -0
  27. shell_prompter-0.6.0/tests/test_markdown.py +125 -0
  28. shell_prompter-0.6.0/tests/test_prompts.py +22 -0
  29. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_providers.py +188 -5
  30. shell_prompter-0.6.0/tests/test_risk.py +85 -0
  31. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_ui.py +42 -0
  32. shell_prompter-0.2.0/tests/test_risk.py +0 -49
  33. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/LICENSE +0 -0
  34. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/__main__.py +0 -0
  35. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/constants.py +0 -0
  36. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/keys.py +0 -0
  37. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/providers/__init__.py +0 -0
  38. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/prompter/shell.py +0 -0
  39. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/setup.cfg +0 -0
  40. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/shell_prompter.egg-info/dependency_links.txt +0 -0
  41. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/shell_prompter.egg-info/entry_points.txt +0 -0
  42. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/shell_prompter.egg-info/requires.txt +0 -0
  43. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/shell_prompter.egg-info/top_level.txt +0 -0
  44. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_completion.py +0 -0
  45. {shell_prompter-0.2.0 → shell_prompter-0.6.0}/tests/test_keys.py +0 -0
  46. {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.2.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
- `curl ... | sh` is always DANGER, because it runs code you have not seen.
189
- `--yolo` removes the gate entirely. Use it only when you trust the task.
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
- `curl ... | sh` is always DANGER, because it runs code you have not seen.
162
- `--yolo` removes the gate entirely. Use it only when you trust the task.
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
 
@@ -16,6 +16,7 @@ the tools it knows how to launch.
16
16
  from __future__ import annotations
17
17
 
18
18
  from .cli import main
19
+ from .config import program_version
19
20
 
20
21
  __all__ = ["main"]
21
- __version__ = "0.1.0"
22
+ __version__ = program_version()
@@ -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.console.begin_stream()
128
- turn = self.provider.complete(
129
- self.conversation.history,
130
- system_texts,
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