utim-cli 2.3.16__tar.gz → 2.3.18__tar.gz

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 (168) hide show
  1. {utim_cli-2.3.16 → utim_cli-2.3.18}/CHANGELOG.md +13 -0
  2. {utim_cli-2.3.16/utim_cli.egg-info → utim_cli-2.3.18}/PKG-INFO +1 -1
  3. {utim_cli-2.3.16 → utim_cli-2.3.18}/pyproject.toml +1 -1
  4. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim/__init__.py +1 -1
  5. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/__init__.py +1 -1
  6. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/_version.py +1 -1
  7. utim_cli-2.3.18/utim_cli/_wizard_smoke_test.py +93 -0
  8. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/bootstrap.py +48 -3
  9. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/client_utils.py +76 -0
  10. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/constants.py +21 -1
  11. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/mcp_client.py +12 -1
  12. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/orchestrator.py +11 -13
  13. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/orchestrator_helpers.py +113 -2
  14. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/orchestrator_llm_transport.py +274 -51
  15. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/orchestrator_tool_exec.py +9 -4
  16. utim_cli-2.3.18/utim_cli/plugins/loader.py +177 -0
  17. utim_cli-2.3.18/utim_cli/plugins/manager.py +558 -0
  18. utim_cli-2.3.18/utim_cli/plugins/runtime.py +175 -0
  19. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/plugins/schema.py +33 -6
  20. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/reflection.py +10 -14
  21. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/auth.py +26 -4
  22. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/concurrency.py +2 -2
  23. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/db.py +14 -11
  24. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/key_rotator.py +807 -43
  25. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/models.py +473 -21
  26. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/router.py +323 -1
  27. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/admin_db_routes.py +549 -549
  28. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/completion_routes.py +459 -297
  29. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/quota_routes.py +44 -10
  30. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/security_routes.py +1 -1
  31. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/utils.py +95 -0
  32. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tools.py +164 -4
  33. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/model_dialog.py +93 -14
  34. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/plugins_dialog.py +319 -219
  35. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/thinking_display.py +145 -137
  36. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/update_checker.py +46 -6
  37. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/utim.py +321 -81
  38. {utim_cli-2.3.16 → utim_cli-2.3.18/utim_cli.egg-info}/PKG-INFO +1 -1
  39. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli.egg-info/SOURCES.txt +3 -0
  40. utim_cli-2.3.16/utim_cli/plugins/manager.py +0 -629
  41. {utim_cli-2.3.16 → utim_cli-2.3.18}/LICENSE +0 -0
  42. {utim_cli-2.3.16 → utim_cli-2.3.18}/MANIFEST.in +0 -0
  43. {utim_cli-2.3.16 → utim_cli-2.3.18}/README.md +0 -0
  44. {utim_cli-2.3.16 → utim_cli-2.3.18}/setup.cfg +0 -0
  45. {utim_cli-2.3.16 → utim_cli-2.3.18}/setup.py +0 -0
  46. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/_verify_repro.py +0 -0
  47. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/_verify_syntax.py +0 -0
  48. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/agent.py +0 -0
  49. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/ask_helper.py +0 -0
  50. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/auth.py +0 -0
  51. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/backup.py +0 -0
  52. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/billing.py +0 -0
  53. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/blender_agent.py +0 -0
  54. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/brain.py +0 -0
  55. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/builtin/skills/utim-guide/SKILL.md +0 -0
  56. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/config.py +0 -0
  57. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/context_pruner.py +0 -0
  58. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/crawler/__init__.py +0 -0
  59. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/crawler/engine.py +0 -0
  60. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/crawler/markdown_distiller.py +0 -0
  61. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/dependency_manager.py +0 -0
  62. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/doctor.py +0 -0
  63. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/evidence_registry.py +0 -0
  64. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/harbor.py +0 -0
  65. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/knowledge_graph.py +0 -0
  66. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/local_db.py +0 -0
  67. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/logger.py +0 -0
  68. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/mcp_clean_wrapper.py +0 -0
  69. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/mcp_registry.json +0 -0
  70. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/models.txt +0 -0
  71. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/orchestrator_turn_history.py +0 -0
  72. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/personalities.py +0 -0
  73. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/platform_installer.py +0 -0
  74. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/plugins/__init__.py +0 -0
  75. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/plugins/hooks.py +0 -0
  76. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/plugins/registry.py +0 -0
  77. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/plugins/tool_loader.py +0 -0
  78. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/report.py +0 -0
  79. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/scrapy_search.py +0 -0
  80. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/sdk/__init__.py +0 -0
  81. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/sdk/config.py +0 -0
  82. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/sdk/context.py +0 -0
  83. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/sdk/events.py +0 -0
  84. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/sdk/handlers.py +0 -0
  85. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/sdk/session.py +0 -0
  86. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/sdk/sidecar.py +0 -0
  87. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/__init__.py +0 -0
  88. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/admin_auth.py +0 -0
  89. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/attribution.py +0 -0
  90. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/audit_log.py +0 -0
  91. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/batch_processor.py +0 -0
  92. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/captcha.py +0 -0
  93. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/cli_auth.py +0 -0
  94. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
  95. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/docs_md/about.md +0 -0
  96. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/docs_md/changelog.md +0 -0
  97. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/docs_md/docs.md +0 -0
  98. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/docs_md/features.md +0 -0
  99. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/docs_md/license.md +0 -0
  100. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/docs_md/pricing.md +0 -0
  101. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/docs_md/privacy.md +0 -0
  102. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/docs_md/refund.md +0 -0
  103. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/docs_md/support.md +0 -0
  104. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/docs_md/terms.md +0 -0
  105. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/email_utils.py +0 -0
  106. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/exchange_rate.py +0 -0
  107. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/firebase.py +0 -0
  108. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/fix_duplicate_users.py +0 -0
  109. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/history.py +0 -0
  110. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/local_llm.py +0 -0
  111. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/logging_config.py +0 -0
  112. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/micro_batcher.py +0 -0
  113. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/model_agent.py +0 -0
  114. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/pricing_updater.py +0 -0
  115. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/provision_build.py +0 -0
  116. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/rate_limit.py +0 -0
  117. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/rewards_engine.py +0 -0
  118. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/__init__.py +0 -0
  119. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/admin_bonus_routes.py +0 -0
  120. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/auth_routes.py +0 -0
  121. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/billing_routes.py +0 -0
  122. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/credit_routes.py +0 -0
  123. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/feedback_routes.py +0 -0
  124. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/marketplace_routes.py +0 -0
  125. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/quota_share_routes.py +0 -0
  126. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/referral_routes.py +0 -0
  127. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/rewards_routes.py +0 -0
  128. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/session_routes.py +0 -0
  129. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/routes/share_routes.py +0 -0
  130. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/server.py +0 -0
  131. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/server/storage_nodes.py +0 -0
  132. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/share.py +0 -0
  133. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/share_tui.py +0 -0
  134. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/situational_scoring.py +0 -0
  135. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/state.py +0 -0
  136. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/subagent_manager.py +0 -0
  137. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/task_dispatcher.py +0 -0
  138. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/task_logger.py +0 -0
  139. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tools_grep.py +0 -0
  140. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/__init__.py +0 -0
  141. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/feedback_dialog.py +0 -0
  142. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/history_dialog.py +0 -0
  143. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/marketplace_app_state.py +0 -0
  144. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/marketplace_dialog.py +0 -0
  145. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/marketplace_layout_engine.py +0 -0
  146. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/mcp_dialog.py +0 -0
  147. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/miniagents_dialog.py +0 -0
  148. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/personality_dialog.py +0 -0
  149. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/publish_dialog.py +0 -0
  150. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/quota_dialog.py +0 -0
  151. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/quota_redeem_dialog.py +0 -0
  152. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/quota_share_dialog.py +0 -0
  153. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/resume_dialog.py +0 -0
  154. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/rewards_tui.py +0 -0
  155. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/skills_dialog.py +0 -0
  156. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/subagents_dialog.py +0 -0
  157. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/tasks_dialog.py +0 -0
  158. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/tools_dialog.py +0 -0
  159. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/tui/update_dialog.py +0 -0
  160. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/utilities.py +0 -0
  161. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/utimmodel.txt +0 -0
  162. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/vector_memory.py +0 -0
  163. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/wheel.py +0 -0
  164. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli/workspace.py +0 -0
  165. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli.egg-info/dependency_links.txt +0 -0
  166. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli.egg-info/entry_points.txt +0 -0
  167. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli.egg-info/requires.txt +0 -0
  168. {utim_cli-2.3.16 → utim_cli-2.3.18}/utim_cli.egg-info/top_level.txt +0 -0
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.3.18] - 2026-09-02
4
+
5
+ ### 🎁 New Free Models Added & Tool Sanitization Fixes
6
+ - Added new free models with key rotation
7
+ - Fixed Google Gemini tool schema sanitization to prevent 400 Bad Request errors
8
+
9
+ ## [2.3.17] - 2026-08-31
10
+
11
+ ### ✨ Premium Model Visibility for Bonus-Credit Users
12
+ - Free-plan users with bonus credits can now see premium models in the model catalog (server-side `/models/catalog` and client-side TUI gating)
13
+ - Added bonus-credit detection helper with caching and graceful offline fallback
14
+ - Bonus credits are deducted exclusively when premium completions finish; visibility switches back automatically once the balance is exhausted
15
+
3
16
  ## [2.3.16] - 2026-08-29
