comodor 0.10.2__tar.gz → 0.11.0__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 (179) hide show
  1. {comodor-0.10.2 → comodor-0.11.0}/PKG-INFO +1 -1
  2. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/_version.py +2 -2
  3. comodor-0.11.0/src/comodor/providers/discover.py +240 -0
  4. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/setup.py +78 -5
  5. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/commands.py +23 -17
  6. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/index.html +40 -0
  7. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/server.py +66 -0
  8. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/session.py +184 -0
  9. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/ui.css +127 -0
  10. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/ui.js +458 -8
  11. comodor-0.11.0/tests/test_discover.py +229 -0
  12. {comodor-0.10.2 → comodor-0.11.0}/tests/test_web.py +330 -37
  13. {comodor-0.10.2 → comodor-0.11.0}/.gitignore +0 -0
  14. {comodor-0.10.2 → comodor-0.11.0}/LICENSE +0 -0
  15. {comodor-0.10.2 → comodor-0.11.0}/README.md +0 -0
  16. {comodor-0.10.2 → comodor-0.11.0}/docs/README.md +0 -0
  17. {comodor-0.10.2 → comodor-0.11.0}/pyproject.toml +0 -0
  18. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/__init__.py +0 -0
  19. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/__main__.py +0 -0
  20. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/agent/__init__.py +0 -0
  21. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/agent/context.py +0 -0
  22. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/agent/loop.py +0 -0
  23. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/agent/prompts.py +0 -0
  24. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/agent/spawn.py +0 -0
  25. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/agent/tokens.py +0 -0
  26. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/browser/__init__.py +0 -0
  27. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/browser/cdp.py +0 -0
  28. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/browser/launch.py +0 -0
  29. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/browser/page.py +0 -0
  30. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/catalogue.py +0 -0
  31. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/cli.py +0 -0
  32. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/config.py +0 -0
  33. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/desktop/__init__.py +0 -0
  34. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/desktop/guard.py +0 -0
  35. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/desktop/keys.py +0 -0
  36. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/desktop/overlay.py +0 -0
  37. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/desktop/png.py +0 -0
  38. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/desktop/screen.py +0 -0
  39. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/desktop/win32.py +0 -0
  40. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/doctor.py +0 -0
  41. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/events.py +0 -0
  42. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/help.py +0 -0
  43. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/__init__.py +0 -0
  44. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/associations.py +0 -0
  45. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/bm25.py +0 -0
  46. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/hotindex.py +0 -0
  47. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/memory.py +0 -0
  48. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/progress.py +0 -0
  49. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/reflect.py +0 -0
  50. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/rules.py +0 -0
  51. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/signals.py +0 -0
  52. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/store.py +0 -0
  53. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/learning/writer.py +0 -0
  54. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/mcp/__init__.py +0 -0
  55. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/mcp/catalogue.py +0 -0
  56. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/mcp/commands.py +0 -0
  57. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/mcp/http.py +0 -0
  58. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/mcp/manager.py +0 -0
  59. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/mcp/protocol.py +0 -0
  60. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/migrate.py +0 -0
  61. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/net/__init__.py +0 -0
  62. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/net/http.py +0 -0
  63. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/net/sse.py +0 -0
  64. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/paths.py +0 -0
  65. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/__init__.py +0 -0
  66. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/anthropic.py +0 -0
  67. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/base.py +0 -0
  68. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/caching.py +0 -0
  69. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/fake.py +0 -0
  70. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/gateway.py +0 -0
  71. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/openai_compat.py +0 -0
  72. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/providers/registry.py +0 -0
  73. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/safety/__init__.py +0 -0
  74. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/safety/checkpoints.py +0 -0
  75. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/safety/permissions.py +0 -0
  76. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/safety/redact.py +0 -0
  77. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/session/__init__.py +0 -0
  78. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/session/search.py +0 -0
  79. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/session/store.py +0 -0
  80. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/skills/__init__.py +0 -0
  81. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/skills/catalogue.py +0 -0
  82. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/skills/commands.py +0 -0
  83. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/skills/examples.py +0 -0
  84. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/skills/loader.py +0 -0
  85. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/skills/propose.py +0 -0
  86. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/skills/registry.py +0 -0
  87. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/__init__.py +0 -0
  88. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/base.py +0 -0
  89. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/browse.py +0 -0
  90. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/browser.py +0 -0
  91. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/computer.py +0 -0
  92. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/delegate.py +0 -0
  93. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/fs.py +0 -0
  94. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/history.py +0 -0
  95. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/mcp.py +0 -0
  96. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/overflow.py +0 -0
  97. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/registry.py +0 -0
  98. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/search.py +0 -0
  99. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/shell.py +0 -0
  100. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/skills.py +0 -0
  101. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/todo.py +0 -0
  102. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/tools/web.py +0 -0
  103. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/__init__.py +0 -0
  104. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/app.py +0 -0
  105. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/banner.py +0 -0
  106. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/bidi.py +0 -0
  107. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/chooser.py +0 -0
  108. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/clipboard.py +0 -0
  109. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/console.py +0 -0
  110. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/input/__init__.py +0 -0
  111. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/input/keys.py +0 -0
  112. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/input/reader.py +0 -0
  113. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/layout.py +0 -0
  114. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/markdown.py +0 -0
  115. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/screen.py +0 -0
  116. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/theme.py +0 -0
  117. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/__init__.py +0 -0
  118. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/buttons.py +0 -0
  119. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/chat.py +0 -0
  120. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/history.py +0 -0
  121. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/overlay.py +0 -0
  122. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/panel.py +0 -0
  123. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/progress.py +0 -0
  124. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/prompt.py +0 -0
  125. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/statusbar.py +0 -0
  126. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/ui/widgets/toast.py +0 -0
  127. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/uninstall.py +0 -0
  128. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/update.py +0 -0
  129. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/__init__.py +0 -0
  130. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/vazirmatn-OFL.txt +0 -0
  131. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/web/vazirmatn.woff2 +0 -0
  132. {comodor-0.10.2 → comodor-0.11.0}/src/comodor/workspace.py +0 -0
  133. {comodor-0.10.2 → comodor-0.11.0}/tests/conftest.py +0 -0
  134. {comodor-0.10.2 → comodor-0.11.0}/tests/support/fake_mcp_server.py +0 -0
  135. {comodor-0.10.2 → comodor-0.11.0}/tests/test_agent_loop.py +0 -0
  136. {comodor-0.10.2 → comodor-0.11.0}/tests/test_app.py +0 -0
  137. {comodor-0.10.2 → comodor-0.11.0}/tests/test_associations.py +0 -0
  138. {comodor-0.10.2 → comodor-0.11.0}/tests/test_banner.py +0 -0
  139. {comodor-0.10.2 → comodor-0.11.0}/tests/test_bidi.py +0 -0
  140. {comodor-0.10.2 → comodor-0.11.0}/tests/test_browser.py +0 -0
  141. {comodor-0.10.2 → comodor-0.11.0}/tests/test_caching.py +0 -0
  142. {comodor-0.10.2 → comodor-0.11.0}/tests/test_chooser.py +0 -0
  143. {comodor-0.10.2 → comodor-0.11.0}/tests/test_computer_tool.py +0 -0
  144. {comodor-0.10.2 → comodor-0.11.0}/tests/test_config_save.py +0 -0
  145. {comodor-0.10.2 → comodor-0.11.0}/tests/test_copy_and_bands.py +0 -0
  146. {comodor-0.10.2 → comodor-0.11.0}/tests/test_delegate.py +0 -0
  147. {comodor-0.10.2 → comodor-0.11.0}/tests/test_desktop.py +0 -0
  148. {comodor-0.10.2 → comodor-0.11.0}/tests/test_doctor.py +0 -0
  149. {comodor-0.10.2 → comodor-0.11.0}/tests/test_help.py +0 -0
  150. {comodor-0.10.2 → comodor-0.11.0}/tests/test_history.py +0 -0
  151. {comodor-0.10.2 → comodor-0.11.0}/tests/test_import_command.py +0 -0
  152. {comodor-0.10.2 → comodor-0.11.0}/tests/test_input.py +0 -0
  153. {comodor-0.10.2 → comodor-0.11.0}/tests/test_layout.py +0 -0
  154. {comodor-0.10.2 → comodor-0.11.0}/tests/test_learning.py +0 -0
  155. {comodor-0.10.2 → comodor-0.11.0}/tests/test_markdown.py +0 -0
  156. {comodor-0.10.2 → comodor-0.11.0}/tests/test_mcp.py +0 -0
  157. {comodor-0.10.2 → comodor-0.11.0}/tests/test_mcp_http.py +0 -0
  158. {comodor-0.10.2 → comodor-0.11.0}/tests/test_migrate.py +0 -0
  159. {comodor-0.10.2 → comodor-0.11.0}/tests/test_overflow.py +0 -0
  160. {comodor-0.10.2 → comodor-0.11.0}/tests/test_overlay.py +0 -0
  161. {comodor-0.10.2 → comodor-0.11.0}/tests/test_performance.py +0 -0
  162. {comodor-0.10.2 → comodor-0.11.0}/tests/test_prefix_stability.py +0 -0
  163. {comodor-0.10.2 → comodor-0.11.0}/tests/test_progress.py +0 -0
  164. {comodor-0.10.2 → comodor-0.11.0}/tests/test_propose.py +0 -0
  165. {comodor-0.10.2 → comodor-0.11.0}/tests/test_providers.py +0 -0
  166. {comodor-0.10.2 → comodor-0.11.0}/tests/test_real_browser.py +0 -0
  167. {comodor-0.10.2 → comodor-0.11.0}/tests/test_real_desktop.py +0 -0
  168. {comodor-0.10.2 → comodor-0.11.0}/tests/test_real_web_ui.py +0 -0
  169. {comodor-0.10.2 → comodor-0.11.0}/tests/test_reflex.py +0 -0
  170. {comodor-0.10.2 → comodor-0.11.0}/tests/test_run_loop.py +0 -0
  171. {comodor-0.10.2 → comodor-0.11.0}/tests/test_setup.py +0 -0
  172. {comodor-0.10.2 → comodor-0.11.0}/tests/test_setup_import.py +0 -0
  173. {comodor-0.10.2 → comodor-0.11.0}/tests/test_skill_budget.py +0 -0
  174. {comodor-0.10.2 → comodor-0.11.0}/tests/test_skill_catalogue.py +0 -0
  175. {comodor-0.10.2 → comodor-0.11.0}/tests/test_skills.py +0 -0
  176. {comodor-0.10.2 → comodor-0.11.0}/tests/test_switching_model.py +0 -0
  177. {comodor-0.10.2 → comodor-0.11.0}/tests/test_tools.py +0 -0
  178. {comodor-0.10.2 → comodor-0.11.0}/tests/test_uninstall.py +0 -0
  179. {comodor-0.10.2 → comodor-0.11.0}/tests/test_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: comodor
