codepp 0.0.437__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. code_puppy/__init__.py +10 -0
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agents/__init__.py +31 -0
  4. code_puppy/agents/agent_c_reviewer.py +155 -0
  5. code_puppy/agents/agent_code_puppy.py +117 -0
  6. code_puppy/agents/agent_code_reviewer.py +90 -0
  7. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  8. code_puppy/agents/agent_creator_agent.py +638 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +124 -0
  11. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  12. code_puppy/agents/agent_manager.py +742 -0
  13. code_puppy/agents/agent_pack_leader.py +385 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +169 -0
  16. code_puppy/agents/agent_python_reviewer.py +90 -0
  17. code_puppy/agents/agent_qa_expert.py +163 -0
  18. code_puppy/agents/agent_qa_kitten.py +208 -0
  19. code_puppy/agents/agent_scheduler.py +121 -0
  20. code_puppy/agents/agent_security_auditor.py +181 -0
  21. code_puppy/agents/agent_terminal_qa.py +323 -0
  22. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  23. code_puppy/agents/base_agent.py +2156 -0
  24. code_puppy/agents/event_stream_handler.py +348 -0
  25. code_puppy/agents/json_agent.py +202 -0
  26. code_puppy/agents/pack/__init__.py +34 -0
  27. code_puppy/agents/pack/bloodhound.py +304 -0
  28. code_puppy/agents/pack/husky.py +327 -0
  29. code_puppy/agents/pack/retriever.py +393 -0
  30. code_puppy/agents/pack/shepherd.py +348 -0
  31. code_puppy/agents/pack/terrier.py +287 -0
  32. code_puppy/agents/pack/watchdog.py +367 -0
  33. code_puppy/agents/prompt_reviewer.py +145 -0
  34. code_puppy/agents/subagent_stream_handler.py +276 -0
  35. code_puppy/api/__init__.py +13 -0
  36. code_puppy/api/app.py +169 -0
  37. code_puppy/api/main.py +21 -0
  38. code_puppy/api/pty_manager.py +453 -0
  39. code_puppy/api/routers/__init__.py +12 -0
  40. code_puppy/api/routers/agents.py +36 -0
  41. code_puppy/api/routers/commands.py +217 -0
  42. code_puppy/api/routers/config.py +75 -0
  43. code_puppy/api/routers/sessions.py +234 -0
  44. code_puppy/api/templates/terminal.html +361 -0
  45. code_puppy/api/websocket.py +154 -0
  46. code_puppy/callbacks.py +692 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +672 -0
  49. code_puppy/cli_runner.py +1073 -0
  50. code_puppy/command_line/__init__.py +1 -0
  51. code_puppy/command_line/add_model_menu.py +1092 -0
  52. code_puppy/command_line/agent_menu.py +662 -0
  53. code_puppy/command_line/attachments.py +395 -0
  54. code_puppy/command_line/autosave_menu.py +704 -0
  55. code_puppy/command_line/clipboard.py +527 -0
  56. code_puppy/command_line/colors_menu.py +532 -0
  57. code_puppy/command_line/command_handler.py +293 -0
  58. code_puppy/command_line/command_registry.py +150 -0
  59. code_puppy/command_line/config_commands.py +719 -0
  60. code_puppy/command_line/core_commands.py +867 -0
  61. code_puppy/command_line/diff_menu.py +865 -0
  62. code_puppy/command_line/file_path_completion.py +73 -0
  63. code_puppy/command_line/load_context_completion.py +52 -0
  64. code_puppy/command_line/mcp/__init__.py +10 -0
  65. code_puppy/command_line/mcp/base.py +32 -0
  66. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  67. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  68. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  69. code_puppy/command_line/mcp/edit_command.py +148 -0
  70. code_puppy/command_line/mcp/handler.py +138 -0
  71. code_puppy/command_line/mcp/help_command.py +147 -0
  72. code_puppy/command_line/mcp/install_command.py +214 -0
  73. code_puppy/command_line/mcp/install_menu.py +705 -0
  74. code_puppy/command_line/mcp/list_command.py +94 -0
  75. code_puppy/command_line/mcp/logs_command.py +235 -0
  76. code_puppy/command_line/mcp/remove_command.py +82 -0
  77. code_puppy/command_line/mcp/restart_command.py +100 -0
  78. code_puppy/command_line/mcp/search_command.py +123 -0
  79. code_puppy/command_line/mcp/start_all_command.py +135 -0
  80. code_puppy/command_line/mcp/start_command.py +117 -0
  81. code_puppy/command_line/mcp/status_command.py +184 -0
  82. code_puppy/command_line/mcp/stop_all_command.py +112 -0
  83. code_puppy/command_line/mcp/stop_command.py +80 -0
  84. code_puppy/command_line/mcp/test_command.py +107 -0
  85. code_puppy/command_line/mcp/utils.py +129 -0
  86. code_puppy/command_line/mcp/wizard_utils.py +334 -0
  87. code_puppy/command_line/mcp_completion.py +174 -0
  88. code_puppy/command_line/model_picker_completion.py +197 -0
  89. code_puppy/command_line/model_settings_menu.py +932 -0
  90. code_puppy/command_line/motd.py +96 -0
  91. code_puppy/command_line/onboarding_slides.py +179 -0
  92. code_puppy/command_line/onboarding_wizard.py +342 -0
  93. code_puppy/command_line/pin_command_completion.py +329 -0
  94. code_puppy/command_line/prompt_toolkit_completion.py +846 -0
  95. code_puppy/command_line/session_commands.py +302 -0
  96. code_puppy/command_line/shell_passthrough.py +145 -0
  97. code_puppy/command_line/skills_completion.py +160 -0
  98. code_puppy/command_line/uc_menu.py +893 -0
  99. code_puppy/command_line/utils.py +93 -0
  100. code_puppy/command_line/wiggum_state.py +78 -0
  101. code_puppy/config.py +1770 -0
  102. code_puppy/error_logging.py +134 -0
  103. code_puppy/gemini_code_assist.py +385 -0
  104. code_puppy/gemini_model.py +754 -0
  105. code_puppy/hook_engine/README.md +105 -0
  106. code_puppy/hook_engine/__init__.py +21 -0
  107. code_puppy/hook_engine/aliases.py +155 -0
  108. code_puppy/hook_engine/engine.py +221 -0
  109. code_puppy/hook_engine/executor.py +296 -0
  110. code_puppy/hook_engine/matcher.py +156 -0
  111. code_puppy/hook_engine/models.py +240 -0
  112. code_puppy/hook_engine/registry.py +106 -0
  113. code_puppy/hook_engine/validator.py +144 -0
  114. code_puppy/http_utils.py +361 -0
  115. code_puppy/keymap.py +128 -0
  116. code_puppy/main.py +10 -0
  117. code_puppy/mcp_/__init__.py +66 -0
  118. code_puppy/mcp_/async_lifecycle.py +286 -0
  119. code_puppy/mcp_/blocking_startup.py +469 -0
  120. code_puppy/mcp_/captured_stdio_server.py +275 -0
  121. code_puppy/mcp_/circuit_breaker.py +290 -0
  122. code_puppy/mcp_/config_wizard.py +507 -0
  123. code_puppy/mcp_/dashboard.py +308 -0
  124. code_puppy/mcp_/error_isolation.py +407 -0
  125. code_puppy/mcp_/examples/retry_example.py +226 -0
  126. code_puppy/mcp_/health_monitor.py +589 -0
  127. code_puppy/mcp_/managed_server.py +428 -0
  128. code_puppy/mcp_/manager.py +807 -0
  129. code_puppy/mcp_/mcp_logs.py +224 -0
  130. code_puppy/mcp_/registry.py +451 -0
  131. code_puppy/mcp_/retry_manager.py +337 -0
  132. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  133. code_puppy/mcp_/status_tracker.py +355 -0
  134. code_puppy/mcp_/system_tools.py +209 -0
  135. code_puppy/mcp_prompts/__init__.py +1 -0
  136. code_puppy/mcp_prompts/hook_creator.py +103 -0
  137. code_puppy/messaging/__init__.py +255 -0
  138. code_puppy/messaging/bus.py +613 -0
  139. code_puppy/messaging/commands.py +167 -0
  140. code_puppy/messaging/markdown_patches.py +57 -0
  141. code_puppy/messaging/message_queue.py +361 -0
  142. code_puppy/messaging/messages.py +569 -0
  143. code_puppy/messaging/queue_console.py +271 -0
  144. code_puppy/messaging/renderers.py +311 -0
  145. code_puppy/messaging/rich_renderer.py +1158 -0
  146. code_puppy/messaging/spinner/__init__.py +83 -0
  147. code_puppy/messaging/spinner/console_spinner.py +240 -0
  148. code_puppy/messaging/spinner/spinner_base.py +95 -0
  149. code_puppy/messaging/subagent_console.py +460 -0
  150. code_puppy/model_factory.py +848 -0
  151. code_puppy/model_switching.py +63 -0
  152. code_puppy/model_utils.py +168 -0
  153. code_puppy/models.json +174 -0
  154. code_puppy/models_dev_api.json +1 -0
  155. code_puppy/models_dev_parser.py +592 -0
  156. code_puppy/plugins/__init__.py +186 -0
  157. code_puppy/plugins/agent_skills/__init__.py +22 -0
  158. code_puppy/plugins/agent_skills/config.py +175 -0
  159. code_puppy/plugins/agent_skills/discovery.py +136 -0
  160. code_puppy/plugins/agent_skills/downloader.py +392 -0
  161. code_puppy/plugins/agent_skills/installer.py +22 -0
  162. code_puppy/plugins/agent_skills/metadata.py +219 -0
  163. code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
  164. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  165. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  166. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  167. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  168. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  169. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  170. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  171. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  172. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  173. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  174. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  175. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  176. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  177. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  178. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  179. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  180. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  181. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  182. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  183. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
  184. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  185. code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
  186. code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
  187. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  188. code_puppy/plugins/claude_code_hooks/config.py +137 -0
  189. code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
  190. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  191. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  192. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  193. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  194. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  195. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  196. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  197. code_puppy/plugins/claude_code_oauth/utils.py +640 -0
  198. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  199. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  200. code_puppy/plugins/example_custom_command/README.md +280 -0
  201. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  202. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  203. code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
  204. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  205. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  206. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  207. code_puppy/plugins/hook_creator/__init__.py +1 -0
  208. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  209. code_puppy/plugins/hook_manager/__init__.py +1 -0
  210. code_puppy/plugins/hook_manager/config.py +290 -0
  211. code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
  212. code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
  213. code_puppy/plugins/oauth_puppy_html.py +228 -0
  214. code_puppy/plugins/scheduler/__init__.py +1 -0
  215. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  216. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  217. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  218. code_puppy/plugins/shell_safety/__init__.py +6 -0
  219. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  220. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  221. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  222. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  223. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  224. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  225. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  226. code_puppy/plugins/universal_constructor/models.py +138 -0
  227. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  228. code_puppy/plugins/universal_constructor/registry.py +302 -0
  229. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  230. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  231. code_puppy/pydantic_patches.py +356 -0
  232. code_puppy/reopenable_async_client.py +232 -0
  233. code_puppy/round_robin_model.py +150 -0
  234. code_puppy/scheduler/__init__.py +41 -0
  235. code_puppy/scheduler/__main__.py +9 -0
  236. code_puppy/scheduler/cli.py +118 -0
  237. code_puppy/scheduler/config.py +126 -0
  238. code_puppy/scheduler/daemon.py +280 -0
  239. code_puppy/scheduler/executor.py +155 -0
  240. code_puppy/scheduler/platform.py +19 -0
  241. code_puppy/scheduler/platform_unix.py +22 -0
  242. code_puppy/scheduler/platform_win.py +32 -0
  243. code_puppy/session_storage.py +338 -0
  244. code_puppy/status_display.py +257 -0
  245. code_puppy/summarization_agent.py +176 -0
  246. code_puppy/terminal_utils.py +418 -0
  247. code_puppy/tools/__init__.py +501 -0
  248. code_puppy/tools/agent_tools.py +603 -0
  249. code_puppy/tools/ask_user_question/__init__.py +26 -0
  250. code_puppy/tools/ask_user_question/constants.py +73 -0
  251. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  252. code_puppy/tools/ask_user_question/handler.py +232 -0
  253. code_puppy/tools/ask_user_question/models.py +304 -0
  254. code_puppy/tools/ask_user_question/registration.py +26 -0
  255. code_puppy/tools/ask_user_question/renderers.py +309 -0
  256. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  257. code_puppy/tools/ask_user_question/theme.py +155 -0
  258. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  259. code_puppy/tools/browser/__init__.py +37 -0
  260. code_puppy/tools/browser/browser_control.py +289 -0
  261. code_puppy/tools/browser/browser_interactions.py +545 -0
  262. code_puppy/tools/browser/browser_locators.py +640 -0
  263. code_puppy/tools/browser/browser_manager.py +378 -0
  264. code_puppy/tools/browser/browser_navigation.py +251 -0
  265. code_puppy/tools/browser/browser_screenshot.py +179 -0
  266. code_puppy/tools/browser/browser_scripts.py +462 -0
  267. code_puppy/tools/browser/browser_workflows.py +221 -0
  268. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  269. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  270. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  271. code_puppy/tools/browser/terminal_tools.py +525 -0
  272. code_puppy/tools/command_runner.py +1346 -0
  273. code_puppy/tools/common.py +1409 -0
  274. code_puppy/tools/display.py +84 -0
  275. code_puppy/tools/file_modifications.py +886 -0
  276. code_puppy/tools/file_operations.py +802 -0
  277. code_puppy/tools/scheduler_tools.py +412 -0
  278. code_puppy/tools/skills_tools.py +244 -0
  279. code_puppy/tools/subagent_context.py +158 -0
  280. code_puppy/tools/tools_content.py +51 -0
  281. code_puppy/tools/universal_constructor.py +889 -0
  282. code_puppy/uvx_detection.py +242 -0
  283. code_puppy/version_checker.py +82 -0
  284. codepp-0.0.437.dist-info/METADATA +766 -0
  285. codepp-0.0.437.dist-info/RECORD +288 -0
  286. codepp-0.0.437.dist-info/WHEEL +4 -0
  287. codepp-0.0.437.dist-info/entry_points.txt +3 -0
  288. codepp-0.0.437.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,664 @@
