ferrum-cli 0.1.1__tar.gz → 0.1.2__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.
- {ferrum_cli-0.1.1/ferrum_cli.egg-info → ferrum_cli-0.1.2}/PKG-INFO +1 -1
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/__init__.py +1 -1
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/agent.py +19 -8
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/apply_patch.py +33 -4
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/builtin_tools.py +129 -6
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/cli.py +7 -5
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/commands/config.py +15 -10
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/commands/doctor.py +6 -3
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/commands/setup.py +1 -1
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/commands/task.py +13 -14
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/config.py +358 -330
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/config_store.py +149 -146
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/context.py +14 -2
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/gitignore.py +0 -2
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/model.py +15 -7
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/prompts/system.md +7 -2
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/safety.py +4 -2
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/verifier.py +17 -9
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2/ferrum_cli.egg-info}/PKG-INFO +1 -1
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/pyproject.toml +10 -1
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_agent.py +65 -9
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_cli.py +1 -2
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_context.py +12 -7
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_model.py +12 -11
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_tools.py +136 -3
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_verifier.py +28 -7
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/LICENSE +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/README.md +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/commands/__init__.py +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/patch.py +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/toolcalls.py +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/tools.py +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/ui.py +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/SOURCES.txt +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/dependency_links.txt +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/entry_points.txt +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/top_level.txt +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/setup.cfg +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_config.py +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_patch.py +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_safety.py +0 -0
- {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_ui.py +0 -0
|
@@ -13,7 +13,7 @@ from ferrum.apply_patch import ApplyPatch
|
|
|
13
13
|
from ferrum.config import Config
|
|
14
14
|
from ferrum.context import ProjectContext
|
|
15
15
|
from ferrum.model import ModelProvider, ModelResponse
|
|
16
|
-
from ferrum.toolcalls import MALFORMED_JSON, extract_tool_calls
|
|
16
|
+
from ferrum.toolcalls import MALFORMED_JSON, ToolCall, extract_tool_calls
|
|
17
17
|
from ferrum.tools import ToolRegistry, ToolResult
|
|
18
18
|
from ferrum.ui import Console
|
|
19
19
|
from ferrum.verifier import Verifier
|
|
@@ -33,13 +33,15 @@ READ_ONLY_NOTE = (
|
|
|
33
33
|
"Read-only mode: investigate thoroughly before answering. A directory "
|
|
34
34
|
"listing alone is not an answer — read the relevant source files and "
|
|
35
35
|
"search for the relevant names; several tool calls are expected. You "
|
|
36
|
-
"cannot modify files, so do not call apply_patch
|
|
36
|
+
"cannot modify files, so do not call apply_patch; run_command is "
|
|
37
|
+
"available for builds, tests, and running the program."
|
|
37
38
|
)
|
|
38
39
|
|
|
39
40
|
FIX_NOTE = (
|
|
40
41
|
"Fix mode: inspect thoroughly before patching — read the file you are "
|
|
41
42
|
"about to change and its callers; several tool calls are expected. "
|
|
42
|
-
"
|
|
43
|
+
"Reproduce the failure with run_command first when a build or test can "
|
|
44
|
+
"show it. Call apply_patch only with text you have read in this session."
|
|
43
45
|
)
|
|
44
46
|
|
|
45
47
|
DRY_RUN_NOTE = (
|
|
@@ -66,6 +68,7 @@ PROGRESS = {
|
|
|
66
68
|
"list_files": "inspecting the project",
|
|
67
69
|
"read_file": "reading",
|
|
68
70
|
"search_code": "searching",
|
|
71
|
+
"run_command": "running",
|
|
69
72
|
"apply_patch": "proposing a patch to",
|
|
70
73
|
}
|
|
71
74
|
|
|
@@ -147,7 +150,9 @@ class Agent:
|
|
|
147
150
|
edit=edit,
|
|
148
151
|
)
|
|
149
152
|
|
|
150
|
-
def _run_tools(
|
|
153
|
+
def _run_tools(
|
|
154
|
+
self, response: ModelResponse, state: _TurnState
|
|
155
|
+
) -> list[dict[str, Any]]:
|
|
151
156
|
"""Run every requested tool, one tool message per call."""
|
|
152
157
|
state.used_tools = True
|
|
153
158
|
messages: list[dict[str, Any]] = []
|
|
@@ -205,7 +210,7 @@ class Agent:
|
|
|
205
210
|
with self.ui.live(f"asking {model}"):
|
|
206
211
|
return self.provider.complete(messages, tools)
|
|
207
212
|
|
|
208
|
-
def _execute(self, call) -> ToolResult:
|
|
213
|
+
def _execute(self, call: ToolCall) -> ToolResult:
|
|
209
214
|
if MALFORMED_JSON in call.arguments:
|
|
210
215
|
raw = str(call.arguments[MALFORMED_JSON])[:500]
|
|
211
216
|
return ToolResult.failure(
|
|
@@ -217,12 +222,14 @@ class Agent:
|
|
|
217
222
|
return ToolResult.failure("tool call had no name")
|
|
218
223
|
return self.registry.execute(call.name, call.arguments)
|
|
219
224
|
|
|
220
|
-
def _progress(self, call) -> None:
|
|
225
|
+
def _progress(self, call: ToolCall) -> None:
|
|
221
226
|
label = PROGRESS.get(call.name, call.name)
|
|
222
227
|
if call.name in ("read_file", "apply_patch"):
|
|
223
228
|
detail = f" {call.arguments.get('path', '')}"
|
|
224
229
|
elif call.name == "search_code":
|
|
225
230
|
detail = f" {call.arguments.get('pattern', '')!r}"
|
|
231
|
+
elif call.name == "run_command":
|
|
232
|
+
detail = f" {str(call.arguments.get('command', ''))[:60]}"
|
|
226
233
|
else:
|
|
227
234
|
detail = ""
|
|
228
235
|
text = f"{label}{detail}"
|
|
@@ -231,7 +238,9 @@ class Agent:
|
|
|
231
238
|
elif self.printer is not None:
|
|
232
239
|
self.printer(f"Ferrum > {text}...")
|
|
233
240
|
|
|
234
|
-
def _finish(
|
|
241
|
+
def _finish(
|
|
242
|
+
self, text: str, turns: int, *, capped: bool, edit: bool
|
|
243
|
+
) -> AgentResult:
|
|
235
244
|
if not text:
|
|
236
245
|
text = "The model returned no answer."
|
|
237
246
|
patched = bool(self.apply_tool and self.apply_tool.applied)
|
|
@@ -277,7 +286,9 @@ def _nudge_for(response: ModelResponse, state: _TurnState) -> str | None:
|
|
|
277
286
|
return None
|
|
278
287
|
|
|
279
288
|
|
|
280
|
-
def _merge_tool_calls(
|
|
289
|
+
def _merge_tool_calls(
|
|
290
|
+
native: list[ToolCall], extracted: list[ToolCall]
|
|
291
|
+
) -> list[ToolCall]:
|
|
281
292
|
"""Native calls first, then JSON-from-text calls that are not duplicates."""
|
|
282
293
|
seen = {(c.name, json.dumps(c.arguments, sort_keys=True)) for c in native}
|
|
283
294
|
merged = list(native)
|
|
@@ -16,7 +16,7 @@ from ferrum.patch import Patch, format_unified_diff
|
|
|
16
16
|
from ferrum.safety import PathEscapeError, is_denied, safe_join
|
|
17
17
|
from ferrum.tools import Tool, ToolError, ToolResult, _require_string
|
|
18
18
|
from ferrum.ui import Console
|
|
19
|
-
from ferrum.verifier import Verifier, VerifyOutcome
|
|
19
|
+
from ferrum.verifier import Verifier, VerifyOutcome, run_command
|
|
20
20
|
|
|
21
21
|
DRY_RUN_RESULT = (
|
|
22
22
|
"Dry run: the patch was NOT applied to {rel}"
|
|
@@ -25,10 +25,11 @@ DRY_RUN_RESULT = (
|
|
|
25
25
|
)
|
|
26
26
|
|
|
27
27
|
DECLINED_RESULT = (
|
|
28
|
-
"The user declined this patch. Do not apply it. "
|
|
29
|
-
"Ask what they would prefer or stop."
|
|
28
|
+
"The user declined this patch. Do not apply it. Ask what they would prefer or stop."
|
|
30
29
|
)
|
|
31
30
|
|
|
31
|
+
GIT_TIMEOUT = 15
|
|
32
|
+
|
|
32
33
|
|
|
33
34
|
class ApplyPatch(Tool):
|
|
34
35
|
name = "apply_patch"
|
|
@@ -36,7 +37,9 @@ class ApplyPatch(Tool):
|
|
|
36
37
|
"Replace exact text in a project file after showing the diff and "
|
|
37
38
|
"asking the user. The old text must match the file verbatim and "
|
|
38
39
|
"uniquely. After a successful patch the project is built and "
|
|
39
|
-
"tested;
|
|
40
|
+
"tested; in a git work tree the result also carries git's status "
|
|
41
|
+
"and diff for the file, and the verification output comes back "
|
|
42
|
+
"afterwards."
|
|
40
43
|
)
|
|
41
44
|
parameters: ClassVar[dict[str, Any]] = {
|
|
42
45
|
"type": "object",
|
|
@@ -102,6 +105,9 @@ class ApplyPatch(Tool):
|
|
|
102
105
|
report = f"Patch applied to {rel}."
|
|
103
106
|
if description:
|
|
104
107
|
report += f" ({description})"
|
|
108
|
+
git_section = _git_report(self.root, rel)
|
|
109
|
+
if git_section:
|
|
110
|
+
report += "\n\n--- git ---\n" + git_section
|
|
105
111
|
if self.verifier is None:
|
|
106
112
|
return report + "\n(verification unavailable)"
|
|
107
113
|
live = (
|
|
@@ -166,3 +172,26 @@ def _write(target: Path, updated: str, crlf: bool, rel: str) -> None:
|
|
|
166
172
|
target.write_bytes(out.encode("utf-8"))
|
|
167
173
|
except OSError as exc:
|
|
168
174
|
raise ToolError(f"cannot write {rel}: {exc}") from exc
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _git_report(root: Path, rel: str) -> str | None:
|
|
178
|
+
"""What git sees for this file after the write.
|
|
179
|
+
|
|
180
|
+
Returns None when git is missing or root is not a work tree, so projects
|
|
181
|
+
outside version control never pay for the extra sections.
|
|
182
|
+
"""
|
|
183
|
+
probe = run_command(
|
|
184
|
+
["git", "rev-parse", "--is-inside-work-tree"], root, timeout=GIT_TIMEOUT
|
|
185
|
+
)
|
|
186
|
+
if not probe.ok or probe.stdout.strip() != "true":
|
|
187
|
+
return None
|
|
188
|
+
status = run_command(
|
|
189
|
+
["git", "status", "--porcelain", "--", rel], root, timeout=GIT_TIMEOUT
|
|
190
|
+
)
|
|
191
|
+
lines = [status.stdout.strip() or "(no changes against the index)"]
|
|
192
|
+
diff = run_command(
|
|
193
|
+
["git", "diff", "--no-color", "--", rel], root, timeout=GIT_TIMEOUT
|
|
194
|
+
)
|
|
195
|
+
if diff.stdout.strip():
|
|
196
|
+
lines.append(diff.stdout.strip())
|
|
197
|
+
return "\n".join(lines)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
"""The
|
|
1
|
+
"""The tools the model calls: list, read, search, and run one command."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import re
|
|
7
|
+
import shlex
|
|
6
8
|
from collections.abc import Mapping
|
|
7
9
|
from pathlib import Path, PurePosixPath
|
|
8
10
|
from typing import Any, ClassVar
|
|
@@ -11,9 +13,14 @@ from ferrum.config import Config
|
|
|
11
13
|
from ferrum.context import collect_files, limit_entries
|
|
12
14
|
from ferrum.safety import PathEscapeError, is_denied, safe_join
|
|
13
15
|
from ferrum.tools import Tool, ToolError, ToolRegistry, ToolResult, _require_string
|
|
16
|
+
from ferrum.verifier import run_command
|
|
14
17
|
|
|
15
18
|
MAX_LINE_CHARS = 300
|
|
16
19
|
|
|
20
|
+
# Shell plumbing is refused: commands run as one program with its arguments,
|
|
21
|
+
# so a model cannot chain, pipe, or redirect its way past the project root.
|
|
22
|
+
SHELL_CHARS = frozenset(";|&<>`\n")
|
|
23
|
+
|
|
17
24
|
|
|
18
25
|
class ListFiles(Tool):
|
|
19
26
|
name = "list_files"
|
|
@@ -167,9 +174,7 @@ class SearchCode(Tool):
|
|
|
167
174
|
raise ToolError(f"invalid regular expression: {exc}") from exc
|
|
168
175
|
return pattern, start, rx
|
|
169
176
|
|
|
170
|
-
def _search(
|
|
171
|
-
self, rx: re.Pattern[str], start: Path
|
|
172
|
-
) -> tuple[list[str], bool]:
|
|
177
|
+
def _search(self, rx: re.Pattern[str], start: Path) -> tuple[list[str], bool]:
|
|
173
178
|
"""path:line snippets, stopping once the result cap is reached."""
|
|
174
179
|
matches: list[str] = []
|
|
175
180
|
truncated = False
|
|
@@ -194,10 +199,128 @@ class SearchCode(Tool):
|
|
|
194
199
|
return matches, truncated
|
|
195
200
|
|
|
196
201
|
|
|
202
|
+
class RunCommand(Tool):
|
|
203
|
+
name = "run_command"
|
|
204
|
+
description = (
|
|
205
|
+
"Run one build, test, or analysis command in the project and return "
|
|
206
|
+
"its output and exit code. One command per call; shell features "
|
|
207
|
+
"(pipes, redirection, chaining) are not supported. Use it to "
|
|
208
|
+
"reproduce a failure, read real compiler diagnostics, or run the "
|
|
209
|
+
"program before and after a patch."
|
|
210
|
+
)
|
|
211
|
+
parameters: ClassVar[dict[str, Any]] = {
|
|
212
|
+
"type": "object",
|
|
213
|
+
"properties": {
|
|
214
|
+
"command": {
|
|
215
|
+
"type": "string",
|
|
216
|
+
"description": (
|
|
217
|
+
"The command line to run, e.g. `make test` or "
|
|
218
|
+
"`clang -Wall -o main main.c`."
|
|
219
|
+
),
|
|
220
|
+
},
|
|
221
|
+
"cwd": {
|
|
222
|
+
"type": "string",
|
|
223
|
+
"description": (
|
|
224
|
+
"Optional subdirectory to run in, relative to the project root."
|
|
225
|
+
),
|
|
226
|
+
},
|
|
227
|
+
"timeout": {
|
|
228
|
+
"type": "integer",
|
|
229
|
+
"description": (
|
|
230
|
+
"Seconds to wait before killing the command "
|
|
231
|
+
"(capped at the configured command timeout)."
|
|
232
|
+
),
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
"required": ["command"],
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
def __init__(self, root: Path, config: Config | None = None) -> None:
|
|
239
|
+
self.root = Path(root)
|
|
240
|
+
self.config = config or Config()
|
|
241
|
+
|
|
242
|
+
def execute(self, arguments: Mapping[str, Any]) -> ToolResult:
|
|
243
|
+
argv = _split_command(_require_string(arguments, "command"))
|
|
244
|
+
cwd = self._cwd(arguments)
|
|
245
|
+
timeout = self._timeout(arguments)
|
|
246
|
+
return ToolResult.success(run_command(argv, cwd, timeout=timeout).render())
|
|
247
|
+
|
|
248
|
+
def _cwd(self, arguments: Mapping[str, Any]) -> Path:
|
|
249
|
+
sub = arguments.get("cwd") or ""
|
|
250
|
+
if not isinstance(sub, str):
|
|
251
|
+
raise ToolError("cwd must be a string")
|
|
252
|
+
if not sub:
|
|
253
|
+
return self.root
|
|
254
|
+
if is_denied(PurePosixPath(sub.replace("\\", "/"))):
|
|
255
|
+
raise ToolError(f"refused: {sub} is excluded")
|
|
256
|
+
try:
|
|
257
|
+
start = safe_join(self.root, sub)
|
|
258
|
+
except PathEscapeError as exc:
|
|
259
|
+
raise ToolError(str(exc)) from exc
|
|
260
|
+
if not start.is_dir():
|
|
261
|
+
raise ToolError(f"not a directory: {sub}")
|
|
262
|
+
return start
|
|
263
|
+
|
|
264
|
+
def _timeout(self, arguments: Mapping[str, Any]) -> int:
|
|
265
|
+
limit = self.config.command_timeout
|
|
266
|
+
raw = arguments.get("timeout")
|
|
267
|
+
if raw is None:
|
|
268
|
+
return limit
|
|
269
|
+
if isinstance(raw, bool) or not isinstance(raw, int) or raw <= 0:
|
|
270
|
+
raise ToolError(f"timeout must be a positive integer, got {raw!r}")
|
|
271
|
+
return min(raw, limit)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _split_command(command: str) -> list[str]:
|
|
275
|
+
"""One command line -> argv, refusing shell plumbing outside quotes."""
|
|
276
|
+
outside = _unquoted(command)
|
|
277
|
+
if (SHELL_CHARS & set(outside)) or "$(" in outside:
|
|
278
|
+
raise ToolError(
|
|
279
|
+
"shell features are not supported (pipes, redirection, chaining); "
|
|
280
|
+
"run a single command with its arguments"
|
|
281
|
+
)
|
|
282
|
+
try:
|
|
283
|
+
argv = shlex.split(command, posix=os.name != "nt")
|
|
284
|
+
except ValueError as exc:
|
|
285
|
+
raise ToolError(f"cannot parse command: {exc}") from exc
|
|
286
|
+
if os.name == "nt":
|
|
287
|
+
# posix=False keeps the quotes in the token; drop the matched pair.
|
|
288
|
+
argv = [
|
|
289
|
+
token[1:-1]
|
|
290
|
+
if len(token) > 1 and token[0] == token[-1] and token[0] in "\"'"
|
|
291
|
+
else token
|
|
292
|
+
for token in argv
|
|
293
|
+
]
|
|
294
|
+
if not argv:
|
|
295
|
+
raise ToolError("command is empty")
|
|
296
|
+
return argv
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _unquoted(command: str) -> str:
|
|
300
|
+
"""The command with quoted spans removed, so payload text is not scanned."""
|
|
301
|
+
out: list[str] = []
|
|
302
|
+
quote: str | None = None
|
|
303
|
+
for char in command:
|
|
304
|
+
if quote is not None:
|
|
305
|
+
if char == quote:
|
|
306
|
+
quote = None
|
|
307
|
+
continue
|
|
308
|
+
if char in "\"'":
|
|
309
|
+
quote = char
|
|
310
|
+
continue
|
|
311
|
+
out.append(char)
|
|
312
|
+
return "".join(out)
|
|
313
|
+
|
|
314
|
+
|
|
197
315
|
def default_tools(root: Path, config: Config | None = None) -> ToolRegistry:
|
|
198
|
-
"""The standard
|
|
316
|
+
"""The standard set: three readers plus one runner."""
|
|
199
317
|
config = config or Config()
|
|
200
318
|
registry = ToolRegistry()
|
|
201
|
-
for tool in (
|
|
319
|
+
for tool in (
|
|
320
|
+
ListFiles(root, config),
|
|
321
|
+
ReadFile(root, config),
|
|
322
|
+
SearchCode(root, config),
|
|
323
|
+
RunCommand(root, config),
|
|
324
|
+
):
|
|
202
325
|
registry.register(tool)
|
|
203
326
|
return registry
|
|
@@ -134,7 +134,7 @@ def _dispatch(args: argparse.Namespace, console: Console) -> int:
|
|
|
134
134
|
|
|
135
135
|
if args.dry_run:
|
|
136
136
|
print(
|
|
137
|
-
|
|
137
|
+
'error: --dry-run only applies to: ferrum fix --dry-run "<task>"',
|
|
138
138
|
file=sys.stderr,
|
|
139
139
|
)
|
|
140
140
|
return EXIT_ERROR
|
|
@@ -171,11 +171,13 @@ def _force_utf8_streams() -> None:
|
|
|
171
171
|
# piped. Line buffering keeps stdout and stderr in the order they were
|
|
172
172
|
# written, so `ferrum ... | tee log` still reads top to bottom.
|
|
173
173
|
for stream in (sys.stdout, sys.stderr):
|
|
174
|
+
# reconfigure only exists on TextIOWrapper, not on a redirected pipe.
|
|
175
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
176
|
+
if reconfigure is None:
|
|
177
|
+
continue
|
|
174
178
|
try:
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
)
|
|
178
|
-
except (AttributeError, ValueError, OSError):
|
|
179
|
+
reconfigure(encoding="utf-8", errors="replace", line_buffering=True)
|
|
180
|
+
except (ValueError, OSError):
|
|
179
181
|
pass
|
|
180
182
|
|
|
181
183
|
|
|
@@ -13,6 +13,7 @@ from ferrum.config import (
|
|
|
13
13
|
LOCAL_PRESET_URL,
|
|
14
14
|
PROFILES,
|
|
15
15
|
STR_FIELDS,
|
|
16
|
+
Config,
|
|
16
17
|
ConfigError,
|
|
17
18
|
config_file_path,
|
|
18
19
|
load_config,
|
|
@@ -89,7 +90,13 @@ def show() -> int:
|
|
|
89
90
|
return EXIT_OK
|
|
90
91
|
|
|
91
92
|
|
|
92
|
-
def _displayed(
|
|
93
|
+
def _displayed(
|
|
94
|
+
config: Config,
|
|
95
|
+
stored: dict[str, dict[str, str]],
|
|
96
|
+
name: str,
|
|
97
|
+
key: str,
|
|
98
|
+
active: str,
|
|
99
|
+
) -> tuple[str, str]:
|
|
93
100
|
"""Value and source for one row: the active profile resolves, others show."""
|
|
94
101
|
if name == active:
|
|
95
102
|
value = getattr(config, key)
|
|
@@ -105,8 +112,7 @@ def get(key: str) -> int:
|
|
|
105
112
|
known = {**STR_FIELDS, **INT_FIELDS}
|
|
106
113
|
if key not in known:
|
|
107
114
|
print(
|
|
108
|
-
f"error: unknown setting {key!r}; choose from: "
|
|
109
|
-
+ ", ".join(sorted(known)),
|
|
115
|
+
f"error: unknown setting {key!r}; choose from: " + ", ".join(sorted(known)),
|
|
110
116
|
file=sys.stderr,
|
|
111
117
|
)
|
|
112
118
|
return EXIT_ERROR
|
|
@@ -187,10 +193,11 @@ def preset(kind: str, rest: list[str]) -> int:
|
|
|
187
193
|
print(note, file=sys.stderr)
|
|
188
194
|
if kind == "cloud":
|
|
189
195
|
_warn_if_cloud_key_missing()
|
|
190
|
-
print("next: ferrum config setup (api key and model, guided)",
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
196
|
+
print("next: ferrum config setup (api key and model, guided)", file=sys.stderr)
|
|
197
|
+
print(
|
|
198
|
+
" or: ferrum config models then ferrum config set model <name>",
|
|
199
|
+
file=sys.stderr,
|
|
200
|
+
)
|
|
194
201
|
return EXIT_OK
|
|
195
202
|
|
|
196
203
|
|
|
@@ -211,7 +218,7 @@ def _warn_if_cloud_key_missing() -> None:
|
|
|
211
218
|
# -- shared helpers -------------------------------------------------------
|
|
212
219
|
|
|
213
220
|
|
|
214
|
-
def _load():
|
|
221
|
+
def _load() -> Config | None:
|
|
215
222
|
"""The active profile's settings, or None after reporting the problem."""
|
|
216
223
|
try:
|
|
217
224
|
return load_config()
|
|
@@ -225,5 +232,3 @@ def _env_override_note(key: str) -> str | None:
|
|
|
225
232
|
if var and os.environ.get(var):
|
|
226
233
|
return f"note: {var} is set in the environment and overrides the file"
|
|
227
234
|
return None
|
|
228
|
-
|
|
229
|
-
|
|
@@ -80,14 +80,17 @@ def _probe_endpoint(config: Config, console: Console) -> tuple[list[str], list[s
|
|
|
80
80
|
|
|
81
81
|
def _toolchain_report() -> list[str]:
|
|
82
82
|
"""Print the build tools found; note what fix mode cannot verify."""
|
|
83
|
-
found = [
|
|
84
|
-
|
|
83
|
+
found = [
|
|
84
|
+
tool
|
|
85
|
+
for tool in ("clang", "gcc", "make", "cargo", "cmake")
|
|
86
|
+
if shutil.which(tool)
|
|
87
|
+
]
|
|
85
88
|
print(f"{'toolchain':<12}{', '.join(found) if found else 'nothing found'}")
|
|
86
89
|
warnings: list[str] = []
|
|
87
90
|
if not {"clang", "gcc"} & set(found):
|
|
88
91
|
warnings.append("no C compiler — fix mode cannot verify C builds")
|
|
89
92
|
if not found:
|
|
90
|
-
warnings.append("
|
|
93
|
+
warnings.append("no build tool found — fix mode cannot verify a fix")
|
|
91
94
|
return warnings
|
|
92
95
|
|
|
93
96
|
|
|
@@ -36,7 +36,7 @@ def run(console: Console, profile: str | None = None) -> int:
|
|
|
36
36
|
_print_needs_terminal()
|
|
37
37
|
return EXIT_ERROR
|
|
38
38
|
profile, current = _setup_target(profile)
|
|
39
|
-
if current is None:
|
|
39
|
+
if profile is None or current is None:
|
|
40
40
|
return EXIT_ERROR
|
|
41
41
|
|
|
42
42
|
print(f"Ferrum setup — {profile} profile, Enter keeps [brackets].")
|
|
@@ -36,9 +36,7 @@ def inspect_target(target: str, console: Console) -> int:
|
|
|
36
36
|
return EXIT_OK
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
def run_task(
|
|
40
|
-
task: str, *, edit: bool, dry_run: bool = False, console: Console
|
|
41
|
-
) -> int:
|
|
39
|
+
def run_task(task: str, *, edit: bool, dry_run: bool = False, console: Console) -> int:
|
|
42
40
|
"""`ferrum "<task>"` or `ferrum fix "<task>"`: one conversation."""
|
|
43
41
|
config = _load_config()
|
|
44
42
|
if config is None:
|
|
@@ -84,9 +82,7 @@ def _describe_project(root: Path, config: Config, console: Console) -> ProjectCo
|
|
|
84
82
|
host = host_of(config.base_url)
|
|
85
83
|
console.step(f"model: {config.model} at {host} [{config.profile}]")
|
|
86
84
|
if not config.api_key and not config.local:
|
|
87
|
-
console.step(
|
|
88
|
-
f"no api_key set for {host} — the endpoint may refuse the request"
|
|
89
|
-
)
|
|
85
|
+
console.step(f"no api_key set for {host} — the endpoint may refuse the request")
|
|
90
86
|
return context
|
|
91
87
|
|
|
92
88
|
|
|
@@ -141,10 +137,10 @@ def _report_missing(config: Config, missing: list[str]) -> int:
|
|
|
141
137
|
"""Nothing to ask the model with — say so in commands the user can type."""
|
|
142
138
|
print(f"error: {', '.join(missing)} is not set.", file=sys.stderr)
|
|
143
139
|
print(file=sys.stderr)
|
|
144
|
-
print(f" ferrum config setup {config.profile
|
|
145
|
-
|
|
146
|
-
print(" ferrum config models
|
|
147
|
-
|
|
140
|
+
print(f" ferrum config setup {config.profile}", file=sys.stderr)
|
|
141
|
+
print(" interactive wizard: base_url, api_key, and model", file=sys.stderr)
|
|
142
|
+
print(" ferrum config models", file=sys.stderr)
|
|
143
|
+
print(" list models at the current endpoint", file=sys.stderr)
|
|
148
144
|
print(" ferrum config set model <name>", file=sys.stderr)
|
|
149
145
|
print(f" (or export {', '.join(missing)}=... for this shell)", file=sys.stderr)
|
|
150
146
|
_print_available_models(config)
|
|
@@ -169,11 +165,14 @@ def _provider_hint(exc: ProviderError) -> None:
|
|
|
169
165
|
"""Turn the raw endpoint failure into the one command that fixes it."""
|
|
170
166
|
if isinstance(exc, AuthenticationError):
|
|
171
167
|
print(" the endpoint rejected the api key:", file=sys.stderr)
|
|
172
|
-
print(
|
|
173
|
-
|
|
168
|
+
print(
|
|
169
|
+
" ferrum config set api_key <key> (or: ferrum config setup)",
|
|
170
|
+
file=sys.stderr,
|
|
171
|
+
)
|
|
174
172
|
elif isinstance(exc, RateLimitError):
|
|
175
|
-
print(
|
|
176
|
-
|
|
173
|
+
print(
|
|
174
|
+
" the endpoint is rate limiting — wait a moment and retry", file=sys.stderr
|
|
175
|
+
)
|
|
177
176
|
|
|
178
177
|
|
|
179
178
|
def confirm(prompt: str, console: Console | None = None) -> bool:
|