3
- Version: 0.10.2
3
+ Version: 0.11.0
4
4
  Summary: Comodor — a self-improving terminal coding agent with a Rich TUI
5
5
  Project-URL: Homepage, https://comodor.ai
6
6
  Project-URL: Repository, https://github.com/ifekri/Comodor
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.10.2'
22
- __version_tuple__ = version_tuple = (0, 10, 2)
21
+ __version__ = version = '0.11.0'
22
+ __version_tuple__ = version_tuple = (0, 11, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,240 @@
1
+ """Credentials and models this machine already has.
2
+
3
+ Setting up an agent means finding an API key, which means a billing page, a
4
+ new secret to keep, and a decision about which provider to pay. That is the
5
+ tedious part, and for a lot of people it is unnecessary: the machine already
6
+ has what is needed and nobody asked it.
7
+
8
+ Two kinds of "already", and they are found differently.
9
+
10
+ **Something running here.** Ollama and LM Studio serve an OpenAI-compatible
11
+ endpoint on a known port and need no key at all. If one is running, the
12
+ correct first offer is not a list of billing pages — it is "this is here, and
13
+ it has these three models". Found by asking the port, because a binary on disk
14
+ is not a server that is up, and the question `/v1/models` answers is exactly
15
+ "are you there, and what can you do".
16
+
17
+ **A key already in the environment.** `OPENROUTER_API_KEY` and its siblings
18
+ are read and applied by the configuration layer already; what was missing was
19
+ saying so. Somebody who exported a key an hour ago should be told it was
20
+ found, not asked to paste it again.
21
+
22
+ Three rules, because this runs on the first-run path and a first run must not
23
+ hang or lie:
24
+
25
+ * **Fast or absent.** A probe that takes two seconds is a setup wizard that
26
+ pauses for no reason on every machine without a local model. The timeout is
27
+ short, the probes run together, and a slow answer is the same as no answer.
28
+ * **Never fatal.** Every failure here is a thing not offered, never an error.
29
+ * **Nothing is claimed that was not seen.** A runtime is reported only if it
30
+ answered, and its models are the ones it listed rather than the ones the
31
+ catalogue guesses.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import os
37
+ import threading
38
+ from dataclasses import dataclass, field
39
+ from typing import Iterable
40
+
41
+ from .. import catalogue
42
+
43
+ #: Long enough for a server on this machine, short enough that two of them not
44
+ #: being there is not a pause anybody notices. Local means local: a runtime
45
+ #: that needs more than this to say hello is not one to put in front of
46
+ #: somebody on their first run.
47
+ PROBE_TIMEOUT = (0.35, 1.5)
48
+
49
+ #: How many models to name. The rest are still there; this is a first
50
+ #: impression, not a catalogue.
51
+ SHOW_MODELS = 8
52
+
53
+
54
+ @dataclass
55
+ class Running:
56
+ """A model server answering on this machine."""
57
+
58
+ provider: str
59
+ label: str
60
+ base_url: str
61
+ models: list[str] = field(default_factory=list)
62
+
63
+ @property
64
+ def usable(self) -> bool:
65
+ """Answering is not enough — it has to have something loaded.
66
+
67
+ Ollama with no models pulled answers `/v1/models` with an empty list.
68
+ Offering it would be offering a provider that fails on the first
69
+ request, which is worse than not offering it.
70
+ """
71
+ return bool(self.models)
72
+
73
+ @property
74
+ def summary(self) -> str:
75
+ if not self.models:
76
+ return "running, but no models are installed yet"
77
+ shown = ", ".join(self.models[:3])
78
+ more = len(self.models) - 3
79
+ return f"{shown}{f', and {more} more' if more > 0 else ''}"
80
+
81
+
82
+ @dataclass
83
+ class Held:
84
+ """A provider whose key is already in the environment."""
85
+
86
+ provider: str
87
+ label: str
88
+ variable: str
89
+
90
+
91
+ def local_specs() -> list[catalogue.ProviderSpec]:
92
+ """The catalogue entries that describe something running on this machine."""
93
+ return [spec for spec in catalogue.CATALOGUE
94
+ if not spec.needs_key and spec.base_url
95
+ and ("localhost" in spec.base_url or "127.0.0.1" in spec.base_url)]
96
+
97
+
98
+ def running_here(specs: Iterable[catalogue.ProviderSpec] | None = None,
99
+ timeout: tuple[float, float] = PROBE_TIMEOUT) -> list[Running]:
100
+ """Ask each local port whether anything is listening, all at once.
101
+
102
+ Together rather than one after another: two runtimes that are both absent
103
+ would otherwise cost two timeouts, and the whole point is that somebody
104
+ with no local model never notices this ran.
105
+ """
106
+ found: list[Running] = []
107
+ lock = threading.Lock()
108
+
109
+ def ask(spec: catalogue.ProviderSpec) -> None:
110
+ models = _models_at(spec.base_url, timeout)
111
+ if models is None:
112
+ return
113
+ with lock:
114
+ found.append(Running(provider=spec.id, label=spec.label,
115
+ base_url=spec.base_url,
116
+ models=models[:SHOW_MODELS]))
117
+
118
+ threads = [threading.Thread(target=ask, args=(spec,), daemon=True)
119
+ for spec in (specs if specs is not None else local_specs())]
120
+ for thread in threads:
121
+ thread.start()
122
+ # Joined with a ceiling of their own, so a socket that neither answers nor
123
+ # refuses cannot hold up a first run.
124
+ deadline = sum(timeout) + 0.5
125
+ for thread in threads:
126
+ thread.join(timeout=deadline)
127
+
128
+ found.sort(key=lambda item: (not item.usable, item.provider))
129
+ return found
130
+
131
+
132
+ def _port_is_open(base_url: str, deadline: float) -> bool:
133
+ """Is anything accepting connections there, on either address family?
134
+
135
+ Asked with a socket before the HTTP client is built, because this is the
136
+ expensive half. `localhost` resolves to both `::1` and `127.0.0.1`, and a
137
+ closed port on Windows drops the connection attempt rather than refusing
138
+ it — so a sequential probe pays the timeout twice for a machine that has
139
+ no local model, which is most machines.
140
+
141
+ Both families are tried at once. Assuming IPv4 would be faster and would
142
+ miss a runtime bound only to `::1`, and something running that does not
143
+ get found is the one outcome this whole module exists to avoid.
144
+ """
145
+ import socket
146
+ from urllib.parse import urlsplit
147
+
148
+ parts = urlsplit(base_url)
149
+ host, port = parts.hostname or "localhost", parts.port
150
+ if not port:
151
+ port = 443 if parts.scheme == "https" else 80
152
+
153
+ try:
154
+ addresses = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
155
+ except OSError:
156
+ return False
157
+
158
+ open_ports: list[bool] = []
159
+ lock = threading.Lock()
160
+
161
+ def knock(family: int, address: tuple) -> None:
162
+ try:
163
+ with socket.socket(family, socket.SOCK_STREAM) as sock:
164
+ sock.settimeout(deadline)
165
+ sock.connect(address)
166
+ except OSError:
167
+ return
168
+ with lock:
169
+ open_ports.append(True)
170
+
171
+ knocks = [threading.Thread(target=knock, args=(family, address), daemon=True)
172
+ for family, _, _, _, address in addresses]
173
+ for thread in knocks:
174
+ thread.start()
175
+ for thread in knocks:
176
+ thread.join(timeout=deadline + 0.2)
177
+ return bool(open_ports)
178
+
179
+
180
+ def _models_at(base_url: str, timeout: tuple[float, float]) -> list[str] | None:
181
+ """What this endpoint says it can run, or None if nothing answered."""
182
+ from ..net import http
183
+
184
+ if not _port_is_open(base_url, deadline=timeout[0]):
185
+ return None
186
+
187
+ # No retries. A refused connection to a port on this machine is a complete
188
+ # answer the first time, and the default policy turns "nothing is running"
189
+ # into three attempts and two backoffs — which is the whole cost of this,
190
+ # paid by everybody who has no local model.
191
+ session = http.Session(retry=http.Retry(total=0, retry_on_connection_error=False))
192
+ try:
193
+ response = session.get(f"{base_url.rstrip('/')}/models", timeout=timeout)
194
+ if response.status_code >= 400:
195
+ return None
196
+ payload = response.json()
197
+ except Exception:
198
+ # Refused, timed out, answered with something that is not JSON: all of
199
+ # them mean the same thing here, which is that there is nothing to
200
+ # offer.
201
+ return None
202
+ finally:
203
+ try:
204
+ session.close()
205
+ except Exception:
206
+ pass
207
+
208
+ entries = payload.get("data", payload) if isinstance(payload, dict) else payload
209
+ models: list[str] = []
210
+ for entry in entries or []:
211
+ if isinstance(entry, dict) and entry.get("id"):
212
+ models.append(str(entry["id"]))
213
+ elif isinstance(entry, str):
214
+ models.append(entry)
215
+ return sorted(models)
216
+
217
+
218
+ def keys_in_the_environment() -> list[Held]:
219
+ """Providers whose key is already exported.
220
+
221
+ The configuration layer reads and applies these on its own; this is only
222
+ so the wizard can say which ones it found. Somebody who exported a key an
223
+ hour ago should be told, not asked to paste it again.
224
+
225
+ The value is never returned — only which variable held it. A first-run
226
+ screen has no business quoting a secret back at the room.
227
+ """
228
+ held: list[Held] = []
229
+ for spec in catalogue.CATALOGUE:
230
+ if not spec.env_key:
231
+ continue
232
+ if os.environ.get(spec.env_key, "").strip():
233
+ held.append(Held(provider=spec.id, label=spec.label,
234
+ variable=spec.env_key))
235
+ return held
236
+
237
+
238
+ def anything_ready() -> tuple[list[Running], list[Held]]:
239
+ """Both kinds, for a caller that wants to open with what is already here."""
240
+ return running_here(), keys_in_the_environment()
@@ -95,6 +95,7 @@ class SetupWizard:
95
95
  """
96
96
  if self._keys:
97
97
  self.console.clear()
98
+ self._crown()
98
99
  self._recap()
99
100
  self.console.print()
100
101
  self.console.print(
@@ -104,6 +105,29 @@ class SetupWizard:
104
105
  )
105
106
  )
