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.
Files changed (42) hide show
  1. {ferrum_cli-0.1.1/ferrum_cli.egg-info → ferrum_cli-0.1.2}/PKG-INFO +1 -1
  2. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/__init__.py +1 -1
  3. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/agent.py +19 -8
  4. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/apply_patch.py +33 -4
  5. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/builtin_tools.py +129 -6
  6. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/cli.py +7 -5
  7. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/commands/config.py +15 -10
  8. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/commands/doctor.py +6 -3
  9. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/commands/setup.py +1 -1
  10. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/commands/task.py +13 -14
  11. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/config.py +358 -330
  12. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/config_store.py +149 -146
  13. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/context.py +14 -2
  14. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/gitignore.py +0 -2
  15. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/model.py +15 -7
  16. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/prompts/system.md +7 -2
  17. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/safety.py +4 -2
  18. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/verifier.py +17 -9
  19. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2/ferrum_cli.egg-info}/PKG-INFO +1 -1
  20. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/pyproject.toml +10 -1
  21. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_agent.py +65 -9
  22. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_cli.py +1 -2
  23. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_context.py +12 -7
  24. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_model.py +12 -11
  25. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_tools.py +136 -3
  26. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_verifier.py +28 -7
  27. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/LICENSE +0 -0
  28. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/README.md +0 -0
  29. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/commands/__init__.py +0 -0
  30. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/patch.py +0 -0
  31. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/toolcalls.py +0 -0
  32. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/tools.py +0 -0
  33. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum/ui.py +0 -0
  34. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/SOURCES.txt +0 -0
  35. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/dependency_links.txt +0 -0
  36. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/entry_points.txt +0 -0
  37. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/top_level.txt +0 -0
  38. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/setup.cfg +0 -0
  39. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_config.py +0 -0
  40. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_patch.py +0 -0
  41. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_safety.py +0 -0
  42. {ferrum_cli-0.1.1 → ferrum_cli-0.1.2}/tests/test_ui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferrum-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: A lightweight coding harness for low-level and systems programming: inspect, understand, patch, verify.
5
5
  Author: Ferrum contributors
6
6
  License-Expression: Apache-2.0
@@ -3,4 +3,4 @@
3
3
  Understand -> Change -> Build -> Verify.
4
4
  """
5
5
 
6
- __version__ = "0.1.1"
6
+ __version__ = "0.1.2"
@@ -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
- "Call apply_patch only with text you have read in this session."
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(self, response, state: _TurnState) -> list[dict[str, Any]]:
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(self, text: str, turns: int, *, capped: bool, edit: bool) -> AgentResult:
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(native: list, extracted: list) -> list:
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; the verification output comes back in the result."
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 read-only tools the model calls: list, read, search."""
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 read-only set."""
316
+ """The standard set: three readers plus one runner."""
199
317
  config = config or Config()
200
318
  registry = ToolRegistry()
201
- for tool in (ListFiles(root, config), ReadFile(root, config), SearchCode(root, config)):
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
- "error: --dry-run only applies to: ferrum fix --dry-run \"<task>\"",
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
- stream.reconfigure(
176
- encoding="utf-8", errors="replace", line_buffering=True
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(config, stored, name, key, active) -> tuple[str, str]:
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
- file=sys.stderr)
192
- print(" or: ferrum config models then ferrum config set model <name>",
193
- file=sys.stderr)
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 = [tool for tool in ("clang", "gcc", "make", "cargo", "cmake")
84
- if shutil.which(tool)]
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("nothing to verify builds with; run commands are unavailable")
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:<25} base_url, api_key, and model",
145
- file=sys.stderr)
146
- print(" ferrum config models list models at the current endpoint",
147
- file=sys.stderr)
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(" ferrum config set api_key <key> (or: ferrum config setup)",
173
- file=sys.stderr)
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(" the endpoint is rate limiting — wait a moment and retry",
176
- file=sys.stderr)
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: