feat(config): add /config TUI with tab-based settings and plugin config framework

- textual-based ConfigApp with General, Plugins, and per-plugin tabs
- GeneralConfig (user_name, assistant_name) + plugin_settings dict added to PyraConfig
- ConfigField dataclass and config_fields() method added to plugin protocol
- /config slash command in chat REPL launches the TUI
- pyra auto-runs setup wizard on first invocation when no config.yaml exists
- CLAUDE.md updated with config_fields() plugin guide and Code Inventory entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-05-18 21:28:19 +02:00
parent 6bb7c77692
commit 1201606187
7 changed files with 291 additions and 4 deletions
+27 -3
View File
@@ -94,7 +94,8 @@ the vault under namespaced keys (`plugin:{name}:{key}`).
| `cli.py` | Click entrypoint. Subcommands: `setup`, `chat`, `memory`, `plugin`, `daemon` |
| `setup/providers.py` | Provider registry — pure data, no I/O |
| `setup/wizard.py` | questionary-based interactive setup wizard |
| `config/schema.py` | Pydantic v2 models — `PyraConfig`, `PluginConfig`, `DaemonConfig` |
| `config/schema.py` | Pydantic v2 models — `PyraConfig`, `GeneralConfig`, `PluginConfig`, `DaemonConfig`; `plugin_settings` dict |
| `config/tui.py` | `textual`-based `/config` TUI — `ConfigApp`, `GENERAL_FIELDS`, `launch_config_tui()` |
| `config/manager.py` | ruamel.yaml round-trip config read/write, chmod 600 enforced |
| `config/dirs.py` | `bootstrap()` — creates `~/.pyra/` tree, checks vault sentinel every startup |
| `chat/session.py` | prompt_toolkit REPL loop, AI tool-use loop, plugin slash commands |
@@ -160,7 +161,7 @@ by convention in each plugin's `setup()` method.
```
2. Create `~/.pyra/plugins/<name>/plugin.py` exporting `get_plugin() -> BasePlugin`:
```python
from pyra.plugins.base import BasePlugin, Tool
from pyra.plugins.base import BasePlugin, ConfigField, Tool
class MyPlugin(BasePlugin):
name = "<name>"
@@ -183,6 +184,15 @@ by convention in each plugin's `setup()` method.
secret = console.input("Enter secret: ")
vault_writer("plugin:<name>:secret", secret)
def config_fields(self):
# Declare user-adjustable settings. Values are saved to config.yaml
# under plugin_settings["<name>"] and rendered in /config → plugin tab.
return [
ConfigField("api_url", "API URL", "text", "https://example.com",
description="Base URL for the service"),
ConfigField("verify_ssl", "Verify SSL", "bool", True),
]
def get_plugin():
return MyPlugin()
```
@@ -192,6 +202,10 @@ by convention in each plugin's `setup()` method.
- Never import from `pyra.vault` directly — use the `vault_reader`/`vault_writer` callables
- All write/destructive tools must set `requires_approval=True`
- Return strings from tool handlers (truncated to 4000 chars by executor)
- Implement `config_fields()` for any user-adjustable settings beyond credentials.
Return a list of `ConfigField` objects — the `/config` TUI renders them automatically
and saves values to `config.yaml` under `plugin_settings["<name>"]`.
Plugins that need no configuration can omit this method (base no-op is used).
---
@@ -322,6 +336,7 @@ Before writing any new utility function, class, or import block, check the **Cod
| `ruamel.yaml` | 0.18.0 | `config/manager.py` | Round-trip YAML read/write (preserves comments and formatting) |
| `pydantic` | 2.0.0 | `config/schema.py` | Config validation via `BaseModel` |
| `httpx` | 0.27.0 | `setup/wizard.py` | HTTP GET for local-server connectivity checks |
| `textual` | 1.0.0 | `config/tui.py` | Full-screen TUI framework — tabs, inputs, switches, data tables for `/config` |
Optional plugin extras (declared in `pyproject.toml [project.optional-dependencies]`):
@@ -388,6 +403,13 @@ Dataclass: `InjectionWarning(pattern_label: str, matched_text: str)`
| `config_exists` | `() -> bool` | True if `config.yaml` exists |
| `config_path` | `() -> Path` | Absolute path to `config.yaml` |
#### `config.tui`
| Symbol | Purpose |
|--------|---------|
| `launch_config_tui` | `() -> None` — opens the full-screen configuration TUI; blocks until user presses `q`/Escape |
| `GENERAL_FIELDS` | List of `_CoreField` entries — the single place to add new core settings to the General tab |
#### `config.dirs`
| Function | Signature | Purpose |
@@ -480,7 +502,8 @@ Import `console` from here; do not create a second `rich.Console()` in new code.
| Class | Module | Notes |
|-------|--------|-------|
| `PyraConfig` | `config.schema` | Top-level config; fields: `ai`, `memory`, `security`, `plugins`, `daemon` |
| `PyraConfig` | `config.schema` | Top-level config; fields: `ai`, `general`, `memory`, `security`, `plugins`, `daemon`, `plugin_settings` |
| `GeneralConfig` | `config.schema` | `general:` block — `user_name`, `assistant_name` |
| `ProviderConfig` | `config.schema` | `ai:` block — `provider_id`, `model`, `base_url` |
| `PluginConfig` | `config.schema` | `plugins:` block — `enabled`, `require_approval`, `log_executions` |
| `DaemonConfig` | `config.schema` | `daemon:` block |
@@ -489,6 +512,7 @@ Import `console` from here; do not create a second `rich.Console()` in new code.
| `ConversationHistory` | `chat.history` | Holds message list; builds API payload via `build_for_api()`; trims to token budget |
| `PluginRegistry` | `plugins.registry` | Singleton (`instance()` / `reset()`); aggregates tools, slash commands, system prompt additions |
| `ToolExecutor` | `plugins.executor` | Approval gate + injection scan + logging; call via `execute()` or `execute_tool_call_batch()` |
| `ConfigField` | `plugins.base` | Dataclass — declares one plugin config option (`key`, `label`, `type`, `default`, `options`, `description`); returned by `config_fields()` |
| `Tool` | `plugins.base` | Dataclass — `name`, `description`, `parameters` (JSON Schema), `handler`, `requires_approval` |
| `PyraPlugin` | `plugins.base` | `@runtime_checkable` Protocol — the plugin interface |
| `BasePlugin` | `plugins.base` | Concrete base with no-op defaults; plugins should inherit this |
+1
View File
@@ -17,6 +17,7 @@ dependencies = [
"pydantic>=2.0.0",
"httpx>=0.27.0",
"python-dotenv>=1.0.0",
"textual>=1.0.0",
]
[project.optional-dependencies]
+10
View File
@@ -33,6 +33,7 @@ _STATIC_COMMANDS = {
"/exit": "Exit Pyra",
"/clear": "Clear conversation history",
"/memory list": "List memory files",
"/config": "Open configuration TUI",
"/help": "Show available slash commands",
}
@@ -198,6 +199,15 @@ def start_chat() -> None:
_show_memory_list()
continue
if user_input == "/config":
from pyra.config.tui import launch_config_tui
launch_config_tui()
try:
cfg = load_config()
except FileNotFoundError:
pass
continue
if user_input in plugin_slash:
try:
plugin_slash[user_input]()
+4
View File
@@ -23,6 +23,10 @@ def main(ctx: click.Context) -> None:
"""Pyra — personal AI assistant."""
_bootstrap_or_exit()
if ctx.invoked_subcommand is None:
from pyra.config.manager import config_exists
if not config_exists():
from pyra.setup.wizard import run_setup
run_setup()
from pyra.chat.session import start_chat
start_chat()
+9
View File
@@ -1,3 +1,5 @@
from typing import Any
from pydantic import BaseModel, Field
@@ -7,6 +9,11 @@ class ProviderConfig(BaseModel):
base_url: str | None = None
class GeneralConfig(BaseModel):
user_name: str = "User"
assistant_name: str = "Pyra"
class MemoryConfig(BaseModel):
max_tokens_in_context: int = 4000
auto_load: bool = True
@@ -33,7 +40,9 @@ class DaemonConfig(BaseModel):
class PyraConfig(BaseModel):
version: int = 1
ai: ProviderConfig
general: GeneralConfig = Field(default_factory=GeneralConfig)
memory: MemoryConfig = Field(default_factory=MemoryConfig)
security: SecurityConfig = Field(default_factory=SecurityConfig)
plugins: PluginConfig = Field(default_factory=PluginConfig)
daemon: DaemonConfig = Field(default_factory=DaemonConfig)
plugin_settings: dict[str, Any] = Field(default_factory=dict)
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
from typing import Any, NamedTuple
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, VerticalScroll
from textual.coordinate import Coordinate
from textual.widget import Widget
from textual.widgets import Button, DataTable, Input, Label, Switch, TabbedContent, TabPane
from pyra.config.manager import load_config, save_config
from pyra.plugins.base import BasePlugin, ConfigField
from pyra.plugins.install import read_manifest
from pyra.plugins.loader import load_plugin_by_name
from pyra.utils.paths import pyra_home
class _CoreField(NamedTuple):
path: str # dotted path in PyraConfig, e.g. "general.user_name"
label: str
type: str # "text" | "bool"
default: Any
# ── Add new core settings here — one line each ────────────────────────────────
GENERAL_FIELDS: list[_CoreField] = [
_CoreField("general.user_name", "Your name", "text", "User"),
_CoreField("general.assistant_name", "Assistant name", "text", "Pyra"),
_CoreField("daemon.enabled", "Enable daemon", "bool", False),
]
# ─────────────────────────────────────────────────────────────────────────────
def _get_nested(obj: Any, path: str) -> Any:
for part in path.split("."):
obj = getattr(obj, part)
return obj
def _set_nested(obj: Any, path: str, value: Any) -> None:
parts = path.split(".")
for part in parts[:-1]:
obj = getattr(obj, part)
setattr(obj, parts[-1], value)
def _installed_plugins() -> list[tuple[str, dict, Any]]:
plugins_dir = pyra_home() / "plugins"
result = []
if plugins_dir.is_dir():
for entry in sorted(plugins_dir.iterdir()):
if entry.is_dir():
manifest = read_manifest(entry)
plugin = load_plugin_by_name(entry.name, plugins_dir)
result.append((entry.name, manifest, plugin))
return result
def _fid(path: str) -> str:
return "f-" + path.replace(".", "-")
def _pfid(plugin_name: str, key: str) -> str:
return f"pf-{plugin_name}-{key}"
# ── Tab widgets ───────────────────────────────────────────────────────────────
class _GeneralTab(Widget):
def compose(self) -> ComposeResult:
cfg = load_config()
with VerticalScroll():
for f in GENERAL_FIELDS:
current = _get_nested(cfg, f.path)
with Horizontal(classes="row"):
yield Label(f.label)
if f.type == "bool":
yield Switch(value=bool(current), id=_fid(f.path))
else:
yield Input(value=str(current), id=_fid(f.path))
with Horizontal(classes="actions"):
yield Button("Save", id="save-general", variant="primary")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id != "save-general":
return
cfg = load_config()
for f in GENERAL_FIELDS:
wid = _fid(f.path)
if f.type == "bool":
cfg_val: Any = self.query_one(f"#{wid}", Switch).value
else:
cfg_val = self.query_one(f"#{wid}", Input).value
_set_nested(cfg, f.path, cfg_val)
save_config(cfg)
self.app.notify("General settings saved.")
event.stop()
class _PluginsTab(Widget):
def compose(self) -> ComposeResult:
cfg = load_config()
enabled = set(cfg.plugins.enabled)
table = DataTable(id="plugins-table")
table.add_columns("Name", "Version", "Status", "Description")
for name, manifest, _ in _installed_plugins():
status = "enabled" if name in enabled else "disabled"
table.add_row(
name,
manifest.get("version", "?"),
status,
manifest.get("description", ""),
)
yield table
with Horizontal(classes="actions"):
yield Button("Enable", id="btn-enable", variant="success")
yield Button("Disable", id="btn-disable")
def on_button_pressed(self, event: Button.Pressed) -> None:
btn_id = event.button.id
if btn_id not in ("btn-enable", "btn-disable"):
return
table = self.query_one("#plugins-table", DataTable)
if table.row_count == 0:
event.stop()
return
plugin_name = str(table.get_cell_at(Coordinate(table.cursor_coordinate.row, 0)))
cfg = load_config()
if btn_id == "btn-enable" and plugin_name not in cfg.plugins.enabled:
cfg.plugins.enabled.append(plugin_name)
elif btn_id == "btn-disable" and plugin_name in cfg.plugins.enabled:
cfg.plugins.enabled.remove(plugin_name)
save_config(cfg)
self._refresh_table()
event.stop()
def _refresh_table(self) -> None:
cfg = load_config()
enabled = set(cfg.plugins.enabled)
table = self.query_one("#plugins-table", DataTable)
table.clear()
for name, manifest, _ in _installed_plugins():
status = "enabled" if name in enabled else "disabled"
table.add_row(
name,
manifest.get("version", "?"),
status,
manifest.get("description", ""),
)
class _PluginConfigTab(Widget):
def __init__(self, name: str, plugin: Any) -> None:
super().__init__()
self._name = name
self._plugin = plugin
def compose(self) -> ComposeResult:
cfg = load_config()
settings = cfg.plugin_settings.get(self._name, {})
with VerticalScroll():
for f in self._plugin.config_fields():
current = settings.get(f.key, f.default)
with Horizontal(classes="row"):
yield Label(f.label)
if f.type == "bool":
yield Switch(value=bool(current), id=_pfid(self._name, f.key))
else:
yield Input(value=str(current), id=_pfid(self._name, f.key))
if f.description:
yield Label(f.description, classes="hint")
with Horizontal(classes="actions"):
yield Button("Save", id=f"save-{self._name}", variant="primary")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id != f"save-{self._name}":
return
cfg = load_config()
settings: dict[str, Any] = dict(cfg.plugin_settings.get(self._name, {}))
for f in self._plugin.config_fields():
wid = _pfid(self._name, f.key)
if f.type == "bool":
settings[f.key] = self.query_one(f"#{wid}", Switch).value
else:
settings[f.key] = self.query_one(f"#{wid}", Input).value
cfg.plugin_settings[self._name] = settings
save_config(cfg)
self.app.notify(f"{self._name} settings saved.")
event.stop()
# ── App ───────────────────────────────────────────────────────────────────────
class ConfigApp(App):
TITLE = "Pyra Configuration"
BINDINGS = [
Binding("q", "quit", "Quit"),
Binding("escape", "quit", "Quit"),
]
CSS = """
Screen { background: $surface; }
.row { height: 3; margin: 0 2; }
.row Label { width: 26; content-align: left middle; }
.hint { color: $text-muted; margin: 0 2 1 28; }
.actions { height: 3; align: right middle; margin: 1 2; }
DataTable { height: 1fr; }
"""
def compose(self) -> ComposeResult:
plugins = _installed_plugins()
with TabbedContent():
with TabPane("General"):
yield _GeneralTab()
with TabPane("Plugins"):
yield _PluginsTab()
for name, _, plugin in plugins:
if plugin is not None and plugin.config_fields():
with TabPane(name):
yield _PluginConfigTab(name, plugin)
def launch_config_tui() -> None:
"""Open the configuration TUI. Blocks until the user quits (q / Escape)."""
ConfigApp().run()
+15 -1
View File
@@ -1,12 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Protocol, runtime_checkable
if TYPE_CHECKING:
from rich.console import Console
@dataclass
class ConfigField:
key: str # key in plugin_settings[name] dict
label: str # display label in the config TUI
type: str # "text" | "bool" | "select"
default: Any = ""
options: list[str] = field(default_factory=list) # for "select" type
description: str = "" # optional hint shown below the field
@dataclass
class Tool:
name: str
@@ -35,6 +45,7 @@ class PyraPlugin(Protocol):
def agent_spec(self) -> AgentSpec | None: ...
def setup(self, console: Console, vault_writer: Callable[[str, str], None]) -> None: ...
def daemon_tasks(self) -> list[Coroutine]: ... # type: ignore[type-arg]
def config_fields(self) -> list[ConfigField]: ...
class BasePlugin:
@@ -64,3 +75,6 @@ class BasePlugin:
def daemon_tasks(self) -> list[Coroutine]: # type: ignore[type-arg]
return []
def config_fields(self) -> list[ConfigField]:
return []