utim-cli 2.3.19__tar.gz → 2.3.20__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 (171) hide show
  1. {utim_cli-2.3.19 → utim_cli-2.3.20}/CHANGELOG.md +5 -0
  2. {utim_cli-2.3.19/utim_cli.egg-info → utim_cli-2.3.20}/PKG-INFO +1 -1
  3. {utim_cli-2.3.19 → utim_cli-2.3.20}/pyproject.toml +1 -1
  4. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim/__init__.py +1 -1
  5. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/__init__.py +1 -1
  6. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/_version.py +1 -1
  7. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/auth.py +5 -3
  8. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/client_utils.py +48 -1
  9. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/orchestrator.py +46 -0
  10. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/orchestrator_helpers.py +12 -1
  11. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/orchestrator_llm_transport.py +51 -136
  12. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/orchestrator_turn_history.py +5 -1
  13. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/hooks.py +11 -3
  14. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/manager.py +27 -0
  15. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/tool_loader.py +8 -0
  16. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/admin_auth.py +194 -163
  17. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/auth.py +144 -32
  18. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/db.py +71 -3
  19. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/key_rotator.py +264 -53
  20. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/models.py +7 -10
  21. utim_cli-2.3.20/utim_cli/server/plan_integrity.py +1064 -0
  22. utim_cli-2.3.20/utim_cli/server/rate_limit.py +38 -0
  23. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/router.py +66 -9
  24. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/admin_bonus_routes.py +8 -0
  25. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/admin_db_routes.py +422 -4
  26. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/auth_routes.py +51 -13
  27. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/completion_routes.py +123 -38
  28. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/credit_routes.py +399 -357
  29. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/quota_routes.py +200 -56
  30. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/rewards_routes.py +14 -5
  31. utim_cli-2.3.20/utim_cli/server/routes/webhook_routes.py +99 -0
  32. utim_cli-2.3.20/utim_cli/server/security_alerts.py +82 -0
  33. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/share_tui.py +1 -1
  34. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tools.py +45 -41
  35. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/model_dialog.py +12 -17
  36. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/plugins_dialog.py +24 -0
  37. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/quota_share_dialog.py +458 -446
  38. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/utim.py +60 -24
  39. {utim_cli-2.3.19 → utim_cli-2.3.20/utim_cli.egg-info}/PKG-INFO +1 -1
  40. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli.egg-info/SOURCES.txt +3 -0
  41. utim_cli-2.3.19/utim_cli/server/rate_limit.py +0 -27
  42. {utim_cli-2.3.19 → utim_cli-2.3.20}/LICENSE +0 -0
  43. {utim_cli-2.3.19 → utim_cli-2.3.20}/MANIFEST.in +0 -0
  44. {utim_cli-2.3.19 → utim_cli-2.3.20}/README.md +0 -0
  45. {utim_cli-2.3.19 → utim_cli-2.3.20}/setup.cfg +0 -0
  46. {utim_cli-2.3.19 → utim_cli-2.3.20}/setup.py +0 -0
  47. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/_verify_repro.py +0 -0
  48. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/_verify_syntax.py +0 -0
  49. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/_wizard_smoke_test.py +0 -0
  50. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/agent.py +0 -0
  51. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/ask_helper.py +0 -0
  52. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/backup.py +0 -0
  53. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/billing.py +0 -0
  54. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/blender_agent.py +0 -0
  55. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/bootstrap.py +0 -0
  56. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/brain.py +0 -0
  57. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/builtin/skills/utim-guide/SKILL.md +0 -0
  58. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/config.py +0 -0
  59. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/constants.py +0 -0
  60. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/context_pruner.py +0 -0
  61. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/crawler/__init__.py +0 -0
  62. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/crawler/engine.py +0 -0
  63. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/crawler/markdown_distiller.py +0 -0
  64. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/dependency_manager.py +0 -0
  65. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/doctor.py +0 -0
  66. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/evidence_registry.py +0 -0
  67. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/harbor.py +0 -0
  68. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/knowledge_graph.py +0 -0
  69. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/local_db.py +0 -0
  70. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/logger.py +0 -0
  71. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/mcp_clean_wrapper.py +0 -0
  72. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/mcp_client.py +0 -0
  73. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/mcp_registry.json +0 -0
  74. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/models.txt +0 -0
  75. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/orchestrator_tool_exec.py +0 -0
  76. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/personalities.py +0 -0
  77. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/platform_installer.py +0 -0
  78. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/__init__.py +0 -0
  79. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/loader.py +0 -0
  80. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/registry.py +0 -0
  81. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/runtime.py +0 -0
  82. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/plugins/schema.py +0 -0
  83. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/reflection.py +0 -0
  84. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/report.py +0 -0
  85. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/scrapy_search.py +0 -0
  86. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/sdk/__init__.py +0 -0
  87. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/sdk/config.py +0 -0
  88. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/sdk/context.py +0 -0
  89. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/sdk/events.py +0 -0
  90. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/sdk/handlers.py +0 -0
  91. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/sdk/session.py +0 -0
  92. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/sdk/sidecar.py +0 -0
  93. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/__init__.py +0 -0
  94. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/attribution.py +0 -0
  95. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/audit_log.py +0 -0
  96. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/batch_processor.py +0 -0
  97. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/captcha.py +0 -0
  98. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/cli_auth.py +0 -0
  99. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/concurrency.py +0 -0
  100. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/SECRET_PROVISIONING.md +0 -0
  101. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/about.md +0 -0
  102. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/changelog.md +0 -0
  103. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/docs.md +0 -0
  104. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/features.md +0 -0
  105. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/license.md +0 -0
  106. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/pricing.md +0 -0
  107. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/privacy.md +0 -0
  108. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/refund.md +0 -0
  109. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/support.md +0 -0
  110. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/docs_md/terms.md +0 -0
  111. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/email_utils.py +0 -0
  112. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/exchange_rate.py +0 -0
  113. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/firebase.py +0 -0
  114. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/fix_duplicate_users.py +0 -0
  115. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/history.py +0 -0
  116. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/local_llm.py +0 -0
  117. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/logging_config.py +0 -0
  118. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/micro_batcher.py +0 -0
  119. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/model_agent.py +0 -0
  120. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/pricing_updater.py +0 -0
  121. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/provision_build.py +0 -0
  122. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/rewards_engine.py +0 -0
  123. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/__init__.py +0 -0
  124. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/billing_routes.py +0 -0
  125. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/feedback_routes.py +0 -0
  126. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/marketplace_routes.py +0 -0
  127. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/quota_share_routes.py +0 -0
  128. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/referral_routes.py +0 -0
  129. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/security_routes.py +0 -0
  130. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/session_routes.py +0 -0
  131. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/routes/share_routes.py +0 -0
  132. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/server.py +0 -0
  133. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/storage_nodes.py +0 -0
  134. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/server/utils.py +0 -0
  135. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/share.py +0 -0
  136. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/situational_scoring.py +0 -0
  137. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/state.py +0 -0
  138. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/subagent_manager.py +0 -0
  139. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/task_dispatcher.py +0 -0
  140. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/task_logger.py +0 -0
  141. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tools_grep.py +0 -0
  142. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/__init__.py +0 -0
  143. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/feedback_dialog.py +0 -0
  144. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/history_dialog.py +0 -0
  145. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/marketplace_app_state.py +0 -0
  146. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/marketplace_dialog.py +0 -0
  147. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/marketplace_layout_engine.py +0 -0
  148. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/mcp_dialog.py +0 -0
  149. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/miniagents_dialog.py +0 -0
  150. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/personality_dialog.py +0 -0
  151. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/publish_dialog.py +0 -0
  152. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/quota_dialog.py +0 -0
  153. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/quota_redeem_dialog.py +0 -0
  154. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/resume_dialog.py +0 -0
  155. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/rewards_tui.py +0 -0
  156. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/skills_dialog.py +0 -0
  157. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/subagents_dialog.py +0 -0
  158. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/tasks_dialog.py +0 -0
  159. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/thinking_display.py +0 -0
  160. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/tools_dialog.py +0 -0
  161. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/tui/update_dialog.py +0 -0
  162. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/update_checker.py +0 -0
  163. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/utilities.py +0 -0
  164. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/utimmodel.txt +0 -0
  165. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/vector_memory.py +0 -0
  166. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/wheel.py +0 -0
  167. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli/workspace.py +0 -0
  168. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli.egg-info/dependency_links.txt +0 -0
  169. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli.egg-info/entry_points.txt +0 -0
  170. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli.egg-info/requires.txt +0 -0
  171. {utim_cli-2.3.19 → utim_cli-2.3.20}/utim_cli.egg-info/top_level.txt +0 -0
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.3.20] - 2026-09-04
4
+
5
+ ### 🛠️ Minor Fixes and Enhancement
6
+ - Minor fixes and enhancement
7
+
3
8
  ## [2.3.19] - 2026-09-02
