oy-cli 0.3.3__tar.gz → 0.3.5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oy-cli
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: Tiny local coding CLI with a small tool surface
5
5
  Author: oy-cli contributors
6
6
  License-Expression: Apache-2.0
@@ -32,6 +32,8 @@ uv tool install oy-cli
32
32
  oy "add docstrings to public functions"
33
33
  ```
34
34
 
35
+ Note: `oy` also supports a Copilot-compatible shim via `OY_SHIM=copilot` or `copilot:model` selection where available.
36
+
35
37
  ## Examples
36
38
 
37
39
  ```bash
@@ -63,7 +65,9 @@ oy --help # Show all commands
63
65
 
64
66
  Most AI coding tools are large, complex, or lock you into a single provider. `oy` is deliberately small, easy to audit, and built around a narrow tool surface.
65
67
 
66
- **Design goals:** small auditable codebase, minimal tool surface, OpenAI-completions-focused CLI loop, multiple backends behind shims, fresh session each run, and explicit checkpoints when needed.
68
+ **Design goals:** small auditable codebase, minimal tool surface,
69
+ OpenAI-completions-focused CLI loop, multiple backends behind shims,
70
+ fresh session each run, and explicit checkpoints when needed.
67
71
 
68
72
  ## System Prompts
69
73
 
@@ -114,7 +118,19 @@ Each tool description is passed directly to the model. These are the exact descr
114
118
  ### Audit Prompt
115
119
 
116
120
  ```markdown
117
- Audit the repo for security, unnecessary complexity, and major obvious performance issues, preserving project and human context. First read key markdown docs, then refresh or generate an audit header at the top of ISSUES.md that includes the current date, the latest Git commit reference, and a concise codebase summary using tools like `scc` or `tokei`. Next, fetch the current OWASP ASVS (or MASVS if more relevant) and grugbrain.dev guidelines using httpx, inspect the codebase against these, and write or merge prioritised findings (max 10-15) into the ISSUES.md file. Ensure each finding is formatted to include its location, category (security, complexity, or performance), standard reference, a clear recommendation, and has a Status, if existing findings have been resolved, summarise and note them in a short log at the end.
121
+ Audit the repo for security, unnecessary complexity, and major
122
+ obvious performance issues, preserving project and human context.
123
+ First read key markdown docs, then refresh or generate an audit
124
+ header at the top of ISSUES.md that includes the current date,
125
+ the latest Git commit reference, and a concise codebase summary
126
+ using tools like `scc` or `tokei`. Next, fetch the current OWASP
127
+ ASVS (or MASVS if more relevant) and grugbrain.dev guidelines
128
+ using httpx, inspect the codebase against these, and write or
129
+ merge prioritised findings (max 10-15) into the ISSUES.md file.
130
+ Ensure each finding is formatted to include its location, category
131
+ (security, complexity, or performance), standard reference, a clear
132
+ recommendation, and has a Status, if existing findings have been
133
+ resolved, summarise and note them in a short log at the end.
118
134
  ```
119
135
 
120
136
  ```bash
@@ -141,18 +157,27 @@ OY_ROOT=./src oy audit # Audit specific directory
141
157
  {"shim": "openai", "model": "glm-5"}
142
158
  ```
143
159
 
144
- The `shim` field pins which backend to use regardless of what else is signed in. Use `oy model <filter>` to pick interactively; it merges models from available signed-in shims into a single list using `shim:model` prefixes.
160
+ The `shim` field pins which backend to use regardless of what else is signed in.
161
+ Use `oy model <filter>` to pick interactively; it merges models from available
162
+ signed-in shims into a single list using `shim:model` prefixes.
145
163
 
146
- On first run, if no model is configured, `oy` prompts you to pick one from the available backends. Set `OY_MODEL`, `OY_SHIM`, or save a config with `oy model` to pin behavior.
164
+ On first run, if no model is configured, `oy` prompts you to pick one from
165
+ the available backends. Set `OY_MODEL`, `OY_SHIM`, or save a config with
166
+ `oy model` to pin behavior.
147
167
 
148
- **Recommended model:** From testing, `glm-5` offers the best balance of intelligence, cost, and tool-use ability. `kimi-k2.5` is another strong option. The [Artificial Analysis Comparison of Open Source Models](https://artificialanalysis.ai/models/open-source) is a good reference.
168
+ **Recommended model:** From testing, `glm-5` offers the best balance of
169
+ intelligence, cost, and tool-use ability. `kimi-k2.5` is another strong option.
170
+ The [Artificial Analysis Comparison of Open Source Models](https://artificialanalysis.ai/models/open-source)
171
+ is a good reference.
149
172
 
150
173
  ## Requirements
151
174
 
152
175
  - Python 3.14+
153
176
  - `bash`
154
177
  - (Optional) `rg` (ripgrep) for faster search
155
- - OpenAI API key or Codex local auth **OR** Gemini CLI OAuth credentials (`~/.gemini/oauth_creds.json`) **OR** Claude Code local auth **OR** AWS CLI configured for Bedrock
178
+ - OpenAI API key or Codex local auth **OR** Gemini CLI OAuth credentials
179
+ (`~/.gemini/oauth_creds.json`) **OR** Claude Code local auth **OR**
180
+ AWS CLI configured for Bedrock
156
181
 
157
182
  ## Installation
158
183
 
@@ -174,7 +199,8 @@ export OPENAI_BASE_URL=https://your-endpoint.example/v1
174
199
  export OPENAI_API_KEY=...
175
200
  ```
176
201
 
177
- Gemini, Claude, Codex (OpenAI) creds should be automatically introspected and used, if creds are available `oy model` will show them in the model list.
202
+ Gemini, Claude, Codex (OpenAI) creds should be automatically introspected
203
+ and used, if creds are available `oy model` will show them in the model list.
178
204
 
179
205
  **AWS Bedrock (automatic):** Uses your default AWS profile/region. Supports auto-refresh of stale SSO sessions.
180
206
  ```bash
@@ -184,7 +210,9 @@ export AWS_REGION=us-west-2
184
210
 
185
211
  ## Troubleshooting
186
212
 
187
- **"Missing API credentials"** -> Set `OPENAI_API_KEY`, sign in with `codex`, `gemini` or `claude`, or configure AWS CLI (`aws configure`). For Bedrock: ensure your profile has `bedrock:InvokeModel` permission.
213
+ **"Missing API credentials"** -> Set `OPENAI_API_KEY`, sign in with `codex`,
214
+ `gemini` or `claude`, or configure AWS CLI (`aws configure`). For Bedrock:
215
+ ensure your profile has `bedrock:InvokeModel` permission.
188
216
 
189
217
  **"stdin is not a TTY"** -> Piping input disables `ask`. Set `OY_NON_INTERACTIVE=1` to make explicit.
190
218
 
@@ -200,7 +228,9 @@ Recommended:
200
228
  - avoid exposing long-lived secrets in the environment
201
229
  - review generated changes before shipping
202
230
 
203
- **Automatic protections:** workspace-bound file access, structured edits through `apply`, sensitive header redaction in `httpx`, and native boto3 credential resolution for Bedrock.
231
+ **Automatic protections:** workspace-bound file access, structured edits
232
+ through `apply`, sensitive header redaction in `httpx`, and native boto3
233
+ credential resolution for Bedrock.
204
234
 
205
235
  ## Links
206
236
 
@@ -9,6 +9,8 @@ uv tool install oy-cli
9
9
  oy "add docstrings to public functions"
10
10
  ```
11
11
 
12
+ Note: `oy` also supports a Copilot-compatible shim via `OY_SHIM=copilot` or `copilot:model` selection where available.
13
+
12
14
  ## Examples
13
15
 
14
16
  ```bash
@@ -40,7 +42,9 @@ oy --help # Show all commands
40
42
 
41
43
  Most AI coding tools are large, complex, or lock you into a single provider. `oy` is deliberately small, easy to audit, and built around a narrow tool surface.
42
44
 
43
- **Design goals:** small auditable codebase, minimal tool surface, OpenAI-completions-focused CLI loop, multiple backends behind shims, fresh session each run, and explicit checkpoints when needed.
45
+ **Design goals:** small auditable codebase, minimal tool surface,
46
+ OpenAI-completions-focused CLI loop, multiple backends behind shims,
47
+ fresh session each run, and explicit checkpoints when needed.
44
48
 
45
49
  ## System Prompts
46
50
 
@@ -91,7 +95,19 @@ Each tool description is passed directly to the model. These are the exact descr
91
95
  ### Audit Prompt
92
96
 
93
97
  ```markdown
94
- Audit the repo for security, unnecessary complexity, and major obvious performance issues, preserving project and human context. First read key markdown docs, then refresh or generate an audit header at the top of ISSUES.md that includes the current date, the latest Git commit reference, and a concise codebase summary using tools like `scc` or `tokei`. Next, fetch the current OWASP ASVS (or MASVS if more relevant) and grugbrain.dev guidelines using httpx, inspect the codebase against these, and write or merge prioritised findings (max 10-15) into the ISSUES.md file. Ensure each finding is formatted to include its location, category (security, complexity, or performance), standard reference, a clear recommendation, and has a Status, if existing findings have been resolved, summarise and note them in a short log at the end.
98
+ Audit the repo for security, unnecessary complexity, and major
99
+ obvious performance issues, preserving project and human context.
100
+ First read key markdown docs, then refresh or generate an audit
101
+ header at the top of ISSUES.md that includes the current date,
102
+ the latest Git commit reference, and a concise codebase summary
103
+ using tools like `scc` or `tokei`. Next, fetch the current OWASP
104
+ ASVS (or MASVS if more relevant) and grugbrain.dev guidelines
105
+ using httpx, inspect the codebase against these, and write or
106
+ merge prioritised findings (max 10-15) into the ISSUES.md file.
107
+ Ensure each finding is formatted to include its location, category
108
+ (security, complexity, or performance), standard reference, a clear
109
+ recommendation, and has a Status, if existing findings have been
110
+ resolved, summarise and note them in a short log at the end.
95
111
  ```
96
112
 
97
113
  ```bash
@@ -118,18 +134,27 @@ OY_ROOT=./src oy audit # Audit specific directory
118
134
  {"shim": "openai", "model": "glm-5"}
119
135
  ```
120
136
 
121
- The `shim` field pins which backend to use regardless of what else is signed in. Use `oy model <filter>` to pick interactively; it merges models from available signed-in shims into a single list using `shim:model` prefixes.
137
+ The `shim` field pins which backend to use regardless of what else is signed in.
138
+ Use `oy model <filter>` to pick interactively; it merges models from available
139
+ signed-in shims into a single list using `shim:model` prefixes.
122
140
 
123
- On first run, if no model is configured, `oy` prompts you to pick one from the available backends. Set `OY_MODEL`, `OY_SHIM`, or save a config with `oy model` to pin behavior.
141
+ On first run, if no model is configured, `oy` prompts you to pick one from
142
+ the available backends. Set `OY_MODEL`, `OY_SHIM`, or save a config with
143
+ `oy model` to pin behavior.
124
144
 
125
- **Recommended model:** From testing, `glm-5` offers the best balance of intelligence, cost, and tool-use ability. `kimi-k2.5` is another strong option. The [Artificial Analysis Comparison of Open Source Models](https://artificialanalysis.ai/models/open-source) is a good reference.
145
+ **Recommended model:** From testing, `glm-5` offers the best balance of
146
+ intelligence, cost, and tool-use ability. `kimi-k2.5` is another strong option.
147
+ The [Artificial Analysis Comparison of Open Source Models](https://artificialanalysis.ai/models/open-source)
148
+ is a good reference.
126
149
 
127
150
  ## Requirements
128
151
 
129
152
  - Python 3.14+
130
153
  - `bash`
131
154
  - (Optional) `rg` (ripgrep) for faster search
132
- - OpenAI API key or Codex local auth **OR** Gemini CLI OAuth credentials (`~/.gemini/oauth_creds.json`) **OR** Claude Code local auth **OR** AWS CLI configured for Bedrock
155
+ - OpenAI API key or Codex local auth **OR** Gemini CLI OAuth credentials
156
+ (`~/.gemini/oauth_creds.json`) **OR** Claude Code local auth **OR**
157
+ AWS CLI configured for Bedrock
133
158
 
134
159
  ## Installation
135
160
 
@@ -151,7 +176,8 @@ export OPENAI_BASE_URL=https://your-endpoint.example/v1
151
176
  export OPENAI_API_KEY=...
152
177
  ```
153
178
 
154
- Gemini, Claude, Codex (OpenAI) creds should be automatically introspected and used, if creds are available `oy model` will show them in the model list.
179
+ Gemini, Claude, Codex (OpenAI) creds should be automatically introspected
180
+ and used, if creds are available `oy model` will show them in the model list.
155
181
 
156
182
  **AWS Bedrock (automatic):** Uses your default AWS profile/region. Supports auto-refresh of stale SSO sessions.
157
183
  ```bash
@@ -161,7 +187,9 @@ export AWS_REGION=us-west-2
161
187
 
162
188
  ## Troubleshooting
163
189
 
164
- **"Missing API credentials"** -> Set `OPENAI_API_KEY`, sign in with `codex`, `gemini` or `claude`, or configure AWS CLI (`aws configure`). For Bedrock: ensure your profile has `bedrock:InvokeModel` permission.
190
+ **"Missing API credentials"** -> Set `OPENAI_API_KEY`, sign in with `codex`,
191
+ `gemini` or `claude`, or configure AWS CLI (`aws configure`). For Bedrock:
192
+ ensure your profile has `bedrock:InvokeModel` permission.
165
193
 
166
194
  **"stdin is not a TTY"** -> Piping input disables `ask`. Set `OY_NON_INTERACTIVE=1` to make explicit.
167
195
 
@@ -177,7 +205,9 @@ Recommended:
177
205
  - avoid exposing long-lived secrets in the environment
178
206
  - review generated changes before shipping
179
207
 
180
- **Automatic protections:** workspace-bound file access, structured edits through `apply`, sensitive header redaction in `httpx`, and native boto3 credential resolution for Bedrock.
208
+ **Automatic protections:** workspace-bound file access, structured edits
209
+ through `apply`, sensitive header redaction in `httpx`, and native boto3
210
+ credential resolution for Bedrock.
181
211
 
182
212
  ## Links
183
213
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: oy-cli
3
- Version: 0.3.3
3
+ Version: 0.3.5
4
4
  Summary: Tiny local coding CLI with a small tool surface
5
5
  Author: oy-cli contributors
6
6
  License-Expression: Apache-2.0
@@ -32,6 +32,8 @@ uv tool install oy-cli
32
32
  oy "add docstrings to public functions"
33
33
  ```
34
34
 
35
+ Note: `oy` also supports a Copilot-compatible shim via `OY_SHIM=copilot` or `copilot:model` selection where available.
36
+
35
37
  ## Examples
36
38
 
37
39
  ```bash
@@ -63,7 +65,9 @@ oy --help # Show all commands
63
65
 
64
66
  Most AI coding tools are large, complex, or lock you into a single provider. `oy` is deliberately small, easy to audit, and built around a narrow tool surface.
65
67
 
66
- **Design goals:** small auditable codebase, minimal tool surface, OpenAI-completions-focused CLI loop, multiple backends behind shims, fresh session each run, and explicit checkpoints when needed.
68
+ **Design goals:** small auditable codebase, minimal tool surface,
69
+ OpenAI-completions-focused CLI loop, multiple backends behind shims,
70
+ fresh session each run, and explicit checkpoints when needed.
67
71
 
68
72
  ## System Prompts
69
73
 
@@ -114,7 +118,19 @@ Each tool description is passed directly to the model. These are the exact descr
114
118
  ### Audit Prompt
115
119
 
116
120
  ```markdown
117
- Audit the repo for security, unnecessary complexity, and major obvious performance issues, preserving project and human context. First read key markdown docs, then refresh or generate an audit header at the top of ISSUES.md that includes the current date, the latest Git commit reference, and a concise codebase summary using tools like `scc` or `tokei`. Next, fetch the current OWASP ASVS (or MASVS if more relevant) and grugbrain.dev guidelines using httpx, inspect the codebase against these, and write or merge prioritised findings (max 10-15) into the ISSUES.md file. Ensure each finding is formatted to include its location, category (security, complexity, or performance), standard reference, a clear recommendation, and has a Status, if existing findings have been resolved, summarise and note them in a short log at the end.
121
+ Audit the repo for security, unnecessary complexity, and major
122
+ obvious performance issues, preserving project and human context.
123
+ First read key markdown docs, then refresh or generate an audit
124
+ header at the top of ISSUES.md that includes the current date,
125
+ the latest Git commit reference, and a concise codebase summary
126
+ using tools like `scc` or `tokei`. Next, fetch the current OWASP
127
+ ASVS (or MASVS if more relevant) and grugbrain.dev guidelines
128
+ using httpx, inspect the codebase against these, and write or
129
+ merge prioritised findings (max 10-15) into the ISSUES.md file.
130
+ Ensure each finding is formatted to include its location, category
131
+ (security, complexity, or performance), standard reference, a clear
132
+ recommendation, and has a Status, if existing findings have been
133
+ resolved, summarise and note them in a short log at the end.
118
134
  ```
119
135
 
120
136
  ```bash
@@ -141,18 +157,27 @@ OY_ROOT=./src oy audit # Audit specific directory
141
157
  {"shim": "openai", "model": "glm-5"}
142
158
  ```
143
159
 
144
- The `shim` field pins which backend to use regardless of what else is signed in. Use `oy model <filter>` to pick interactively; it merges models from available signed-in shims into a single list using `shim:model` prefixes.
160
+ The `shim` field pins which backend to use regardless of what else is signed in.
161
+ Use `oy model <filter>` to pick interactively; it merges models from available
162
+ signed-in shims into a single list using `shim:model` prefixes.
145
163
 
146
- On first run, if no model is configured, `oy` prompts you to pick one from the available backends. Set `OY_MODEL`, `OY_SHIM`, or save a config with `oy model` to pin behavior.
164
+ On first run, if no model is configured, `oy` prompts you to pick one from
165
+ the available backends. Set `OY_MODEL`, `OY_SHIM`, or save a config with
166
+ `oy model` to pin behavior.
147
167
 
148
- **Recommended model:** From testing, `glm-5` offers the best balance of intelligence, cost, and tool-use ability. `kimi-k2.5` is another strong option. The [Artificial Analysis Comparison of Open Source Models](https://artificialanalysis.ai/models/open-source) is a good reference.
168
+ **Recommended model:** From testing, `glm-5` offers the best balance of
169
+ intelligence, cost, and tool-use ability. `kimi-k2.5` is another strong option.
170
+ The [Artificial Analysis Comparison of Open Source Models](https://artificialanalysis.ai/models/open-source)
171
+ is a good reference.
149
172
 
150
173
  ## Requirements
151
174
 
152
175
  - Python 3.14+
153
176
  - `bash`
154
177
  - (Optional) `rg` (ripgrep) for faster search
155
- - OpenAI API key or Codex local auth **OR** Gemini CLI OAuth credentials (`~/.gemini/oauth_creds.json`) **OR** Claude Code local auth **OR** AWS CLI configured for Bedrock
178
+ - OpenAI API key or Codex local auth **OR** Gemini CLI OAuth credentials
179
+ (`~/.gemini/oauth_creds.json`) **OR** Claude Code local auth **OR**
180
+ AWS CLI configured for Bedrock
156
181
 
157
182
  ## Installation
158
183
 
@@ -174,7 +199,8 @@ export OPENAI_BASE_URL=https://your-endpoint.example/v1
174
199
  export OPENAI_API_KEY=...
175
200
  ```
176
201
 
177
- Gemini, Claude, Codex (OpenAI) creds should be automatically introspected and used, if creds are available `oy model` will show them in the model list.
202
+ Gemini, Claude, Codex (OpenAI) creds should be automatically introspected
203
+ and used, if creds are available `oy model` will show them in the model list.
178
204
 
179
205
  **AWS Bedrock (automatic):** Uses your default AWS profile/region. Supports auto-refresh of stale SSO sessions.
180
206
  ```bash
@@ -184,7 +210,9 @@ export AWS_REGION=us-west-2
184
210
 
185
211
  ## Troubleshooting
186
212
 
187
- **"Missing API credentials"** -> Set `OPENAI_API_KEY`, sign in with `codex`, `gemini` or `claude`, or configure AWS CLI (`aws configure`). For Bedrock: ensure your profile has `bedrock:InvokeModel` permission.
213
+ **"Missing API credentials"** -> Set `OPENAI_API_KEY`, sign in with `codex`,
214
+ `gemini` or `claude`, or configure AWS CLI (`aws configure`). For Bedrock:
215
+ ensure your profile has `bedrock:InvokeModel` permission.
188
216
 
189
217
  **"stdin is not a TTY"** -> Piping input disables `ask`. Set `OY_NON_INTERACTIVE=1` to make explicit.
190
218
 
@@ -200,7 +228,9 @@ Recommended:
200
228
  - avoid exposing long-lived secrets in the environment
201
229
  - review generated changes before shipping
202
230
 
203
- **Automatic protections:** workspace-bound file access, structured edits through `apply`, sensitive header redaction in `httpx`, and native boto3 credential resolution for Bedrock.
231
+ **Automatic protections:** workspace-bound file access, structured edits
232
+ through `apply`, sensitive header redaction in `httpx`, and native boto3
233
+ credential resolution for Bedrock.
204
234
 
205
235
  ## Links
206
236
 
@@ -4,7 +4,6 @@ from dataclasses import dataclass
4
4
  import json
5
5
  import logging
6
6
  import os
7
- import readline
8
7
  import re
9
8
  import sys
10
9
  import tempfile
@@ -89,8 +88,9 @@ def _init_debug_log() -> tuple[logging.Logger | None, str | None]:
89
88
  raw = os.environ.get("OY_DEBUG", "").strip().lower()
90
89
  if raw not in {"1", "true", "yes", "on"}:
91
90
  return None, None
92
- fd, path = tempfile.mkstemp(prefix="oy-debug-", suffix=".jsonl")
93
- os.close(fd)
91
+ debug_dir = CONFIG_PATH.parent
92
+ debug_dir.mkdir(parents=True, exist_ok=True)
93
+ path = str(debug_dir / "debug.jsonl")
94
94
  logger = logging.getLogger("oy.debug")
95
95
  logger.setLevel(logging.DEBUG)
96
96
  logger.propagate = False
@@ -256,7 +256,7 @@ def abort(m, c=1):
256
256
  def clip_tokens(text, limit=MAX_TOOL_OUTPUT_TOKENS, tail=0):
257
257
  """Truncate *text* to *limit* tokens, optionally keeping *tail* tokens from the end."""
258
258
  e = get_tokenizer()
259
- ids = e.encode(text)
259
+ ids = e.encode(text, disallowed_special=())
260
260
  n = len(ids)
261
261
  if n <= limit:
262
262
  return text
@@ -312,15 +312,23 @@ def _http_body(text, ct):
312
312
  )
313
313
 
314
314
 
315
+ _JSON_PATH_MAX_DEPTH = 20
316
+
317
+
315
318
  def _json_path(v, p):
316
319
  """Walk into *v* using dot-separated *p* (supports dict keys and list indices)."""
317
- for part in (p or "").split("."):
320
+ for i, part in enumerate((p or "").split(".")):
318
321
  if not part:
319
322
  continue
323
+ if i >= _JSON_PATH_MAX_DEPTH:
324
+ raise ValueError(f"json_path exceeded max depth of {_JSON_PATH_MAX_DEPTH}")
320
325
  if isinstance(v, list):
321
326
  if not part.isdigit():
322
327
  raise ValueError(f"json_path expected index, got {part}")
323
- v = v[int(part)]
328
+ try:
329
+ v = v[int(part)]
330
+ except IndexError:
331
+ raise ValueError(f"json_path index {part} out of range (length {len(v)})")
324
332
  elif isinstance(v, dict):
325
333
  if part not in v:
326
334
  raise ValueError(f"json_path key not found: {part}")
@@ -1419,21 +1427,36 @@ def tool_httpx(
1419
1427
  if parsed.scheme not in {"http", "https"}:
1420
1428
  raise ValueError("httpx only supports http and https")
1421
1429
  _print("status", "Fetching HTTP content.", err=True)
1430
+ max_bytes = max_tokens * 16 # generous: ~16 bytes per token
1422
1431
  try:
1423
1432
  with httpx.Client(
1424
- follow_redirects=True, timeout=float(timeout_seconds)
1433
+ follow_redirects=True, timeout=float(timeout_seconds), max_redirects=10,
1425
1434
  ) as http:
1426
- response = http.request(
1435
+ with http.stream(
1427
1436
  method,
1428
1437
  parsed.geturl(),
1429
1438
  headers=_norm_map(headers, "headers"),
1430
1439
  params=_norm_map(params, "params"),
1431
1440
  content=body,
1432
1441
  json=json_body,
1433
- )
1442
+ ) as response:
1443
+ chunks: list[bytes] = []
1444
+ total = 0
1445
+ for chunk in response.iter_bytes():
1446
+ chunks.append(chunk)
1447
+ total += len(chunk)
1448
+ if total > max_bytes:
1449
+ break
1434
1450
  except httpx.HTTPError as exc:
1435
1451
  raise ValueError(_httpx_err(exc, timeout_seconds)) from exc
1436
- out = render_httpx_output(response, response_mode, json_path=json_path)
1452
+ # Reconstruct a response with bounded content for render_httpx_output
1453
+ bounded = httpx.Response(
1454
+ status_code=response.status_code,
1455
+ headers=response.headers,
1456
+ content=b"".join(chunks)[:max_bytes],
1457
+ request=response.request,
1458
+ )
1459
+ out = render_httpx_output(bounded, response_mode, json_path=json_path)
1437
1460
  show(out, 1)
1438
1461
  return clip_tokens(out, max_tokens)
1439
1462
 
@@ -1513,7 +1536,7 @@ def truncate_str_to_tokens(text: str, max_tokens: int = MAX_MESSAGE_TOKENS) -> s
1513
1536
  characters were removed so the model knows the content was cut.
1514
1537
  """
1515
1538
  enc = get_tokenizer()
1516
- ids = enc.encode(text)
1539
+ ids = enc.encode(text, disallowed_special=())
1517
1540
  if len(ids) <= max_tokens:
1518
1541
  return text
1519
1542
  kept = enc.decode(ids[:max_tokens])
@@ -1757,6 +1780,10 @@ def audit(prompt: str = ""):
1757
1780
 
1758
1781
  def _setup_readline():
1759
1782
  """Configure readline with persistent history for shell-like UX."""
1783
+ try:
1784
+ import readline
1785
+ except ImportError:
1786
+ return # no readline on minimal builds (Alpine, WASM)
1760
1787
  history_path = CONFIG_PATH.parent / "history"
1761
1788
  history_path.parent.mkdir(parents=True, exist_ok=True)
1762
1789
  try:
@@ -1764,24 +1791,76 @@ def _setup_readline():
1764
1791
  except FileNotFoundError:
1765
1792
  pass
1766
1793
  readline.set_history_length(1000)
1794
+ # Ensure history file has restrictive permissions (M2: OWASP ASVS V8.3.4)
1795
+ history_path.touch(mode=0o600, exist_ok=True)
1767
1796
  import atexit
1768
1797
 
1769
1798
  atexit.register(readline.write_history_file, str(history_path))
1770
1799
 
1771
1800
 
1801
+ def _drain_stdin(timeout: float = 0.05) -> str:
1802
+ """Read any data already buffered on stdin (e.g. the tail of a paste).
1803
+
1804
+ Uses select() with a short timeout. Returns the extra text, or "".
1805
+ Only works on real ttys; returns "" for piped stdin.
1806
+ """
1807
+ import select
1808
+ if not sys.stdin.isatty():
1809
+ return ""
1810
+ chunks: list[str] = []
1811
+ while True:
1812
+ ready, _, _ = select.select([sys.stdin], [], [], timeout)
1813
+ if not ready:
1814
+ break
1815
+ chunk = os.read(sys.stdin.fileno(), 4096)
1816
+ if not chunk:
1817
+ break
1818
+ chunks.append(chunk.decode("utf-8", errors="replace"))
1819
+ # After first chunk, use a tighter timeout for the rest.
1820
+ timeout = 0.01
1821
+ return "".join(chunks)
1822
+
1823
+
1772
1824
  def _read_input():
1773
- """Read user input, supporting \\ continuation for multi-line."""
1825
+ '''Read user input, with automatic paste detection.
1826
+
1827
+ Input modes:
1828
+ 1. Single line -- type and press Enter.
1829
+ 2. Paste -- paste multiline text; lines that arrive within a
1830
+ few milliseconds of Enter are collected automatically.
1831
+ 3. Block mode -- start with ``"""`` to open a fenced block;
1832
+ close it with ``"""`` on its own line.
1833
+
1834
+ Paste detection works by draining stdin right after readline returns.
1835
+ During normal typing there is nothing buffered, so it is a no-op.
1836
+ During a paste, the remaining lines are already queued up.
1837
+ '''
1774
1838
  line = input("oy > ")
1775
- if not line.endswith("\\"):
1776
- return line
1777
- parts = [line[:-1]]
1778
- while True:
1779
- cont = input("... ")
1780
- if not cont.endswith("\\"):
1839
+
1840
+ # --- block mode: triple-quote fence (still supported) ------------------
1841
+ stripped = line.strip()
1842
+ if stripped == '"""' or stripped.startswith('"""'):
1843
+ if stripped == '"""':
1844
+ parts: list[str] = []
1845
+ else:
1846
+ parts = [stripped[3:]]
1847
+ while True:
1848
+ try:
1849
+ cont = input('... ')
1850
+ except EOFError:
1851
+ break
1852
+ if cont.strip() == '"""':
1853
+ break
1781
1854
  parts.append(cont)
1782
- break
1783
- parts.append(cont[:-1])
1784
- return "\n".join(parts)
1855
+ return "\n".join(parts)
1856
+
1857
+ # --- paste detection: drain any remaining buffered input ---------------
1858
+ extra = _drain_stdin()
1859
+ if extra:
1860
+ # Strip trailing newline that the terminal added from the final Enter.
1861
+ return line + "\n" + extra.rstrip("\n")
1862
+
1863
+ return line
1785
1864
 
1786
1865
 
1787
1866
 
@@ -1850,7 +1929,8 @@ def _chat_command(cmd, transcript, system_prompt, model_spec):
1850
1929
  "- `/clear` -- reset conversation (keeps system prompt)",
1851
1930
  "- `/quit` or `/exit` -- end session",
1852
1931
  "",
1853
- "Tip: end a line with `\\` to continue on the next line.",
1932
+ "Tip: paste multiline text extra lines are detected automatically.",
1933
+ 'Tip: type `"""` to start a multiline block, `"""` to end it.',
1854
1934
  ]), err=True)
1855
1935
  return True
1856
1936
  if cmd == "/tokens":
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "oy-cli"
7
- version = "0.3.3"
7
+ version = "0.3.5"
8
8
  description = "Tiny local coding CLI with a small tool surface"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -13,6 +13,7 @@ from dataclasses import dataclass
13
13
  from datetime import datetime, timezone
14
14
  from email.utils import parsedate_to_datetime
15
15
  from functools import lru_cache
16
+ from types import MappingProxyType
16
17
  from pathlib import Path
17
18
  from typing import Any, Awaitable, Callable, TypeAlias
18
19
  from urllib.parse import quote
@@ -35,6 +36,7 @@ SHIM_GEMINI = "gemini"
35
36
  SHIM_BEDROCK = "bedrock"
36
37
  SHIM_MANTLE = "bedrock-mantle"
37
38
  SHIM_CLAUDE = "claude"
39
+ SHIM_COPILOT = "copilot"
38
40
  SHIM_ORDER = (
39
41
  SHIM_OPENAI,
40
42
  SHIM_CODEX,
@@ -42,6 +44,7 @@ SHIM_ORDER = (
42
44
  SHIM_BEDROCK,
43
45
  SHIM_MANTLE,
44
46
  SHIM_CLAUDE,
47
+ SHIM_COPILOT,
45
48
  )
46
49
  KNOWN_SHIMS = set(SHIM_ORDER)
47
50
 
@@ -567,7 +570,11 @@ def run_cmd(cmd, cwd=None, env=None, timeout=120, stdin_text=None):
567
570
  # If env vars change mid-process (e.g. in tests), the cache will be stale.
568
571
  @lru_cache(maxsize=8)
569
572
  def command_env(cwd=None):
570
- """Build a shell environment dict, merging Homebrew and mise paths."""
573
+ """Build a shell environment dict, merging Homebrew and mise paths.
574
+
575
+ Returns a read-only ``MappingProxyType`` so the cached value cannot be
576
+ accidentally mutated by callers.
577
+ """
571
578
  env = os.environ.copy()
572
579
  if brew := which("brew", env.get("PATH")):
573
580
  prefix = Path(brew).parent.parent
@@ -576,7 +583,7 @@ def command_env(cwd=None):
576
583
  [str(prefix / "bin"), str(prefix / "sbin")], split_path(env.get("PATH"))
577
584
  )
578
585
  if not (mise := which("mise", env.get("PATH"))):
579
- return env
586
+ return MappingProxyType(env)
580
587
  try:
581
588
  result = run_cmd(
582
589
  [mise, "env", "--json"],
@@ -598,7 +605,7 @@ def command_env(cwd=None):
598
605
  if key == "PATH"
599
606
  else value
600
607
  )
601
- return merged
608
+ return MappingProxyType(merged)
602
609
 
603
610
 
604
611
  def http_client(**kw):
@@ -1640,9 +1647,6 @@ def _decode_responses_output(response: Any) -> AssistantMessage:
1640
1647
  )
1641
1648
 
1642
1649
 
1643
- parse_tool_call_arguments = _decode_tool_call_arguments
1644
- _responses_output_to_message = _decode_responses_output
1645
-
1646
1650
 
1647
1651
  def _http_error_message(prefix: str, response: httpx.Response) -> str:
1648
1652
  try:
@@ -2484,6 +2488,160 @@ def _list_mantle_models(
2484
2488
  return _build_mantle_client(region, cwd).list_models()
2485
2489
 
2486
2490
 
2491
+ # ---------------------------------------------------------------------------
2492
+ # Copilot shim – uses the GitHub Copilot API (api.githubcopilot.com)
2493
+ # with a GitHub PAT obtained from COPILOT_GITHUB_TOKEN / GH_TOKEN /
2494
+ # GITHUB_TOKEN / `gh auth token`. Set COPILOT_BASE_URL to override
2495
+ # (e.g. https://api.business.githubcopilot.com for enterprise).
2496
+ # Models that support /responses use that API; others fall back to
2497
+ # /chat/completions automatically.
2498
+ # ---------------------------------------------------------------------------
2499
+
2500
+ _COPILOT_BASE_URL = os.environ.get(
2501
+ "COPILOT_BASE_URL", "https://api.githubcopilot.com"
2502
+ )
2503
+ _COPILOT_INTEGRATION_ID = "copilot-developer-cli"
2504
+ _COPILOT_EDITOR_VERSION = "copilot-developer-cli/1.0.6"
2505
+
2506
+
2507
+ def _get_github_token() -> str | None:
2508
+ """Return a GitHub PAT from env vars or the ``gh`` CLI, or *None*.
2509
+
2510
+ Checks (in order): ``COPILOT_GITHUB_TOKEN``, ``GH_TOKEN``,
2511
+ ``GITHUB_TOKEN``, then ``gh auth token``.
2512
+ """
2513
+ for var in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"):
2514
+ val = os.environ.get(var)
2515
+ if isinstance(val, str) and val:
2516
+ return val
2517
+ gh = which("gh")
2518
+ if not gh:
2519
+ return None
2520
+ try:
2521
+ proc = subprocess.run(
2522
+ [gh, "auth", "token"],
2523
+ capture_output=True,
2524
+ text=True,
2525
+ timeout=10,
2526
+ )
2527
+ token = proc.stdout.strip()
2528
+ return token if proc.returncode == 0 and token else None
2529
+ except Exception:
2530
+ return None
2531
+
2532
+
2533
+ def _copilot_default_headers() -> dict[str, str]:
2534
+ return {
2535
+ "Copilot-Integration-Id": _COPILOT_INTEGRATION_ID,
2536
+ "Editor-Version": _COPILOT_EDITOR_VERSION,
2537
+ }
2538
+
2539
+
2540
+ def _copilot_openai_pair(token: str) -> tuple[AsyncOpenAI, OpenAI]:
2541
+ kwargs: dict[str, Any] = {
2542
+ "api_key": token,
2543
+ "base_url": _COPILOT_BASE_URL,
2544
+ "max_retries": 0,
2545
+ "default_headers": _copilot_default_headers(),
2546
+ }
2547
+ return AsyncOpenAI(**kwargs), OpenAI(**kwargs)
2548
+
2549
+
2550
+ def _require_copilot_env(_: Path | None = None) -> None:
2551
+ _require_string(
2552
+ _get_github_token(),
2553
+ "No GitHub token found (set GH_TOKEN, GITHUB_TOKEN, or run `gh auth login`)",
2554
+ )
2555
+
2556
+
2557
+ def _fetch_copilot_models_raw(token: str) -> list[JSONDict]:
2558
+ """Fetch the full model metadata list from the Copilot API."""
2559
+ resp = httpx.get(
2560
+ f"{_COPILOT_BASE_URL}/models",
2561
+ headers={
2562
+ "Authorization": f"Bearer {token}",
2563
+ **_copilot_default_headers(),
2564
+ },
2565
+ timeout=15,
2566
+ )
2567
+ resp.raise_for_status()
2568
+ data = resp.json()
2569
+ return data.get("data", []) if isinstance(data, dict) else []
2570
+
2571
+
2572
+ def _copilot_chat_model_ids(token: str) -> list[str]:
2573
+ """Return sorted model IDs that support chat (not embeddings)."""
2574
+ raw = _fetch_copilot_models_raw(token)
2575
+ return sorted(
2576
+ m["id"]
2577
+ for m in raw
2578
+ if isinstance(m.get("id"), str)
2579
+ and m.get("capabilities", {}).get("type") == "chat"
2580
+ )
2581
+
2582
+
2583
+ def _copilot_responses_model_ids(token: str) -> set[str]:
2584
+ """Return model IDs that support the /responses endpoint."""
2585
+ raw = _fetch_copilot_models_raw(token)
2586
+ return {
2587
+ m["id"]
2588
+ for m in raw
2589
+ if isinstance(m.get("id"), str)
2590
+ and "/responses" in (m.get("supported_endpoints") or [])
2591
+ }
2592
+
2593
+
2594
+ def _build_copilot_client(
2595
+ region: str | None = None, cwd: Path | None = None
2596
+ ) -> CompletionClient:
2597
+ """Build a Copilot client that routes to /responses or /chat/completions."""
2598
+ _ = region, cwd
2599
+ token = _require_string(_get_github_token(), "No GitHub token found")
2600
+ async_client, sync_client = _copilot_openai_pair(token)
2601
+
2602
+ # Probe which models support /responses vs /chat/completions
2603
+ try:
2604
+ responses_models = _copilot_responses_model_ids(token)
2605
+ except Exception:
2606
+ responses_models = set()
2607
+
2608
+ responses_inner = _openai_responses_client(
2609
+ async_client, sync_client, fallback_models=None, default_models=None
2610
+ )
2611
+ chat_inner = _openai_chat_completions_client(
2612
+ async_client, sync_client, tools_map=_tool_specs_to_openai
2613
+ )
2614
+
2615
+ async def chat_completion(
2616
+ model: str,
2617
+ messages: list[ChatMessage],
2618
+ tools: list[ToolSpec] | None = None,
2619
+ tool_choice: str = "auto",
2620
+ on_retry=None,
2621
+ ) -> AssistantMessage:
2622
+ inner = responses_inner if model in responses_models else chat_inner
2623
+ return await inner.chat_completion(model, messages, tools, tool_choice, on_retry)
2624
+
2625
+ def list_models() -> list[str]:
2626
+ try:
2627
+ return _copilot_chat_model_ids(token)
2628
+ except Exception:
2629
+ return sorted(
2630
+ m.id
2631
+ for m in sync_client.models.list()
2632
+ if not m.id.startswith("text-embedding")
2633
+ )
2634
+
2635
+ return CompletionClient(chat_completion=chat_completion, list_models=list_models)
2636
+
2637
+
2638
+ def _list_copilot_models(
2639
+ region: str | None = None, cwd: Path | None = None
2640
+ ) -> list[str]:
2641
+ _ = region, cwd
2642
+ return _build_copilot_client(region, cwd).list_models()
2643
+
2644
+
2487
2645
  SHIM_SPECS: dict[str, ShimSpec] = {
2488
2646
  SHIM_OPENAI: ShimSpec(
2489
2647
  name=SHIM_OPENAI,
@@ -2521,6 +2679,12 @@ SHIM_SPECS: dict[str, ShimSpec] = {
2521
2679
  build_client=_build_claude_shim,
2522
2680
  list_models=_list_claude_models,
2523
2681
  ),
2682
+ SHIM_COPILOT: ShimSpec(
2683
+ name=SHIM_COPILOT,
2684
+ ensure_env=_require_copilot_env,
2685
+ build_client=_build_copilot_client,
2686
+ list_models=_list_copilot_models,
2687
+ ),
2524
2688
  }
2525
2689
 
2526
2690
 
@@ -220,5 +220,49 @@ class ClipTokensTests(unittest.TestCase):
220
220
  self.assertIn("tokens omitted", result)
221
221
 
222
222
 
223
+ class JsonPathTests(unittest.TestCase):
224
+ """Tests for _json_path including the depth cap (M3)."""
225
+
226
+ def test_simple_dict_traversal(self):
227
+ self.assertEqual(oy_cli._json_path({"a": {"b": 1}}, "a.b"), 1)
228
+
229
+ def test_list_index(self):
230
+ self.assertEqual(oy_cli._json_path([10, 20, 30], "1"), 20)
231
+
232
+ def test_missing_key_raises(self):
233
+ with self.assertRaisesRegex(ValueError, "key not found"):
234
+ oy_cli._json_path({"a": 1}, "b")
235
+
236
+ def test_non_integer_index_raises(self):
237
+ with self.assertRaisesRegex(ValueError, "expected index"):
238
+ oy_cli._json_path([1, 2], "foo")
239
+
240
+ def test_depth_cap_raises(self):
241
+ # Build a deeply nested structure and a path exceeding 20 levels
242
+ obj = 0
243
+ for _ in range(25):
244
+ obj = {"a": obj}
245
+ deep = ".".join(["a"] * 21)
246
+ with self.assertRaisesRegex(ValueError, "json_path exceeded max depth"):
247
+ oy_cli._json_path(obj, deep)
248
+
249
+ def test_index_error_caught(self):
250
+ with self.assertRaisesRegex(ValueError, "out of range"):
251
+ oy_cli._json_path([1, 2], "5")
252
+
253
+ def test_empty_path_returns_value(self):
254
+ self.assertEqual(oy_cli._json_path(42, ""), 42)
255
+
256
+
257
+ class CommandEnvImmutabilityTests(unittest.TestCase):
258
+ """Test that command_env returns an immutable mapping (L3)."""
259
+
260
+ def test_command_env_is_immutable(self):
261
+ from shim import command_env
262
+ env = command_env()
263
+ with self.assertRaises(TypeError):
264
+ env["SHOULD_NOT_WORK"] = "value"
265
+
266
+
223
267
  if __name__ == "__main__":
224
268
  unittest.main()
File without changes
File without changes
File without changes