Add TUI publish feature and fix web UI dropdown

- Add Publish button to TUI for publishing tools directly from the
  graphical interface without using the command line
- Fix user dropdown menu in web UI header that stayed open after
  clicking (replaced Alpine.js directives with vanilla JS)
- Update test to use correct registry URL (cmdforge.brrd.tech)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-13 21:12:42 -04:00
parent 79739369f0
commit e18a575f76
4 changed files with 133 additions and 6 deletions

View File

@ -177,6 +177,7 @@ class CmdForgeUI:
edit_btn = Button3DCompact("Edit", lambda _: self._edit_selected_tool()) edit_btn = Button3DCompact("Edit", lambda _: self._edit_selected_tool())
delete_btn = Button3DCompact("Delete", lambda _: self._delete_selected_tool()) delete_btn = Button3DCompact("Delete", lambda _: self._delete_selected_tool())
test_btn = Button3DCompact("Test", lambda _: self._test_selected_tool()) test_btn = Button3DCompact("Test", lambda _: self._test_selected_tool())
publish_btn = Button3DCompact("Publish", lambda _: self._publish_selected_tool())
registry_btn = Button3DCompact("Registry", lambda _: self.browse_registry()) registry_btn = Button3DCompact("Registry", lambda _: self.browse_registry())
providers_btn = Button3DCompact("Providers", lambda _: self.manage_providers()) providers_btn = Button3DCompact("Providers", lambda _: self.manage_providers())
@ -189,6 +190,8 @@ class CmdForgeUI:
('pack', urwid.Text(" ")), ('pack', urwid.Text(" ")),
('pack', test_btn), ('pack', test_btn),
('pack', urwid.Text(" ")), ('pack', urwid.Text(" ")),
('pack', publish_btn),
('pack', urwid.Text(" ")),
('pack', registry_btn), ('pack', registry_btn),
('pack', urwid.Text(" ")), ('pack', urwid.Text(" ")),
('pack', providers_btn), ('pack', providers_btn),
@ -347,6 +350,113 @@ class CmdForgeUI:
else: else:
self.message_box("Test", "No tool selected.") self.message_box("Test", "No tool selected.")
def _publish_selected_tool(self):
"""Publish the currently selected tool to the registry."""
if not self._selected_tool_name:
self.message_box("Publish", "No tool selected.")
return
tool = load_tool(self._selected_tool_name)
if not tool:
self.message_box("Publish", "Could not load tool.")
return
# Check for version
tool_dir = get_tools_dir() / self._selected_tool_name
config_path = tool_dir / "config.yaml"
if not config_path.exists():
self.message_box("Publish", "Tool config not found.")
return
import yaml
config_text = config_path.read_text()
config_data = yaml.safe_load(config_text)
version = config_data.get("version", "")
if not version:
# Prompt for version
self._prompt_for_version_and_publish(tool, config_path, config_data, config_text)
else:
self._do_publish(tool, version)
def _prompt_for_version_and_publish(self, tool, config_path, config_data, config_text):
"""Prompt user for version and then publish."""
def on_version(version):
version = version.strip()
if not version:
self.message_box("Publish", "Version is required for publishing.")
return
# Add version to config
import yaml
config_data["version"] = version
config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False))
self._do_publish(tool, version)
self.input_dialog(
"Version Required",
"Enter version (e.g., 1.0.0)",
"1.0.0",
on_version
)
def _do_publish(self, tool, version):
"""Perform the actual publish."""
from ..config import load_config, set_registry_token
config = load_config()
if not config.registry.token:
self.message_box(
"Authentication Required",
"No registry token configured.\n\n"
"To publish tools:\n"
"1. Register at cmdforge.brrd.tech/register\n"
"2. Log in and go to Dashboard > Tokens\n"
"3. Generate a token\n"
"4. Run: cmdforge config set-token <token>"
)
return
def do_publish():
try:
client = RegistryClient()
tool_dir = get_tools_dir() / tool.name
config_yaml = (tool_dir / "config.yaml").read_text()
readme_path = tool_dir / "README.md"
readme = readme_path.read_text() if readme_path.exists() else ""
result = client.publish_tool(config_yaml, readme)
status = result.get("status", "")
pr_url = result.get("pr_url", "")
if status == "published" or result.get("version"):
owner = result.get("owner", "unknown")
name = result.get("name", tool.name)
self.message_box("Success", f"Published {owner}/{name}@{version}")
elif pr_url:
self.message_box("Pending Review", f"PR created: {pr_url}\n\nYour tool is pending review.")
else:
self.message_box("Success", "Tool published successfully!")
except RegistryError as e:
if e.code == "UNAUTHORIZED":
self.message_box("Error", "Authentication failed.\nYour token may have expired.")
elif e.code == "VERSION_EXISTS":
self.message_box("Error", f"Version {version} already exists.\nBump the version and try again.")
else:
self.message_box("Error", f"Publish failed: {e.message}")
except Exception as e:
self.message_box("Error", f"Publish failed: {e}")
self.yes_no(
"Publish Tool",
f"Publish {tool.name}@{version} to registry?",
on_yes=do_publish
)
def exit_app(self): def exit_app(self):
"""Exit the application.""" """Exit the application."""
raise urwid.ExitMainLoop() raise urwid.ExitMainLoop()

View File

@ -93,6 +93,24 @@ function closeMobileMenu() {
} }
} }
// User dropdown toggle
function toggleUserDropdown(event) {
event.stopPropagation();
const dropdown = document.getElementById('user-dropdown');
if (dropdown) {
dropdown.classList.toggle('hidden');
}
}
// Close user dropdown when clicking outside
document.addEventListener('click', function(event) {
const dropdown = document.getElementById('user-dropdown');
const container = document.getElementById('user-dropdown-container');
if (dropdown && container && !container.contains(event.target)) {
dropdown.classList.add('hidden');
}
});
// Mobile filters toggle (tools page) // Mobile filters toggle (tools page)
function toggleMobileFilters() { function toggleMobileFilters() {
const filters = document.getElementById('mobile-filters'); const filters = document.getElementById('mobile-filters');

View File

@ -60,17 +60,16 @@
<!-- Auth links --> <!-- Auth links -->
{% if session.get('user') %} {% if session.get('user') %}
<div class="relative" x-data="{ open: false }"> <div class="relative" id="user-dropdown-container">
<button @click="open = !open" <button onclick="toggleUserDropdown(event)"
class="flex items-center space-x-2 px-3 py-2 rounded-md hover:bg-slate-700 transition-colors"> class="flex items-center space-x-2 px-3 py-2 rounded-md hover:bg-slate-700 transition-colors">
<span class="text-sm font-medium">{{ session.user.display_name }}</span> <span class="text-sm font-medium">{{ session.user.display_name }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg> </svg>
</button> </button>
<div x-show="open" <div id="user-dropdown"
@click.away="open = false" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
<a href="{{ url_for('web.dashboard') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Dashboard</a> <a href="{{ url_for('web.dashboard') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Dashboard</a>
<a href="{{ url_for('web.dashboard_settings') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Settings</a> <a href="{{ url_for('web.dashboard_settings') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Settings</a>
<hr class="my-1"> <hr class="my-1">

View File

@ -186,7 +186,7 @@ class TestConfig:
def test_default_config(self): def test_default_config(self):
config = Config() config = Config()
assert config.registry.url == "https://gitea.brrd.tech/api/v1" assert config.registry.url == "https://cmdforge.brrd.tech/api/v1"
assert config.auto_fetch_from_registry is True assert config.auto_fetch_from_registry is True
assert config.client_id.startswith("anon_") assert config.client_id.startswith("anon_")