1
+ """Interactive terminal UI for browsing and installing remote agent skills.
2
+
3
+ Launched from `/skills install` (wiring may live elsewhere). Provides a
4
+ split-panel prompt_toolkit UI:
5
+ - Left: categories, then skills within a category
6
+ - Right: live details preview for the current selection
7
+
8
+ Installation happens after the TUI exits, with a confirmation prompt via
9
+ `safe_input()`, and uses `download_and_install_skill()` to fetch and extract
10
+ remote ZIPs.
11
+
12
+ This module is intentionally defensive: if the remote catalog isn't available,
13
+ it shows an empty menu and returns False.
14
+ """
15
+
16
+ import logging
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+ from typing import List, Optional
21
+
22
+ from prompt_toolkit.application import Application
23
+ from prompt_toolkit.key_binding import KeyBindings
24
+ from prompt_toolkit.layout import Dimension, Layout, VSplit, Window
25
+ from prompt_toolkit.layout.controls import FormattedTextControl
26
+ from prompt_toolkit.widgets import Frame
27
+
28
+ from code_puppy.command_line.utils import safe_input
29
+ from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning
30
+ from code_puppy.plugins.agent_skills.downloader import download_and_install_skill
31
+ from code_puppy.plugins.agent_skills.installer import InstallResult
32
+ from code_puppy.plugins.agent_skills.skill_catalog import SkillCatalogEntry, catalog
33
+ from code_puppy.tools.command_runner import set_awaiting_user_input
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ PAGE_SIZE = 12
38
+
39
+
40
+ def is_skill_installed(skill_id: str) -> bool:
41
+ """Return True if the skill is already installed locally."""
42
+
43
+ return (Path.home() / ".code_puppy" / "skills" / skill_id / "SKILL.md").is_file()
44
+
45
+
46
+ def _format_bytes(num_bytes: int) -> str:
47
+ """Format bytes into a human-readable string."""
48
+
49
+ try:
50
+ size = float(max(0, int(num_bytes)))
51
+ except Exception:
52
+ return "0 B"
53
+
54
+ for unit in ("B", "KB", "MB", "GB"):
55
+ if size < 1024.0 or unit == "GB":
56
+ if unit == "B":
57
+ return f"{int(size)} {unit}"
58
+ return f"{size:.1f} {unit}"
59
+ size /= 1024.0
60
+ return f"{size:.1f} GB"
61
+
62
+
63
+ def _wrap_text(text: str, width: int) -> List[str]:
64
+ """Simple word-wrap for display in the details panel."""
65
+
66
+ if not text:
67
+ return []
68
+
69
+ words = text.split()
70
+ lines: list[str] = []
71
+ current = ""
72
+
73
+ for word in words:
74
+ if not current:
75
+ current = word
76
+ continue
77
+
78
+ if len(current) + 1 + len(word) > width:
79
+ lines.append(current)
80
+ current = word
81
+ else:
82
+ current = f"{current} {word}"
83
+
84
+ if current:
85
+ lines.append(current)
86
+
87
+ return lines
88
+
89
+
90
+ def _category_key(category: str) -> str:
91
+ """Normalize a category string for icon lookup."""
92
+
93
+ return "".join(ch for ch in (category or "").casefold() if ch.isalnum())
94
+
95
+
96
+ class SkillsInstallMenu:
97
+ """Interactive TUI for browsing and installing remote skills."""
98
+
99
+ def __init__(self):
100
+ """Initialize the skills install menu with catalog data."""
101
+
102
+ self.catalog = catalog
103
+ self.categories: List[str] = []
104
+ self.current_category: Optional[str] = None
105
+ self.current_skills: List[SkillCatalogEntry] = []
106
+
107
+ # State
108
+ self.view_mode = "categories" # categories | skills
109
+ self.selected_category_idx = 0
110
+ self.selected_skill_idx = 0
111
+ self.current_page = 0
112
+ self.result: Optional[str] = None
113
+ self.pending_entry: Optional[SkillCatalogEntry] = None
114
+
115
+ # UI controls
116
+ self.menu_control: Optional[FormattedTextControl] = None
117
+ self.preview_control: Optional[FormattedTextControl] = None
118
+
119
+ self._initialize_catalog()
120
+
121
+ def _initialize_catalog(self) -> None:
122
+ """Load categories from the remote-backed catalog."""
123
+
124
+ try:
125
+ self.categories = self.catalog.list_categories() if self.catalog else []
126
+ except Exception as e:
127
+ emit_error(f"Skill catalog not available: {e}")
128
+ self.categories = []
129
+
130
+ def _get_category_icon(self, category: str) -> str:
131
+ """Return an emoji icon for a skill category name."""
132
+
133
+ icons = {
134
+ "data": "📊",
135
+ "finance": "💰",
136
+ "legal": "⚖️",
137
+ "office": "📄",
138
+ "productmanagement": "📦",
139
+ "sales": "💼",
140
+ "biology": "🧬",
141
+ }
142
+ return icons.get(_category_key(category), "📁")
143
+
144
+ def _get_current_category(self) -> Optional[str]:
145
+ """Get the currently highlighted category name."""
146
+
147
+ if 0 <= self.selected_category_idx < len(self.categories):
148
+ return self.categories[self.selected_category_idx]
149
+ return None
150
+
151
+ def _get_current_skill(self) -> Optional[SkillCatalogEntry]:
152
+ """Get the currently highlighted skill entry."""
153
+
154
+ if self.view_mode == "skills" and self.current_skills:
155
+ if 0 <= self.selected_skill_idx < len(self.current_skills):
156
+ return self.current_skills[self.selected_skill_idx]
157
+ return None
158
+
159
+ def _render_navigation_hints(self, lines: List) -> None:
160
+ """Render keyboard shortcut hints at the bottom."""
161
+
162
+ lines.append(("", "\n"))
163
+ lines.append(("fg:ansibrightblack", " ↑/↓ "))
164
+ lines.append(("", "Navigate "))
165
+ lines.append(("fg:ansibrightblack", "←/→ "))
166
+ lines.append(("", "Page\n"))
167
+
168
+ if self.view_mode == "categories":
169
+ lines.append(("fg:ansigreen", " Enter "))
170
+ lines.append(("", "Browse Skills\n"))
171
+ else:
172
+ lines.append(("fg:ansigreen", " Enter "))
173
+ lines.append(("", "Install Skill\n"))
174
+ lines.append(("fg:ansibrightblack", " Esc/Back "))
175
+ lines.append(("", "Back\n"))
176
+
177
+ lines.append(("fg:ansired", " Ctrl+C "))
178
+ lines.append(("", "Cancel"))
179
+
180
+ def _render_category_list(self) -> List:
181
+ """Render the left panel with category navigation."""
182
+
183
+ lines = []
184
+
185
+ lines.append(("bold cyan", " 📂 CATEGORIES"))
186
+ lines.append(("", "\n\n"))
187
+
188
+ if not self.categories:
189
+ lines.append(("fg:ansiyellow", " No remote categories available."))
190
+ lines.append(("", "\n"))
191
+ lines.append(
192
+ (
193
+ "fg:ansibrightblack",
194
+ " (Remote catalog unavailable or empty)\n",
195
+ )
196
+ )
197
+ self._render_navigation_hints(lines)
198
+ return lines
199
+
200
+ total_pages = (len(self.categories) + PAGE_SIZE - 1) // PAGE_SIZE
201
+ start_idx = self.current_page * PAGE_SIZE
202
+ end_idx = min(start_idx + PAGE_SIZE, len(self.categories))
203
+
204
+ for i in range(start_idx, end_idx):
205
+ category = self.categories[i]
206
+ is_selected = i == self.selected_category_idx
207
+ icon = self._get_category_icon(category)
208
+ count = 0
209
+ try:
210
+ count = (
211
+ len(self.catalog.get_by_category(category)) if self.catalog else 0
212
+ )
213
+ except Exception:
214
+ count = 0
215
+
216
+ prefix = " > " if is_selected else " "
217
+ label = f"{prefix}{icon} {category} ({count})"
218
+
219
+ if is_selected:
220
+ lines.append(("fg:ansibrightcyan bold", label))
221
+ else:
222
+ lines.append(("fg:ansibrightblack", label))
223
+ lines.append(("", "\n"))
224
+
225
+ lines.append(("", "\n"))
226
+ if total_pages > 1:
227
+ lines.append(
228
+ ("fg:ansibrightblack", f" Page {self.current_page + 1}/{total_pages}")
229
+ )
230
+ lines.append(("", "\n"))
231
+
232
+ self._render_navigation_hints(lines)
233
+ return lines
234
+
235
+ def _render_skill_list(self) -> List:
236
+ """Render the middle panel with skills in the selected category."""
237
+
238
+ lines = []
239
+
240
+ if not self.current_category:
241
+ lines.append(("fg:ansiyellow", " No category selected."))
242
+ lines.append(("", "\n\n"))
243
+ self._render_navigation_hints(lines)
244
+ return lines
245
+
246
+ icon = self._get_category_icon(self.current_category)
247
+ lines.append(("bold cyan", f" {icon} {self.current_category.upper()}"))
248
+ lines.append(("", "\n\n"))
249
+
250
+ if not self.current_skills:
251
+ lines.append(("fg:ansiyellow", " No skills in this category."))
252
+ lines.append(("", "\n\n"))
253
+ self._render_navigation_hints(lines)
254
+ return lines
255
+
256
+ total_pages = (len(self.current_skills) + PAGE_SIZE - 1) // PAGE_SIZE
257
+ start_idx = self.current_page * PAGE_SIZE
258
+ end_idx = min(start_idx + PAGE_SIZE, len(self.current_skills))
259
+
260
+ for i in range(start_idx, end_idx):
261
+ entry = self.current_skills[i]
262
+ is_selected = i == self.selected_skill_idx
263
+
264
+ installed = is_skill_installed(entry.id)
265
+ status_icon = "✓" if installed else "○"
266
+ status_style = "fg:ansigreen" if installed else "fg:ansibrightblack"
267
+
268
+ prefix = " > " if is_selected else " "
269
+ label = f"{prefix}{status_icon} {entry.display_name}"
270
+
271
+ if is_selected:
272
+ lines.append(("fg:ansibrightcyan bold", label))
273
+ else:
274
+ lines.append((status_style, label))
275
+
276
+ lines.append(("", "\n"))
277
+
278
+ lines.append(("", "\n"))
279
+ if total_pages > 1:
280
+ lines.append(
281
+ ("fg:ansibrightblack", f" Page {self.current_page + 1}/{total_pages}")
282
+ )
283
+ lines.append(("", "\n"))
284
+
285
+ self._render_navigation_hints(lines)
286
+ return lines
287
+
288
+ def _render_details(self) -> List:
289
+ """Render the right panel with details for the selected skill."""
290
+
291
+ lines = []
292
+
293
+ lines.append(("bold cyan", " 📋 DETAILS"))
294
+ lines.append(("", "\n\n"))
295
+
296
+ if self.view_mode == "categories":
297
+ category = self._get_current_category()
298
+ if not category:
299
+ lines.append(("fg:ansiyellow", " No category selected."))
300
+ return lines
301
+
302
+ icon = self._get_category_icon(category)
303
+ lines.append(("bold", f" {icon} {category}"))
304
+ lines.append(("", "\n\n"))
305
+
306
+ skills = []
307
+ try:
308
+ skills = self.catalog.get_by_category(category) if self.catalog else []
309
+ except Exception:
310
+ skills = []
311
+
312
+ lines.append(("fg:ansibrightblack", f" {len(skills)} skills available"))
313
+ lines.append(("", "\n\n"))
314
+
315
+ # Show a preview of the first few skills
316
+ if skills:
317
+ lines.append(("bold", " Preview:"))
318
+ lines.append(("", "\n"))
319
+ for entry in skills[:6]:
320
+ lines.append(("fg:ansibrightblack", f" • {entry.display_name}"))
321
+ lines.append(("", "\n"))
322
+
323
+ return lines
324
+
325
+ entry = self._get_current_skill()
326
+ if not entry:
327
+ lines.append(("fg:ansiyellow", " No skill selected."))
328
+ return lines
329
+
330
+ installed = is_skill_installed(entry.id)
331
+ installed_text = "Installed" if installed else "Not installed"
332
+ installed_style = "fg:ansigreen" if installed else "fg:ansiyellow"
333
+
334
+ lines.append(("bold", f" {entry.display_name}"))
335
+ lines.append(("", "\n"))
336
+ lines.append((installed_style, f" {installed_text}"))
337
+ lines.append(("", "\n\n"))
338
+
339
+ lines.append(("bold", " ID:"))
340
+ lines.append(("", "\n"))
341
+ lines.append(("fg:ansibrightblack", f" {entry.id}"))
342
+ lines.append(("", "\n\n"))
343
+
344
+ lines.append(("bold", " Description:"))
345
+ lines.append(("", "\n"))
346
+ desc = entry.description or "No description available"
347
+ for line in _wrap_text(desc, 56):
348
+ lines.append(("fg:ansibrightblack", f" {line}"))
349
+ lines.append(("", "\n"))
350
+ lines.append(("", "\n"))
351
+
352
+ lines.append(("bold", " Category:"))
353
+ lines.append(("", "\n"))
354
+ lines.append(("fg:ansibrightblack", f" {entry.category}"))
355
+ lines.append(("", "\n\n"))
356
+
357
+ lines.append(("bold", " Tags:"))
358
+ lines.append(("", "\n"))
359
+ tags = entry.tags or []
360
+ lines.append(("fg:ansicyan", f" {', '.join(tags) if tags else '(none)'}"))
361
+ lines.append(("", "\n\n"))
362
+
363
+ lines.append(("bold", " Contents:"))
364
+ lines.append(("", "\n"))
365
+ lines.append(
366
+ (
367
+ "fg:ansibrightblack",
368
+ f" scripts: {'yes' if entry.has_scripts else 'no'}",
369
+ )
370
+ )
371
+ lines.append(("", "\n"))
372
+ lines.append(
373
+ (
374
+ "fg:ansibrightblack",
375
+ f" references: {'yes' if entry.has_references else 'no'}",
376
+ )
377
+ )
378
+ lines.append(("", "\n"))
379
+ lines.append(("fg:ansibrightblack", f" files: {entry.file_count}"))
380
+ lines.append(("", "\n\n"))
381
+
382
+ lines.append(("bold", " Download:"))
383
+ lines.append(("", "\n"))
384
+ lines.append(
385
+ (
386
+ "fg:ansibrightblack",
387
+ f" size: {_format_bytes(entry.zip_size_bytes)}",
388
+ )
389
+ )
390
+ lines.append(("", "\n"))
391
+ lines.append(("fg:ansibrightblack", f" url: {entry.download_url}"))
392
+ lines.append(("", "\n"))
393
+
394
+ return lines
395
+
396
+ def update_display(self) -> None:
397
+ """Refresh all three panels of the TUI display."""
398
+
399
+ if self.view_mode == "categories":
400
+ self.menu_control.text = self._render_category_list()
401
+ else:
402
+ self.menu_control.text = self._render_skill_list()
403
+
404
+ self.preview_control.text = self._render_details()
405
+
406
+ def _enter_category(self) -> None:
407
+ """Enter the currently highlighted category to browse skills."""
408
+
409
+ category = self._get_current_category()
410
+ if not category or not self.catalog:
411
+ return
412
+
413
+ self.current_category = category
414
+ try:
415
+ self.current_skills = self.catalog.get_by_category(category)
416
+ except Exception:
417
+ self.current_skills = []
418
+
419
+ self.view_mode = "skills"
420
+ self.selected_skill_idx = 0
421
+ self.current_page = 0
422
+ self.update_display()
423
+
424
+ def _go_back_to_categories(self) -> None:
425
+ """Navigate back from skill list to category list."""
426
+
427
+ self.view_mode = "categories"
428
+ self.current_category = None
429
+ self.current_skills = []
430
+ self.selected_skill_idx = 0
431
+ self.current_page = 0
432
+ self.update_display()
433
+
434
+ def _select_current_skill(self) -> None:
435
+ """Download and install the currently highlighted skill."""
436
+
437
+ entry = self._get_current_skill()
438
+ if entry:
439
+ self.pending_entry = entry
440
+ self.result = "pending_install"
441
+
442
+ def run(self) -> bool:
443
+ """Run the skills install menu.
444
+
445
+ Returns:
446
+ True if a skill was installed, False otherwise.
447
+ """
448
+
449
+ # Build UI
450
+ self.menu_control = FormattedTextControl(text="")
451
+ self.preview_control = FormattedTextControl(text="")
452
+
453
+ menu_window = Window(
454
+ content=self.menu_control, wrap_lines=True, width=Dimension(weight=35)
455
+ )
456
+ preview_window = Window(
457
+ content=self.preview_control, wrap_lines=True, width=Dimension(weight=65)
458
+ )
459
+
460
+ menu_frame = Frame(menu_window, width=Dimension(weight=35), title="Browse")
461
+ preview_frame = Frame(
462
+ preview_window, width=Dimension(weight=65), title="Details"
463
+ )
464
+
465
+ root_container = VSplit([menu_frame, preview_frame])
466
+
467
+ kb = KeyBindings()
468
+
469
+ @kb.add("up")
470
+ def _(event):
471
+ """Move cursor up."""
472
+
473
+ if self.view_mode == "categories":
474
+ if self.selected_category_idx > 0:
475
+ self.selected_category_idx -= 1
476
+ self.current_page = self.selected_category_idx // PAGE_SIZE
477
+ else:
478
+ if self.selected_skill_idx > 0:
479
+ self.selected_skill_idx -= 1
480
+ self.current_page = self.selected_skill_idx // PAGE_SIZE
481
+ self.update_display()
482
+
483
+ @kb.add("down")
484
+ def _(event):
485
+ """Move cursor down."""
486
+
487
+ if self.view_mode == "categories":
488
+ if self.selected_category_idx < len(self.categories) - 1:
489
+ self.selected_category_idx += 1
490
+ self.current_page = self.selected_category_idx // PAGE_SIZE
491
+ else:
492
+ if self.selected_skill_idx < len(self.current_skills) - 1:
493
+ self.selected_skill_idx += 1
494
+ self.current_page = self.selected_skill_idx // PAGE_SIZE
495
+ self.update_display()
496
+
497
+ @kb.add("left")
498
+ def _(event):
499
+ """Navigate to previous page."""
500
+
501
+ if self.current_page > 0:
502
+ self.current_page -= 1
503
+ if self.view_mode == "categories":
504
+ self.selected_category_idx = self.current_page * PAGE_SIZE
505
+ else:
506
+ self.selected_skill_idx = self.current_page * PAGE_SIZE
507
+ self.update_display()
508
+
509
+ @kb.add("right")
510
+ def _(event):
511
+ """Navigate to next page."""
512
+
513
+ if self.view_mode == "categories":
514
+ total_items = len(self.categories)
515
+ else:
516
+ total_items = len(self.current_skills)
517
+
518
+ total_pages = (total_items + PAGE_SIZE - 1) // PAGE_SIZE
519
+ if self.current_page < total_pages - 1:
520
+ self.current_page += 1
521
+ if self.view_mode == "categories":
522
+ self.selected_category_idx = self.current_page * PAGE_SIZE
523
+ else:
524
+ self.selected_skill_idx = self.current_page * PAGE_SIZE
525
+ self.update_display()
526
+
527
+ @kb.add("enter")
528
+ def _(event):
529
+ """Select/enter the current item."""
530
+
531
+ if self.view_mode == "categories":
532
+ self._enter_category()
533
+ else:
534
+ self._select_current_skill()
535
+ event.app.exit()
536
+
537
+ @kb.add("escape")
538
+ def _(event):
539
+ """Go back."""
540
+
541
+ if self.view_mode == "skills":
542
+ self._go_back_to_categories()
543
+
544
+ @kb.add("backspace")
545
+ def _(event):
546
+ """Go back."""
547
+
548
+ if self.view_mode == "skills":
549
+ self._go_back_to_categories()
550
+
551
+ @kb.add("c-c")
552
+ def _(event):
553
+ """Quit the menu."""
554
+
555
+ event.app.exit()
556
+
557
+ layout = Layout(root_container)
558
+ app = Application(
559
+ layout=layout,
560
+ key_bindings=kb,
561
+ full_screen=False,
562
+ mouse_support=False,
563
+ )
564
+
565
+ set_awaiting_user_input(True)
566
+
567
+ # Enter alternate screen buffer
568
+ sys.stdout.write("\033[?1049h")
569
+ sys.stdout.write("\033[2J\033[H")
570
+ sys.stdout.flush()
571
+ time.sleep(0.05)
572
+
573
+ try:
574
+ self.update_display()
575
+ sys.stdout.write("\033[2J\033[H")
576
+ sys.stdout.flush()
577
+
578
+ app.run(in_thread=True)
579
+
580
+ finally:
581
+ sys.stdout.write("\033[?1049l")
582
+ sys.stdout.flush()
583
+
584
+ # Flush any buffered input to prevent stale keypresses
585
+ try:
586
+ import termios
587
+
588
+ termios.tcflush(sys.stdin.fileno(), termios.TCIFLUSH)
589
+ except Exception:
590
+ pass # ImportError on Windows, termios.error, or not a tty
591
+
592
+ # Small delay to let terminal settle before any output
593
+ time.sleep(0.1)
594
+ set_awaiting_user_input(False)
595
+
596
+ # Handle install after TUI exits
597
+ if self.result == "pending_install" and self.pending_entry:
598
+ return _prompt_and_install(self.pending_entry)
599
+
600
+ emit_info("✓ Exited skills install browser")
601
+ return False
602
+
603
+
604
+ def _prompt_and_install(entry: SkillCatalogEntry) -> bool:
605
+ """Prompt for confirmation and install the given skill."""
606
+
607
+ installed = is_skill_installed(entry.id)
608
+ size_str = _format_bytes(entry.zip_size_bytes)
609
+
610
+ try:
611
+ if installed:
612
+ answer = safe_input(
613
+ f"Skill '{entry.display_name}' is already installed. Reinstall ({size_str})? [y/N] "
614
+ )
615
+ if answer.strip().lower() not in {"y", "yes"}:
616
+ emit_info("Installation cancelled")
617
+ return False
618
+ force = True
619
+ else:
620
+ answer = safe_input(
621
+ f"Install skill '{entry.display_name}' ({size_str})? [y/N] "
622
+ )
623
+ if answer.strip().lower() not in {"y", "yes"}:
624
+ emit_info("Installation cancelled")
625
+ return False
626
+ force = False
627
+
628
+ except (KeyboardInterrupt, EOFError):
629
+ emit_warning("Installation cancelled")
630
+ return False
631
+
632
+ emit_info(f"Downloading: {entry.display_name} ({size_str})")
633
+
634
+ result: InstallResult
635
+ try:
636
+ result = download_and_install_skill(
637
+ skill_name=entry.id,
638
+ download_url=entry.download_url,
639
+ force=force,
640
+ )
641
+ except Exception as e:
642
+ logger.exception(f"Unexpected error during skill install: {e}")
643
+ emit_error(f"Installation error: {e}")
644
+ return False
645
+
646
+ if result.success:
647
+ emit_success(result.message)
648
+ if result.installed_path:
649
+ emit_info(f"Installed to: {result.installed_path}")
650
+ return True
651
+
652
+ emit_error(result.message)
653
+ return False
654
+
655
+
656
+ def run_skills_install_menu() -> bool:
657
+ """Run the bundled skills install menu.
658
+
659
+ Returns:
660
+ True if a skill was installed, False otherwise.
661
+ """
662
+
663
+ menu = SkillsInstallMenu()
664
+ return menu.run()