Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,30 @@ OPENROUTER_API_KEY=sk-or-...
Get one at https://openrouter.ai/keys. (`OPENAI_API_KEY` is also accepted as a
fallback.)

## Configuration file

GCode reads optional settings from a `.gcoderc` file (in the project root, or
`~/.gcode/.gcoderc` for user-wide defaults). The format is simple `key = value`
lines with `#` comments. Command-line flags and environment variables still
take precedence over the file.

```
# .gcoderc
model = qwen/qwen3-coder:free
auto_approve = false
bash_timeout = 300
system_prompt = You are GCode, a coding agent.
```

Supported keys:

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `model` | string | first free model | Default model id (overridden by `--model` / `GCODE_MODEL`) |
| `auto_approve` | bool | `false` | Skip bash confirmation (overridden by `--yes`) |
| `bash_timeout` | int | `300` | Seconds before a bash command is killed |
| `system_prompt` | string | built-in | Custom system prompt for new sessions |

## Use

```bash
Expand Down
20 changes: 16 additions & 4 deletions gcode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
from gcode import __version__
from gcode import tools as tool_module
from gcode.agent import build_model, run_turn, trim_history
from gcode.config import (
load_config,
resolve_auto_approve,
resolve_bash_timeout,
resolve_model,
resolve_system_prompt,
)
from gcode.errors import format_model_error, missing_api_key_message
from gcode.history import DEFAULT_SESSION, clear, load, save
from gcode.models import (
Expand Down Expand Up @@ -250,8 +257,13 @@ def main() -> None:
# Load ~/.gcode/.env first (setup module's config location)
load_env()

# Load optional .gcoderc configuration (CLI/env still win over it)
config = load_config(project_root=os.getcwd())
tool_module.set_bash_timeout(resolve_bash_timeout(config, tool_module.BASH_TIMEOUT))
system_prompt = resolve_system_prompt(config, SYSTEM_PROMPT)

# Determine the model id early — Ollama models don't need an API key
model_id = args.model or os.environ.get("GCODE_MODEL") or DEFAULT_MODEL
model_id = resolve_model(config, args.model, os.environ.get("GCODE_MODEL")) or DEFAULT_MODEL
using_ollama = model_id.startswith("ollama/")

api_key = get_api_key()
Expand All @@ -273,7 +285,7 @@ def main() -> None:
# Running an Ollama model without any key — that's fine
api_key = "ollama"

tool_module.set_auto_approve(args.yes)
tool_module.set_auto_approve(resolve_auto_approve(config, args.yes))

try:
model = build_model(model_id, api_key)
Expand All @@ -285,7 +297,7 @@ def main() -> None:
session = args.session
messages = load(session)
if messages is None:
messages = [SystemMessage(content=SYSTEM_PROMPT)]
messages = [SystemMessage(content=system_prompt)]
else:
ui.info(f"Resumed session '{session}' — {len(messages)} messages.")

Expand Down Expand Up @@ -355,7 +367,7 @@ def main() -> None:
ui.info("Setup cancelled.")
elif cmd == "clear":
clear(session)
messages[:] = [SystemMessage(content=SYSTEM_PROMPT)]
messages[:] = [SystemMessage(content=system_prompt)]
ui.info("Started a fresh session.")
else:
ui.info(f"Unknown command: /{cmd} (try /help)")
Expand Down
102 changes: 102 additions & 0 deletions gcode/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Optional persistent configuration for GCode.

Reads ``.gcoderc`` (project root) then ``~/.gcode/.gcoderc`` (user home) using
a simple ``key = value`` format with ``#`` comments. Settings are optional and
override built-in defaults, but command-line flags and environment variables
still take precedence:

CLI flag / env var > config file > default

Supported keys: ``model``, ``auto_approve`` (true/false), ``system_prompt``,
and ``bash_timeout`` (seconds).
"""

from pathlib import Path
from typing import Any

CONFIG_FILE_NAME = ".gcoderc"
USER_CONFIG_PATH = Path.home() / ".gcode" / CONFIG_FILE_NAME

_BOOLS = {"true": True, "false": False, "yes": True, "no": False, "1": True, "0": False}


def _parse_value(raw: str) -> Any:
"""Parse a single config value into bool/int/string."""
value = raw.strip()
lowered = value.lower()
if lowered in _BOOLS:
return _BOOLS[lowered]
try:
return int(value)
except ValueError:
pass
return value.strip("\"'")


def _load_file(path: Path) -> dict[str, Any]:
"""Parse a config file into a plain dict."""
settings: dict[str, Any] = {}
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError:
return settings
for line in lines:
line = line.split("#", 1)[0].strip()
if not line or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
if key:
settings[key] = _parse_value(value)
return settings


def load_config(project_root: str | None = None) -> dict[str, Any]:
"""Load merged config: project ``.gcoderc`` overrides user config.

Later files win, so the project-local ``.gcoderc`` takes precedence over
the user-level ``~/.gcode/.gcoderc``.
"""
merged: dict[str, Any] = {}
candidates = []
if project_root:
candidates.append(Path(project_root) / CONFIG_FILE_NAME)
candidates.append(USER_CONFIG_PATH)
for path in candidates:
merged.update(_load_file(path))
return merged


def resolve_model(
config: dict[str, Any], cli_model: str | None, env_model: str | None
) -> str | None:
"""Resolve the model id: CLI > env > config > None."""
return (
cli_model
or env_model
or (config.get("model") if isinstance(config.get("model"), str) else None)
)


def resolve_auto_approve(config: dict[str, Any], cli_flag: bool) -> bool:
"""Resolve auto-approve: CLI flag > config > False."""
if cli_flag:
return True
value = config.get("auto_approve")
return value is True


def resolve_bash_timeout(config: dict[str, Any], default: int) -> int:
"""Resolve the bash tool timeout in seconds (config > default)."""
value = config.get("bash_timeout")
if isinstance(value, int) and value > 0:
return value
return default


def resolve_system_prompt(config: dict[str, Any], default: str) -> str:
"""Resolve the system prompt (config > default)."""
value = config.get("system_prompt")
if isinstance(value, str) and value.strip():
return value.strip()
return default
6 changes: 6 additions & 0 deletions gcode/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ def is_auto_approve() -> bool:
return AUTO_APPROVE


def set_bash_timeout(value: int) -> None:
"""Set the bash tool timeout in seconds (configurable via .gcoderc)."""
global BASH_TIMEOUT
BASH_TIMEOUT = value


@tool
def execute_bash(command: str) -> str:
"""Execute a bash command on the local machine and return its output.
Expand Down
67 changes: 67 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
from pathlib import Path

from gcode.config import (
load_config,
resolve_auto_approve,
resolve_bash_timeout,
resolve_model,
resolve_system_prompt,
)


def _write(tmp_path: Path, text: str) -> Path:
path = tmp_path / ".gcoderc"
path.write_text(text, encoding="utf-8")
return path


def test_load_config_parses_values(tmp_path):
_write(
tmp_path,
"# comment\n"
"model = qwen/qwen3-coder:free\n"
"auto_approve = true\n"
"bash_timeout = 120\n"
'system_prompt = "Custom prompt here"\n',
)
cfg = load_config(str(tmp_path))
assert cfg["model"] == "qwen/qwen3-coder:free"
assert cfg["auto_approve"] is True
assert cfg["bash_timeout"] == 120
assert cfg["system_prompt"] == "Custom prompt here"


def test_load_config_missing_file_is_empty(tmp_path):
assert load_config(str(tmp_path)) == {}


def test_load_config_ignores_garbage_lines(tmp_path):
_write(tmp_path, "not a valid line\n\n= no key\n# just a comment\n")
assert load_config(str(tmp_path)) == {}


def test_resolve_model_precedence_cli_wins():
cfg = {"model": "from-config"}
assert resolve_model(cfg, "from-cli", "from-env") == "from-cli"
assert resolve_model(cfg, None, "from-env") == "from-env"
assert resolve_model(cfg, None, None) == "from-config"
assert resolve_model({}, None, None) is None


def test_resolve_auto_approve_flag_wins():
assert resolve_auto_approve({"auto_approve": False}, True) is True
assert resolve_auto_approve({"auto_approve": True}, False) is True
assert resolve_auto_approve({"auto_approve": False}, False) is False
assert resolve_auto_approve({}, False) is False


def test_resolve_bash_timeout_config_wins():
assert resolve_bash_timeout({"bash_timeout": 60}, 300) == 60
assert resolve_bash_timeout({"bash_timeout": 0}, 300) == 300
assert resolve_bash_timeout({}, 300) == 300


def test_resolve_system_prompt_config_wins():
assert resolve_system_prompt({"system_prompt": "Custom"}, "default") == "Custom"
assert resolve_system_prompt({"system_prompt": " "}, "default") == "default"
assert resolve_system_prompt({}, "default") == "default"