"""DatabaseManager widget — full CRUD editor for the tool registry. Presents a split-pane layout: a searchable / filterable tool table on the left and a scrollable edit form on the right. Every field from the registry schema is editable — categorisation (layer, role, category, level), all 8 flags, installer spec, launcher spec, dependencies, license, description, and filesystem paths. **Categorisation cascade**: selecting a Category auto-populates Layer, Level, and Role from the built-in category map (derived from the registry). The user can still override any of them. Changes are validated against the registry schema before being written to ``ecosystem.json`` via atomic write. """ try: from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QBrush, QColor, QFont from PySide6.QtWidgets import ( QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSizePolicy, QSpinBox, QSplitter, QStyledItemDelegate, QTableWidget, QTableWidgetItem, QTextEdit, QVBoxLayout, QWidget, ) _HAS_QT = True except ImportError: _HAS_QT = False # ── Category → default Layer / Level / Role mapping ────────────────── # Derived from the canonical registry (11-layer taxonomy, v3.1.1b: # Routing=L5, Orchestrators=L6, Security=L7, Observability=L8, # User Interfaces=L9, DevOps=L10, Knowledge Management=L11). # When the user picks a category these fields auto-fill; the user can # still override afterwards. CATEGORY_MAP: dict[str, dict[str, object]] = { "AI Agent": {"layer": "Orchestrators", "level": 6, "role": "Sensory Bridge"}, "AI Assistant Platform": {"layer": "User Interfaces", "level": 9, "role": "Central Intelligence"}, "AI Augmentation": {"layer": "Orchestrators", "level": 6, "role": "Curation"}, "AI Coding Agent": {"layer": "DevOps", "level": 10, "role": "Coding Agent"}, "AI Monitoring": {"layer": "Observability", "level": 8, "role": "Health Monitor"}, "AI Observability": {"layer": "Observability", "level": 8, "role": "LLM Tracing"}, "AI Operating System": {"layer": "DevOps", "level": 10, "role": "OS Integration"}, "Academic References": {"layer": "Knowledge Management", "level": 11, "role": "Reference Manager"}, "Agent Framework": {"layer": "Orchestrators", "level": 6, "role": "Multi-Agent"}, "Agent Network": {"layer": "DevOps", "level": 10, "role": "Discovery"}, "Agent OS": {"layer": "Orchestrators", "level": 6, "role": "Hands"}, "Agent Toolkit": {"layer": "Orchestrators", "level": 6, "role": "Tool Integration"}, "Agent Workflow": {"layer": "Orchestrators", "level": 6, "role": "Reasoning"}, "Algorithm Toolkit": {"layer": "DevOps", "level": 10, "role": "Hands"}, "Analytical Database": {"layer": "Host Platform", "level": 1, "role": "Foundation"}, "Antivirus": {"layer": "Security", "level": 7, "role": "Scanner"}, "Build": {"layer": "Development Environment", "level": 2, "role": "Build Tool"}, "Audio Parsing": {"layer": "Knowledge Management", "level": 11, "role": "Memory"}, "Auth": {"layer": "Security", "level": 7, "role": "Identity"}, "Build Monitoring": {"layer": "Observability", "level": 8, "role": "Monitoring"}, "Cache": {"layer": "Host Platform", "level": 1, "role": "Foundation"}, "Chat": {"layer": "User Interfaces", "level": 9, "role": "Face"}, "Chat Agent Platform": {"layer": "User Interfaces", "level": 9, "role": "Face"}, "Chat Frontend": {"layer": "User Interfaces", "level": 9, "role": "Face"}, "Claude Code Skill": {"layer": "Orchestrators", "level": 6, "role": "Knowledge Graph Builder"}, "Cluster SSH": {"layer": "Orchestrators", "level": 6, "role": "Coordination"}, "Code Analysis": {"layer": "DevOps", "level": 10, "role": "Inspector"}, "Code Generation": {"layer": "DevOps", "level": 10, "role": "Hands"}, "Computer Vision": {"layer": "User Interfaces", "level": 9, "role": "Vision"}, "Config Management": {"layer": "DevOps", "level": 10, "role": "Configuration Management"}, "Container Ops": {"layer": "Orchestrators", "level": 6, "role": "Sandbox"}, "Container Security": {"layer": "Security", "level": 7, "role": "Scanner"}, "Containers": {"layer": "Host Platform", "level": 1, "role": "Container Runtime"}, "Context Manager": {"layer": "DevOps", "level": 10, "role": "Context"}, "Cortex Memory": {"layer": "Knowledge Management", "level": 11, "role": "Memory"}, "Dashboard": {"layer": "User Interfaces", "level": 9, "role": "Face"}, "Data Pipeline": {"layer": "Knowledge Management", "level": 11, "role": "Ingestion"}, "Data Sync": {"layer": "Knowledge Management", "level": 11, "role": "Integration"}, "Debugging": {"layer": "Development Environment", "level": 2, "role": "Profiling"}, "Database": {"layer": "Host Platform", "level": 1, "role": "Foundation"}, "Desktop Agent": {"layer": "User Interfaces", "level": 9, "role": "Face"}, "Dev Automation": {"layer": "DevOps", "level": 10, "role": "Hands"}, "Development": {"layer": "DevOps", "level": 10, "role": "Hands"}, "Distributed Compilation": {"layer": "Orchestrators", "level": 6, "role": "Distribution"}, "Distributed Compute": {"layer": "Orchestrators", "level": 6, "role": "Scaling"}, "Document Converter": {"layer": "Knowledge Management", "level": 11, "role": "File Parsing"}, "Document Management": {"layer": "Knowledge Management", "level": 11, "role": "Document Archive"}, "Document Understanding": {"layer": "Knowledge Management", "level": 11, "role": "Comprehension"}, "Ebook Library": {"layer": "Knowledge Management", "level": 11, "role": "Library Manager"}, "Ecosystem Dashboard": {"layer": "User Interfaces", "level": 9, "role": "Face"}, "Efficient LLM": {"layer": "Engines", "level": 4, "role": "Engine"}, "File Parsing": {"layer": "Knowledge Management", "level": 11, "role": "Memory"}, "Find Tool": {"layer": "Development Environment", "level": 2, "role": "Search"}, "GPU": {"layer": "GPU Runtimes", "level": 3, "role": "Acceleration"}, "GPU Computing": {"layer": "Development Environment", "level": 2, "role": "GPU Acceleration"}, "Graph Database": {"layer": "Knowledge Management", "level": 11, "role": "Memory"}, "Graph RAG": {"layer": "Knowledge Management", "level": 11, "role": "Knowledge Synthesis"}, "Homepage": {"layer": "User Interfaces", "level": 9, "role": "Face"}, "IaC": {"layer": "DevOps", "level": 10, "role": "Infrastructure as Code"}, "IaC Control Plane": {"layer": "DevOps", "level": 10, "role": "Infrastructure as Code"}, "IaC Wrapper": {"layer": "DevOps", "level": 10, "role": "Infrastructure as Code"}, "Image Generation": {"layer": "User Interfaces", "level": 9, "role": "Face"}, "Infrastructure": {"layer": "Orchestrators", "level": 6, "role": "Resource Manager"}, "Intrusion Prevention": {"layer": "Security", "level": 7, "role": "IDS"}, "Knowledge Graph": {"layer": "DevOps", "level": 10, "role": "Graph Builder"}, "Knowledge Graph Notes": {"layer": "User Interfaces", "level": 9, "role": "Face"}, "LLM Evaluation": {"layer": "Observability", "level": 8, "role": "Evaluation"}, "Memory System": {"layer": "Knowledge Management", "level": 11, "role": "Memory"}, "LLM Fine-tuning": {"layer": "GPU Runtimes", "level": 3, "role": "Abliteration"}, "LLM Framework": {"layer": "Orchestrators", "level": 6, "role": "Orchestration"}, "LLM GUI": {"layer": "User Interfaces", "level": 9, "role": "Face"}, "LLM Mesh": {"layer": "Routing", "level": 5, "role": "API Gateway"}, "LLM Router": {"layer": "Routing", "level": 5, "role": "API Gateway"}, "LLM Runtime": {"layer": "Engines", "level": 4, "role": "Engine"}, "LLM Serving": {"layer": "Engines", "level": 4, "role": "Engine"}, "MCP Server": {"layer": "Orchestrators", "level": 6, "role": "Code Audit"}, "Metrics": {"layer": "Observability", "level": 8, "role": "Metrics Collector"}, "Mesh Client": {"layer": "Routing", "level": 5, "role": "Coding Agent"}, "Mixed Precision": {"layer": "GPU Runtimes", "level": 3, "role": "Optimization"}, "Model Surgery": {"layer": "Engines", "level": 4, "role": "Abliteration"}, "Model Training": {"layer": "Development Environment", "level": 2, "role": "Training"}, "Multi-Agent": {"layer": "Orchestrators", "level": 6, "role": "Coordination"}, "Networking": {"layer": "Host Platform", "level": 1, "role": "Tunnel"}, "Notes": {"layer": "Knowledge Management", "level": 11, "role": "Note Taking"}, "OCI Export": {"layer": "DevOps", "level": 10, "role": "Runtime Packaging"}, "Observability": {"layer": "Observability", "level": 8, "role": "Monitoring"}, "Outliner": {"layer": "Knowledge Management", "level": 11, "role": "Knowledge Graph"}, "PDF Pipeline": {"layer": "Knowledge Management", "level": 11, "role": "Extraction"}, "Parser": {"layer": "Development Environment", "level": 2, "role": "Parsing"}, "Persistent Memory": {"layer": "Knowledge Management", "level": 11, "role": "Memory"}, "Pipeline": {"layer": "Routing", "level": 5, "role": "Pipeline Orchestrator"}, "Policy Engine": {"layer": "Security", "level": 7, "role": "Policy"}, "Procfile Runner": {"layer": "DevOps", "level": 10, "role": "Process Manager"}, "Project Management": {"layer": "DevOps", "level": 10, "role": "Management"}, "Prompt Tooling": {"layer": "DevOps", "level": 10, "role": "Prompt Management"}, "Provisioning": {"layer": "DevOps", "level": 10, "role": "Provisioning"}, "Proxy": {"layer": "Routing", "level": 5, "role": "API Gateway"}, "Reasoning Engine": {"layer": "Orchestrators", "level": 6, "role": "Brain"}, "Runtime": {"layer": "Development Environment", "level": 2, "role": "Build System"}, "Sandbox": {"layer": "DevOps", "level": 10, "role": "Isolation"}, "Search Engine": {"layer": "Knowledge Management", "level": 11, "role": "Memory"}, "Search Tool": {"layer": "Development Environment", "level": 2, "role": "Search"}, "Shell": {"layer": "Development Environment", "level": 2, "role": "Shell"}, "Secrets Management": {"layer": "Security", "level": 7, "role": "Secrets"}, "Serverless Framework": {"layer": "Development Environment", "level": 2, "role": "Full-Stack Framework"}, "Single-File LLM": {"layer": "Engines", "level": 4, "role": "Engine"}, "Skill Analysis": {"layer": "DevOps", "level": 10, "role": "Assessment"}, "Skill Inspection": {"layer": "DevOps", "level": 10, "role": "Analysis"}, "Spaced Repetition": {"layer": "Knowledge Management", "level": 11, "role": "Memory"}, "Spec Writer": {"layer": "DevOps", "level": 10, "role": "Documentation"}, "Speech Recognition": {"layer": "User Interfaces", "level": 9, "role": "Senses"}, "Task Runner": {"layer": "DevOps", "level": 10, "role": "Scheduler"}, "Telemetry": {"layer": "Observability", "level": 8, "role": "Collector"}, "Terminal": {"layer": "Host Platform", "level": 1, "role": "Multiplexer"}, "Text-to-Speech": {"layer": "User Interfaces", "level": 9, "role": "Voice"}, "Uncensored Models": {"layer": "Engines", "level": 4, "role": "Engine"}, "VCS": {"layer": "Host Platform", "level": 1, "role": "Version Control"}, "Vector Engine": {"layer": "Knowledge Management", "level": 11, "role": "Embedding"}, "Vector Store": {"layer": "Knowledge Management", "level": 11, "role": "Memory"}, "Visualization": {"layer": "Observability", "level": 8, "role": "Dashboard"}, "Virtualization": {"layer": "Host Platform", "level": 1, "role": "MicroVM"}, "Web Crawler": {"layer": "Knowledge Management", "level": 11, "role": "Data Harvesting"}, "Workflow": {"layer": "Orchestrators", "level": 6, "role": "Visual Builder"}, "Workflow Automation": {"layer": "Orchestrators", "level": 6, "role": "Workflow Orchestrator"}, } # ── Field-constant lookups (populated once on first use) ───────────── _LAYER_ORDER: list[str] = [] _ALL_CATEGORIES: list[str] = [] _ROLE_SET: set[str] = set() _LICENSE_SPDX_IDS: list[str] = [] _INSTALLER_TYPES: list[str] = [ "ollama", "uv", "pipx", "pip", "npm", "git", "git_node", "pacman", "dnf", "apt", "script", "custom", ] _LAUNCHER_TYPES: list[str] = ["systemd", "tmux", "desktop", "lxc"] _FLAG_KEYS: list[str] = [ "has_cli", "has_gui", "has_web", "is_ollama", "is_passive", "is_mcp", "is_skills_collection", ] _FLAG_LABELS: dict[str, str] = { "has_cli": "CLI", "has_gui": "GUI", "has_web": "Web UI", "is_ollama": "Ollama-native", "is_passive": "Passive (library/collection)", "is_mcp": "MCP API tool", "is_skills_collection": "Skills collection", } def _populate_lookups(registry: dict) -> None: """Populate the layer / category / role dropdowns from live data.""" global _LAYER_ORDER, _ALL_CATEGORIES, _ROLE_SET, _LICENSE_SPDX_IDS if _LAYER_ORDER: return from ai_lsc.constants import NAV_LAYER_ORDER _LAYER_ORDER = list(NAV_LAYER_ORDER) # Categories: start with the canonical CATEGORY_MAP keys, then # append any extras found in live data. seen_cats: set[str] = set(CATEGORY_MAP.keys()) for meta in registry.values(): if not isinstance(meta, dict): continue cat = meta.get("category", "") if cat: seen_cats.add(cat) role = meta.get("role", "") if role: _ROLE_SET.add(role) _ALL_CATEGORIES = sorted(seen_cats) try: from ai_lsc.registry.licenses import CATALOG _LICENSE_SPDX_IDS = sorted(CATALOG.keys()) except ImportError: _LICENSE_SPDX_IDS = ["MIT", "Apache-2.0", "GPL-3.0", "Proprietary"] def _set_combo_text(combo: QComboBox, text: str) -> None: """Set a QComboBox to *text*, adding it if not present.""" idx = combo.findText(text) if idx >= 0: combo.setCurrentIndex(idx) else: combo.setEditText(text) if _HAS_QT: class _CategoryDelegate(QStyledItemDelegate): """Combo-box delegate for the Category column in the tool table. When the user picks a new category the change is immediately cascaded (layer / level / role) and auto-saved to ecosystem.json. """ def __init__(self, parent: DatabaseManager) -> None: super().__init__(parent) self._db: DatabaseManager = parent def createEditor(self, parent, option, index): combo = QComboBox(parent) combo.addItem("") # blank = clear for cat in _ALL_CATEGORIES: combo.addItem(cat) combo.setEditable(True) # Pre-select current value current = index.data(Qt.DisplayRole) or "" idx = combo.findText(current) if idx >= 0: combo.setCurrentIndex(idx) else: combo.setEditText(current) combo.currentTextChanged.connect( lambda text, idx=index: self._on_category_picked( idx, text ) ) return combo def setModelData(self, editor, model, index): text = editor.currentText() model.setData(index, text, Qt.DisplayRole) def _on_category_picked( self, table_index, new_category: str ) -> None: row = table_index.row() tool_id_item = self._db.tool_table.item(row, 0) if not tool_id_item: return tool_id = tool_id_item.data(Qt.UserRole) if not tool_id or not new_category: return # Cascade: update layer / level / role from CATEGORY_MAP mapping = CATEGORY_MAP.get(new_category) meta = self._db.main.registry_mgr.data.get(tool_id, {}) if mapping: meta["layer"] = mapping["layer"] meta["level"] = mapping["level"] meta["role"] = mapping["role"] meta["category"] = new_category # Write back self._db.main.registry_mgr.data[tool_id] = meta from ai_lsc.ui.main_window import _atomic_write_json _atomic_write_json( self._db.main.registry_mgr.registry_file, self._db.main.registry_mgr.data, ) # Update table cells for this row lvl_item = self._db.tool_table.item(row, 2) if lvl_item: lvl_item.setText(str(meta.get("level", ""))) cat_item = self._db.tool_table.item(row, 3) if cat_item: cat_item.setText(new_category) # If this tool is currently loaded in the edit form, # refresh it too. if tool_id == self._db._current_tool_id: self._db._load_tool(tool_id) self._db._show_status( "ok", f"{tool_id} category → " f"{new_category} (auto-saved).", ) class DatabaseManager(QWidget): """Full CRUD editor for the ecosystem tool registry. Layout ------ Left (40 %) — searchable table of all tools. Right (60 %) — scrollable edit form for the selected tool. The form groups fields into logical sections that match the registry schema: Identity, Categorisation, Installer, Launcher, Flags, License, Dependencies, Filesystem. **Categorisation cascade**: changing Category auto-fills Layer, Level, and Role from :data:`CATEGORY_MAP`. """ def __init__(self, main_window) -> None: super().__init__() self.main = main_window self._current_tool_id: str | None = None self._dirty = False self._loading = False # guard to suppress dirty during programmatic set _populate_lookups(main_window.registry_mgr.get_all_tools()) root = QVBoxLayout(self) root.setContentsMargins(4, 4, 4, 4) # ── Title bar ──────────────────────────────────────────── title = QLabel( "Tool Database Manager " "" "— edit categorisation, flags, and metadata per tool" "" ) title.setWordWrap(True) root.addWidget(title) # ── Status bar (validation results / save feedback) ────── self.status_label = QLabel("") self.status_label.setWordWrap(True) self.status_label.setStyleSheet( "padding: 4px 8px; border-radius: 4px;" ) root.addWidget(self.status_label) # ── Splitter ───────────────────────────────────────────── splitter = QSplitter(Qt.Horizontal) # ── Left panel: tool list ──────────────────────────────── left = QWidget() left_layout = QVBoxLayout(left) left_layout.setContentsMargins(0, 0, 0, 0) # Filter row filter_row = QHBoxLayout() filter_row.addWidget(QLabel("Search:")) self.search_edit = QLineEdit() self.search_edit.setPlaceholderText( "Filter by tool_id, name, category..." ) self.search_edit.textChanged.connect(self._apply_filter) filter_row.addWidget(self.search_edit) self.layer_filter = QComboBox() self.layer_filter.addItem("All Layers") for layer in _LAYER_ORDER: self.layer_filter.addItem(layer) self.layer_filter.currentIndexChanged.connect(self._apply_filter) filter_row.addWidget(QLabel("Layer:")) filter_row.addWidget(self.layer_filter) left_layout.addLayout(filter_row) # Tool table self.tool_table = QTableWidget() self.tool_table.setColumnCount(5) self.tool_table.setHorizontalHeaderLabels([ "Tool ID", "Name", "Lvl", "Category", "License", ]) self.tool_table.setSelectionBehavior( QTableWidget.SelectRows ) self.tool_table.setSelectionMode( QTableWidget.SingleSelection ) # Only the Category column (3) is editable via delegate; # all other columns are read-only. self.tool_table.setEditTriggers( QTableWidget.SelectedClicked ) self.tool_table.setItemDelegateForColumn( 3, _CategoryDelegate(self) ) self.tool_table.setSortingEnabled(True) self.tool_table.verticalHeader().setDefaultSectionSize(24) self.tool_table.horizontalHeader().setStretchLastSection( True ) self.tool_table.setColumnWidth(0, 160) self.tool_table.setColumnWidth(1, 200) self.tool_table.setColumnWidth(2, 36) self.tool_table.setColumnWidth(3, 150) self.tool_table.setColumnWidth(4, 120) self.tool_table.itemSelectionChanged.connect( self._on_tool_selected ) left_layout.addWidget(self.tool_table) # Action buttons below table btn_row = QHBoxLayout() self.btn_add = QPushButton("+ Add Tool") self.btn_add.clicked.connect(self._add_tool) btn_row.addWidget(self.btn_add) self.btn_delete = QPushButton("Delete Tool") self.btn_delete.clicked.connect(self._delete_tool) btn_row.addWidget(self.btn_delete) self.btn_validate_all = QPushButton("Validate All") self.btn_validate_all.clicked.connect( self._validate_all ) btn_row.addWidget(self.btn_validate_all) left_layout.addLayout(btn_row) splitter.addWidget(left) # ── Right panel: edit form ─────────────────────────────── self.edit_scroll = QScrollArea() self.edit_scroll.setWidgetResizable(True) self.edit_container = QWidget() self.edit_layout = QVBoxLayout(self.edit_container) self.edit_layout.setSpacing(8) self.edit_container.setSizePolicy( QSizePolicy.Expanding, QSizePolicy.Expanding ) self.edit_scroll.setWidget(self.edit_container) # No-selection placeholder self.placeholder = QLabel( "Select a tool from the table to edit its properties." "" ) self.placeholder.setAlignment(Qt.AlignCenter) self.placeholder.setSizePolicy( QSizePolicy.Expanding, QSizePolicy.Expanding ) self.placeholder.setStyleSheet("color: #7f8c8d; padding: 40px;") self.edit_layout.addWidget(self.placeholder) splitter.addWidget(self.edit_scroll) splitter.setStretchFactor(0, 4) splitter.setStretchFactor(1, 6) root.addWidget(splitter, 1) # Populate the table self._populate_table() # ── Table population ────────────────────────────────────────── def _populate_table(self) -> None: registry = self.main.registry_mgr.get_all_tools() self.tool_table.setRowCount(0) self._all_rows: list[tuple[str, dict]] = [] for t_id, meta in sorted(registry.items()): if not isinstance(meta, dict): continue self._all_rows.append((t_id, meta)) self._apply_filter() def _apply_filter(self) -> None: search = self.search_edit.text().lower().strip() layer_idx = self.layer_filter.currentIndex() layer_filter = ( _LAYER_ORDER[layer_idx - 1] if layer_idx > 0 else "" ) self.tool_table.setRowCount(0) row_idx = 0 for t_id, meta in self._all_rows: name = meta.get("name", t_id) cat = meta.get("category", "") layer = meta.get("layer", "") lic = meta.get("license", "") if layer_filter and layer != layer_filter: continue if search: haystack = f"{t_id} {name} {cat} {layer} {lic}".lower() if search not in haystack: continue self.tool_table.insertRow(row_idx) items = [ t_id, name, str(meta.get("level", "")), cat, lic, ] for col, text in enumerate(items): item = QTableWidgetItem(text) item.setData(Qt.UserRole, t_id) if col != 3: # Only Category column is editable item.setFlags( item.flags() & ~Qt.ItemIsEditable ) if col == 0: item.setFont( QFont("Segoe UI", 9, QFont.Bold) ) self.tool_table.setItem(row_idx, col, item) row_idx += 1 def _on_tool_selected(self) -> None: rows = self.tool_table.selectionModel().selectedRows() if not rows: return row = rows[0].row() tool_id = ( self.tool_table.item(row, 0) .data(Qt.UserRole) ) if tool_id == self._current_tool_id: return self._save_current_if_dirty() self._load_tool(tool_id) # ── Edit form builders ──────────────────────────────────────── def _clear_edit_form(self) -> None: """Remove all edit widgets from the form.""" while self.edit_layout.count(): child = self.edit_layout.takeAt(0) w = child.widget() if w: w.deleteLater() def _load_tool(self, tool_id: str) -> None: self._current_tool_id = tool_id self._dirty = False self._loading = True self._clear_edit_form() meta = self.main.registry_mgr.get_tool(tool_id) if not meta: self.edit_layout.addWidget(QLabel( f"{tool_id} not found in registry." )) self._loading = False return # ── Identity ──────────────────────────────────────────── grp_id = QGroupBox("Identity") form_id = QFormLayout(grp_id) self.f_tool_id = QLabel(tool_id) self.f_tool_id.setTextInteractionFlags( Qt.TextSelectableByMouse ) self.f_tool_id.setStyleSheet( "font-family: monospace; color: #3498db;" ) form_id.addRow("Tool ID:", self.f_tool_id) self.f_name = QLineEdit(meta.get("name", tool_id)) form_id.addRow("Name:", self.f_name) self.f_description = QTextEdit( meta.get("description", "") ) self.f_description.setMaximumHeight(60) form_id.addRow("Description:", self.f_description) self.edit_layout.addWidget(grp_id) # ── Categorisation ───────────────────────────────────── grp_cat = QGroupBox("Categorisation") form_cat = QFormLayout(grp_cat) # Category — fixed dropdown, NOT editable. # This is the primary selector; Layer / Level / Role # auto-fill when it changes. self.f_category = QComboBox() self.f_category.setEditable(False) self.f_category.addItem("") # blank = custom / unknown for cat in _ALL_CATEGORIES: self.f_category.addItem(cat) idx = self.f_category.findText(meta.get("category", "")) if idx >= 0: self.f_category.setCurrentIndex(idx) else: self.f_category.setCurrentIndex(0) form_cat.addRow("Category:", self.f_category) # Level — auto-determined from category. self.f_level = QSpinBox() self.f_level.setRange(1, 10) self.f_level.setValue(meta.get("level", 1)) form_cat.addRow("Level:", self.f_level) # Layer — auto-determined from category. self.f_layer = QComboBox() self.f_layer.setEditable(True) for layer in _LAYER_ORDER: self.f_layer.addItem(layer) idx = self.f_layer.findText(meta.get("layer", "")) if idx >= 0: self.f_layer.setCurrentIndex(idx) else: self.f_layer.setEditText(meta.get("layer", "")) form_cat.addRow("Layer:", self.f_layer) # Role — auto-selected from category default. self.f_role = QComboBox() self.f_role.setEditable(True) for role in sorted(_ROLE_SET): self.f_role.addItem(role) idx = self.f_role.findText(meta.get("role", "")) if idx >= 0: self.f_role.setCurrentIndex(idx) else: self.f_role.setEditText(meta.get("role", "")) form_cat.addRow("Role:", self.f_role) # Cascade signal: category change → auto-fill level/layer/role self.f_category.currentTextChanged.connect( self._on_category_changed ) self.edit_layout.addWidget(grp_cat) # ── Installer ────────────────────────────────────────── inst = meta.get("installer", {}) grp_inst = QGroupBox("Installer") form_inst = QFormLayout(grp_inst) self.f_inst_type = QComboBox() self.f_inst_type.addItems(_INSTALLER_TYPES) idx = self.f_inst_type.findText( inst.get("type", "pacman") ) if idx >= 0: self.f_inst_type.setCurrentIndex(idx) form_inst.addRow("Type:", self.f_inst_type) self.f_inst_pkg = QLineEdit(inst.get("pkg", "")) form_inst.addRow("Package / URL:", self.f_inst_pkg) self.f_inst_cmd = QLineEdit(inst.get("cmd", "") or "") self.f_inst_cmd.setPlaceholderText( "(script type only)" ) form_inst.addRow("Command:", self.f_inst_cmd) self.f_inst_post = QLineEdit( inst.get("post_install", "") or "" ) form_inst.addRow("Post-install:", self.f_inst_post) self.f_inst_update = QLineEdit( inst.get("update_cmd", "") or "" ) form_inst.addRow("Update cmd:", self.f_inst_update) self.edit_layout.addWidget(grp_inst) # ── Launcher ────────────────────────────────────────── launch = meta.get("launcher", {}) grp_launch = QGroupBox("Launcher") form_launch = QFormLayout(grp_launch) self.f_launch_type = QComboBox() self.f_launch_type.addItems(_LAUNCHER_TYPES) idx = self.f_launch_type.findText( launch.get("type", "desktop") ) if idx >= 0: self.f_launch_type.setCurrentIndex(idx) form_launch.addRow("Type:", self.f_launch_type) self.f_launch_cmd = QLineEdit(launch.get("cmd", "")) form_launch.addRow("Command:", self.f_launch_cmd) self.f_launch_port = QSpinBox() self.f_launch_port.setRange(0, 65535) port_val = launch.get("default_port") self.f_launch_port.setValue(port_val if port_val else 0) self.f_launch_port.setSpecialValueText("None") form_launch.addRow("Default Port:", self.f_launch_port) self.edit_layout.addWidget(grp_launch) # ── Flags ────────────────────────────────────────────── grp_flags = QGroupBox("Flags") flags_layout = QVBoxLayout(grp_flags) flags = meta.get("flags", {}) self._flag_widgets: dict[str, QCheckBox] = {} row_flags = QHBoxLayout() col_count = 0 for key in _FLAG_KEYS: cb = QCheckBox(_FLAG_LABELS.get(key, key)) cb.setChecked(bool(flags.get(key, False))) self._flag_widgets[key] = cb row_flags.addWidget(cb) col_count += 1 if col_count == 4: flags_layout.addLayout(row_flags) row_flags = QHBoxLayout() col_count = 0 if col_count > 0: flags_layout.addLayout(row_flags) # Quick presets preset_row = QHBoxLayout() preset_row.addWidget(QLabel("Presets:")) for label, preset_flags in [ ("Web Service", { "has_cli": True, "has_web": True, "has_gui": False, "is_ollama": False, "is_passive": False, "is_mcp": False, "is_skills_collection": False, }), ("CLI Tool", { "has_cli": True, "has_web": False, "has_gui": False, "is_ollama": False, "is_passive": False, "is_mcp": False, "is_skills_collection": False, }), ("Passive Library", { "has_cli": False, "has_web": False, "has_gui": False, "is_ollama": False, "is_passive": True, "is_mcp": False, "is_skills_collection": False, }), ("MCP API", { "has_cli": False, "has_web": False, "has_gui": False, "is_ollama": False, "is_passive": False, "is_mcp": True, "is_skills_collection": False, }), ("Skills Pack", { "has_cli": False, "has_web": False, "has_gui": False, "is_ollama": False, "is_passive": False, "is_mcp": False, "is_skills_collection": True, }), ]: btn = QPushButton(label) btn.setMaximumWidth(120) btn.clicked.connect( lambda _, pf=preset_flags: self._apply_flag_preset(pf) ) preset_row.addWidget(btn) preset_row.addStretch() flags_layout.addLayout(preset_row) self.edit_layout.addWidget(grp_flags) # ── License ──────────────────────────────────────────── grp_lic = QGroupBox("License") form_lic = QFormLayout(grp_lic) self.f_license = QComboBox() self.f_license.setEditable(True) self.f_license.addItems(_LICENSE_SPDX_IDS) idx = self.f_license.findText( meta.get("license", "Proprietary") ) if idx >= 0: self.f_license.setCurrentIndex(idx) else: self.f_license.setEditText( meta.get("license", "Proprietary") ) form_lic.addRow("SPDX ID:", self.f_license) self.edit_layout.addWidget(grp_lic) # ── Dependencies ─────────────────────────────────────── grp_deps = QGroupBox("Dependencies") deps_layout = QVBoxLayout(grp_deps) self.f_deps = QLineEdit( ", ".join(meta.get("deps", [])) ) self.f_deps.setPlaceholderText( "Comma-separated tool IDs (e.g. redis, qdrant)" ) deps_layout.addWidget(self.f_deps) self.edit_layout.addWidget(grp_deps) # ── Filesystem ───────────────────────────────────────── fs = meta.get("filesystem", {}) grp_fs = QGroupBox("Filesystem Paths (relative to base)") form_fs = QFormLayout(grp_fs) self._fs_fields: dict[str, QLineEdit] = {} for fs_key in ( "install", "config", "cache", "data", "logs", "runtime", "models", ): le = QLineEdit(fs.get(fs_key, "") or "") le.setPlaceholderText(f"e.g. tools/{tool_id}") self._fs_fields[fs_key] = le form_fs.addRow(f"{fs_key}:", le) self.edit_layout.addWidget(grp_fs) # ── Action buttons ───────────────────────────────────── btn_bar = QHBoxLayout() self.btn_save = QPushButton("Save Changes") self.btn_save.setStyleSheet( "background-color: #27ae60; font-weight: bold;" ) self.btn_save.clicked.connect(self._save_current) btn_bar.addWidget(self.btn_save) self.btn_revert = QPushButton("Revert") self.btn_revert.clicked.connect( lambda: self._load_tool(self._current_tool_id) ) btn_bar.addWidget(self.btn_revert) self.btn_validate = QPushButton("Validate Entry") self.btn_validate.clicked.connect(self._validate_current) btn_bar.addWidget(self.btn_validate) btn_bar.addStretch() self.edit_layout.addLayout(btn_bar) # ── Raw JSON preview ─────────────────────────────────── grp_raw = QGroupBox("Raw JSON Preview") raw_layout = QVBoxLayout(grp_raw) self.raw_preview = QTextEdit() self.raw_preview.setReadOnly(True) self.raw_preview.setMaximumHeight(180) self.raw_preview.setStyleSheet( "font-family: monospace; font-size: 11px;" ) raw_layout.addWidget(self.raw_preview) self._refresh_raw_preview() self.edit_layout.addWidget(grp_raw) # Mark dirty on any edit for w in ( self.f_name, self.f_description, self.f_inst_pkg, self.f_inst_cmd, self.f_inst_post, self.f_inst_update, self.f_launch_cmd, self.f_deps, ): if isinstance(w, QLineEdit): w.textChanged.connect(self._mark_dirty) elif isinstance(w, QTextEdit): w.textChanged.connect(self._mark_dirty) for w in ( self.f_level, self.f_layer, self.f_role, self.f_inst_type, self.f_launch_type, self.f_launch_port, self.f_license, ): if isinstance(w, QComboBox): w.currentIndexChanged.connect(self._mark_dirty) elif isinstance(w, QSpinBox): w.valueChanged.connect(self._mark_dirty) for cb in self._flag_widgets.values(): cb.stateChanged.connect(self._mark_dirty) for le in self._fs_fields.values(): le.textChanged.connect(self._mark_dirty) self.edit_layout.addStretch() self._loading = False # ── Category cascade ────────────────────────────────────────── def _on_category_changed(self, category: str) -> None: """Auto-fill Level, Layer, and Role when Category changes.""" if self._loading: return mapping = CATEGORY_MAP.get(category) if mapping is None: return self._loading = True self.f_level.setValue(mapping["level"]) _set_combo_text(self.f_layer, mapping["layer"]) _set_combo_text(self.f_role, mapping["role"]) self._loading = False self._mark_dirty() # ── Flag presets ────────────────────────────────────────────── def _apply_flag_preset( self, preset: dict[str, bool] ) -> None: for key, val in preset.items(): cb = self._flag_widgets.get(key) if cb: cb.setChecked(val) self._mark_dirty() # ── Dirty tracking ──────────────────────────────────────────── def _mark_dirty(self) -> None: if self._loading: return self._dirty = True self.btn_save.setStyleSheet( "background-color: #e67e22; font-weight: bold;" ) self._refresh_raw_preview() def _refresh_raw_preview(self) -> None: if not self._current_tool_id: return data = self._collect_form_data() import json self.raw_preview.setPlainText( json.dumps(data, indent=2, ensure_ascii=False) ) # ── Collect form data back into a dict ──────────────────────── def _collect_form_data(self) -> dict: port_val = self.f_launch_port.value() deps_text = self.f_deps.text().strip() deps_list = [ d.strip() for d in deps_text.split(",") if d.strip() ] flags = {} for key, cb in self._flag_widgets.items(): flags[key] = cb.isChecked() fs = {} for key, le in self._fs_fields.items(): val = le.text().strip() fs[key] = val return { "name": self.f_name.text().strip(), "level": self.f_level.value(), "layer": self.f_layer.currentText(), "role": self.f_role.currentText(), "category": self.f_category.currentText(), "description": self.f_description.toPlainText().strip(), "license": self.f_license.currentText().strip(), "installer": { "type": self.f_inst_type.currentText(), "pkg": self.f_inst_pkg.text().strip(), "cmd": self.f_inst_cmd.text().strip() or None, "post_install": ( self.f_inst_post.text().strip() or None ), "update_cmd": ( self.f_inst_update.text().strip() or None ), }, "launcher": { "type": self.f_launch_type.currentText(), "cmd": self.f_launch_cmd.text().strip(), "default_port": port_val if port_val > 0 else None, }, "deps": deps_list, "flags": flags, "filesystem": fs, } # ── Save / Revert / Validate ────────────────────────────────── def _save_current(self) -> None: if not self._current_tool_id: return data = self._collect_form_data() tool_id = self._current_tool_id # Validate before writing from ai_lsc.registry.validator import validate_registry test_registry = dict(self.main.registry_mgr.data) test_registry[tool_id] = data errors = validate_registry(test_registry) tool_errors = [ e for e in errors if e.startswith(f"{tool_id}:") ] if tool_errors: self._show_status( "error", f"Validation failed for {tool_id}:
" + "
".join( f"• {e}" for e in tool_errors ), ) return # Write via atomic write self.main.registry_mgr.data[tool_id] = data registry_path = ( self.main.registry_mgr.registry_file ) from ai_lsc.ui.main_window import _atomic_write_json _atomic_write_json(registry_path, self.main.registry_mgr.data) self._dirty = False self.btn_save.setStyleSheet( "background-color: #27ae60; font-weight: bold;" ) self._show_status( "ok", f"{tool_id} saved successfully.", ) self._populate_table() self._reselect_tool(tool_id) def _save_current_if_dirty(self) -> None: if self._dirty and self._current_tool_id: reply = QMessageBox.question( self, "Unsaved changes", f"Save changes to {self._current_tool_id} " f"before switching?", QMessageBox.Save | QMessageBox.Discard, QMessageBox.Save, ) if reply == QMessageBox.Save: self._save_current() def _validate_current(self) -> None: if not self._current_tool_id: return data = self._collect_form_data() tool_id = self._current_tool_id from ai_lsc.registry.validator import validate_registry test_registry = dict(self.main.registry_mgr.data) test_registry[tool_id] = data errors = validate_registry(test_registry) tool_errors = [ e for e in errors if e.startswith(f"{tool_id}:") ] other_count = len(errors) - len(tool_errors) if not tool_errors: self._show_status( "ok", f"{tool_id} — no validation errors." + ( f" ({other_count} warning(s) in other tools)" if other_count else "" ), ) else: self._show_status( "error", f"{tool_id} — {len(tool_errors)} error(s):" "
" + "
".join( f"• {e}" for e in tool_errors ), ) def _validate_all(self) -> None: # Re-read from disk in case user edited outside self.main.registry_mgr._bootstrap() from ai_lsc.registry.validator import validate_registry errors = validate_registry(self.main.registry_mgr.data) total_tools = len(self.main.registry_mgr.data) if not errors: self._show_status( "ok", f"All {total_tools} tools pass validation.", ) else: self._show_status( "error", f"{len(errors)} error(s) across " f"{total_tools} tools:
" + "
".join( f"• {e}" for e in errors[:30] ) + ( f"
... and {len(errors) - 30} more" if len(errors) > 30 else "" ), ) # ── Add / Delete tool ───────────────────────────────────────── def _add_tool(self) -> None: new_id, ok = _NewToolDialog.get_tool_id(self) if not ok or not new_id: return registry = self.main.registry_mgr.data if new_id in registry: self._show_status( "error", f"{new_id} already exists in the registry.", ) return # Template with sensible defaults registry[new_id] = { "name": new_id.replace("_", " ").title(), "level": 1, "layer": _LAYER_ORDER[0] if _LAYER_ORDER else "", "role": "", "category": "", "description": "", "license": "Proprietary", "installer": { "type": "pacman", "pkg": new_id, }, "launcher": { "type": "desktop", "cmd": new_id, "default_port": None, }, "deps": [], "flags": { k: False for k in _FLAG_KEYS }, "filesystem": {}, } from ai_lsc.ui.main_window import _atomic_write_json _atomic_write_json( self.main.registry_mgr.registry_file, registry ) self._populate_table() self._reselect_tool(new_id) self._show_status( "ok", f"{new_id} added with defaults. " f"Edit and save to finalise.", ) def _delete_tool(self) -> None: if not self._current_tool_id: return tool_id = self._current_tool_id reply = QMessageBox.warning( self, "Delete tool?", f"Permanently remove {tool_id} from the " f"registry? This cannot be undone.", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if reply != QMessageBox.Yes: return self.main.registry_mgr.data.pop(tool_id, None) from ai_lsc.ui.main_window import _atomic_write_json _atomic_write_json( self.main.registry_mgr.registry_file, self.main.registry_mgr.data, ) self._current_tool_id = None self._dirty = False self._clear_edit_form() self.edit_layout.addWidget(QLabel( "Tool deleted. Select another to edit." )) self._populate_table() self._show_status( "ok", f"{tool_id} removed from registry.", ) # ── Helpers ────────────────────────────────────────────────── def _reselect_tool(self, tool_id: str) -> None: """Re-select the given tool_id in the table after refresh.""" for row in range(self.tool_table.rowCount()): item = self.tool_table.item(row, 0) if item and item.data(Qt.UserRole) == tool_id: self.tool_table.selectRow(row) self.tool_table.scrollTo( self.tool_table.model().index(row, 0) ) return def _show_status( self, level: str, html: str ) -> None: colors = { "ok": ("#27ae60", "#1a3a2a"), "error": ("#e74c3c", "#3a1a1a"), "info": ("#3498db", "#1a2a3a"), } fg, bg = colors.get(level, ("#bdc3c7", "#1a1a1a")) self.status_label.setStyleSheet( f"padding: 6px 10px; border-radius: 4px; " f"color: {fg}; background-color: {bg};" ) self.status_label.setText(html) def refresh(self) -> None: """Called by the main window when the nav tab is activated.""" _populate_lookups( self.main.registry_mgr.get_all_tools() ) self._populate_table() # ── Dialogs ──────────────────────────────────────────────────────── class _NewToolDialog(QDialog): """Modal dialog that asks the user for a new tool_id.""" @staticmethod def get_tool_id(parent: QWidget) -> tuple[str, bool]: dlg = _NewToolDialog(parent) result = dlg.exec() return dlg.input_field.text().strip(), ( result == QDialog.DialogCode.Accepted ) def __init__(self, parent: QWidget) -> None: super().__init__(parent) self.setWindowTitle("Add New Tool") self.setMinimumWidth(400) layout = QVBoxLayout(self) layout.addWidget(QLabel( "Enter the new tool identifier (lowercase, " "alphanumeric + underscores):" )) self.input_field = QLineEdit() self.input_field.setPlaceholderText("e.g. my_new_tool") layout.addWidget(self.input_field) buttons = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel ) buttons.accepted.connect(self._validate_and_accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) def _validate_and_accept(self) -> None: import re text = self.input_field.text().strip() if not text: self.input_field.setStyleSheet( "border: 2px solid #e74c3c;" ) return if not re.match(r"^[a-z0-9_]+$", text): self.input_field.setStyleSheet( "border: 2px solid #e74c3c;" ) return self.accept() else: DatabaseManager = None