ferrum-cli 0.1.0__py3-none-any.whl
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/__init__.py +6 -0
- ferrum/agent.py +387 -0
- ferrum/cli.py +479 -0
- ferrum/config.py +178 -0
- ferrum/context.py +379 -0
- ferrum/model.py +302 -0
- ferrum/patch.py +65 -0
- ferrum/prompts/system.md +89 -0
- ferrum/safety.py +124 -0
- ferrum/tools.py +296 -0
- ferrum/verifier.py +227 -0
- ferrum_cli-0.1.0.dist-info/METADATA +74 -0
- ferrum_cli-0.1.0.dist-info/RECORD +17 -0
- ferrum_cli-0.1.0.dist-info/WHEEL +5 -0
- ferrum_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ferrum_cli-0.1.0.dist-info/licenses/LICENSE +211 -0
- ferrum_cli-0.1.0.dist-info/top_level.txt +1 -0
ferrum/cli.py
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""ferrum command line: inspect a directory, diagnose a task, or fix one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ferrum import __version__
|
|
13
|
+
from ferrum.agent import Agent
|
|
14
|
+
from ferrum.config import (
|
|
15
|
+
CLOUD_PRESET_URL,
|
|
16
|
+
INT_FIELDS,
|
|
17
|
+
LOCAL_PRESET_URL,
|
|
18
|
+
STR_FIELDS,
|
|
19
|
+
Config,
|
|
20
|
+
ConfigError,
|
|
21
|
+
config_file_path,
|
|
22
|
+
load_config,
|
|
23
|
+
mask_secret,
|
|
24
|
+
read_config_file,
|
|
25
|
+
setting_source,
|
|
26
|
+
write_config_value,
|
|
27
|
+
write_config_values,
|
|
28
|
+
)
|
|
29
|
+
from ferrum.context import discover, find_project_root
|
|
30
|
+
from ferrum.model import (
|
|
31
|
+
OpenAICompatibleProvider,
|
|
32
|
+
ProviderError,
|
|
33
|
+
list_models,
|
|
34
|
+
probe_endpoint,
|
|
35
|
+
)
|
|
36
|
+
from ferrum.tools import default_tools
|
|
37
|
+
from ferrum.verifier import Verifier
|
|
38
|
+
|
|
39
|
+
PROG = "ferrum"
|
|
40
|
+
DESCRIPTION = "Ferrum — a coding harness for low-level and systems programming."
|
|
41
|
+
|
|
42
|
+
EXIT_OK = 0
|
|
43
|
+
EXIT_ERROR = 1
|
|
44
|
+
|
|
45
|
+
EPILOG = """examples:
|
|
46
|
+
ferrum . inspect a project
|
|
47
|
+
ferrum "task" diagnose with the model (read-only)
|
|
48
|
+
ferrum fix "task" patch, build, and verify
|
|
49
|
+
ferrum fix --dry-run "t" show the proposed patch, write nothing
|
|
50
|
+
ferrum doctor check config, endpoint, and toolchain
|
|
51
|
+
ferrum config show base_url / model / api_key
|
|
52
|
+
ferrum config local switch to Ollama on localhost
|
|
53
|
+
ferrum config cloud switch to the cloud endpoint
|
|
54
|
+
ferrum config models list models at the current endpoint
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
CONFIG_USAGE = """usage:
|
|
58
|
+
ferrum config show current settings
|
|
59
|
+
ferrum config get <key> print one setting
|
|
60
|
+
ferrum config set <key> <value> save one setting ('' clears it)
|
|
61
|
+
ferrum config models list models at the current endpoint
|
|
62
|
+
ferrum config local use Ollama at http://localhost:11434/v1
|
|
63
|
+
ferrum config cloud [url] use the cloud endpoint
|
|
64
|
+
keys: base_url, model, api_key, """ + ", ".join(sorted(INT_FIELDS))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
68
|
+
parser = argparse.ArgumentParser(
|
|
69
|
+
prog=PROG,
|
|
70
|
+
description=DESCRIPTION,
|
|
71
|
+
epilog=EPILOG,
|
|
72
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"-V",
|
|
76
|
+
"--version",
|
|
77
|
+
action="version",
|
|
78
|
+
version=f"{PROG} {__version__}",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"-v",
|
|
82
|
+
"--verbose",
|
|
83
|
+
action="store_true",
|
|
84
|
+
help="enable debug logging on stderr",
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--dry-run",
|
|
88
|
+
action="store_true",
|
|
89
|
+
help="with fix: show the proposed patch but never write files",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"target",
|
|
93
|
+
nargs="?",
|
|
94
|
+
metavar="TARGET",
|
|
95
|
+
help="directory to inspect, or a task for the model",
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"extra",
|
|
99
|
+
nargs="*",
|
|
100
|
+
help=argparse.SUPPRESS,
|
|
101
|
+
)
|
|
102
|
+
return parser
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def setup_logging(verbose: bool = False) -> None:
|
|
106
|
+
"""Quiet by default; -v turns on debug output."""
|
|
107
|
+
logging.basicConfig(
|
|
108
|
+
level=logging.DEBUG if verbose else logging.WARNING,
|
|
109
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
110
|
+
stream=sys.stderr,
|
|
111
|
+
force=True,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _force_utf8_streams() -> None:
|
|
116
|
+
# Without this, the box-drawing line in the summary dies when output is piped.
|
|
117
|
+
for stream in (sys.stdout, sys.stderr):
|
|
118
|
+
try:
|
|
119
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
120
|
+
except (AttributeError, ValueError, OSError):
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _inspect(target: str) -> int:
|
|
125
|
+
path = Path(target)
|
|
126
|
+
if not path.is_dir():
|
|
127
|
+
print(
|
|
128
|
+
f"error: not a directory: {target} (pass a directory to inspect)",
|
|
129
|
+
file=sys.stderr,
|
|
130
|
+
)
|
|
131
|
+
return EXIT_ERROR
|
|
132
|
+
context = discover(find_project_root(path), load_config())
|
|
133
|
+
print(context.summary())
|
|
134
|
+
return EXIT_OK
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _env_override_note(key: str) -> str | None:
|
|
138
|
+
var = STR_FIELDS.get(key) or INT_FIELDS.get(key)
|
|
139
|
+
if var and os.environ.get(var):
|
|
140
|
+
return f"note: {var} is set in the environment and overrides the file"
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _config_show() -> int:
|
|
145
|
+
try:
|
|
146
|
+
config = load_config()
|
|
147
|
+
path = config_file_path()
|
|
148
|
+
sources = {key: setting_source(key) for key in STR_FIELDS}
|
|
149
|
+
except ConfigError as exc:
|
|
150
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
151
|
+
return EXIT_ERROR
|
|
152
|
+
for key in ("base_url", "model", "api_key"):
|
|
153
|
+
value = getattr(config, key)
|
|
154
|
+
if key == "api_key":
|
|
155
|
+
display = mask_secret(value)
|
|
156
|
+
else:
|
|
157
|
+
display = value or "(not set)"
|
|
158
|
+
print(f"{key:<9} = {display} [{sources[key]}]")
|
|
159
|
+
suffix = "" if path.exists() else " (not created yet)"
|
|
160
|
+
print(f"config file: {path}{suffix}")
|
|
161
|
+
return EXIT_OK
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _config_get(key: str) -> int:
|
|
165
|
+
known = {**STR_FIELDS, **INT_FIELDS}
|
|
166
|
+
if key not in known:
|
|
167
|
+
print(
|
|
168
|
+
f"error: unknown setting {key!r}; choose from: "
|
|
169
|
+
+ ", ".join(sorted(known)),
|
|
170
|
+
file=sys.stderr,
|
|
171
|
+
)
|
|
172
|
+
return EXIT_ERROR
|
|
173
|
+
try:
|
|
174
|
+
value = getattr(load_config(), key)
|
|
175
|
+
except ConfigError as exc:
|
|
176
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
177
|
+
return EXIT_ERROR
|
|
178
|
+
if value == "" or value is None:
|
|
179
|
+
print(f"{key} is not set.", file=sys.stderr)
|
|
180
|
+
return EXIT_ERROR
|
|
181
|
+
print(value)
|
|
182
|
+
return EXIT_OK
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _config_set(key: str, value: str) -> int:
|
|
186
|
+
try:
|
|
187
|
+
path = write_config_value(key, value)
|
|
188
|
+
except ConfigError as exc:
|
|
189
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
190
|
+
return EXIT_ERROR
|
|
191
|
+
if value == "":
|
|
192
|
+
print(f"{key} cleared ({path})")
|
|
193
|
+
else:
|
|
194
|
+
display = mask_secret(value) if key == "api_key" else value
|
|
195
|
+
print(f"{key} = {display} [saved to {path}]")
|
|
196
|
+
note = _env_override_note(key)
|
|
197
|
+
if note:
|
|
198
|
+
print(note, file=sys.stderr)
|
|
199
|
+
return EXIT_OK
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _config_models() -> int:
|
|
203
|
+
try:
|
|
204
|
+
config = load_config()
|
|
205
|
+
except ConfigError as exc:
|
|
206
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
207
|
+
return EXIT_ERROR
|
|
208
|
+
try:
|
|
209
|
+
available = list_models(config.base_url, timeout=15, api_key=config.api_key)
|
|
210
|
+
except ProviderError as exc:
|
|
211
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
212
|
+
return EXIT_ERROR
|
|
213
|
+
if not available:
|
|
214
|
+
print(
|
|
215
|
+
f"no models listed at {config.base_url}"
|
|
216
|
+
" (the server may not support GET /models,"
|
|
217
|
+
" or the api_key is wrong)",
|
|
218
|
+
file=sys.stderr,
|
|
219
|
+
)
|
|
220
|
+
return EXIT_ERROR
|
|
221
|
+
for name in available:
|
|
222
|
+
print(name)
|
|
223
|
+
print(f"\nset one: ferrum config set model {available[0]}", file=sys.stderr)
|
|
224
|
+
return EXIT_OK
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _config_preset(kind: str, rest: list[str]) -> int:
|
|
228
|
+
# The api_key is never touched: local servers ignore it, and clearing it
|
|
229
|
+
# would force re-entering the cloud key on every switch back.
|
|
230
|
+
if kind == "local":
|
|
231
|
+
url = LOCAL_PRESET_URL
|
|
232
|
+
else:
|
|
233
|
+
url = rest[0] if rest else CLOUD_PRESET_URL
|
|
234
|
+
try:
|
|
235
|
+
path = write_config_values({"base_url": url})
|
|
236
|
+
except ConfigError as exc:
|
|
237
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
238
|
+
return EXIT_ERROR
|
|
239
|
+
print(f"base_url = {url} [saved to {path}]")
|
|
240
|
+
note = _env_override_note("base_url")
|
|
241
|
+
if note:
|
|
242
|
+
print(note, file=sys.stderr)
|
|
243
|
+
if kind == "cloud" and not os.environ.get("FERRUM_API_KEY") and not _file_api_key():
|
|
244
|
+
print(
|
|
245
|
+
"warning: api_key is not set —"
|
|
246
|
+
" ferrum config set api_key <key>",
|
|
247
|
+
file=sys.stderr,
|
|
248
|
+
)
|
|
249
|
+
print("pick a model: ferrum config models"
|
|
250
|
+
" then ferrum config set model <name>", file=sys.stderr)
|
|
251
|
+
return EXIT_OK
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _file_api_key() -> str:
|
|
255
|
+
value = read_config_file().get("api_key", "")
|
|
256
|
+
return value if isinstance(value, str) else ""
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _config_command(words: list[str]) -> int:
|
|
260
|
+
if not words:
|
|
261
|
+
return _config_show()
|
|
262
|
+
cmd = words[0]
|
|
263
|
+
if cmd == "get":
|
|
264
|
+
if len(words) != 2:
|
|
265
|
+
print("error: usage: ferrum config get <key>", file=sys.stderr)
|
|
266
|
+
return EXIT_ERROR
|
|
267
|
+
return _config_get(words[1])
|
|
268
|
+
if cmd == "set":
|
|
269
|
+
if len(words) < 3:
|
|
270
|
+
print("error: usage: ferrum config set <key> <value>", file=sys.stderr)
|
|
271
|
+
return EXIT_ERROR
|
|
272
|
+
return _config_set(words[1], " ".join(words[2:]))
|
|
273
|
+
if cmd == "models":
|
|
274
|
+
return _config_models()
|
|
275
|
+
if cmd in {"local", "cloud"}:
|
|
276
|
+
return _config_preset(cmd, words[1:])
|
|
277
|
+
print(f"error: unknown config command: {cmd}", file=sys.stderr)
|
|
278
|
+
print(CONFIG_USAGE, file=sys.stderr)
|
|
279
|
+
return EXIT_ERROR
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _doctor() -> int:
|
|
283
|
+
"""One screen that answers: can ferrum run a task here, and if not, why?"""
|
|
284
|
+
problems: list[str] = []
|
|
285
|
+
warnings: list[str] = []
|
|
286
|
+
|
|
287
|
+
print(f"{'python':<12}{sys.version.split()[0]} (need >= 3.10)")
|
|
288
|
+
keys = ("base_url", "model", "api_key")
|
|
289
|
+
try:
|
|
290
|
+
config = load_config()
|
|
291
|
+
sources = {key: setting_source(key) for key in keys}
|
|
292
|
+
except ConfigError as exc:
|
|
293
|
+
print(f"{'config':<12}broken — {exc}")
|
|
294
|
+
problems.append("fix or delete the config file")
|
|
295
|
+
config = Config() # defaults, so the rest of the report still runs
|
|
296
|
+
sources = {key: "default" for key in keys}
|
|
297
|
+
|
|
298
|
+
path = config_file_path()
|
|
299
|
+
print(f"{'config file':<12}{path}{' ' if path.exists() else ' (not created yet)'}")
|
|
300
|
+
for key in keys:
|
|
301
|
+
value = getattr(config, key)
|
|
302
|
+
display = mask_secret(value) if key == "api_key" else (value or "(not set)")
|
|
303
|
+
print(f"{key:<12}{display} [{sources[key]}]")
|
|
304
|
+
if key == "model" and not value:
|
|
305
|
+
problems.append("model is not set — ferrum config set model <name>")
|
|
306
|
+
if not config.api_key and not config.base_url.startswith("http://localhost"):
|
|
307
|
+
warnings.append("cloud endpoints usually need an api_key")
|
|
308
|
+
|
|
309
|
+
models, error = probe_endpoint(config.base_url, timeout=10, api_key=config.api_key)
|
|
310
|
+
if error:
|
|
311
|
+
print(f"{'endpoint':<12}unreachable — {error}")
|
|
312
|
+
problems.append(f"cannot reach {config.base_url} (is the server running?)")
|
|
313
|
+
elif models:
|
|
314
|
+
print(f"{'endpoint':<12}ok — {len(models)} models listed")
|
|
315
|
+
else:
|
|
316
|
+
print(f"{'endpoint':<12}answered, but listed no models")
|
|
317
|
+
warnings.append("the server listed no models; chat may still work")
|
|
318
|
+
|
|
319
|
+
found = [tool for tool in ("clang", "gcc", "make", "cargo", "cmake")
|
|
320
|
+
if shutil.which(tool)]
|
|
321
|
+
print(f"{'toolchain':<12}{', '.join(found) if found else 'nothing found'}")
|
|
322
|
+
if not {"clang", "gcc"} & set(found):
|
|
323
|
+
warnings.append("no C compiler — fix mode cannot verify C builds")
|
|
324
|
+
if not found:
|
|
325
|
+
warnings.append("nothing to verify builds with; run commands are unavailable")
|
|
326
|
+
|
|
327
|
+
print()
|
|
328
|
+
for line in problems:
|
|
329
|
+
print(f"problem: {line}")
|
|
330
|
+
for line in warnings:
|
|
331
|
+
print(f"warning: {line}")
|
|
332
|
+
if problems:
|
|
333
|
+
print("verdict: not ready")
|
|
334
|
+
return EXIT_ERROR
|
|
335
|
+
print("verdict: ready")
|
|
336
|
+
return EXIT_OK
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _confirm(prompt: str) -> bool:
|
|
340
|
+
try:
|
|
341
|
+
answer = input(prompt)
|
|
342
|
+
except (EOFError, KeyboardInterrupt):
|
|
343
|
+
print()
|
|
344
|
+
return False
|
|
345
|
+
return answer.strip().lower() in {"y", "yes"}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _agent_task(task: str, *, edit: bool, dry_run: bool = False) -> int:
|
|
349
|
+
try:
|
|
350
|
+
config = load_config()
|
|
351
|
+
except ConfigError as exc:
|
|
352
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
353
|
+
return EXIT_ERROR
|
|
354
|
+
missing = config.missing()
|
|
355
|
+
if missing:
|
|
356
|
+
print(f"error: {', '.join(missing)} is not set.", file=sys.stderr)
|
|
357
|
+
print(
|
|
358
|
+
f" point it at a model served by {config.base_url}:",
|
|
359
|
+
file=sys.stderr,
|
|
360
|
+
)
|
|
361
|
+
print(" ferrum config models", file=sys.stderr)
|
|
362
|
+
print(" ferrum config set model <name>", file=sys.stderr)
|
|
363
|
+
print(
|
|
364
|
+
f" (or export {', '.join(missing)}=... for this shell)",
|
|
365
|
+
file=sys.stderr,
|
|
366
|
+
)
|
|
367
|
+
try:
|
|
368
|
+
available = list_models(
|
|
369
|
+
config.base_url, timeout=15, api_key=config.api_key
|
|
370
|
+
)
|
|
371
|
+
except ProviderError:
|
|
372
|
+
available = []
|
|
373
|
+
if available:
|
|
374
|
+
print(f" available: {', '.join(available)}", file=sys.stderr)
|
|
375
|
+
else:
|
|
376
|
+
print(f" ferrum cannot see any model at {config.base_url}", file=sys.stderr)
|
|
377
|
+
return EXIT_ERROR
|
|
378
|
+
|
|
379
|
+
root = find_project_root(Path.cwd())
|
|
380
|
+
context = discover(root, config)
|
|
381
|
+
provider = OpenAICompatibleProvider(
|
|
382
|
+
config.base_url,
|
|
383
|
+
config.model,
|
|
384
|
+
config.api_key,
|
|
385
|
+
config.request_timeout,
|
|
386
|
+
config.max_tokens,
|
|
387
|
+
)
|
|
388
|
+
agent = Agent(
|
|
389
|
+
config,
|
|
390
|
+
provider,
|
|
391
|
+
default_tools(root, config),
|
|
392
|
+
printer=print,
|
|
393
|
+
verifier=Verifier(root, timeout=config.command_timeout),
|
|
394
|
+
)
|
|
395
|
+
try:
|
|
396
|
+
result = agent.run(task, context, edit=edit, confirm=_confirm, dry_run=dry_run)
|
|
397
|
+
except ProviderError as exc:
|
|
398
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
399
|
+
return EXIT_ERROR
|
|
400
|
+
|
|
401
|
+
if result.text:
|
|
402
|
+
print()
|
|
403
|
+
print(result.text)
|
|
404
|
+
if result.patched:
|
|
405
|
+
print()
|
|
406
|
+
if result.verified:
|
|
407
|
+
print("✓ Fix verified")
|
|
408
|
+
elif result.verified is False:
|
|
409
|
+
print("✗ Fix not verified — build or tests did not pass")
|
|
410
|
+
if dry_run:
|
|
411
|
+
print()
|
|
412
|
+
print("(dry run — no files were changed)")
|
|
413
|
+
if result.capped:
|
|
414
|
+
return EXIT_ERROR
|
|
415
|
+
if result.patched and result.verified is False:
|
|
416
|
+
return EXIT_ERROR
|
|
417
|
+
return EXIT_OK
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def main(argv: list[str] | None = None) -> int:
|
|
421
|
+
# Plain white text, always: Python 3.14+ colours argparse help and
|
|
422
|
+
# tracebacks on a terminal otherwise.
|
|
423
|
+
os.environ["PYTHON_COLORS"] = "0"
|
|
424
|
+
_force_utf8_streams()
|
|
425
|
+
args_list = list(sys.argv[1:] if argv is None else argv)
|
|
426
|
+
|
|
427
|
+
parser = build_parser()
|
|
428
|
+
args = parser.parse_args(args_list)
|
|
429
|
+
setup_logging(args.verbose)
|
|
430
|
+
|
|
431
|
+
if args.target is None:
|
|
432
|
+
if args.dry_run:
|
|
433
|
+
print(
|
|
434
|
+
'error: usage: ferrum fix --dry-run "<task>"',
|
|
435
|
+
file=sys.stderr,
|
|
436
|
+
)
|
|
437
|
+
return EXIT_ERROR
|
|
438
|
+
parser.print_help()
|
|
439
|
+
return EXIT_OK
|
|
440
|
+
|
|
441
|
+
if args.target == "fix":
|
|
442
|
+
task = " ".join(args.extra).strip()
|
|
443
|
+
if not task:
|
|
444
|
+
print('error: usage: ferrum fix "<task>"', file=sys.stderr)
|
|
445
|
+
return EXIT_ERROR
|
|
446
|
+
return _agent_task(task, edit=True, dry_run=args.dry_run)
|
|
447
|
+
|
|
448
|
+
if args.dry_run:
|
|
449
|
+
print(
|
|
450
|
+
'error: --dry-run only applies to: ferrum fix --dry-run "<task>"',
|
|
451
|
+
file=sys.stderr,
|
|
452
|
+
)
|
|
453
|
+
return EXIT_ERROR
|
|
454
|
+
|
|
455
|
+
if args.target == "config":
|
|
456
|
+
return _config_command(args.extra)
|
|
457
|
+
|
|
458
|
+
if args.target == "doctor":
|
|
459
|
+
if args.extra:
|
|
460
|
+
print("error: usage: ferrum doctor", file=sys.stderr)
|
|
461
|
+
return EXIT_ERROR
|
|
462
|
+
return _doctor()
|
|
463
|
+
|
|
464
|
+
target = args.target
|
|
465
|
+
path = Path(target)
|
|
466
|
+
if path.is_dir():
|
|
467
|
+
return _inspect(target)
|
|
468
|
+
if path.exists():
|
|
469
|
+
return _inspect(target) # prints the not-a-directory error
|
|
470
|
+
if "/" in target or "\\" in target or target.startswith("."):
|
|
471
|
+
print(f"error: no such directory: {target}", file=sys.stderr)
|
|
472
|
+
return EXIT_ERROR
|
|
473
|
+
|
|
474
|
+
task = " ".join([target, *args.extra])
|
|
475
|
+
return _agent_task(task, edit=False)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
if __name__ == "__main__":
|
|
479
|
+
sys.exit(main())
|
ferrum/config.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Harness settings: model endpoint plus the limits that keep Ferrum small.
|
|
2
|
+
|
|
3
|
+
Settings resolve in layers: environment variable > config file > default.
|
|
4
|
+
The file lives outside the project (Windows: %APPDATA%\\ferrum\\config.json,
|
|
5
|
+
else $XDG_CONFIG_HOME/ferrum/config.json) so switching between a local
|
|
6
|
+
Ollama server and a cloud endpoint is one ``ferrum config`` command away.
|
|
7
|
+
Local models need no API key; every secret is optional.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
LOCAL_PRESET_URL = "http://localhost:11434/v1"
|
|
19
|
+
CLOUD_PRESET_URL = "https://api.routeway.ai/v1"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigError(Exception):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
INT_FIELDS = {
|
|
27
|
+
"max_file_bytes": "FERRUM_MAX_FILE_BYTES",
|
|
28
|
+
"max_context_bytes": "FERRUM_MAX_CONTEXT_BYTES",
|
|
29
|
+
"max_files": "FERRUM_MAX_FILES",
|
|
30
|
+
"max_search_results": "FERRUM_MAX_SEARCH_RESULTS",
|
|
31
|
+
"max_iterations": "FERRUM_MAX_ITERATIONS",
|
|
32
|
+
"max_tokens": "FERRUM_MAX_TOKENS",
|
|
33
|
+
"request_timeout": "FERRUM_TIMEOUT",
|
|
34
|
+
"command_timeout": "FERRUM_COMMAND_TIMEOUT",
|
|
35
|
+
}
|
|
36
|
+
STR_FIELDS = {
|
|
37
|
+
"base_url": "FERRUM_BASE_URL",
|
|
38
|
+
"model": "FERRUM_MODEL",
|
|
39
|
+
"api_key": "FERRUM_API_KEY",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def config_file_path() -> Path:
|
|
44
|
+
if sys.platform == "win32":
|
|
45
|
+
base = os.environ.get("APPDATA")
|
|
46
|
+
root = Path(base) if base else Path.home() / ".config"
|
|
47
|
+
else:
|
|
48
|
+
root = Path(os.environ.get("XDG_CONFIG_HOME")) if os.environ.get(
|
|
49
|
+
"XDG_CONFIG_HOME"
|
|
50
|
+
) else Path.home() / ".config"
|
|
51
|
+
return root / "ferrum" / "config.json"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_config_file() -> dict[str, object]:
|
|
55
|
+
path = config_file_path()
|
|
56
|
+
if not path.exists():
|
|
57
|
+
return {}
|
|
58
|
+
try:
|
|
59
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
60
|
+
except (OSError, ValueError) as exc:
|
|
61
|
+
raise ConfigError(f"cannot read {path}: {exc}") from exc
|
|
62
|
+
if not isinstance(data, dict):
|
|
63
|
+
raise ConfigError(f"{path} must contain a JSON object")
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write_config_values(values: dict[str, str]) -> Path:
|
|
68
|
+
"""Merge settings into the config file; an empty value removes the key."""
|
|
69
|
+
known = {**STR_FIELDS, **INT_FIELDS}
|
|
70
|
+
for key in values:
|
|
71
|
+
if key not in known:
|
|
72
|
+
raise ConfigError(
|
|
73
|
+
f"unknown setting {key!r}; choose from: "
|
|
74
|
+
+ ", ".join(sorted(known))
|
|
75
|
+
)
|
|
76
|
+
path = config_file_path()
|
|
77
|
+
data = read_config_file()
|
|
78
|
+
for key, value in values.items():
|
|
79
|
+
if value == "":
|
|
80
|
+
data.pop(key, None)
|
|
81
|
+
elif key in INT_FIELDS:
|
|
82
|
+
try:
|
|
83
|
+
data[key] = int(value)
|
|
84
|
+
except ValueError as exc:
|
|
85
|
+
raise ConfigError(
|
|
86
|
+
f"{key} must be an integer, got {value!r}"
|
|
87
|
+
) from exc
|
|
88
|
+
else:
|
|
89
|
+
data[key] = value
|
|
90
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
92
|
+
return path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_config_value(key: str, value: str) -> Path:
|
|
96
|
+
return write_config_values({key: value})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def mask_secret(value: str) -> str:
|
|
100
|
+
if not value:
|
|
101
|
+
return "(not set)"
|
|
102
|
+
if len(value) <= 8:
|
|
103
|
+
return "*" * len(value)
|
|
104
|
+
return value[:6] + "..." + value[-4:]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class Config:
|
|
109
|
+
max_file_bytes: int = 256 * 1024 # one file
|
|
110
|
+
max_context_bytes: int = 400_000 # whole listing
|
|
111
|
+
max_files: int = 500
|
|
112
|
+
max_search_results: int = 200
|
|
113
|
+
max_iterations: int = 25 # agent loop cap
|
|
114
|
+
max_tokens: int = 2048 # per model response
|
|
115
|
+
request_timeout: int = 120 # per model request (CPU-served models are slow)
|
|
116
|
+
command_timeout: int = 300 # per build/test command
|
|
117
|
+
|
|
118
|
+
base_url: str = LOCAL_PRESET_URL
|
|
119
|
+
model: str = ""
|
|
120
|
+
api_key: str = ""
|
|
121
|
+
|
|
122
|
+
def __post_init__(self) -> None:
|
|
123
|
+
for name in INT_FIELDS:
|
|
124
|
+
value = getattr(self, name)
|
|
125
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
126
|
+
raise ConfigError(f"{name} must be a positive integer, got {value!r}")
|
|
127
|
+
if not isinstance(self.base_url, str) or not self.base_url.strip():
|
|
128
|
+
raise ConfigError(f"base_url must be a non-empty string, got {self.base_url!r}")
|
|
129
|
+
|
|
130
|
+
def missing(self) -> list[str]:
|
|
131
|
+
"""Env/file keys that must be set before the model can be used."""
|
|
132
|
+
missing = []
|
|
133
|
+
if not self.model:
|
|
134
|
+
missing.append(STR_FIELDS["model"])
|
|
135
|
+
return missing
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def setting_source(name: str) -> str:
|
|
139
|
+
"""Where a setting's effective value comes from: env, file, or default."""
|
|
140
|
+
var = STR_FIELDS.get(name) or INT_FIELDS.get(name)
|
|
141
|
+
if var and os.environ.get(var):
|
|
142
|
+
return f"env {var}"
|
|
143
|
+
if name in read_config_file():
|
|
144
|
+
return "file"
|
|
145
|
+
return "default"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_config(**overrides: object) -> Config:
|
|
149
|
+
values: dict[str, object] = {}
|
|
150
|
+
path = config_file_path()
|
|
151
|
+
for name, raw in read_config_file().items():
|
|
152
|
+
if name in STR_FIELDS:
|
|
153
|
+
if not isinstance(raw, str):
|
|
154
|
+
raise ConfigError(f"{path}: {name} must be a string")
|
|
155
|
+
if raw:
|
|
156
|
+
values[name] = raw
|
|
157
|
+
elif name in INT_FIELDS:
|
|
158
|
+
if not isinstance(raw, int) or isinstance(raw, bool):
|
|
159
|
+
raise ConfigError(f"{path}: {name} must be an integer")
|
|
160
|
+
values[name] = raw
|
|
161
|
+
else:
|
|
162
|
+
raise ConfigError(f"{path}: unknown setting {name!r}")
|
|
163
|
+
for name, var in STR_FIELDS.items():
|
|
164
|
+
raw = os.environ.get(var)
|
|
165
|
+
if raw:
|
|
166
|
+
values[name] = raw
|
|
167
|
+
for name, var in INT_FIELDS.items():
|
|
168
|
+
raw = os.environ.get(var)
|
|
169
|
+
if raw:
|
|
170
|
+
try:
|
|
171
|
+
values[name] = int(raw)
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
raise ConfigError(f"{var} must be an integer, got {raw!r}") from exc
|
|
174
|
+
values.update(overrides)
|
|
175
|
+
try:
|
|
176
|
+
return Config(**values)
|
|
177
|
+
except TypeError as exc:
|
|
178
|
+
raise ConfigError(str(exc)) from exc
|