Skip to content

Commit f0c3212

Browse files
authored
feat: retrieve config value using template replacement in skill content (#209)
Signed-off-by: Frost Ming <me@frostming.com>
1 parent 7939dc3 commit f0c3212

5 files changed

Lines changed: 189 additions & 7 deletions

File tree

src/bub/configure.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
CONFIG_MAP: dict[str, list[type[BaseSettings]]] = {}
88
ROOT = ""
9+
MISSING = object()
910

1011
_global_config: dict[str, list[BaseSettings]] = {}
1112
_config_data: dict[str, Any] = {}
@@ -95,6 +96,22 @@ def ensure_config[C: BaseSettings](config_cls: type[C]) -> C:
9596
return instance
9697

9798

99+
def get_value(path: str, default: Any = MISSING) -> Any:
100+
"""Get a loaded config value by dotted path, preserving registered settings behavior."""
101+
102+
parts = [part for part in path.split(".") if part]
103+
if not parts:
104+
raise ValueError("config path must not be empty")
105+
106+
value = _lookup_registered_config(parts)
107+
if value is not MISSING:
108+
return value
109+
110+
if default is not MISSING:
111+
return default
112+
raise KeyError(path)
113+
114+
98115
def _copy_dict(data: dict[str, Any]) -> dict[str, Any]:
99116
copied: dict[str, Any] = {}
100117
for key, value in data.items():
@@ -105,6 +122,36 @@ def _copy_dict(data: dict[str, Any]) -> dict[str, Any]:
105122
return copied
106123

107124

125+
def _lookup_registered_config(parts: list[str]) -> Any:
126+
section, *subpath = parts
127+
if section in CONFIG_MAP and section != ROOT:
128+
for config_cls in CONFIG_MAP[section]:
129+
value = _lookup_path(ensure_config(config_cls), subpath)
130+
if value is not MISSING:
131+
return value
132+
133+
for config_cls in CONFIG_MAP.get(ROOT, []):
134+
value = _lookup_path(ensure_config(config_cls), parts)
135+
if value is not MISSING:
136+
return value
137+
138+
return MISSING
139+
140+
141+
def _lookup_path(value: Any, parts: list[str]) -> Any:
142+
current = value
143+
for part in parts:
144+
if isinstance(current, dict):
145+
if part not in current:
146+
return MISSING
147+
current = current[part]
148+
continue
149+
if not hasattr(current, part):
150+
return MISSING
151+
current = getattr(current, part)
152+
return current
153+
154+
108155
def _merge_into(target: dict[str, Any], incoming: dict[str, Any], path: tuple[str, ...]) -> None:
109156
for key, value in incoming.items():
110157
existing = target.get(key)

src/bub/skills.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,14 @@
1313

1414
import yaml
1515

16+
import bub.configure as configure
17+
1618
PROJECT_SKILLS_DIR = ".agents/skills"
1719
LEGACY_SKILLS_DIR = ".agent/skills"
1820
SKILL_FILE_NAME = "SKILL.md"
1921
SKILL_SOURCES = ("project", "global", "builtin")
2022
SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
23+
CONFIG_TEMPLATE_PATTERN = re.compile(r"\$\{\s*config\.([a-zA-Z0-9_.-]+)\s*\}")
2124

2225

2326
@dataclass(frozen=True)
@@ -33,11 +36,15 @@ class SkillMetadata:
3336
def body(self) -> str:
3437
front_matter_pattern = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL)
3538
try:
36-
template = string.Template(self.location.read_text(encoding="utf-8").strip())
39+
template_content = self.location.read_text(encoding="utf-8").strip()
3740
except OSError:
3841
return ""
39-
content = template.safe_substitute({"SKILL_DIR": str(self.location.parent), "PYTHON": sys.executable})
40-
return front_matter_pattern.sub("", content, count=1).strip()
42+
raw_content = front_matter_pattern.sub("", template_content, count=1).strip()
43+
content = _render_config_templates(raw_content)
44+
return string.Template(content).safe_substitute({
45+
"SKILL_DIR": str(self.location.parent),
46+
"PYTHON": sys.executable,
47+
})
4148

4249

4350
def discover_skills(workspace_path: Path) -> list[SkillMetadata]:
@@ -60,6 +67,23 @@ def discover_skills(workspace_path: Path) -> list[SkillMetadata]:
6067
return sorted(skills_by_name.values(), key=lambda item: item.name.casefold())
6168

6269

70+
def _render_config_templates(content: str) -> str:
71+
def replace(match: re.Match[str]) -> str:
72+
try:
73+
value = configure.get_value(match.group(1), default="")
74+
except KeyError:
75+
return match.group(0)
76+
if isinstance(value, str):
77+
return value
78+
if isinstance(value, bool):
79+
return "true" if value else "false"
80+
if isinstance(value, int | float):
81+
return str(value)
82+
return yaml.safe_dump(value, sort_keys=False).strip()
83+
84+
return CONFIG_TEMPLATE_PATTERN.sub(replace, content)
85+
86+
6387
def _read_skill(skill_dir: Path, *, source: str) -> SkillMetadata | None:
6488
skill_file = skill_dir / SKILL_FILE_NAME
6589
if not skill_file.is_file():

src/skills/telegram/SKILL.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ metadata:
1313

1414
Agent-facing execution guide for Telegram outbound communication.
1515

16-
Assumption: `BUB_TELEGRAM_TOKEN` is already available.
16+
Env vars:
17+
18+
- `BUB_TELEGRAM_TOKEN=${config.telegram.token}`
1719

1820
## Required Inputs
1921
Collect these before execution:
@@ -76,18 +78,18 @@ Paths are relative to this skill directory.
7678