4
9
 
5
10
  ### 🛠️ Model Dialog Tier Sync & Free Model Preservation
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: utim-cli
3
- Version: 2.3.19
3
+ Version: 2.3.20
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.19"
7
+ version = "2.3.20"
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.19"
17
+ __version__ = "2.3.20"
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.19"
8
+ __version__ = "2.3.20"
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.19"
14
+ VERSION = "2.3.20"
15
15
 
16
16
 
@@ -497,7 +497,7 @@ def login(restart: bool = False) -> None:
497
497
  config.set("uid", body.get("user_id", ""))
498
498
  config.set("name", body.get("display_name") or (body.get("email", "") or "").split("@")[0])
499
499
  plan_name = body.get("plan")
500
- if not plan_name and body.get("api_key"):
500
+ if (not plan_name or plan_name == "free") and body.get("api_key"):
501
501
  try:
502
502
  _vr = requests.get(
503
503
  f"{SERVER_URL}/api/user-plan",
@@ -505,10 +505,12 @@ def login(restart: bool = False) -> None:
505
505
  timeout=3.0,
506
506
  )
507
507
  if _vr.status_code == 200:
508
- plan_name = (_vr.json() or {}).get("plan")
508
+ _pdata = _vr.json() or {}
509
+ plan_name = _pdata.get("plan") or _pdata.get("plan_name")
509
510
  except Exception:
