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 |