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:
@@ -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 |
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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 []
|
||||
|
||||
Reference in New Issue
Block a user