510
511
  pass
511
- config.set("user_plan", (plan_name or "free").lower())
512
+ clean_plan = str(plan_name or "free").lower().removesuffix(" plan").strip()
513
+ config.set("user_plan", clean_plan)
512
514
  if body.get("api_key"):
513
515
  config.set("api_key", body["api_key"])
514
516
  raw_print("")
@@ -64,6 +64,44 @@ def get_server_url() -> str:
64
64
  return "https://api.utim.dev"
65
65
 
66
66
 
67
+ def is_paid_plan(plan_name: Optional[str]) -> bool:
68
+ """Return True if the given plan name represents a paid subscription tier."""
69
+ if not plan_name:
70
+ return False
71
+ p = str(plan_name).lower().removesuffix(" plan").strip()
72
+ return p not in ("free", "free tier", "", "none", "null", "guest", "undefined")
73
+
74
+
75
+ def is_user_paid(force_refresh: bool = False) -> bool:
76
+ """Helper to check if the current user has an active paid subscription tier."""
77
+ try:
78
+ from utim_cli.config import config
79
+ api_key = config.get("api_key")
80
+ cached_plan = config.get("user_plan")
81
+
82
+ if not force_refresh and cached_plan and is_paid_plan(cached_plan):
83
+ return True
84
+
85
+ if not api_key:
86
+ return is_paid_plan(cached_plan)
87
+
88
+ try:
89
+ from utim_cli.orchestrator_helpers import get_cached_quota
90
+ quota = get_cached_quota(api_key, force_refresh=force_refresh)
91
+ if quota:
92
+ plan = quota.get("plan") or quota.get("plan_name")
93
+ if plan:
94
+ clean_plan = str(plan).lower().removesuffix(" plan").strip()
95
+ config.set("user_plan", clean_plan)
96
+ return is_paid_plan(clean_plan)
97
+ except Exception:
98
+ pass
99
+
100
+ return is_paid_plan(cached_plan)
101
+ except Exception:
102
+ return False
103
+
104
+
67
105
  def _sanitize_json(obj):