4
17
 
5
18
  ### 🛠️ Minor Enhancements and Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: utim-cli
3
- Version: 2.3.16
3
+ Version: 2.3.18
4
4
  Summary: UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal.
5
5
  License: Emend AI Proprietary EULA
6
6
  Project-URL: Homepage, https://utim.dev
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "utim-cli"
7
- version = "2.3.16"
7
+ version = "2.3.18"
8
8
 
9
9
  description = "UTIM – Universal Terminal Intelligence Manager. A powerful agentic AI coding assistant for your terminal."
10
10
  readme = "README.md"
@@ -14,7 +14,7 @@ Quickstart:
14
14
  try:
15
15
  from utim_cli._version import VERSION as __version__
16
16
  except ImportError:
17
- __version__ = "2.3.16"
17
+ __version__ = "2.3.18"
18
18
 
19
19
  # Developer-friendly core classes (similar to `import genai` / `import openai`)
20
20
  from utim_cli.sdk.session import UtimSession as Session
@@ -5,7 +5,7 @@ try:
5
5
  from utim_cli._version import VERSION as __version__
6
6
  except ImportError:
7
7
  # Fallback during first-install / editable-install edge cases
8
- __version__ = "2.3.16"
8
+ __version__ = "2.3.18"
9
9
 
10
10
 
