From 3ffab8a3dde8b27896475371875e5c80301e08e3 Mon Sep 17 00:00:00 2001 From: dullfig Date: Sun, 18 Jan 2026 18:00:11 -0800 Subject: [PATCH] fixing docs --- agentserver/config/__init__.py | 11 + agentserver/config/features.py | 1 + agentserver/console/editor.py | 537 +++++++++++++++++++++++++- agentserver/console/secure_console.py | 181 ++++++++- pyproject.toml | 5 +- 5 files changed, 725 insertions(+), 10 deletions(-) diff --git a/agentserver/config/__init__.py b/agentserver/config/__init__.py index 71d9c59..72d844c 100644 --- a/agentserver/config/__init__.py +++ b/agentserver/config/__init__.py @@ -3,6 +3,7 @@ Configuration management for xml-pipeline. Handles: - Agent configs (~/.xml-pipeline/agents/*.yaml) +- Listener configs (~/.xml-pipeline/listeners/*.yaml) - Organism config (organism.yaml) """ @@ -13,11 +14,21 @@ from .agents import ( CONFIG_DIR, AGENTS_DIR, ) +from .listeners import ( + ListenerConfigStore, + get_listener_config_store, + LISTENERS_DIR, +) __all__ = [ + # Agent config "AgentConfig", "AgentConfigStore", "get_agent_config_store", "CONFIG_DIR", "AGENTS_DIR", + # Listener config + "ListenerConfigStore", + "get_listener_config_store", + "LISTENERS_DIR", ] diff --git a/agentserver/config/features.py b/agentserver/config/features.py index e5f1c30..55211a7 100644 --- a/agentserver/config/features.py +++ b/agentserver/config/features.py @@ -23,6 +23,7 @@ FEATURES: dict[str, tuple[Callable[[], bool], str]] = { "search": (lambda: _check_import("duckduckgo_search"), "DuckDuckGo search"), "auth": (lambda: _check_import("pyotp") and _check_import("argon2"), "TOTP auth"), "server": (lambda: _check_import("websockets"), "WebSocket server"), + "lsp": (lambda: _check_import("lsp_client"), "LSP client for config editor"), } diff --git a/agentserver/console/editor.py b/agentserver/console/editor.py index 6b9261b..680642b 100644 --- a/agentserver/console/editor.py +++ b/agentserver/console/editor.py @@ -2,31 +2,100 @@ Full-screen text editor using prompt_toolkit. Provides a vim-like editing experience for configuration files. +Supports optional LSP integration for YAML files. """ -from typing import Optional, Tuple +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from agentserver.console.lsp import YAMLLSPClient, ASLSClient + from typing import Union + LSPClientType = Union[YAMLLSPClient, ASLSClient] + +logger = logging.getLogger(__name__) try: from prompt_toolkit import Application from prompt_toolkit.buffer import Buffer from prompt_toolkit.layout import Layout, HSplit, VSplit - from prompt_toolkit.layout.containers import Window, ConditionalContainer + from prompt_toolkit.layout.containers import Window, ConditionalContainer, Float, FloatContainer from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl + from prompt_toolkit.layout.menus import CompletionsMenu from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.filters import Condition from prompt_toolkit.styles import Style from prompt_toolkit.lexers import PygmentsLexer + from prompt_toolkit.completion import Completer, Completion + from prompt_toolkit.document import Document PROMPT_TOOLKIT_AVAILABLE = True except ImportError: PROMPT_TOOLKIT_AVAILABLE = False try: from pygments.lexers.data import YamlLexer + from pygments.lexers.javascript import TypeScriptLexer PYGMENTS_AVAILABLE = True except ImportError: PYGMENTS_AVAILABLE = False +# Supported syntax types and their lexers +SYNTAX_LEXERS = { + "yaml": "YamlLexer", + "typescript": "TypeScriptLexer", + "assemblyscript": "TypeScriptLexer", # AS uses TS syntax + "ts": "TypeScriptLexer", + "as": "TypeScriptLexer", +} + + +def get_lexer_for_syntax(syntax: str) -> Optional[object]: + """ + Get a Pygments lexer for the given syntax type. + + Args: + syntax: Syntax name ("yaml", "typescript", "ts", "as", "assemblyscript") + + Returns: + PygmentsLexer instance or None + """ + if not PYGMENTS_AVAILABLE: + return None + + syntax_lower = syntax.lower() + + if syntax_lower in ("yaml", "yml"): + return PygmentsLexer(YamlLexer) + elif syntax_lower in ("typescript", "ts", "assemblyscript", "as"): + return PygmentsLexer(TypeScriptLexer) + else: + return None + + +def detect_syntax_from_path(path: str | Path) -> str: + """ + Detect syntax type from file extension. + + Returns: + Syntax name for use with get_lexer_for_syntax() + """ + ext = Path(path).suffix.lower() + + extension_map = { + ".yaml": "yaml", + ".yml": "yaml", + ".ts": "typescript", + ".as": "assemblyscript", + } + + return extension_map.get(ext, "text") + + def edit_text( initial_text: str, title: str = "Editor", @@ -38,7 +107,7 @@ def edit_text( Args: initial_text: Text to edit title: Title shown in header - syntax: Syntax highlighting ("yaml", "text") + syntax: Syntax highlighting ("yaml", "typescript", "ts", "as", "text") Returns: (edited_text, saved) - edited_text is None if cancelled @@ -79,9 +148,7 @@ def edit_text( event.app.exit() # Syntax highlighting - lexer = None - if PYGMENTS_AVAILABLE and syntax == "yaml": - lexer = PygmentsLexer(YamlLexer) + lexer = get_lexer_for_syntax(syntax) # Layout header = Window( @@ -225,3 +292,461 @@ def edit_with_system_editor(filepath: str) -> bool: return mtime_before is None or mtime_after > mtime_before return False + + +# ============================================================================= +# LSP-Enhanced Editor +# ============================================================================= + + +class LSPEditor: + """ + Full-screen editor with optional LSP integration. + + Provides: + - Syntax highlighting (YAML, TypeScript/AssemblyScript via Pygments) + - Autocompletion (LSP when available) + - Inline diagnostics (LSP when available) + - Hover documentation on F1 (LSP when available) + + Usage: + # YAML config editing + editor = LSPEditor(schema_type="listener") + edited_text, saved = await editor.edit(initial_text, title="greeter.yaml") + + # AssemblyScript listener editing + editor = LSPEditor(syntax="assemblyscript") + edited_text, saved = await editor.edit(source_code, title="handler.ts") + """ + + def __init__( + self, + schema_type: Optional[str] = None, + schema_uri: Optional[str] = None, + syntax: str = "yaml", + ): + """ + Initialize the LSP editor. + + Args: + schema_type: Schema type ("organism" or "listener") for YAML modeline + schema_uri: Explicit schema URI to use + syntax: Syntax highlighting ("yaml", "typescript", "assemblyscript", "ts", "as") + """ + self.schema_type = schema_type + self.schema_uri = schema_uri + self.syntax = syntax + self._lsp_client: Optional["LSPClientType"] = None + self._lsp_type: Optional[str] = None # "yaml" or "assemblyscript" + self._diagnostics_text = "" + self._hover_text = "" + self._show_hover = False + self._document_version = 0 + + async def edit( + self, + initial_text: str, + title: str = "Editor", + document_uri: Optional[str] = None, + ) -> Tuple[Optional[str], bool]: + """ + Open the editor with LSP support. + + Args: + initial_text: Initial content to edit + title: Title shown in header + document_uri: URI for LSP (auto-generated if not provided) + + Returns: + (edited_text, saved) - edited_text is None if cancelled + """ + if not PROMPT_TOOLKIT_AVAILABLE: + print("Error: prompt_toolkit not installed") + return None, False + + # Determine LSP type based on syntax + syntax_lower = self.syntax.lower() + if syntax_lower in ("yaml", "yml"): + self._lsp_type = "yaml" + elif syntax_lower in ("typescript", "ts", "assemblyscript", "as"): + self._lsp_type = "assemblyscript" + else: + self._lsp_type = None + + # Try to get appropriate LSP client + try: + from agentserver.console.lsp import get_lsp_manager, LSPServerType + manager = get_lsp_manager() + + if self._lsp_type == "yaml": + self._lsp_client = await manager.get_yaml_client() + elif self._lsp_type == "assemblyscript": + self._lsp_client = await manager.get_asls_client() + else: + self._lsp_client = None + + except Exception as e: + logger.debug(f"LSP not available: {e}") + self._lsp_client = None + + # Generate document URI with appropriate extension + if document_uri is None: + ext = ".yaml" if self._lsp_type == "yaml" else ".ts" + document_uri = f"file:///temp/{title.replace(' ', '_')}{ext}" + + # Ensure schema modeline is present (YAML only) + if self._lsp_type == "yaml": + initial_text = self._ensure_modeline(initial_text) + + # Open document in LSP + if self._lsp_client: + await self._lsp_client.did_open(document_uri, initial_text) + + try: + result = await self._run_editor(initial_text, title, document_uri) + finally: + # Close document in LSP + if self._lsp_client: + await self._lsp_client.did_close(document_uri) + try: + from agentserver.console.lsp import get_lsp_manager, LSPServerType + manager = get_lsp_manager() + if self._lsp_type == "yaml": + await manager.release_client(LSPServerType.YAML) + elif self._lsp_type == "assemblyscript": + await manager.release_client(LSPServerType.ASSEMBLYSCRIPT) + except Exception: + pass + + return result + + def _ensure_modeline(self, text: str) -> str: + """Ensure YAML has schema modeline if schema_type is set.""" + if self.schema_type is None: + return text + + modeline = f"# yaml-language-server: $schema=~/.xml-pipeline/schemas/{self.schema_type}.schema.json" + + # Check if modeline already exists + lines = text.split("\n") + for line in lines[:3]: # Check first 3 lines + if "yaml-language-server" in line and "$schema" in line: + return text + + # Add modeline at the top + return modeline + "\n" + text + + async def _run_editor( + self, + initial_text: str, + title: str, + uri: str, + ) -> Tuple[Optional[str], bool]: + """Run the editor application.""" + result = {"text": None, "saved": False} + + # Create buffer + buffer = Buffer(multiline=True, name="editor") + buffer.text = initial_text + + # Key bindings + kb = KeyBindings() + + @kb.add("c-s") # Ctrl+S to save + def save(event): + result["text"] = buffer.text + result["saved"] = True + event.app.exit() + + @kb.add("c-q") # Ctrl+Q to quit without saving + def quit_nosave(event): + result["text"] = None + result["saved"] = False + event.app.exit() + + @kb.add("escape") # Escape to quit + def escape(event): + result["text"] = None + result["saved"] = False + event.app.exit() + + @kb.add("c-space") # Ctrl+Space for completion + async def trigger_completion(event): + if self._lsp_client: + doc = buffer.document + line = doc.cursor_position_row + col = doc.cursor_position_col + completions = await self._lsp_client.completion(uri, line, col) + if completions: + # Show first completion as hint + self._diagnostics_text = f"Completions: {', '.join(c.label for c in completions[:5])}" + event.app.invalidate() + + @kb.add("f1") # F1 for hover + async def show_hover(event): + if self._lsp_client: + doc = buffer.document + line = doc.cursor_position_row + col = doc.cursor_position_col + hover = await self._lsp_client.hover(uri, line, col) + if hover: + self._hover_text = hover.contents[:200] # Truncate + self._show_hover = True + else: + self._hover_text = "" + self._show_hover = False + event.app.invalidate() + + @kb.add("escape", filter=Condition(lambda: self._show_hover)) + def hide_hover(event): + self._show_hover = False + event.app.invalidate() + + @kb.add("c-p") # Ctrl+P for signature help (ASLS only) + async def show_signature_help(event): + # Only available for ASLS + if self._lsp_client and self._lsp_type == "assemblyscript": + doc = buffer.document + line = doc.cursor_position_row + col = doc.cursor_position_col + try: + sig_help = await self._lsp_client.signature_help(uri, line, col) + if sig_help and sig_help.get("signatures"): + sig = sig_help["signatures"][0] + label = sig.get("label", "") + self._hover_text = f"Signature: {label}" + self._show_hover = True + else: + self._hover_text = "" + self._show_hover = False + except Exception: + pass + event.app.invalidate() + + # Syntax highlighting + lexer = get_lexer_for_syntax(self.syntax) + + # Header + def get_header(): + if self._lsp_client: + if self._lsp_type == "yaml": + lsp_status = " [YAML LSP]" + elif self._lsp_type == "assemblyscript": + lsp_status = " [ASLS]" + else: + lsp_status = " [LSP]" + else: + lsp_status = "" + + parts = [ + ("class:header", f" {title}{lsp_status} "), + ("class:header.key", " Ctrl+S"), + ("class:header", "=Save "), + ("class:header.key", " Ctrl+Q"), + ("class:header", "=Quit "), + ("class:header.key", " F1"), + ("class:header", "=Hover "), + ] + + # Add Ctrl+P hint for AssemblyScript + if self._lsp_type == "assemblyscript" and self._lsp_client: + parts.extend([ + ("class:header.key", " Ctrl+P"), + ("class:header", "=Sig "), + ]) + + return parts + + header = Window( + height=1, + content=FormattedTextControl(get_header), + style="class:header", + ) + + # Editor window + editor_window = Window( + content=BufferControl( + buffer=buffer, + lexer=lexer, + ), + ) + + # Status bar + def get_status(): + row = buffer.document.cursor_position_row + 1 + col = buffer.document.cursor_position_col + 1 + lines = len(buffer.text.split("\n")) + + parts = [("class:status", f" Line {row}/{lines}, Col {col} ")] + + if self._diagnostics_text: + parts.append(("class:status.diag", f" | {self._diagnostics_text}")) + + return parts + + status_bar = Window( + height=1, + content=FormattedTextControl(get_status), + style="class:status", + ) + + # Hover popup (shown conditionally) + def get_hover_text(): + if self._show_hover and self._hover_text: + return [("class:hover", self._hover_text)] + return [] + + hover_window = ConditionalContainer( + Window( + height=3, + content=FormattedTextControl(get_hover_text), + style="class:hover", + ), + filter=Condition(lambda: self._show_hover and bool(self._hover_text)), + ) + + # Layout + layout = Layout( + HSplit([ + header, + editor_window, + hover_window, + status_bar, + ]) + ) + + # Styles + style = Style.from_dict({ + "header": "bg:#005f87 #ffffff", + "header.key": "bg:#005f87 #ffff00 bold", + "status": "bg:#444444 #ffffff", + "status.diag": "bg:#444444 #ff8800", + "hover": "bg:#333333 #ffffff italic", + "diagnostic.error": "bg:#5f0000 #ffffff", + "diagnostic.warning": "bg:#5f5f00 #ffffff", + }) + + # Set up diagnostics callback + async def on_text_changed(buff): + if self._lsp_client: + self._document_version += 1 + diagnostics = await self._lsp_client.did_change( + uri, buff.text, self._document_version + ) + if diagnostics: + errors = sum(1 for d in diagnostics if d.severity == "error") + warnings = sum(1 for d in diagnostics if d.severity == "warning") + parts = [] + if errors: + parts.append(f"{errors} error{'s' if errors > 1 else ''}") + if warnings: + parts.append(f"{warnings} warning{'s' if warnings > 1 else ''}") + self._diagnostics_text = " | ".join(parts) if parts else "" + else: + self._diagnostics_text = "" + + buffer.on_text_changed += lambda buff: asyncio.create_task(on_text_changed(buff)) + + # Create and run application + app: Application = Application( + layout=layout, + key_bindings=kb, + style=style, + full_screen=True, + mouse_support=True, + ) + + await app.run_async() + + return result["text"], result["saved"] + + +async def edit_text_async( + initial_text: str, + title: str = "Editor", + schema_type: Optional[str] = None, + syntax: str = "yaml", +) -> Tuple[Optional[str], bool]: + """ + Async wrapper for LSP-enabled text editing. + + Args: + initial_text: Text to edit + title: Title shown in header + schema_type: "organism" or "listener" for YAML schema modeline + syntax: Syntax highlighting ("yaml", "typescript", "assemblyscript", "ts", "as") + + Returns: + (edited_text, saved) - edited_text is None if cancelled + """ + editor = LSPEditor(schema_type=schema_type, syntax=syntax) + return await editor.edit(initial_text, title=title) + + +async def edit_file_async( + filepath: str, + title: Optional[str] = None, + schema_type: Optional[str] = None, + syntax: Optional[str] = None, +) -> bool: + """ + Edit a file with LSP support. + + Args: + filepath: Path to file + title: Optional title (defaults to filename) + schema_type: "organism" or "listener" for YAML schema modeline + syntax: Syntax highlighting (auto-detected from extension if not specified) + + Returns: + True if saved, False if cancelled + """ + path = Path(filepath) + title = title or path.name + + # Auto-detect syntax from extension if not specified + if syntax is None: + syntax = detect_syntax_from_path(path) + + # Load existing content or empty + if path.exists(): + initial_text = path.read_text() + else: + initial_text = "" + + # Edit + edited_text, saved = await edit_text_async( + initial_text, + title=title, + schema_type=schema_type, + syntax=syntax, + ) + + # Save if requested + if saved and edited_text is not None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(edited_text) + return True + + return False + + +async def edit_assemblyscript_source( + filepath: str, + title: Optional[str] = None, +) -> bool: + """ + Edit an AssemblyScript listener source file with ASLS support. + + Args: + filepath: Path to .ts or .as file + title: Optional title (defaults to filename) + + Returns: + True if saved, False if cancelled + """ + return await edit_file_async( + filepath, + title=title, + syntax="assemblyscript", + ) diff --git a/agentserver/console/secure_console.py b/agentserver/console/secure_console.py index b4f85c4..b367d9e 100644 --- a/agentserver/console/secure_console.py +++ b/agentserver/console/secure_console.py @@ -494,7 +494,12 @@ class SecureConsole: cprint(" /buffer Inspect thread's context buffer", Colors.DIM) cprint(" /monitor Show recent messages from thread", Colors.DIM) cprint(" /monitor * Show recent messages from all threads", Colors.DIM) - cprint(" /config View current configuration", Colors.DIM) + cprint("") + cprint("Configuration:", Colors.CYAN) + cprint(" /config Show current config", Colors.DIM) + cprint(" /config -e Edit organism.yaml", Colors.DIM) + cprint(" /config @name Edit listener config", Colors.DIM) + cprint(" /config --list List listener configs", Colors.DIM) cprint("") cprint("Protected (require password):", Colors.YELLOW) cprint(" /restart Restart the pipeline", Colors.DIM) @@ -670,16 +675,186 @@ class SecureConsole: cprint(f" {payload_str}", Colors.DIM) async def _cmd_config(self, args: str) -> None: - """View current configuration (read-only).""" + """ + Edit configuration files. + + /config - Edit organism.yaml + /config @name - Edit listener config (e.g., /config @greeter) + /config --list - List available listener configs + /config --show - Show current config (read-only) + """ + args = args.strip() if args else "" + + if args == "--list": + await self._config_list() + elif args == "--show" or args == "": + await self._config_show() + elif args.startswith("@"): + listener_name = args[1:].strip() + if listener_name: + await self._config_edit_listener(listener_name) + else: + cprint("Usage: /config @listener_name", Colors.RED) + elif args == "--edit" or args == "-e": + await self._config_edit_organism() + else: + cprint(f"Unknown option: {args}", Colors.RED) + cprint("Usage:", Colors.DIM) + cprint(" /config Show current config", Colors.DIM) + cprint(" /config -e Edit organism.yaml", Colors.DIM) + cprint(" /config @name Edit listener config", Colors.DIM) + cprint(" /config --list List listener configs", Colors.DIM) + + async def _config_show(self) -> None: + """Show current configuration (read-only).""" cprint(f"\nOrganism: {self.pump.config.name}", Colors.CYAN) cprint(f"Port: {self.pump.config.port}", Colors.DIM) cprint(f"Thread scheduling: {self.pump.config.thread_scheduling}", Colors.DIM) cprint(f"Max concurrent pipelines: {self.pump.config.max_concurrent_pipelines}", Colors.DIM) cprint(f"Max concurrent handlers: {self.pump.config.max_concurrent_handlers}", Colors.DIM) cprint(f"Max concurrent per agent: {self.pump.config.max_concurrent_per_agent}", Colors.DIM) - cprint("\nTo modify: /quit -> edit organism.yaml -> restart", Colors.DIM) + cprint("\nUse /config -e to edit organism.yaml", Colors.DIM) + cprint("Use /config @listener to edit a listener config", Colors.DIM) cprint("") + async def _config_list(self) -> None: + """List available listener configs.""" + from agentserver.config import get_listener_config_store + + store = get_listener_config_store() + listeners = store.list_listeners() + + cprint("\nListener configurations:", Colors.CYAN) + cprint(f"Directory: {store.listeners_dir}", Colors.DIM) + cprint("") + + if not listeners: + cprint(" No listener configs found.", Colors.DIM) + cprint(" Use /config @name to create one.", Colors.DIM) + else: + for name in sorted(listeners): + config = store.get(name) + agent_tag = "[agent]" if config.agent else "[tool]" if config.tool else "" + cprint(f" @{name:20} {agent_tag} {config.description or ''}", Colors.DIM) + + # Also show registered listeners without config files + unconfigured = [ + name for name in self.pump.listeners.keys() + if name not in listeners + ] + if unconfigured: + cprint("\nRegistered listeners without config files:", Colors.YELLOW) + for name in sorted(unconfigured): + listener = self.pump.listeners[name] + agent_tag = "[agent]" if listener.is_agent else "" + cprint(f" @{name:20} {agent_tag} {listener.description}", Colors.DIM) + + cprint("") + + async def _config_edit_organism(self) -> None: + """Edit organism.yaml in the full-screen editor.""" + from agentserver.console.editor import edit_text_async + from agentserver.config.schema import ensure_schemas + from agentserver.config.split_loader import ( + get_organism_yaml_path, + load_organism_yaml_content, + save_organism_yaml_content, + ) + + # Ensure schemas are written for LSP + try: + ensure_schemas() + except Exception as e: + cprint(f"Warning: Could not write schemas: {e}", Colors.YELLOW) + + # Find organism.yaml + config_path = get_organism_yaml_path() + if config_path is None: + cprint("No organism.yaml found.", Colors.RED) + cprint("Searched in:", Colors.DIM) + cprint(" ~/.xml-pipeline/organism.yaml", Colors.DIM) + cprint(" ./organism.yaml", Colors.DIM) + cprint(" ./config/organism.yaml", Colors.DIM) + return + + # Load content + try: + content = load_organism_yaml_content(config_path) + except Exception as e: + cprint(f"Failed to load config: {e}", Colors.RED) + return + + # Edit + cprint(f"Editing: {config_path}", Colors.CYAN) + cprint("Press Ctrl+S to save, Ctrl+Q to cancel", Colors.DIM) + cprint("") + + edited_text, saved = await edit_text_async( + content, + title=f"organism.yaml ({config_path.name})", + schema_type="organism", + ) + + if saved and edited_text is not None: + try: + save_organism_yaml_content(config_path, edited_text) + cprint("Configuration saved.", Colors.GREEN) + cprint("Note: Restart required for changes to take effect.", Colors.YELLOW) + except yaml.YAMLError as e: + cprint(f"Invalid YAML: {e}", Colors.RED) + except Exception as e: + cprint(f"Failed to save: {e}", Colors.RED) + else: + cprint("Edit cancelled.", Colors.DIM) + + async def _config_edit_listener(self, name: str) -> None: + """Edit a listener config in the full-screen editor.""" + from agentserver.config import get_listener_config_store + from agentserver.console.editor import edit_text_async + from agentserver.config.schema import ensure_schemas + + # Ensure schemas are written for LSP + try: + ensure_schemas() + except Exception as e: + cprint(f"Warning: Could not write schemas: {e}", Colors.YELLOW) + + store = get_listener_config_store() + + # Load or create content + if store.exists(name): + content = store.load_yaml(name) + cprint(f"Editing: {store.path_for(name)}", Colors.CYAN) + else: + # Check if it's a registered listener + if name in self.pump.listeners: + cprint(f"Creating new config for registered listener: {name}", Colors.CYAN) + else: + cprint(f"Creating new config for: {name}", Colors.CYAN) + content = store._default_template(name) + + cprint("Press Ctrl+S to save, Ctrl+Q to cancel", Colors.DIM) + cprint("") + + # Edit + edited_text, saved = await edit_text_async( + content, + title=f"{name}.yaml", + schema_type="listener", + ) + + if saved and edited_text is not None: + try: + path = store.save_yaml(name, edited_text) + cprint(f"Saved: {path}", Colors.GREEN) + cprint("Note: Restart required for changes to take effect.", Colors.YELLOW) + except yaml.YAMLError as e: + cprint(f"Invalid YAML: {e}", Colors.RED) + except Exception as e: + cprint(f"Failed to save: {e}", Colors.RED) + else: + cprint("Edit cancelled.", Colors.DIM) + # ------------------------------------------------------------------ # Commands: Protected # ------------------------------------------------------------------ diff --git a/pyproject.toml b/pyproject.toml index 9abbf65..fa0c817 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,9 +63,12 @@ auth = [ # WebSocket server (for remote connections) server = ["websockets"] +# LSP support for config editor (requires yaml-language-server: npm install -g yaml-language-server) +lsp = ["lsp-client>=0.3.0"] + # All optional features all = [ - "xml-pipeline[anthropic,openai,redis,search,auth,server]", + "xml-pipeline[anthropic,openai,redis,search,auth,server,lsp]", ] # Development