106
107
 
108
+ def _crown(self) -> None:
109
+ """The wordmark, at the top of whatever is about to be drawn.
110
+
111
+ Every question clears the screen, so this is a header rather than a
112
+ repetition — without it each step arrives on a blank terminal with
113
+ nothing on it naming the program doing the asking.
114
+
115
+ It shrinks on a short terminal as well as on a narrow one. Five rows
116
+ of logo on a twenty-row screen is five rows the questions do not get,
117
+ which is the same trade the width rule already makes.
118
+ """
119
+ from .ui.banner import TAGLINE, wordmark
120
+
121
+ size = self.console.size
122
+ if size.height < 22 or size.width < 51:
123
+ self.console.print(Text.assemble(
124
+ ("Comodor", self.theme.style("accent", bold=True)),
125
+ (f" {TAGLINE}", self.theme.style("dim")),
126
+ ))
127
+ return
128
+ self.console.print(wordmark(self.theme, size.width))
129
+ self.console.print(Text(f" {TAGLINE}", style=self.theme.style("dim")))
130
+
107
131
  def _recap(self) -> None:
108
132
  """The questions already answered, one quiet line each."""
109
133
  if not self._done:
@@ -368,6 +392,38 @@ class SetupWizard:
368
392
  self.console.print(Text(f" not imported: {note}",
369
393
  style=self.theme.style("dim")))