68
106
  """Recursively scrub a payload for values that break strict JSON parsers.
69
107
 
@@ -1026,10 +1064,19 @@ def repair_and_normalize_tool_call(tc: dict) -> dict:
1026
1064
  raw_name = str(fn.get("name", "")).strip()
1027
1065
  raw_args = fn.get("arguments", "{}")
1028
1066
 
1029
- # 1. Clean and normalize tool name
1067
+ raw_name_orig = raw_name
1030
1068
  for known in KNOWN_TOOLS:
1031
1069
  if raw_name.startswith(known):
1070
+ remainder = raw_name[len(known):].strip()
1032
1071
  raw_name = known
1072
+ if remainder and (not str(raw_args).strip() or str(raw_args).strip() == "{}"):
1073
+ attrs = re.findall(r'(\w+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|\'((?:[^\'\\]|\\.)*)\'|(\S+))', remainder)
1074
+ if attrs:
1075
+ parsed_attrs = {}
1076
+ for _k, _v1, _v2, _v3 in attrs:
1077
+ _v = _v1 or _v2 or _v3 or ""
1078
+ parsed_attrs[_k] = _v
1079
+ raw_args = parsed_attrs
1033
1080
  break
1034
1081
  if raw_name == known + known:
1035
1082
  raw_name = known
@@ -594,6 +594,30 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
594
594
  except Exception:
595
595
  pass
596
596
 
597
+ # ── Sanitize: strip base64-encoded binary blobs ─────────────────
598
+ # On Termux, copying an image file can place raw base64 data into the
599
+ # clipboard. If any paste-chip expansion path leaks this binary noise
600
+ # into the user message, we detect and truncate it here before it
601
+ # reaches the model context.
602
+ _B64_RUN_RE = re.compile(r'[A-Za-z0-9+/=]{400,}')
603
+ if _B64_RUN_RE.search(user_message):
604
+ # Count total base64-characters vs meaningful content
605
+ b64_chars = sum(1 for c in user_message if 'A' <= c <= 'Z' or 'a' <= c <= 'z' or '0' <= c <= '9' or c in '+/=')
606
+ total_chars = max(len(user_message), 1)
607
+ if total_chars > 600 and (b64_chars / total_chars) > 0.85:
608
+ # The message is dominated by binary noise — keep only
609
+ # non-base64 lines and any embedded file paths.
610
+ clean_lines = []
611
+ for line in user_message.split('\n'):
612
+ line_stripped = line.strip()
613
+ b64_ratio = sum(1 for c in line_stripped if 'A' <= c <= 'Z' or 'a' <= c <= 'z' or '0' <= c <= '9' or c in '+/=') / max(len(line_stripped), 1)
614
+ # Keep lines that are paths or have low base64 ratio
615
+ if b64_ratio < 0.70 or re.search(r'[A-Za-z]:[\\/]|/[^\s]+\.\w{2,4}', line_stripped):
616
+ clean_lines.append(line)
617
+ if clean_lines:
618
+ user_message = '\n'.join(clean_lines)
619
+ user_message += f"\n\n[Pasted binary clipboard data stripped — {total_chars} chars of base64 noise removed]"
620
+
597
621
  # Auto-detect image paths in user_message and send direct multimodal vision payload
598
622
  try:
599
623
  img_matches = re.findall(r'(?:[A-Za-z]:[\\/][^\s"\']+\.(?:png|jpg|jpeg|webp|gif)|/[^\s"\']+\.(?:png|jpg|jpeg|webp|gif))', user_message, re.IGNORECASE)
@@ -829,6 +853,17 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
829
853
 
830
854
  break # success
831
855
  except _ServerUnavailableError as exc:
856
+ _err_text = str(exc).lower()
857
+ if "rate limit" in _err_text or "rate-limit" in _err_text or "429" in _err_text or "too many requests" in _err_text:
858
+ self.console.print(f"\n[bold yellow]⏳ Model is temporarily rate-limited by the provider.[/bold yellow]")
859
+ self.console.print(" [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n")
860
+ if len(self.messages) <= turn_msg_start + 1:
861
+ self.messages.append({
862
+ "role": "assistant",
863
+ "content": "⏳ **Model Rate Limited**: The model is temporarily rate-limited by the provider. Please wait a short moment and retry, or use `/model` to switch models."
864
+ })
865
+ self._persist_messages()
866
+ return
832
867
  if self.cancel_event.is_set():
833
868
  del self.messages[turn_msg_start + 1:]
834
869
  self._persist_messages()
@@ -875,6 +910,17 @@ class Orchestrator(LLMTransportMixin, TurnHistoryMixin, ToolExecutionMixin):
875
910
  self.messages.append({"role": "assistant", "content": "[Aborted by user]"})
876
911
  break
877
912
  except Exception as exc:
913
+ _err_text = str(exc).lower()
914
+ if "rate limit" in _err_text or "rate-limit" in _err_text or "429" in _err_text or "too many requests" in _err_text:
915
+ self.console.print(f"\n[bold yellow]⏳ Model is temporarily rate-limited by the provider.[/bold yellow]")
916
+ self.console.print(" [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n")
917
+ if len(self.messages) <= turn_msg_start + 1:
918
+ self.messages.append({
919
+ "role": "assistant",
920
+ "content": "⏳ **Model Rate Limited**: The model is temporarily rate-limited by the provider. Please wait a short moment and retry, or use `/model` to switch models."
921
+ })
922
+ self._persist_messages()
923
+ return
878
924
  if self.cancel_event.is_set():
879
925
  if len(self.messages) <= turn_msg_start + 1:
880
926
  self.messages.append({"role": "assistant", "content": "[Aborted by user]"})
@@ -244,7 +244,13 @@ def get_cached_quota(api_key: str, force_refresh: bool = False) -> Optional[Dict
244
244
  session = get_http_session()
245
245
  resp = session.get(f"{SERVER_URL}/quota", headers={"X-API-Key": api_key}, timeout=2.0)
246
246
  if resp.status_code == 200:
247
- _QUOTA_CACHE.update({"data": resp.json(), "timestamp": time.time(), "api_key": api_key})
247
+ data = resp.json()
248
+ _QUOTA_CACHE.update({"data": data, "timestamp": time.time(), "api_key": api_key})
249
+ plan = data.get("plan") or data.get("plan_name")
250
+ if plan:
251
+ from utim_cli.config import config
252
+ clean_plan = str(plan).lower().removesuffix(" plan").strip()
253
+ config.set("user_plan", clean_plan)
248
254
  except Exception:
249
255
  pass
250
256
  threading.Thread(target=_bg_quota_refresh, daemon=True).start()
@@ -263,6 +269,11 @@ def get_cached_quota(api_key: str, force_refresh: bool = False) -> Optional[Dict
263
269
  data = resp.json()
264
270
  _QUOTA_CACHE.clear()
265
271
  _QUOTA_CACHE.update({"data": data, "timestamp": now, "api_key": api_key})
272
+ plan = data.get("plan") or data.get("plan_name")
273
+ if plan:
274
+ from utim_cli.config import config
275
+ clean_plan = str(plan).lower().removesuffix(" plan").strip()
276
+ config.set("user_plan", clean_plan)
266
277
  return data
267
278
  except Exception:
268
279
  pass
@@ -133,6 +133,11 @@ Contains: _get_configured_model_source, _is_custom_model_selection,
133
133
  _resolve_model_endpoint, _call_llm. Behavior-identical; Orchestrator inherits.
134
134
  """
135
135
  class LLMTransportMixin:
136
+ _PRE_THINK_PATTERNS = re.compile(
137
+ r"^\s*(\*\s*Thinking\.\.\.?|\.\.\.(\s*thinking)?|thinking\.\.\.?)\s*$",
138
+ re.IGNORECASE | re.MULTILINE,
139
+ )
140
+
136
141
  def _get_configured_model_source(self, model_id: str, *, is_primary: bool = False) -> Optional[str]:
137
142
  """Return the selected source for a model id when the picker recorded one."""
138
143
  if is_primary:
@@ -736,7 +741,9 @@ class LLMTransportMixin:
736
741
  # 1. Check if quota is exhausted (only for non-custom models)
737
742
  is_5h_exhausted = bool(quota.get("five_hour_quota_exhausted", False))
738
743
  is_monthly_exhausted = bool(quota.get("is_exhausted", False))
739
- is_exhausted = is_5h_exhausted or is_monthly_exhausted
744
+ bonus_bal = float(quota.get("bonus_balance", quota.get("free_bonus_balance", 0.0)) or 0.0)
745
+ # If the user has available bonus credits, inference is funded by bonus quota
746
+ is_exhausted = (is_5h_exhausted or is_monthly_exhausted) and bonus_bal <= 0.0
740
747
 
741
748
  if not is_custom and is_exhausted:
742
749
  reset_raw = quota.get("five_hour_reset_at") if is_5h_exhausted else (quota.get("reset_at") or quota.get("five_hour_reset_at", ""))
@@ -771,7 +778,7 @@ class LLMTransportMixin:
771
778
 
772
779
  # 2. Check if chosen model is allowed — skip entirely for BYOK/custom models
773
780
  models_allowed = quota.get("models_allowed", [])
774
- free_bonus_balance = quota.get("free_bonus_balance", 0.0) or 0.0
781
+ free_bonus_balance = float(quota.get("bonus_balance", quota.get("free_bonus_balance", 0.0)) or 0.0)
775
782
  chosen_model = override_model if override_model else self.model_id
776
783
  is_custom = self._is_custom_model_selection(chosen_model, is_primary=not override_model)
777
784
  is_free_user = (quota.get("plan") == "free" or str(quota.get("display_name", "")).lower() == "free")
@@ -802,18 +809,8 @@ class LLMTransportMixin:
802
809
  self.model_source = "utim"
803
810
  config.set("main_model_source", "utim")
804
811
  else:
805
- # Paid plan (plan != free): gate APINEX models
806
- if is_apinex:
807
- fallback_model = "anthropic/claude-3-5-sonnet-20241022"
808
- self.console.print(f"\n[bold yellow]APINEX model '{chosen_model}' is exclusively for the Free plan.[/bold yellow]")
809
- self.console.print(f" Switching to your plan's model: '{fallback_model}'.\n")
810
- if override_model:
811
- override_model = fallback_model
812
- else:
813
- self.model_id = fallback_model
814
- self.model_source = "utim"
815
- config.set("main_model_source", "utim")
816
- elif models_allowed != ["all"] and chosen_model not in models_allowed and free_bonus_balance <= 0.0:
812
+ # Paid plan: APINEX models are free gateway models — allowed on every plan
813
+ if models_allowed != ["all"] and chosen_model not in models_allowed and not is_apinex and free_bonus_balance <= 0.0:
817
814
  fallback_model = DEFAULT_MODEL
818
815
  self.console.print(f"\n[bold yellow]Model '{chosen_model}' is gated under your current '{quota.get('display_name', 'Free')}' plan.[/bold yellow]")
819
816
  self.console.print(f" Downgrading to default allowed model: '{fallback_model}' for this request.")
@@ -1314,8 +1311,10 @@ class LLMTransportMixin:
1314
1311
  # NOTE: last_content_time is updated further below, only when
1315
1312
  # actual content or tool-call data is parsed from the chunk.
1316
1313
 
1317
- # Clean and normalize the raw line
1318
- line_str = raw_line.strip()
1314
+ if isinstance(raw_line, bytes):
1315
+ line_str = raw_line.decode("utf-8", errors="ignore").strip()
1316
+ else:
1317
+ line_str = str(raw_line).strip()
1319
1318
  if not line_str:
1320
1319
  continue
1321
1320
 
@@ -1356,10 +1355,15 @@ class LLMTransportMixin:
1356
1355
  if t == "queue_status":
1357
1356
  pos = chunk.get("position", 1)
1358
1357
  status = chunk.get("status", "queued")
1358
+ tier = chunk.get("tier", "")
1359
+ prio = chunk.get("priority", 0)
1359
1360
  if status == "queued":
1360
1361
  try:
1361
1362
  from utim_cli.state import STATE
1362
- STATE["thinking_topic"] = f"Request Queued Position {pos}"
1363
+ if prio > 0 and tier:
1364
+ STATE["thinking_topic"] = f"Request Queued Position {pos} ({str(tier).title()} Priority)"
1365
+ else:
1366
+ STATE["thinking_topic"] = f"Request Queued Position {pos}"
1363
1367
  except Exception:
1364
1368
  pass
1365
1369
  elif status == "processing":
@@ -1522,12 +1526,23 @@ class LLMTransportMixin:
1522
1526
  "tool_calls": None,
1523
1527
  }, False
1524
1528
  elif _is_rate_limit:
1529
+ rate_limit_display = (
1530
+ f"⏳ **Model Rate Limited**: `{current_model}` is currently experiencing high demand and is temporarily rate-limited by the provider.\n\n"
1531
+ f"- **What to do:** Please wait a moment before retrying, or switch to a different model with `/model` or `Ctrl+M`.\n"
1532
+ f"- **Retry:** You can resend your prompt anytime."
1533
+ )
1525
1534
  if not silent:
1526
1535
  self.console.print(
1527
- f"\n[dim yellow] {_friendly_name} is currently rate-limited "
1528
- f"switching to the next available model…[/dim yellow]"
1536
+ f"\n[bold yellow]⏳ Model '{current_model}' is temporarily rate-limited by the provider.[/bold yellow]"
1529
1537
  )
1530
- raise RuntimeError(f"__RATE_LIMITED__:{current_model}")
1538
+ self.console.print(
1539
+ " [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n"
1540
+ )
1541
+ return {
1542
+ "role": "assistant",
1543
+ "content": rate_limit_display,
1544
+ "tool_calls": None,
1545
+ }, False
1531
1546
  elif _is_provider_upstream:
1532
1547
  if not silent:
1533
1548
  self.console.print(
@@ -1948,7 +1963,8 @@ class LLMTransportMixin:
1948
1963
  timeout=5,
1949
1964
  )
1950
1965
  if _qr.status_code == 200:
1951
- _has_bonus = (_qr.json().get("free_bonus_balance") or 0.0) > 0.0
1966
+ _data = _qr.json()
1967
+ _has_bonus = (float(_data.get("bonus_balance", _data.get("free_bonus_balance", 0.0)) or 0.0) > 0.0)
1952
1968
  except Exception:
1953
1969
  pass
1954
1970
  if _has_bonus:
@@ -2067,124 +2083,23 @@ class LLMTransportMixin:
2067
2083
  "tool_calls": None,
2068
2084
  }, False
2069
2085
 
2070
- if not msg_str:
2071
- msg_str = "Provider is temporarily rate-limiting this model."
2072
-
2073
- # Stash the original message so the final-exhaustion branch can use it
2074
- _rate_limit_detail = msg_str
2075
- _rate_limit_upgrade_url = upgrade_url
2076
-
2077
- # Are there more models in the fallback chain to try?
2078
- _has_more_models = model_idx < len(models_to_try) - 1
2079
-
2080
- if _has_more_models:
2081
- # Soft, dim notice — let the next model in the chain take over.
2082
- # The user doesn't need to (and shouldn't) see provider jargon.
2083
- try:
2084
- self.console.print(
2085
- f"\n[dim yellow]↪ {current_model} is busy right now — "
2086
- f"switching to the next available model…[/dim yellow]"
2087
- )
2088
- except Exception:
2089
- pass
2090
- # Break the inner retry loop → outer loop will pick the next model
2091
- last_exc = exc
2092
- break
2093
- else:
2094
- # Last model in chain also rate-limited — show a CLEAN, friendly
2095
- # banner. Mask raw OpenRouter jargon. If the model name looks
2096
- # like a free/OpenRouter endpoint, frame it as "all free
2097
- # models are momentarily busy" instead of a paid-upgrade CTA.
2098
- _is_free_tier_model = (
2099
- ":free" in current_model
2100
- or "/free" in current_model
2101
- or "openrouter" in current_model.lower()
2102
- )
2103
- _display_msg = _rate_limit_detail
2104
- # Strip provider-specific keywords that leak through to users
2105
- import re as _re
2106
- for _jargon in (
2107
- "tokens per minute", "requests per minute",
2108
- "free model", "free tier", "provider",
2109
- ):
2110
- _display_msg = _re.sub(
2111
- _re.escape(_jargon),
2112
- "",
2113
- _display_msg,
2114
- flags=_re.IGNORECASE,
2115
- )
2116
- # Word-boundary stripping for short tokens (tpm, rpm)
2117
- for _jargon in ("tpm", "rpm"):
2118
- _display_msg = _re.sub(
2119
- rf"\b{_jargon}\b",
2120
- "",
2121
- _display_msg,
2122
- flags=_re.IGNORECASE,
2123
- )
2124
- # Collapse leftover whitespace + dangling punctuation
2125
- _display_msg = _re.sub(r"\s+", " ", _display_msg).strip(" .,;:—-")
2126
- # Drop the generic "returned error:" prefix — it's provider
2127
- # boilerplate that adds no value to a human reader.
2128
- _display_msg = _re.sub(
2129
- r"^(?:returned error|error)\s*[:\-—]?\s*",
2130
- "",
2131
- _display_msg,
2132
- flags=_re.IGNORECASE,
2133
- ).strip(" .,;:—-")
2134
- # If the sanitized message is too short or doesn't really say
2135
- # anything useful, swap in a polished default.
2136
- _low = _display_msg.lower()
2137
- # Count substantive words (>= 3 chars, alpha) to detect
2138
- # sentences that were gutted by jargon stripping.
2139
- _substantive = sum(
2140
- 1 for _w in _re.findall(r"[A-Za-z]{3,}", _display_msg)
2141
- )
2142
- # Detect a "stranded period" artifact: a period that's now
2143
- # surrounded by whitespace because its surrounding words
2144
- # were stripped. This is a strong signal that the message
2145
- # was gutted by jargon removal and is no longer readable.
2146
- _has_stranded_period = bool(_re.search(r"\s\.\s", _display_msg))
2147
- _is_uninformative = (
2148
- len(_display_msg) < 12
2149
- or _substantive < 3
2150
- or _has_stranded_period
2151
- or _low in ("returned error", "error", "rate limit", "limit")
2152
- or _low in ("", ".", "..", "...")
2153
- )
2154
- if _is_uninformative:
2155
- if _is_free_tier_model:
2156
- _display_msg = (
2157
- "All free models are momentarily busy. "
2158
- "This clears automatically within a minute or two."
2159
- )
2160
- else:
2161
- _display_msg = "Rate limit reached for this model."
2162
-
2086
+ rate_limit_display = (
2087
+ f"⏳ **Model Rate Limited**: `{current_model}` is currently experiencing high demand and is temporarily rate-limited by the provider.\n\n"
2088
+ f"- **What to do:** Please wait a moment before retrying, or switch to a different model with `/model` or `Ctrl+M`.\n"
2089
+ f"- **Retry:** You can resend your prompt anytime."
2090
+ )
2091
+ if not silent:
2163
2092
  self.console.print(
2164
- f"\n[bold yellow]⏳ {_display_msg}[/bold yellow]"
2093
+ f"\n[bold yellow]⏳ Model '{current_model}' is temporarily rate-limited by the provider.[/bold yellow]"
2165
2094
  )
2166
- if _is_free_tier_model:
2167
- # Free tier no upgrade CTA, just helpful next steps
2168
- self.console.print(
2169
- " [dim]Free models rotate through a shared capacity pool — "
2170
- "this usually clears in under a minute.[/dim]"
2171
- )
2172
- self.console.print(
2173
- " [dim]You can also pick a different model with "
2174
- "[bold]/model[/bold] or press Ctrl+M.[/dim]\n"
2175
- )
2176
- else:
2177
- if not upgrade_url:
2178
- upgrade_url = "https://utim.dev/pricing"
2179
- self.console.print(
2180
- f" [dim]Top up at [bold]{upgrade_url}[/bold] or run "
2181
- f"[bold]/balance[/bold] to check your remaining quota.[/dim]\n"
2182
- )
2183
-
2184
- raise _FatalClientError(
2185
- f"All models in the fallback chain are rate-limited: "
2186
- f"{_rate_limit_detail}"
2095
+ self.console.print(
2096
+ " [dim]Please wait a moment before retrying, or switch models using [bold]/model[/bold] (Ctrl+M).[/dim]\n"
2187
2097
  )
2098
+ return {
2099
+ "role": "assistant",
2100
+ "content": rate_limit_display,
2101
+ "tool_calls": None,
2102
+ }, False
2188
2103
  if attempt < model_retries:
2189
2104
  delay = 3 * (attempt + 1)
2190
2105
  # Interruptible wait — so Ctrl+C can abort immediately
@@ -1381,7 +1381,11 @@ class TurnHistoryMixin:
1381
1381
  with open("package.json", "r", encoding="utf-8") as f:
1382
1382
  pkg = json.load(f)
1383
1383
  if "scripts" in pkg and "test" in pkg["scripts"]:
1384
- res = subprocess.run(["npm", "test"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, shell=True)
1384
+ from shutil import which as _which_npm
1385
+ _npm = _which_npm("npm")
1386
+ if _npm is None:
1387
+ return "npm not found on PATH"
1388
+ res = subprocess.run([_npm, "test"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60)
1385
1389
  if res.returncode != 0:
1386
1390
  return _compact_test_err("npm test", res.stdout, res.stderr)
1387
1391
  return None
@@ -107,11 +107,19 @@ def run_single_hook(
107
107
 
108
108
  argv = _resolve_interpreter(command, Path.cwd())
109
109
  stdin_data = json.dumps(payload)
110
+ # Clamp per-hook timeout: a misconfigured plugin manifest must not be
111
+ # able to hold the dispatch thread pool hostage (DoS guard).
112
+ h_timeout = min(max(float(h_timeout), 0.1), 60.0)
110
113
  if argv is None:
111
- # Shell mode
112
- argv = command
114
+ # Shell mode — run through an explicit shell argv instead of
115
+ # shell=True, which is injection-prone and resolves slower. On
116
+ # Windows, /d also skips AutoRun registry scripts (hijack guard).
117
+ if os.name == "nt":
118
+ shell_argv = ["cmd.exe", "/d", "/s", "/c", command]
119
+ else:
120
+ shell_argv = ["/bin/sh", "-c", command]
113
121
  result = subprocess.run(
114
- argv, shell=True, input=stdin_data, capture_output=True,
122
+ shell_argv, shell=False, input=stdin_data, capture_output=True,
115
123
  text=True, timeout=h_timeout, env=_build_env(plugin_name, event, payload),
116
124
  )
117
125
  else:
@@ -21,6 +21,7 @@ from pathlib import Path
21
21
  from typing import Any, Dict, List, Optional, Set
22
22
 
23
23
  from utim_cli.config import get_utim_dir, config
24
+ from utim_cli.client_utils import is_user_paid
24
25
  from utim_cli.plugins.schema import Plugin, PluginManifest
25
26
  from utim_cli.plugins.registry import get_registry_plugins, find_registry_plugin
26
27
  from utim_cli.plugins import loader as staged_loader
@@ -149,6 +150,8 @@ class PluginManager:
149
150
  # ── Enable / Disable / Toggle ────────────────────────────────────────────
150
151
 
151
152
  def enable_plugin(self, name: str) -> bool:
153
+ if not is_user_paid():
154
+ return False
152
155
  plugin = self.get_plugin(name)
153
156
  if not plugin:
154
157
  return False
@@ -171,6 +174,8 @@ class PluginManager:
171
174
  return True
172
175
 
173
176
  def toggle_plugin(self, name: str) -> bool:
177
+ if not is_user_paid():
178
+ return False
174
179
  return self.disable_plugin(name) if self.is_enabled(name) else self.enable_plugin(name)
175
180
 
176
181
  # ── Installation ─────────────────────────────────────────────────────────
@@ -191,6 +196,9 @@ class PluginManager:
191
196
  def install_plugin(self, identifier: str, force: bool = False, plugin_type: str = "auto") -> Dict[str, Any]:
192
197
  """Install a plugin from a registry name, git URL, or local path."""
193
198
  result: Dict[str, Any] = {"ok": False, "plugin": identifier, "message": ""}
199
+ if not is_user_paid():
200
+ result["message"] = "Plugins are only available on paid plans (Hobby plan or higher). Please upgrade your subscription at https://utim.dev/upgrade"
201
+ return result
194
202
  meta = find_registry_plugin(identifier)
195
203
  git_url = self._parse_git_url(identifier)
196
204
 
@@ -280,6 +288,12 @@ class PluginManager:
280
288
  • Any other page -> knowledge plugin (scraped markdown becomes
281
289
  a SKILL.md the model gets injected with)
282
290
  """
291
+ if not is_user_paid():
292
+ return {
293
+ "ok": False,
294
+ "message": "Plugins are only available on paid plans (Hobby plan or higher). Please upgrade your subscription at https://utim.dev/upgrade",
295
+ }
296
+
283
297
  def _say(msg: str) -> None:
284
298
  if progress_cb:
285
299
  progress_cb(msg)
@@ -392,6 +406,9 @@ class PluginManager:
392
406
 
393
407
  Returns (ok: bool, message: str, path: Optional[Path]).
394
408
  """
409
+ if not is_user_paid():
410
+ return False, "Plugins are only available on paid plans (Hobby plan or higher). Please upgrade your subscription at https://utim.dev/upgrade", None
411
+
395
412
  clean = re.sub(r"[^a-zA-Z0-9_\-]", "-", name.strip().lower()).strip("-")
396
413
  if not clean:
397
414
  return False, "Invalid plugin name", None
@@ -469,6 +486,8 @@ class PluginManager:
469
486
  # ── Aggregation (effectors) ──────────────────────────────────────────────
470
487
 
471
488
  def get_active_skills(self) -> Dict[str, Dict[str, Any]]:
489
+ if not is_user_paid():
490
+ return {}
472
491
  skills: Dict[str, Dict[str, Any]] = {}
473
492
  for p in self.list_plugins():
474
493
  if p.enabled and p.is_valid:
@@ -480,6 +499,8 @@ class PluginManager:
480
499
  return skills
481
500
 
482
501
  def get_active_rules(self) -> List[Dict[str, Any]]:
502
+ if not is_user_paid():
503
+ return []
483
504
  rules: List[Dict[str, Any]] = []
484
505
  for p in self.list_plugins():
485
506
  if p.enabled and p.is_valid:
@@ -487,6 +508,8 @@ class PluginManager:
487
508
  return rules
488
509
 
489
510
  def get_active_mcp_servers(self) -> Dict[str, Dict[str, Any]]:
511
+ if not is_user_paid():
512
+ return {}
490
513
  servers: Dict[str, Dict[str, Any]] = {}
491
514
  for p in self.list_plugins():
492
515
  if p.enabled and p.is_valid:
@@ -494,6 +517,8 @@ class PluginManager:
494
517
  return servers
495
518
 
496
519
  def get_active_tools(self) -> List[Dict[str, Any]]:
520
+ if not is_user_paid():
521
+ return []
497
522
  tools: List[Dict[str, Any]] = []
498
523
  for p in self.list_plugins():
499
524
  if p.enabled and p.is_valid:
@@ -511,6 +536,8 @@ class PluginManager:
511
536
  (string/dict commands from manifests) go through hooks.py in
512
537
  parallel with per-hook timeouts.
513
538
  """
539
+ if not is_user_paid():
540
+ return []
514
541
  from utim_cli.plugins import hooks as hook_engine
515
542
  payload = payload or {}
516
543
  output: Dict[str, Any] = {}