7779
```bash
7880
# Send message (ALWAYS use heredoc stdin, never inline text in arguments)
79-
cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_send.py --chat-id <CHAT_ID> --message -
81+
cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_send.py --chat-id <CHAT_ID> --token "$BUB_TELEGRAM_TOKEN" --message -
8082
Your message content here.
8183
Special characters are safe: $100, "quotes", 'apostrophes', !exclamation
8284
EOF
8385

8486
# Reply to a specific message
85-
cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_send.py --chat-id <CHAT_ID> --reply-to <MESSAGE_ID> --message -
87+
cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_send.py --chat-id <CHAT_ID> --token "$BUB_TELEGRAM_TOKEN" --reply-to <MESSAGE_ID> --message -
8688
Reply content here.
8789
EOF
8890

8991
# Edit an existing message
90-
cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_edit.py --chat-id <CHAT_ID> --message-id <MESSAGE_ID> --text -
92+
cat << 'EOF' | uv run ${SKILL_DIR}/scripts/telegram_edit.py --chat-id <CHAT_ID> --token "$BUB_TELEGRAM_TOKEN" --message-id <MESSAGE_ID> --text -
9193
Updated content here.
9294
EOF
9395
```

tests/test_configure.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,59 @@ def test_save_writes_yaml_and_refreshes_loaded_config(tmp_path: Path) -> None:
7575
assert configure.ensure_config(TelegramSettings).token == expected_token
7676
finally:
7777
os.chdir(previous_cwd)
78+
79+
80+
def test_get_value_reads_registered_section_from_yaml(load_config) -> None:
81+
with patch.dict(os.environ, {}, clear=True):
82+
load_config(
83+
"""
84+
telegram:
85+
token: yaml-token
86+
""".strip(),
87+
)
88+
89+
assert configure.get_value("telegram.token") == "yaml-token"
90+
91+
92+
def test_get_value_prefers_registered_env_over_yaml(load_config) -> None:
93+
load_config(
94+
"""
95+
telegram:
96+
token: yaml-token
97+
""".strip(),
98+
)
99+
100+
with patch.dict(os.environ, {"BUB_TELEGRAM_TOKEN": "env-token"}, clear=True):
101+
configure._global_config.clear()
102+
103+
assert configure.get_value("telegram.token") == "env-token"
104+
105+
106+
def test_get_value_descends_into_registered_dict_field(load_config) -> None:
107+
with patch.dict(os.environ, {}, clear=True):
108+
load_config(
109+
"""
110+
api_key:
111+
openai: sk-yaml
112+
""".strip(),
113+
)
114+
115+
assert configure.get_value("api_key") == {"openai": "sk-yaml"}
116+
assert configure.get_value("api_key.openai") == "sk-yaml"
117+
118+
119+
def test_get_value_ignores_raw_unregistered_path(load_config) -> None:
120+
load_config(
121+
"""
122+
custom:
123+
nested:
124+
value: raw-value
125+
""".strip(),
126+
)
127+
128+
with pytest.raises(KeyError):
129+
configure.get_value("custom.nested.value")
130+
131+
132+
def test_get_value_returns_default_for_missing_path() -> None:
133+
assert configure.get_value("missing.value", default="fallback") == "fallback"

tests/test_skills.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from pathlib import Path
2+
from unittest.mock import patch
23

4+
import bub.configure as configure
5+
from bub.channels.telegram import TelegramSettings
36
from bub.skills import (
47
SKILL_FILE_NAME,
58
SkillMetadata,
@@ -46,6 +49,56 @@ def test_skill_metadata_body_strips_frontmatter(tmp_path: Path) -> None:
4649
assert metadata.body() == "Line 1\nLine 2"
4750

4851

52+
def test_skill_metadata_body_renders_config_templates(tmp_path: Path, load_config) -> None:
53+
assert TelegramSettings.__name__ == "TelegramSettings"
54+
skill_file = _write_skill(
55+
tmp_path,
56+
"demo-skill",
57+
body='Token: "${config.telegram.token}"\nSkill dir: $SKILL_DIR',
58+
)
59+
metadata = SkillMetadata(
60+
name="demo-skill",
61+
description="Demo",
62+
location=skill_file,
63+
source="project",
64+
)
65+
66+
with patch.dict("os.environ", {}, clear=True):
67+
load_config(
68+
"""
69+
telegram:
70+
token: yaml-token
71+
""".strip(),
72+
)
73+
74+
body = metadata.body()
75+
76+
assert 'Token: "yaml-token"' in body
77+
assert f"Skill dir: {tmp_path / 'demo-skill'}" in body
78+
79+
80+
def test_skill_metadata_body_renders_env_over_config(tmp_path: Path, load_config) -> None:
81+
assert TelegramSettings.__name__ == "TelegramSettings"
82+
skill_file = _write_skill(tmp_path, "demo-skill", body='Token: "${config.telegram.token}"')
83+
metadata = SkillMetadata(
84+
name="demo-skill",
85+
description="Demo",
86+
location=skill_file,
87+
source="project",
88+
)
89+
load_config(
90+
"""
91+
telegram:
92+
token: yaml-token
93+
""".strip(),
94+
)
95+
96+
with patch.dict("os.environ", {"BUB_TELEGRAM_TOKEN": "env-token"}, clear=True):
97+
configure._global_config.clear()
98+
99+
assert metadata.body() == 'Token: "env-token"'
100+
101+
49102
def test_read_skill_rejects_invalid_metadata_field_type(tmp_path: Path) -> None:
50103
skill_dir = tmp_path / "bad-skill"
51104
skill_dir.mkdir()

0 commit comments

Comments
 (0)