370
394
 
395
+ def _already_here(self) -> dict[str, str]:
396
+ """What this machine can already do, said before anything is asked.
397
+
398
+ A local runtime that is up needs no key and no account, and an
399
+ exported key is one somebody found an hour ago. Opening with eighteen
400
+ billing pages when the answer is a port that is already open is asking
401
+ a question that has been answered.
402
+
403
+ Returns a note per provider, for the list to show beside it. Never
404
+ raises: this is on the path that draws the first screen anybody sees.
405
+ """
406
+ try:
407
+ from .providers import discover
408
+
409
+ running = discover.running_here()
410
+ exported = discover.keys_in_the_environment()
411
+ except Exception:
412
+ return {}
413
+
414
+ notes: dict[str, str] = {}
415
+ for item in running:
416
+ notes[item.provider] = (f"running here — {item.summary}"
417
+ if item.usable else
418
+ "running here, but no models installed yet")
419
+ for item in exported:
420
+ notes.setdefault(item.provider, f"key already in ${item.variable}")
421
+
422
+ # Returned rather than announced. The list leads with these and names
423
+ # their models, so a sentence above it saying the same thing is the
424
+ # same fact twice — and the second telling is the one nobody reads.
425
+ return notes
426
+
371
427
  def _ask_skills(self, step: int, total: int) -> list[str]:
372
428
  """Offer the library, once, at the only moment it is not an interruption.
373
429
 
@@ -419,9 +475,10 @@ class SetupWizard:
419
475
  return chosen
420
476
 
421
477
  def _banner(self) -> None:
478
+
479
+ self.console.print()
480
+ self._crown()
422
481
  body = Text.assemble(
423
- ("Comodor", self.theme.style("accent", bold=True)),
424
- (" — it learns the way you correct it.\n\n", self.theme.style("text")),
425
482
  ("A few questions, once. The answers are saved to\n",
426
483
  self.theme.style("dim")),
427
484
  (str(self.config.paths.config_file), self.theme.style("value")),
@@ -430,7 +487,6 @@ class SetupWizard:
430
487
  ("/settings", self.theme.style("accent")),
431
488
  (".", self.theme.style("dim")),
432
489
  )
433
- self.console.print()
434
490
  self.console.print(Panel(body, box=self.theme.box,
435
491
  border_style=self.theme.style("border"),
436
492
  padding=(1, 2)))
@@ -440,6 +496,7 @@ class SetupWizard:
440
496
  self.console.print(
441
497
  Text(" You can add more later; this is just the one to start with.\n",
442
498
  style=self.theme.style("dim")))
499
+ here = self._already_here()
443
500
  # A provider that already has a key — imported a moment ago, or found
444
501
  # in the environment — leads. The key is the expensive part of setting
445
502
  # this up, and offering a default that needs a new one, immediately
@@ -449,11 +506,24 @@ class SetupWizard:
449
506
  entry = self.config.providers.get(spec.id)
450
507
  return bool(entry is not None and entry.api_key)
451
508
 
509
+ def usable_now(spec: catalogue.ProviderSpec) -> bool:
510
+ """Nothing left to find before this one can answer.
511
+
512
+ A runtime already up on this machine is the same argument as a key
513
+ already set, taken further: it needs no key at all.
514
+ """
515
+ return keyed(spec) or spec.id in here
516
+
452
517
  offered = list(catalogue.offered())
453
- ready = [spec for spec in offered if keyed(spec)]
454
- rest = [spec for spec in offered if not keyed(spec)]
518
+ ready = [spec for spec in offered if usable_now(spec)]
519
+ rest = [spec for spec in offered if not usable_now(spec)]
455
520
 
456
521
  def blurb(spec: catalogue.ProviderSpec) -> str:
522
+ # What was found about this one outranks what the catalogue says
523
+ # about it: "running here, three models" is the answer, and the
524
+ # sales line is what you read when there is no answer yet.
525
+ if spec.id in here:
526
+ return here[spec.id]
457
527
  if not keyed(spec):
458
528
  return spec.blurb
459
529
  why = "key imported" if spec.label in self.imported.keys \
@@ -669,6 +739,9 @@ class SetupWizard:
669
739
  return done
670
740
 
671
741
  def finish(self, config: Config) -> None:
742
+ if self._keys:
743
+ self.console.clear()
744
+ self._crown()
672
745
  entry = config.active()
673
746
  body = Text.assemble(
674
747
  ("Ready.\n\n", self.theme.style("good", bold=True)),
@@ -66,12 +66,17 @@ def run(config: Config, args: argparse.Namespace) -> int:
66
66
 
67
67
 
68
68
  def _configured_or_explain(config: Config) -> Config | None:
69
- """A usable configuration, or None having said what is missing.
69
+ """A usable configuration, or one the page will finish setting up.
70
70
 
71
- The browser interface can switch between providers that are already set
72
- up, but it has nowhere to type a key into and would be a poor place for
73
- one anyway. Starting with none configured produces a URL, a browser
74
- window, and a failure on the first task with nothing on screen to act on.
71
+ This used to refuse to start. That was right while the page had nowhere to
72
+ type a key, and it made the browser interface unreachable for the person
73
+ most likely to want it - somebody whose Comodor is on a server or in a
74
+ container, with no terminal in front of them.
75
+
76
+ So the terminal is still offered the wizard, because it is a nicer place
77
+ to answer questions, and declining it no longer stops anything: the page
78
+ asks instead. What decides whether a key may be typed there is where the
79
+ request comes from, which the server checks per request.
75
80
  """
76
81
  if not config.needs_setup:
77
82
  return config
@@ -89,25 +94,26 @@ def _configured_or_explain(config: Config) -> Config | None:
89
94
  try:
90
95
  config = run_setup(config)
91
96
  except (KeyboardInterrupt, EOFError):
92
- print("\nSetup cancelled. Run `comodor setup` when you are ready.",
97
+ # Cancelled, not refused. The page can finish the job, and
98
+ # stopping here would leave somebody with nothing at all.
99
+ print("\nSetup cancelled — you can finish it in the browser.",
93
100
  file=sys.stderr)
94
- return None
95
- return None if config.needs_setup else config
101
+ return config
96
102
 
97
- # Nobody to ask: a container, a service unit, a pipeline. This message is
98
- # the only thing that person will see, so it has to carry the answer.
99
- print("Comodor has no provider configured, and the browser interface has "
100
- "no way to add one.", file=sys.stderr)
103
+ # Nobody at a terminal: a container, a service unit, a pipeline. It starts
104
+ # anyway and the page asks. Said out loud, because a URL that opens onto a
105
+ # form is a surprise worth warning about.
106
+ print("No provider is configured yet — the page will ask when you open it.",
107
+ file=sys.stderr)
101
108
  print(file=sys.stderr)
102
- print(" In Docker, pass a key in as an environment variable:",
109
+ print(" To skip that, pass a key in as an environment variable:",
103
110
  file=sys.stderr)
104
111
  print(" -e ANTHROPIC_API_KEY=... -e OPENAI_API_KEY=...",
105
112
  file=sys.stderr)
106
- print(" or mount a config file at "
107
- f"{config.paths.config_file}.", file=sys.stderr)
108
- print(" Anywhere with a terminal, `comodor setup` asks a few questions.",
113
+ print(f" or mount a config file at {config.paths.config_file}.",
109
114
  file=sys.stderr)
110
- return None
115
+ print(file=sys.stderr)
116
+ return config
111
117
 
112
118
 
113
119
  def banner_shown(server: Server) -> bool:
@@ -35,6 +35,28 @@
35
35
  stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
36
36
  <path d="M10 4.5v11M4.5 10h11"/>
37
37
  </symbol>
38
+ <symbol id="i-rule" viewBox="0 0 20 20" fill="none" stroke="currentColor"
39
+ stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
40
+ <path d="M4.5 3.5h11v13h-11z"/><path d="M7 7h6M7 10h6M7 13h3.5"/>
41
+ </symbol>
42
+ <symbol id="i-off" viewBox="0 0 20 20" fill="none" stroke="currentColor"
43
+ stroke-width="1.6" stroke-linecap="round">
44
+ <circle cx="10" cy="10" r="6.6"/><path d="m5.6 5.6 8.8 8.8"/>
45
+ </symbol>
46
+ <symbol id="i-on" viewBox="0 0 20 20" fill="none" stroke="currentColor"
47
+ stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
48
+ <path d="m4.5 10.4 3.4 3.4 7.6-7.6"/>
49
+ </symbol>
50
+ <symbol id="i-pin" viewBox="0 0 20 20" fill="none" stroke="currentColor"
51
+ stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
52
+ <path d="M12.4 2.6 17.4 7.6l-2.5 1.2-1.6 4.4-5.5-5.5 4.4-1.6z"/>
53
+ <path d="M7.8 12.2 3.4 16.6"/>
54
+ </symbol>
55
+ <symbol id="i-export" viewBox="0 0 20 20" fill="none" stroke="currentColor"
56
+ stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
57
+ <path d="M10 13V3.5M6.5 7 10 3.5 13.5 7"/>
58
+ <path d="M3.5 12.5v3a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-3"/>
59
+ </symbol>
38
60
  <symbol id="i-close" viewBox="0 0 20 20" fill="none" stroke="currentColor"
39
61
  stroke-width="1.8" stroke-linecap="round">
40
62
  <path d="m5.5 5.5 9 9M14.5 5.5l-9 9"/>
@@ -143,6 +165,8 @@
143
165
  <div class="tabs" role="tablist" aria-label="Sidebar">
144
166
  <button role="tab" id="tab-chat" aria-selected="true"
145
167
  aria-controls="panel-chat">Chat</button>
168
+ <button role="tab" id="tab-rules" aria-selected="false"
169
+ aria-controls="panel-rules">Rules</button>
146
170
  <button role="tab" id="tab-admin" aria-selected="false"
147
171
  aria-controls="panel-admin">Admin</button>
148
172
  </div>
@@ -164,6 +188,20 @@
164
188
  <div id="chats" role="list"></div>
165
189
  </div>
166
190
 
191
+ <div class="panel" id="panel-rules" role="tabpanel" aria-labelledby="tab-rules">
192
+ <form class="finder" id="rule-form">
193
+ <label class="field">
194
+ <svg aria-hidden="true"><use href="#i-rule"/></svg>
195
+ <input id="rule-text" type="text" maxlength="300"
196
+ placeholder="Always run the tests before saying done"
197
+ autocomplete="off" aria-label="Write a rule">
198
+ </label>
199
+ <button class="ghost-btn" type="submit" id="rule-add" title="Add this rule"
200
+ aria-label="Add this rule"><svg><use href="#i-new"/></svg></button>
201
+ </form>
202
+ <div id="rules-body"></div>
203
+ </div>
204
+
167
205
  <div class="panel" id="panel-admin" role="tabpanel" aria-labelledby="tab-admin">
168
206
  <div id="admin-body"></div>
169
207
  </div>
@@ -181,6 +219,8 @@
181
219
  <div id="log" tabindex="0" aria-label="Conversation">
182
220
  <div class="stream" id="stream" aria-live="polite"></div>
183
221
  </div>
222
+
223
+ <section id="setup" hidden aria-label="Set Comodor up"></section>
184
224
  </main>
185
225
 
186
226
  <!-- --------------------------------------------------------------- foot -->
@@ -45,6 +45,8 @@ from .session import Session
45
45
 
46
46
  #: Loopback names, for deciding whether a warning is owed.
47
47
  LOCAL = ("127.0.0.1", "::1", "localhost")
48
+ #: Addresses a request can arrive *from* and still be on this machine.
49
+ LOOPBACK = ("127.0.0.1", "::1", "::ffff:127.0.0.1")
48
50
  COOKIE = "comodor_token"
49
51
  #: A header no cross-origin form or image can set, so its presence proves the
50
52
  #: request came from our own page rather than from somebody else's.
@@ -180,6 +182,25 @@ def _handler_for(server: Server) -> type[BaseHTTPRequestHandler]:
180
182
  def _json(self, status: int, payload: Any) -> None:
181
183
  self._send(status, json.dumps(payload).encode("utf-8"))
182
184
 
185
+ def _from_this_machine(self) -> bool:
186
+ """Whether this request never crossed a network.
187
+
188
+ Asked of the request, not of the bind: a server listening on every
189
+ address still answers loopback, and a rule about the bind would
190
+ have refused that. What it decides is whether an API key may be
191
+ typed into the page - there is no TLS here, and a key is a
192
+ credential with a bill attached.
193
+
194
+ A container is allowed as well. Its loopback is not the operator's
195
+ machine, but the operator chose how to publish the port and was
196
+ told at startup what that choice means.
197
+ """
198
+ try:
199
+ where = self.client_address[0]
200
+ except (AttributeError, IndexError):
201
+ return False
202
+ return where in LOOPBACK or server.contained
203
+
183
204
  def _token(self) -> str:
184
205
  cookies = self.headers.get("Cookie") or ""
185
206
  for part in cookies.split(";"):
@@ -280,6 +301,17 @@ def _handler_for(server: Server) -> type[BaseHTTPRequestHandler]:
280
301
  self._json(200, server.session.admin())
281
302
  return
282
303
 
304
+ if route == "/api/rules":
305
+ self._json(200, server.session.rules())
306
+ return
307
+
308
+ if route == "/api/setup":
309
+ offer = server.session.offer()
310
+ offer["may_enter_a_key"] = self._from_this_machine()
311
+ offer["port"] = server.port
312
+ self._json(200, offer)
313
+ return
314
+
283
315
  if route == "/api/events":
284
316
  try:
285
317
  cursor = int((query.get("cursor") or ["0"])[0])
@@ -366,6 +398,40 @@ def _handler_for(server: Server) -> type[BaseHTTPRequestHandler]:
366
398
  server.stop()
367
399
  return
368
400
 
401
+ if route == "/api/setup":
402
+ if not self._from_this_machine():
403
+ self._json(403, {
404
+ "ok": False,
405
+ "error": "There is no TLS here, so a key typed into "
406
+ "this page would cross the network in the "
407
+ "clear. Set it up on the machine Comodor is "
408
+ "running on, or reach this page through an "
409
+ "SSH tunnel."})
410
+ return
411
+ done, why = server.session.set_up(
412
+ str(body.get("provider") or ""),
413
+ api_key=str(body.get("api_key") or ""),
414
+ model=str(body.get("model") or ""),
415
+ base_url=str(body.get("base_url") or ""))
416
+ self._json(200 if done else 400,
417
+ {"ok": done, "error": why,
418
+ "state": server.session.state()})
419
+ return
420
+
421
+ if route == "/api/rules":
422
+ action = str(body.get("action") or "")
423
+ if action == "export":
424
+ done, why, where = server.session.export_rules()
425
+ self._json(200 if done else 500,
426
+ {"ok": done, "error": why, "path": where})
427
+ return
428
+ done, why, extra = server.session.rule(
429
+ action, id=body.get("id"),
430
+ statement=body.get("statement"))
431
+ self._json(200 if done else 400,
432
+ {"ok": done, "error": why, "rule": extra})
433
+ return
434
+
369
435
  if route == "/api/setting":
370
436
  done, why = server.session.setting(str(body.get("key") or ""),
371
437
  body.get("value"))