11
11
  import builtins, os, pathlib, urllib.parse, re
@@ -11,6 +11,6 @@
11
11
  # from here so every surface stays in sync automatically.
12
12
  # ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
- VERSION = "2.3.16"
14
+ VERSION = "2.3.18"
15
15
 
16
16
 
@@ -0,0 +1,93 @@
1
+ """Smoke test for the new plugin scaffold wizard.
2
+
3
+ Simulates a user filling out the wizard end-to-end and verifies that the
4
+ generated plugin directory + plugin.json contain the basic fields.
5
+ """
6
+ import os, sys, json, shutil, tempfile
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+
10
+ # Set up an isolated UTIM home before importing the dialog
11
+ TEST_HOME = Path(tempfile.mkdtemp(prefix="utim_wiz_test_"))
12
+ os.environ["UTIM_HOME"] = str(TEST_HOME)
13
+ os.environ["XDG_CONFIG_HOME"] = str(TEST_HOME)
14
+ os.environ["XDG_DATA_HOME"] = str(TEST_HOME)
15
+
16
+ sys.path.insert(0, ".")
17
+ from utim_cli.plugins.manager import PluginManager
18
+ from utim_cli.tui.plugins_dialog import _run_plugin_scaffold_wizard
19
+
20
+ mgr = PluginManager()
21
+ # Redirect workspace plugins dir via monkey-patch (property is read-only)
22
+ import utim_cli.plugins.manager as mgr_mod
23
+ real_prop = mgr_mod.PluginManager.workspace_plugins_dir
24
+ fake_workspace_plugins = TEST_HOME / "plugins"
25
+ fake_workspace_plugins.mkdir(parents=True, exist_ok=True)
26
+ def _fake_workspace_dir(self):
27
+ return fake_workspace_plugins
28
+ mgr_mod.PluginManager.workspace_plugins_dir = property(_fake_workspace_dir)
29
+
30
+ # Sequence of inputs the user will type — matches the current 3-step wizard:
31
+ # Step 1: Plugin name
32
+ # Step 2: Description
33
+ # Step 3: Choice (1-4), scope (y/n), confirm (y/n), enable (y/n)
34
+ inputs = iter([
35
+ # Step 1 — Plugin name
36
+ "my-cloud-tools",
37
+ # Step 2 — Description
38
+ "Awesome cloud dev tools",
39
+ # Step 3 — Choice 1 (Skill module)
40
+ "1",
41
+ # Scope: workspace (n = not global)
42
+ "n",
43
+ # Generate now?
44
+ "y",
45
+ # Enable now?
46
+ "y",
47
+ ])
48
+
49
+ # Use prompt_toolkit's print_formatted_text + fake_prompter via pt_prompt monkeypatch
50
+ from prompt_toolkit import prompt as pt_prompt
51
+ original = pt_prompt
52
+ def fake_prompt(message="", **kw):
53
+ try:
54
+ v = next(inputs)
55
+ except StopIteration:
56
+ raise EOFError
57
+ return v
58
+
59
+ # Patch the import inside the dialog module
60
+ import utim_cli.tui.plugins_dialog as pd
61
+ with patch.object(pd, "pt_prompt", side_effect=fake_prompt):
62
+ # Also patch the bare `from prompt_toolkit import prompt` reference if any
63
+ try:
64
+ pd._run_plugin_scaffold_wizard(mgr)
65
+ except (StopIteration, EOFError):
66
+ print("WIZARD: ran out of input (likely fine if it finished early)")
67
+
68
+ # Verify the generated plugin
69
+ plugin_dir = mgr.workspace_plugins_dir / "my-cloud-tools"
70
+ print("=" * 60)
71
+ print(f"Plugin dir exists: {plugin_dir.exists()}")
72
+ if plugin_dir.exists():
73
+ print("Files:")
74
+ for p in sorted(plugin_dir.rglob("*")):
75
+ if p.is_file():
76
+ print(f" {p.relative_to(plugin_dir)}")
77
+ print("=" * 60)
78
+ manifest = json.loads((plugin_dir / "plugin.json").read_text(encoding="utf-8"))
79
+ print("plugin.json manifest:")
80
+ print(json.dumps(manifest, indent=2))
81
+ print("=" * 60)
82
+
83
+ # Assertions
84
+ assert manifest["name"] == "my-cloud-tools"
85
+ assert manifest["description"] == "Awesome cloud dev tools"
86
+ assert manifest["author"] == "Developer"
87
+ assert manifest["license"] == "MIT"
88
+ assert len(manifest["skills"]) == 1
89
+ assert manifest["skills"][0] == "skills/my-cloud-tools-workflow/SKILL.md"
90
+ print("✅ ALL ASSERTIONS PASSED")
91
+ else:
92
+ print("❌ Plugin directory was not created")
93
+ sys.exit(1)
@@ -292,9 +292,54 @@ def scan_available_skills(force_reload: bool = False):
292
292
  try:
293
293
  from utim_cli.plugins.manager import get_plugin_manager
294
294
  p_mgr = get_plugin_manager()
295
- for p_skill_name, p_skill_data in p_mgr.get_active_skills().items():
296
- if p_skill_name not in skills and p_skill_name not in disabled:
297
- skills[p_skill_name] = p_skill_data
295
+ active_skills_dict = p_mgr.get_active_skills()
296
+ p_items = active_skills_dict.items() if isinstance(active_skills_dict, dict) else [(s.get("name", s.get("raw_name")), s) for s in active_skills_dict]
297
+ for p_skill_name, p_skill_data in p_items:
298
+ if p_skill_name not in disabled:
299
+ s_path = p_skill_data.get("path")
300
+ s_content = p_skill_data.get("content", "")
301
+ s_desc = p_skill_data.get("description", "")
302
+ s_raw_name = p_skill_data.get("raw_name", p_skill_name)
303
+
304
+ if not s_desc and s_content and s_content.startswith("---"):
305
+ parts = s_content.split("---", 2)
306
+ if len(parts) >= 3:
307
+ dm = re.search(r"^description:\s*(.+)$", parts[1], re.MULTILINE)
308
+ if dm:
309
+ s_desc = dm.group(1).strip().strip("\"'")
310
+
311
+ words = set()
312
+ for word in p_skill_name.replace('-', ' ').replace('_', ' ').replace(':', ' ').split():
313
+ if len(word) > 2:
314
+ words.add(word.lower())
315
+ for word in s_raw_name.replace('-', ' ').replace('_', ' ').split():
316
+ if len(word) > 2:
317
+ words.add(word.lower())
318
+ for word in s_desc.replace(',', ' ').replace('.', ' ').replace('(', ' ').replace(')', ' ').split():
319
+ w = word.lower().strip()
320
+ if len(w) > 2 and w not in stop_words:
321
+ words.add(w)
322
+
323
+ size_bytes = 0
324
+ try:
325
+ if hasattr(s_path, "stat"):
326
+ size_bytes = s_path.stat().st_size
327
+ except Exception:
328
+ size_bytes = len(s_content.encode("utf-8")) if s_content else 0
329
+
330
+ skill_entry = {
331
+ "path": s_path,
332
+ "name": p_skill_name,
333
+ "folder_name": s_raw_name,
334
+ "frontmatter_name": s_raw_name,
335
+ "description": s_desc,
336
+ "keywords": list(words),
337
+ "content": s_content,
338
+ "size_bytes": size_bytes,
339
+ }
340
+ skills[p_skill_name] = skill_entry
341
+ if s_raw_name not in skills and s_raw_name not in disabled:
342
+ skills[s_raw_name] = skill_entry
298
343
  except Exception:
299
344
  pass
300
345
 
@@ -572,6 +572,82 @@ def proxy_openrouter_request(json_data: dict, stream: bool = False, timeout=None
572
572
  return requests.post("https://openrouter.ai/api/v1/chat/completions", json=json_data, headers=headers, stream=stream or json_data.get("stream"), timeout=timeout, verify=config.verify_ssl)
573
573
 
574
574
 
575
+ def sanitize_tools_for_api(tools: Optional[List[Dict[str, Any]]], model_id: Optional[str] = None) -> Optional[List[Dict[str, Any]]]:
576
+ """Sanitize and validate JSON schemas in tool declarations.
577
+
578
+ Removes unsupported JSON schema keywords (like $schema, additionalProperties,
579
+ propertyNames, patternProperties, definitions), ensures arrays have valid 'items' schemas,
580
+ and converts uppercase / invalid type declarations that cause Google Gemini / GLM / OpenAPI
581
+ validators to reject payloads with 400 'Invalid JSON payload' errors.
582
+ """
583
+ if not tools:
584
+ return tools
585
+
586
+ TYPE_MAP = {
587
+ "STRING": "string",
588
+ "NUMBER": "number",
589
+ "INTEGER": "integer",
590
+ "BOOLEAN": "boolean",
591
+ "ARRAY": "array",
592
+ "OBJECT": "object",
593
+ "NULL": "null"
594
+ }
595
+
596
+ def _clean_schema_node(node):
597
+ if not isinstance(node, dict):
598
+ return node
599
+ cleaned = {}
600
+ disallowed_keys = {
601
+ "$schema", "$id", "definitions", "$defs", "propertyNames",
602
+ "patternProperties", "additionalProperties", "unevaluatedProperties",
603
+ }
604
+ for k, v in node.items():
605
+ if k in disallowed_keys:
606
+ continue
607
+
608
+ # Standardize JSON schema type names to lowercase
609
+ if k == "type":
610
+ if isinstance(v, str):
611
+ v = TYPE_MAP.get(v, v.lower())
612
+ elif isinstance(v, list):
613
+ v = [TYPE_MAP.get(item, item.lower()) if isinstance(item, str) else item for item in v]
614
+
615
+ if isinstance(v, dict):
616
+ cleaned[k] = _clean_schema_node(v)
617
+ elif isinstance(v, list):
618
+ cleaned[k] = [_clean_schema_node(item) if isinstance(item, dict) else item for item in v]
619
+ else:
620
+ cleaned[k] = v
621
+
622
+ # Strict validator fix: If type is 'array' and 'items' is missing or empty dict, default items to string or object
623
+ if cleaned.get("type") == "array":
624
+ items = cleaned.get("items")
625
+ if not items or items == {}:
626
+ cleaned["items"] = {"type": "string"}
627
+ elif isinstance(items, dict):
628
+ cleaned["items"] = _clean_schema_node(items)
629
+
630
+ # Strict validator fix: If type is 'object', ensure properties is a dict if present
631
+ if cleaned.get("type") == "object":
632
+ if "properties" in cleaned and not isinstance(cleaned["properties"], dict):
633
+ cleaned["properties"] = {}
634
+
635
+ return cleaned
636
+
637
+ sanitized_tools = []
638
+ for tool in tools:
639
+ if not isinstance(tool, dict):
640
+ continue
641
+ tool_copy = dict(tool)
642
+ if "function" in tool_copy and isinstance(tool_copy["function"], dict):
643
+ fn_copy = dict(tool_copy["function"])
644
+ if "parameters" in fn_copy and isinstance(fn_copy["parameters"], dict):
645
+ fn_copy["parameters"] = _clean_schema_node(fn_copy["parameters"])
646
+ tool_copy["function"] = fn_copy
647
+ sanitized_tools.append(tool_copy)
648
+ return sanitized_tools
649
+
650
+
575
651
  def sanitize_messages_for_api(messages, model_id: Optional[str] = None):
576
652
  """Normalize conversation history into a provider-safe shape before sending.
577
653
 
@@ -11,7 +11,26 @@ SUBAGENT_DEFAULTS = {
11
11
  "analyze_image": "google/gemma-4-31b-it:free",
12
12
  }
13
13
 
14
-
14
+ APINEX_MODEL_IDS = {
15
+ "claude/opus-5",
16
+ "claude/sonnet-5",
17
+ "deepseek/v4-flash-0731",
18
+ "deepseek/v4-pro-0813",
19
+ "free/deepseek-v4-flash-0731",
20
+ "free/deepseek-v4-pro-0813",
21
+ "free/glm-5.3-flash",
22
+ "free/gemini-3.1-pro",
23
+ "free/gemini-3.7-flash",
24
+ "gemini/3.1-pro",
25
+ "gemini/3.7-flash",
26
+ "kimi/k3",
27
+ "gpt/5.6-luna",
28
+ "gpt/5.6-sol",
29
+ "gpt/5.6-terra",
30
+ "glm/5.3",
31
+ "glm/5.3-flash",
32
+ "grok/4.6",
33
+ }
15
34
 
16
35
 
17
36
  import sys
@@ -19,3 +38,4 @@ _IS_LEGACY_WIN = (sys.platform == "win32" and "WT_SESSION" not in os.environ)
19
38
  PROMPT_SYMBOL = ">" if _IS_LEGACY_WIN else "❯"
20
39
  ARROW_SYMBOL = "->" if _IS_LEGACY_WIN else "➔"
21
40
 
41
+
@@ -213,7 +213,18 @@ class MCPManager:
213
213
  servers = config.get("mcpServers") or {}
214
214
  if not isinstance(servers, dict):
215
215
  servers = {}
216
-
216
+
217
+ # Merge active MCP servers declared by enabled plugins
218
+ try:
219
+ from utim_cli.plugins.manager import get_plugin_manager
220
+ plugin_mcp = get_plugin_manager().get_active_mcp_servers()
221
+ if isinstance(plugin_mcp, dict):
222
+ for srv_name, srv_conf in plugin_mcp.items():
223
+ if srv_name not in servers:
224
+ servers[srv_name] = srv_conf
225
+ except Exception:
226
+ pass
227
+
217
228
  for name, srv_config in servers.items():
218
229
  command = srv_config.get("command")
219
230
  args = srv_config.get("args", [])
@@ -99,25 +99,23 @@ from .orchestrator_helpers import ( # noqa: F401,E402
99
99
  )
100
100
 
101
101
 
102
- class _ServerUnavailableError(RuntimeError):
103
- """Raised when the UTIM server cannot be reached and no local key is configured.
104
- Caught in run_task to display a clean user-facing message (no traceback).
105
- """
106
-
107
-
108
- class _FatalClientError(RuntimeError):
109
- """Raised when a non-retryable gating or credit validation error occurs.
110
- Caught in run_task to immediately abort the turn without retry.
111
- """
112
-
113
-
114
102
  from .orchestrator_tool_exec import ToolExecutionMixin # noqa: E402
115
103
 
116
104
 
117
105
  from .orchestrator_turn_history import TurnHistoryMixin # noqa: E402
118
106
 
119
107
 
120
- from .orchestrator_llm_transport import LLMTransportMixin # noqa: E402
108
+ from .orchestrator_llm_transport import ( # noqa: E402,F401
109
+ LLMTransportMixin,
110
+ # ── IMPORTANT: bind the transport's exception classes here ──
111
+ # The transport raises ITS OWN _ServerUnavailableError/_FatalClientError.
112
+ # Previously orchestrator.py defined duplicate same-named classes, so
113
+ # `except _FatalClientError` never caught transport-raised errors and
114
+ # quota/rate-limit failures fell into the generic transient-retry loop
115
+ # ("Transient error on iteration 1 … Retrying in 3s…" x3 then abort).
116
+ _ServerUnavailableError,
117
+ _FatalClientError,
118
+ )
121
119
 
122
120
 
123
121
  class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
@@ -269,6 +269,57 @@ def get_cached_quota(api_key: str, force_refresh: bool = False) -> Optional[Dict
269
269
  return _QUOTA_CACHE.get("data")
270
270
 
271
271
 
272
+ def format_quota_reset_countdown(reset_at_iso: Optional[str]) -> Tuple[str, str]:
273
+ """Parse ISO reset timestamp and return (countdown_str, human_time_str).
274
+
275
+ countdown_str format: '1h:45m', '0h:32m', etc.
276
+ human_time_str format: '2026-09-02 02:45 UTC' or 'momentarily'
277
+ """
278
+ if not reset_at_iso:
279
+ return "in a few hours", "next refill window"
280
+
281
+ try:
282
+ from datetime import datetime, timezone
283
+ clean_iso = str(reset_at_iso).strip()
284
+ if clean_iso.endswith("+00:00Z"):
285
+ clean_iso = clean_iso[:-1]
286
+ elif clean_iso.endswith("Z"):
287
+ if "+" not in clean_iso and "-" not in clean_iso[10:]:
288
+ clean_iso = clean_iso[:-1] + "+00:00"
289
+ else:
290
+ clean_iso = clean_iso[:-1]
291
+
292
+ try:
293
+ reset_dt = datetime.fromisoformat(clean_iso)
294
+ except ValueError:
295
+ clean_iso_alt = str(reset_at_iso).strip().replace("Z", "+00:00")
296
+ reset_dt = datetime.fromisoformat(clean_iso_alt)
297
+
298
+ now_dt = datetime.now(timezone.utc)
299
+ if reset_dt.tzinfo is None:
300
+ reset_dt = reset_dt.replace(tzinfo=timezone.utc)
301
+
302
+ diff = reset_dt - now_dt
303
+ total_seconds = max(0, int(round(diff.total_seconds())))
304
+ if total_seconds <= 30:
305
+ return "0h:01m", "momentarily"
306
+
307
+ total_minutes = int(round(total_seconds / 60.0))
308
+ hours = total_minutes // 60
309
+ minutes = total_minutes % 60
310
+ countdown = f"{hours}h:{minutes:02d}m" if hours > 0 else f"0h:{minutes:02d}m"
311
+ human_time = reset_dt.strftime("%Y-%m-%d %H:%M UTC")
312
+ return countdown, human_time
313
+ except Exception:
314
+ return "in a few hours", str(reset_at_iso)
315
+
316
+
317
+ def invalidate_quota_cache() -> None:
318
+ """Invalidate in-memory quota cache to force a fresh server check."""
319
+ global _QUOTA_CACHE
320
+ _QUOTA_CACHE["timestamp"] = 0
321
+
322
+
272
323
  from utim_cli.config import get_utim_dir
273
324
  _utim_dir_posix = get_utim_dir().as_posix()
274
325
 
@@ -1252,7 +1303,7 @@ def get_frozen_system_prompt(user_prompt: str = "", current_iteration: int = 0,
1252
1303
  for pr in active_plugin_rules:
1253
1304
  p_content = pr.get("content", "").strip()
1254
1305
  p_name = pr.get("name", "plugin_rule")
1255
- p_plugin = pr.get("plugin_name", "plugin")
1306
+ p_plugin = pr.get("plugin_name") or pr.get("plugin") or "plugin"
1256
1307
  if p_content:
1257
1308
  plugin_rules_blocks.append(f"#### Plugin Rule [{p_plugin} / {p_name}]\n{p_content}")
1258
1309
  if plugin_rules_blocks:
@@ -1354,6 +1405,66 @@ def get_frozen_system_prompt(user_prompt: str = "", current_iteration: int = 0,
1354
1405
  except Exception:
1355
1406
  pass
1356
1407
 
1408
+ plugins_prompt = ""
1409
+ try:
1410
+ from utim_cli.plugins.manager import get_plugin_manager
1411
+ from utim_cli.config import get_utim_dir
1412
+ p_mgr = get_plugin_manager()
1413
+ discovered_plugins = p_mgr.discover_plugins()
1414
+ global_plugins_path = str((get_utim_dir() / "plugins").resolve()).replace("\\", "/")
1415
+ workspace_plugins_path = str((p_mgr.workspace_plugins_dir).resolve()).replace("\\", "/")
1416
+
1417
+ plugin_lines = []
1418
+ for pname, p in sorted(discovered_plugins.items(), key=lambda x: x[0]):
1419
+ status = "active (enabled)" if p.enabled and p.is_valid else ("disabled" if not p.enabled else "invalid")
1420
+ p_path_str = str(p.path.resolve()).replace("\\", "/")
1421
+ counts = p.get_summary_counts()
1422
+ details = []
1423
+ if counts.get("skills"):
1424
+ skill_names = [s.get("raw_name", s.get("name")) for s in p.get_skills()]
1425
+ details.append(f"skills: {', '.join(skill_names)}")
1426
+ if counts.get("rules"):
1427
+ rule_names = [r.get("name") for r in p.get_rules()]
1428
+ details.append(f"rules: {', '.join(rule_names)}")
1429
+ if counts.get("tools"):
1430
+ tool_names = [t.get("name") for t in p.get_tools()]
1431
+ details.append(f"native tools: {', '.join(tool_names)}")
1432
+ if counts.get("mcp"):
1433
+ mcp_names = list(p.get_mcp_servers().keys())
1434
+ details.append(f"mcp servers: {', '.join(mcp_names)}")
1435
+ if counts.get("hooks"):
1436
+ hook_names = list(p.get_hooks().keys())
1437
+ details.append(f"hooks: {', '.join(hook_names)}")
1438
+
1439
+ detail_str = f" [{'; '.join(details)}]" if details else ""
1440
+ desc = f" — {p.description}" if p.description else ""
1441
+ plugin_lines.append(f"- **{p.name}** (v{p.version}, {p.source}, status: {status}, path: file:///{p_path_str}){desc}{detail_str}")
1442
+
1443
+ if plugin_lines:
1444
+ plugins_prompt = (
1445
+ f"\n\n### INSTALLED & ACTIVE PLUGINS ###\n"
1446
+ f"Global Plugins Directory: file:///{global_plugins_path}\n"
1447
+ f"Workspace Plugins Directory: file:///{workspace_plugins_path}\n\n"
1448
+ f"The following plugins are registered in the environment:\n"
1449
+ + "\n".join(plugin_lines) + "\n\n"
1450
+ f"Plugin capabilities:\n"
1451
+ f"- **Skills**: Specialized instructions matching tasks; read their SKILL.md via `read_file` when relevant.\n"
1452
+ f"- **Rules**: Plugin guidelines and coding standards automatically applied to code generation.\n"
1453
+ f"- **Tools**: Native callable python/shell tools namespaced as `<plugin>__<tool>`.\n"
1454
+ f"- **MCP Servers**: Model Context Protocol servers automatically started to provide external tools.\n"
1455
+ f"- **Hooks**: Lifecycle hooks triggered on session/agent events.\n"
1456
+ f"- Users can manage plugins via `/plugins` interactive menu or CLI (`/plugin list`, `/plugin info <name>`, `/plugin enable <name>`, `/plugin disable <name>`, `/plugin install <name/url>`, `/plugin create <name>`).\n"
1457
+ )
1458
+ else:
1459
+ plugins_prompt = (
1460
+ f"\n\n### PLUGINS DIRECTORY & CONFIGURATION ###\n"
1461
+ f"Global Plugins Directory: file:///{global_plugins_path}\n"
1462
+ f"Workspace Plugins Directory: file:///{workspace_plugins_path}\n"
1463
+ f"Plugins extend UTIM with bundled skills, rules, tools, MCP servers, and hooks. Use `/plugins` to browse and install plugins.\n"
1464
+ )
1465
+ except Exception:
1466
+ pass
1467
+
1357
1468
  session_summary = ""
1358
1469
  import os
1359
1470
  from utim_cli.config import get_utim_dir
@@ -1392,7 +1503,7 @@ def get_frozen_system_prompt(user_prompt: str = "", current_iteration: int = 0,
1392
1503
  except Exception:
1393
1504
  personality_prompt = ""
1394
1505
 
1395
- return base_prompt + rules_prompt + mcp_prompt + skills_prompt + session_prompt + personality_prompt
1506
+ return base_prompt + rules_prompt + mcp_prompt + plugins_prompt + skills_prompt + session_prompt + personality_prompt
1396
1507
 
1397
1508
 
1398
1509
  def get_live_context_block(user_prompt: str = "", messages: Optional[List[Dict]] = None) -> str: