hx-cli 0.2.1__tar.gz → 0.2.2__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 (211) hide show
  1. {hx_cli-0.2.1 → hx_cli-0.2.2}/CHANGELOG.md +34 -1
  2. {hx_cli-0.2.1 → hx_cli-0.2.2}/PKG-INFO +44 -7
  3. {hx_cli-0.2.1 → hx_cli-0.2.2}/README.md +42 -5
  4. {hx_cli-0.2.1 → hx_cli-0.2.2}/install.sh +1 -1
  5. {hx_cli-0.2.1 → hx_cli-0.2.2}/pyproject.toml +1 -1
  6. hx_cli-0.2.2/src/hx/__init__.py +5 -0
  7. hx_cli-0.2.2/src/hx/auth/oauth/browser.py +133 -0
  8. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/auth/oauth/codex.py +10 -103
  9. hx_cli-0.2.2/src/hx/auth/oauth/devin.py +169 -0
  10. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/auth/resolve.py +8 -0
  11. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/cli.py +33 -15
  12. hx_cli-0.2.2/src/hx/providers/devin.py +589 -0
  13. hx_cli-0.2.2/src/hx/providers/devin_catalogue.py +301 -0
  14. hx_cli-0.2.2/src/hx/providers/devin_wire.py +512 -0
  15. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/providers/models.py +205 -92
  16. hx_cli-0.2.2/src/hx/providers/protowire.py +225 -0
  17. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/providers/registry.py +71 -2
  18. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/loop.py +46 -5
  19. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/terminal.py +82 -8
  20. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/commands.py +15 -14
  21. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/runtime.py +83 -4
  22. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/views/login.py +50 -29
  23. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/views/status.py +1 -3
  24. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/auth/test_codex_oauth.py +4 -4
  25. hx_cli-0.2.2/tests/auth/test_devin_oauth.py +119 -0
  26. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/conftest.py +8 -0
  27. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/providers/test_catalogue.py +10 -6
  28. hx_cli-0.2.2/tests/providers/test_devin.py +453 -0
  29. hx_cli-0.2.2/tests/providers/test_devin_catalogue.py +206 -0
  30. hx_cli-0.2.2/tests/providers/test_protowire.py +100 -0
  31. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/providers/test_registry.py +35 -2
  32. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/term/test_loop.py +75 -1
  33. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/term/test_terminal.py +86 -0
  34. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/test_cli.py +1 -1
  35. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/test_install.py +1 -1
  36. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/test_live_codex.py +8 -2
  37. hx_cli-0.2.2/tests/test_live_devin.py +130 -0
  38. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/test_login.py +58 -1
  39. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/views/test_runtime.py +77 -2
  40. hx_cli-0.2.1/src/hx/__init__.py +0 -5
  41. {hx_cli-0.2.1 → hx_cli-0.2.2}/.github/workflows/ci.yml +0 -0
  42. {hx_cli-0.2.1 → hx_cli-0.2.2}/.gitignore +0 -0
  43. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/agents/__init__.py +0 -0
  44. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/agents/definitions.py +0 -0
  45. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/agents/subagent.py +0 -0
  46. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/auth/__init__.py +0 -0
  47. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/auth/oauth/__init__.py +0 -0
  48. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/auth/oauth/callback.py +0 -0
  49. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/auth/oauth/pkce.py +0 -0
  50. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/auth/store.py +0 -0
  51. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/config.py +0 -0
  52. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/core/__init__.py +0 -0
  53. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/core/checkpoints.py +0 -0
  54. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/core/compaction.py +0 -0
  55. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/core/context.py +0 -0
  56. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/core/events.py +0 -0
  57. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/core/lateinject.py +0 -0
  58. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/core/loop.py +0 -0
  59. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/core/messages.py +0 -0
  60. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/core/session.py +0 -0
  61. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/core/title.py +0 -0
  62. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/core/usage.py +0 -0
  63. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/docs.py +0 -0
  64. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/frontmatter.py +0 -0
  65. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/git.py +0 -0
  66. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/keys.py +0 -0
  67. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/mcp/__init__.py +0 -0
  68. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/mcp/client.py +0 -0
  69. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/mcp/manager.py +0 -0
  70. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/net.py +0 -0
  71. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/paths.py +0 -0
  72. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/permissions/__init__.py +0 -0
  73. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/permissions/engine.py +0 -0
  74. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/permissions/parser.py +0 -0
  75. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/permissions/sandbox.py +0 -0
  76. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/providers/__init__.py +0 -0
  77. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/providers/base.py +0 -0
  78. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/providers/codex.py +0 -0
  79. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/providers/codex_catalogue.py +0 -0
  80. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/providers/fake.py +0 -0
  81. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/providers/openrouter.py +0 -0
  82. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/providers/responses_codec.py +0 -0
  83. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/skills/__init__.py +0 -0
  84. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/skills/loader.py +0 -0
  85. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/skills/runtime.py +0 -0
  86. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/__init__.py +0 -0
  87. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/ansi.py +0 -0
  88. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/buffer.py +0 -0
  89. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/component.py +0 -0
  90. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/demo.py +0 -0
  91. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/editor.py +0 -0
  92. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/keydecode.py +0 -0
  93. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/markdown.py +0 -0
  94. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/primitives.py +0 -0
  95. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/sanitize.py +0 -0
  96. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/screen.py +0 -0
  97. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/syntax.py +0 -0
  98. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/undo.py +0 -0
  99. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/width.py +0 -0
  100. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/term/word_nav.py +0 -0
  101. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tools/__init__.py +0 -0
  102. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tools/base.py +0 -0
  103. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tools/bash.py +0 -0
  104. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tools/edit.py +0 -0
  105. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tools/glob.py +0 -0
  106. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tools/grep.py +0 -0
  107. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tools/output.py +0 -0
  108. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tools/read.py +0 -0
  109. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tools/registry.py +0 -0
  110. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tools/task.py +0 -0
  111. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tools/todo.py +0 -0
  112. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tools/websearch.py +0 -0
  113. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tools/write.py +0 -0
  114. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/__init__.py +0 -0
  115. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/app.py +0 -0
  116. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/clipboard.py +0 -0
  117. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/format.py +0 -0
  118. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/fuzzy.py +0 -0
  119. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/glyphs.py +0 -0
  120. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/killring.py +0 -0
  121. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/limits.py +0 -0
  122. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/paint.py +0 -0
  123. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/renderers.py +0 -0
  124. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/roles.py +0 -0
  125. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/theme.py +0 -0
  126. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/theme_json.py +0 -0
  127. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/themes/ansi.json +0 -0
  128. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/themes/dark.json +0 -0
  129. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/themes/light.json +0 -0
  130. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/views/__init__.py +0 -0
  131. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/views/blocks.py +0 -0
  132. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/views/dialog.py +0 -0
  133. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/views/permission.py +0 -0
  134. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/views/pickers.py +0 -0
  135. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/views/prompt.py +0 -0
  136. {hx_cli-0.2.1 → hx_cli-0.2.2}/src/hx/tui/views/transcript.py +0 -0
  137. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/__init__.py +0 -0
  138. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/agents/__init__.py +0 -0
  139. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/agents/test_subagent.py +0 -0
  140. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/auth/__init__.py +0 -0
  141. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/auth/test_pkce.py +0 -0
  142. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/auth/test_resolve.py +0 -0
  143. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/auth/test_store.py +0 -0
  144. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/core/__init__.py +0 -0
  145. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/core/test_checkpoints.py +0 -0
  146. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/core/test_compaction.py +0 -0
  147. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/core/test_context.py +0 -0
  148. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/core/test_lateinject.py +0 -0
  149. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/core/test_loop.py +0 -0
  150. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/core/test_rewind.py +0 -0
  151. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/core/test_session.py +0 -0
  152. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/core/test_settings_layers.py +0 -0
  153. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/core/test_settings_migration.py +0 -0
  154. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/core/test_steering.py +0 -0
  155. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/core/test_title.py +0 -0
  156. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/core/test_usage.py +0 -0
  157. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/mcp/__init__.py +0 -0
  158. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/mcp/fixtures/echo_server.py +0 -0
  159. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/mcp/test_manager.py +0 -0
  160. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/permissions/__init__.py +0 -0
  161. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/permissions/test_engine.py +0 -0
  162. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/permissions/test_parser.py +0 -0
  163. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/permissions/test_sandbox.py +0 -0
  164. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/providers/__init__.py +0 -0
  165. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/providers/test_codex.py +0 -0
  166. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/providers/test_codex_catalogue.py +0 -0
  167. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/providers/test_openrouter.py +0 -0
  168. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/providers/test_responses_codec.py +0 -0
  169. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/skills/__init__.py +0 -0
  170. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/skills/test_loader.py +0 -0
  171. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/term/__init__.py +0 -0
  172. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/term/conftest.py +0 -0
  173. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/term/test_ansi.py +0 -0
  174. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/term/test_keydecode.py +0 -0
  175. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/term/test_markdown.py +0 -0
  176. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/term/test_primitives.py +0 -0
  177. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/term/test_sanitize.py +0 -0
  178. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/term/test_screen.py +0 -0
  179. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/term/test_width.py +0 -0
  180. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/test_docs.py +0 -0
  181. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/test_git.py +0 -0
  182. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/test_integration.py +0 -0
  183. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/test_live.py +0 -0
  184. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/test_live_tavily.py +0 -0
  185. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/test_net.py +0 -0
  186. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/test_paths.py +0 -0
  187. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/test_readme.py +0 -0
  188. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tools/__init__.py +0 -0
  189. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tools/test_background.py +0 -0
  190. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tools/test_bash.py +0 -0
  191. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tools/test_files.py +0 -0
  192. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tools/test_output.py +0 -0
  193. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tools/test_todo.py +0 -0
  194. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tools/test_websearch.py +0 -0
  195. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/__init__.py +0 -0
  196. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/support.py +0 -0
  197. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/test_autocomplete.py +0 -0
  198. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/test_clipboard.py +0 -0
  199. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/test_effort.py +0 -0
  200. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/test_keys.py +0 -0
  201. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/test_renderers.py +0 -0
  202. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/test_session.py +0 -0
  203. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/test_theme_json.py +0 -0
  204. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/views/__init__.py +0 -0
  205. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/views/test_blocks.py +0 -0
  206. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/views/test_permission.py +0 -0
  207. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/views/test_pickers.py +0 -0
  208. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/views/test_prompt.py +0 -0
  209. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/views/test_status.py +0 -0
  210. {hx_cli-0.2.1 → hx_cli-0.2.2}/tests/tui/views/test_transcript.py +0 -0
  211. {hx_cli-0.2.1 → hx_cli-0.2.2}/uv.lock +0 -0
@@ -28,6 +28,38 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.2] - 2026-09-17
32
+
33
+ ### Added
34
+
35
+ - Devin subscription as a third route: `hx auth login devin` (or `/login`)
36
+ signs in through the browser, and `devin/<model>` ids run against the plan -
37
+ every model the account includes, listed by `/model` and refreshed with
38
+ `/models refresh`. Reasoning depths Devin ships as separate models are folded
39
+ into one, so `/effort` picks the depth, and router models such as
40
+ `devin/adaptive` are assigned per turn. A session token in `DEVIN_API_KEY`
41
+ works where there is no browser. Devin Enterprise accounts sign in through
42
+ the same command by choosing "Log in with Devin for Enterprise".
43
+
44
+ ### Fixed
45
+
46
+ - The `/login` dialog no longer sits on "Starting…" for the whole sign-in: it
47
+ now updates as the flow moves on, and keeps the instructions on screen
48
+ alongside the link, progress and paste field instead of overwriting them.
49
+
50
+ - Resizing the terminal no longer crashes the session. A window manager sends
51
+ a burst of SIGWINCH for one drag of a corner, and a signal landing while the
52
+ previous frame was still writing re-entered stdout: `RuntimeError: reentrant
53
+ call inside <_io.BufferedWriter>`, and HX was gone. The redraw is handed to
54
+ the event loop now, and the burst collapses into one repaint.
55
+
56
+ - `/resume` and `hx resume` bring the whole conversation back, not only its
57
+ text: the reasoning, tool calls, their output and the plan are drawn again.
58
+ A resumed session used to show a blank tinted bar for each tool result and
59
+ nothing at all for the calls themselves.
60
+
61
+ ---
62
+
31
63
  ## [0.2.1] - 2026-09-14
32
64
 
33
65
  ### Changed
@@ -408,7 +440,8 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
408
440
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
409
441
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
410
442
 
411
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.1...HEAD
443
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.2...HEAD
444
+ [0.2.2]: https://github.com/aletisunil/hx/compare/v0.2.1...v0.2.2
412
445
  [0.2.1]: https://github.com/aletisunil/hx/compare/v0.2.0...v0.2.1
413
446
  [0.2.0]: https://github.com/aletisunil/hx/compare/v0.1.9...v0.2.0
414
447
  [0.1.9]: https://github.com/aletisunil/hx/compare/v0.1.8...v0.1.9
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.2.1
4
- Summary: HX - a terminal coding agent
3
+ Version: 0.2.2
4
+ Summary: HX - an agent harness for the terminal
5
5
  Project-URL: Homepage, https://github.com/aletisunil/hx
6
6
  Project-URL: Issues, https://github.com/aletisunil/hx/issues
7
7
  Author: Sunil Aleti
@@ -35,8 +35,8 @@ Description-Content-Type: text/markdown
35
35
 
36
36
  # HX
37
37
 
38
- A terminal coding agent. Python core, its own terminal renderer, OpenRouter for
39
- models.
38
+ An agent harness for the terminal. Python core, its own terminal renderer,
39
+ OpenRouter for models.
40
40
 
41
41
  HX runs in your project directory, reads and edits your code, runs commands in
42
42
  a sandboxed shell, and shows you what every turn costs.
@@ -85,13 +85,14 @@ Requires Python 3.11+. macOS and Linux. Update with `hx upgrade`.
85
85
 
86
86
  ### Credentials
87
87
 
88
- HX reaches models over two routes. Which one serves a turn is decided by the
88
+ HX reaches models over three routes. Which one serves a turn is decided by the
89
89
  model id alone, so "what paid for that" is always answerable by reading it:
90
90
 
91
91
  | Model id | Route | Billing |
92
92
  |---|---|---|
93
93
  | `anthropic/claude-sonnet-4.5`, `openai/gpt-5`, … | OpenRouter | per token, API key |
94
94
  | `openai-codex/gpt-5.6-terra` | ChatGPT Plus/Pro | your subscription |
95
+ | `devin/swe-1-6`, `devin/claude-opus-5`, … | Devin | your subscription |
95
96
 
96
97
  Credentials live in `~/.hx/auth.json`, mode 0600, one entry per route.
97
98
 
@@ -185,6 +186,38 @@ They join the `/model` picker on the next start. HX cannot ask how large an
185
186
  unknown model's context window is, so it assumes a conservative 200k — that
186
187
  costs an accurate context gauge, not the use of the model.
187
188
 
189
+ **Devin.** Sign in with the same browser flow the official `devin` CLI uses,
190
+ and run every model your Devin plan includes - SWE-1.6, and whatever frontier
191
+ models the plan offers - against the subscription:
192
+
193
+ ```sh
194
+ hx auth login devin # or /login inside the TUI
195
+ hx --model devin/swe-1-6
196
+ hx auth logout devin
197
+ ```
198
+
199
+ **Devin Enterprise** signs in the same way: on the page that opens, choose
200
+ "Log in with Devin for Enterprise", enter your company, and finish through its
201
+ identity provider. Your admin has to grant the "Use Devin CLI" permission first.
202
+ Usage counts toward the organization's allocation, and an enterprise tenant
203
+ served from its own API server is followed there for both turns and the model
204
+ list.
205
+
206
+ The browser opens to `app.devin.ai` and redirects to a loopback listener on
207
+ `127.0.0.1:59653`. Over SSH, paste the final redirect URL into the prompt
208
+ instead. A Devin login does not refresh: when it expires, HX says so and asks
209
+ you to sign in again. On a machine with no browser at all, a session token in
210
+ `HX_DEVIN_API_KEY` or `DEVIN_API_KEY` is used when nothing is saved.
211
+
212
+ Signing in fetches the account's own model list, and `/model` offers exactly
213
+ that; `/models refresh` asks again. Devin publishes each reasoning depth as a
214
+ model of its own - Claude Opus 5 High, Claude Opus 5 Max - and HX folds each
215
+ family into one model, so `/effort` picks the depth on Devin exactly as it does
216
+ on Codex. A fast lane or a 1M-context lane stays a separate model, since it
217
+ changes what a turn costs or how much fits. Router models such as
218
+ `devin/adaptive` are resolved to a concrete model on every turn by Devin itself.
219
+ If the list cannot be fetched, `devin/swe-1-6` and `devin/swe-1-6-fast` stand in.
220
+
188
221
  **Web search.** Optional, and the one credential that is not a model route.
189
222
  It powers two tools:
190
223
 
@@ -270,7 +303,7 @@ hx --system-prompt @p.md # replace the system prompt for one run
270
303
  hx --append-system-prompt "Always run the tests" # add to it; repeatable
271
304
  hx mcp list|add|remove # manage MCP servers
272
305
  hx auth [set|clear] # manage the OpenRouter key
273
- hx auth login [provider] # sign in (openrouter, openai-codex)
306
+ hx auth login [provider] # sign in (openrouter, openai-codex, devin)
274
307
  hx auth logout <provider> # forget a stored credential
275
308
  hx docs [section] # the manual; no argument lists its sections
276
309
  hx changelog [version] # what shipped in each version
@@ -807,6 +840,10 @@ OPENROUTER_API_KEY=... uv run pytest -m live tests/test_live.py
807
840
  # the Codex route draws on your ChatGPT subscription instead
808
841
  hx auth login openai-codex
809
842
  uv run pytest -m live tests/test_live_codex.py
843
+
844
+ # and the Devin route on your Devin subscription
845
+ hx auth login devin
846
+ uv run pytest -m live tests/test_live_devin.py
810
847
  ```
811
848
 
812
849
  They are the only place the wire formats, streaming, tool use and a genuine
@@ -830,7 +867,7 @@ src/hx/
830
867
  cli.py config.py paths.py frontmatter.py git.py
831
868
  core/ loop, context assembly, compaction, late injection, sessions, usage
832
869
  auth/ credential store, OAuth flows, per-route resolution
833
- providers/ OpenRouter, Codex, the model catalogue, a scripted provider for tests
870
+ providers/ OpenRouter, Codex, Devin, the model catalogue, a scripted provider for tests
834
871
  tools/ Bash, Read, Write, Edit, Glob, Grep, TodoWrite, Task, WebSearch,
835
872
  WebFetch, output capping
836
873
  permissions/ rule engine, shell decomposition, Seatbelt/bubblewrap
@@ -1,7 +1,7 @@
1
1
  # HX
2
2
 
3
- A terminal coding agent. Python core, its own terminal renderer, OpenRouter for
4
- models.
3
+ An agent harness for the terminal. Python core, its own terminal renderer,
4
+ OpenRouter for models.
5
5
 
6
6
  HX runs in your project directory, reads and edits your code, runs commands in
7
7
  a sandboxed shell, and shows you what every turn costs.
@@ -50,13 +50,14 @@ Requires Python 3.11+. macOS and Linux. Update with `hx upgrade`.
50
50
 
51
51
  ### Credentials
52
52
 
53
- HX reaches models over two routes. Which one serves a turn is decided by the
53
+ HX reaches models over three routes. Which one serves a turn is decided by the
54
54
  model id alone, so "what paid for that" is always answerable by reading it:
55
55
 
56
56
  | Model id | Route | Billing |
57
57
  |---|---|---|
58
58
  | `anthropic/claude-sonnet-4.5`, `openai/gpt-5`, … | OpenRouter | per token, API key |
59
59
  | `openai-codex/gpt-5.6-terra` | ChatGPT Plus/Pro | your subscription |
60
+ | `devin/swe-1-6`, `devin/claude-opus-5`, … | Devin | your subscription |
60
61
 
61
62
  Credentials live in `~/.hx/auth.json`, mode 0600, one entry per route.
62
63
 
@@ -150,6 +151,38 @@ They join the `/model` picker on the next start. HX cannot ask how large an
150
151
  unknown model's context window is, so it assumes a conservative 200k — that
151
152
  costs an accurate context gauge, not the use of the model.
152
153
 
154
+ **Devin.** Sign in with the same browser flow the official `devin` CLI uses,
155
+ and run every model your Devin plan includes - SWE-1.6, and whatever frontier
156
+ models the plan offers - against the subscription:
157
+
158
+ ```sh
159
+ hx auth login devin # or /login inside the TUI
160
+ hx --model devin/swe-1-6
161
+ hx auth logout devin
162
+ ```
163
+
164
+ **Devin Enterprise** signs in the same way: on the page that opens, choose
165
+ "Log in with Devin for Enterprise", enter your company, and finish through its
166
+ identity provider. Your admin has to grant the "Use Devin CLI" permission first.
167
+ Usage counts toward the organization's allocation, and an enterprise tenant
168
+ served from its own API server is followed there for both turns and the model
169
+ list.
170
+
171
+ The browser opens to `app.devin.ai` and redirects to a loopback listener on
172
+ `127.0.0.1:59653`. Over SSH, paste the final redirect URL into the prompt
173
+ instead. A Devin login does not refresh: when it expires, HX says so and asks
174
+ you to sign in again. On a machine with no browser at all, a session token in
175
+ `HX_DEVIN_API_KEY` or `DEVIN_API_KEY` is used when nothing is saved.
176
+
177
+ Signing in fetches the account's own model list, and `/model` offers exactly
178
+ that; `/models refresh` asks again. Devin publishes each reasoning depth as a
179
+ model of its own - Claude Opus 5 High, Claude Opus 5 Max - and HX folds each
180
+ family into one model, so `/effort` picks the depth on Devin exactly as it does
181
+ on Codex. A fast lane or a 1M-context lane stays a separate model, since it
182
+ changes what a turn costs or how much fits. Router models such as
183
+ `devin/adaptive` are resolved to a concrete model on every turn by Devin itself.
184
+ If the list cannot be fetched, `devin/swe-1-6` and `devin/swe-1-6-fast` stand in.
185
+
153
186
  **Web search.** Optional, and the one credential that is not a model route.
154
187
  It powers two tools:
155
188
 
@@ -235,7 +268,7 @@ hx --system-prompt @p.md # replace the system prompt for one run
235
268
  hx --append-system-prompt "Always run the tests" # add to it; repeatable
236
269
  hx mcp list|add|remove # manage MCP servers
237
270
  hx auth [set|clear] # manage the OpenRouter key
238
- hx auth login [provider] # sign in (openrouter, openai-codex)
271
+ hx auth login [provider] # sign in (openrouter, openai-codex, devin)
239
272
  hx auth logout <provider> # forget a stored credential
240
273
  hx docs [section] # the manual; no argument lists its sections
241
274
  hx changelog [version] # what shipped in each version
@@ -772,6 +805,10 @@ OPENROUTER_API_KEY=... uv run pytest -m live tests/test_live.py
772
805
  # the Codex route draws on your ChatGPT subscription instead
773
806
  hx auth login openai-codex
774
807
  uv run pytest -m live tests/test_live_codex.py
808
+
809
+ # and the Devin route on your Devin subscription
810
+ hx auth login devin
811
+ uv run pytest -m live tests/test_live_devin.py
775
812
  ```
776
813
 
777
814
  They are the only place the wire formats, streaming, tool use and a genuine
@@ -795,7 +832,7 @@ src/hx/
795
832
  cli.py config.py paths.py frontmatter.py git.py
796
833
  core/ loop, context assembly, compaction, late injection, sessions, usage
797
834
  auth/ credential store, OAuth flows, per-route resolution
798
- providers/ OpenRouter, Codex, the model catalogue, a scripted provider for tests
835
+ providers/ OpenRouter, Codex, Devin, the model catalogue, a scripted provider for tests
799
836
  tools/ Bash, Read, Write, Edit, Glob, Grep, TodoWrite, Task, WebSearch,
800
837
  WebFetch, output capping
801
838
  permissions/ rule engine, shell decomposition, Seatbelt/bubblewrap
@@ -129,7 +129,7 @@ stream_text() {
129
129
  banner() {
130
130
  animate_logo
131
131
  printf '\n'
132
- stream_text 'HX is a terminal coding agent that works inside your project.'
132
+ stream_text 'HX is an agent harness that works inside your project.'
133
133
  stream_text 'It reads and edits files, runs commands in a sandbox, and handles larger tasks with persistent sessions, skills, tools, and subagents.'
134
134
  stream_text 'Connect to models through OpenRouter and see context, cost, and usage for every turn.'
135
135
  printf ' Questions or issues? Contact \033[1;33m%s\033[0m\n\n' "$HX_SUPPORT_EMAIL"
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
  [project]
6
6
  name = "hx-cli"
7
7
  dynamic = ["version"]
8
- description = "HX - a terminal coding agent"
8
+ description = "HX - an agent harness for the terminal"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
  license = { text = "MIT" }
@@ -0,0 +1,5 @@
1
+ """HX - an agent harness for the terminal."""
2
+
3
+ __version__ = "0.2.2"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,133 @@
1
+ """The browser half of an authorization-code sign-in, shared by every route.
2
+
3
+ A route supplies the authorize URL and the loopback port it registered; this
4
+ opens the browser, listens for the redirect, and races it against a paste
5
+ prompt. What the route does with the code - which token endpoint, which body -
6
+ stays in the route's own module.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import webbrowser
13
+ from typing import Protocol
14
+
15
+ from hx.auth.oauth.callback import CallbackError, CallbackResult, LoopbackCallback, parse_redirect
16
+
17
+
18
+ class OAuthError(Exception):
19
+ """A sign-in that could not complete. The message is shown as-is."""
20
+
21
+
22
+ class LoginInteraction(Protocol):
23
+ """How a login flow talks to whoever started it (CLI prompt or TUI modal)."""
24
+
25
+ def show_url(self, url: str, instructions: str) -> None: ...
26
+
27
+ def show_device_code(self, user_code: str, verification_uri: str) -> None: ...
28
+
29
+ def progress(self, message: str) -> None: ...
30
+
31
+ async def prompt_paste(self, message: str) -> str:
32
+ """Return a pasted redirect URL or code.
33
+
34
+ May never return - the browser callback usually wins the race - so
35
+ implementations must tolerate cancellation.
36
+ """
37
+ ...
38
+
39
+
40
+ async def authorize_in_browser(
41
+ url: str,
42
+ *,
43
+ port: int,
44
+ path: str,
45
+ state: str,
46
+ interaction: LoginInteraction,
47
+ instructions: str = "",
48
+ ) -> CallbackResult:
49
+ """Send the user to ``url`` and return the code the provider hands back.
50
+
51
+ The callback server and the paste prompt race each other: over SSH the
52
+ browser opens on the wrong machine and can never reach the loopback port,
53
+ so pasting the final redirect URL has to work just as well.
54
+
55
+ ``instructions`` is anything route-specific the user needs to know about
56
+ the page they are about to see, said before the generic part.
57
+
58
+ Raises:
59
+ OAuthError: when the returned state is not the one sent.
60
+ CallbackError: when the provider reports a failure or nothing usable
61
+ was pasted.
62
+ """
63
+ callback = LoopbackCallback(port, path, state=state)
64
+ callback.start()
65
+ try:
66
+ interaction.show_url(
67
+ url,
68
+ (f"{instructions} " if instructions else "")
69
+ + "Complete the sign-in in your browser. On a remote machine, paste the "
70
+ "final redirect URL here instead.",
71
+ )
72
+ if not callback.listening:
73
+ interaction.progress(
74
+ f"Port {port} is busy, so the browser cannot hand the code back. "
75
+ "Paste the redirect URL here instead."
76
+ )
77
+ elif not await open_browser(url):
78
+ interaction.progress("Could not open a browser - open the URL above manually.")
79
+
80
+ result = await _race_callback_and_paste(callback, interaction)
81
+ finally:
82
+ try:
83
+ await callback.aclose()
84
+ except asyncio.CancelledError:
85
+ # Cancelled mid-teardown: finish the job on this thread rather than
86
+ # leaving the port bound for the life of the process, which would
87
+ # cost the *next* login its browser callback.
88
+ callback.close()
89
+ raise
90
+
91
+ if result.state is not None and result.state != state:
92
+ raise OAuthError("OAuth state mismatch - discard this login and try again.")
93
+ return result
94
+
95
+
96
+ async def _race_callback_and_paste(
97
+ callback: LoopbackCallback,
98
+ interaction: LoginInteraction,
99
+ ) -> CallbackResult:
100
+ paste_task = asyncio.ensure_future(
101
+ interaction.prompt_paste("Paste the redirect URL or authorization code:")
102
+ )
103
+ wait_task = asyncio.ensure_future(callback.wait())
104
+ try:
105
+ done, _ = await asyncio.wait({paste_task, wait_task}, return_when=asyncio.FIRST_COMPLETED)
106
+ # Prefer the loopback result: it is the one whose state we validated.
107
+ if wait_task in done and not wait_task.cancelled():
108
+ exc = wait_task.exception()
109
+ if exc is None:
110
+ return wait_task.result()
111
+ if paste_task not in done:
112
+ raise exc
113
+ if paste_task in done:
114
+ return parse_redirect(paste_task.result())
115
+ raise CallbackError("Login did not complete.")
116
+ finally:
117
+ for task in (paste_task, wait_task):
118
+ if not task.done():
119
+ task.cancel()
120
+
121
+
122
+ async def open_browser(url: str) -> bool:
123
+ """Open the URL without stalling the caller's event loop.
124
+
125
+ ``webbrowser.open`` is not a quick handoff: on macOS it writes AppleScript
126
+ to ``osascript`` and waits for it, which takes as long as the browser takes
127
+ to come up. On the event loop that freezes the whole TUI - including the
128
+ Escape that cancels the login and the field the user is meant to paste into.
129
+ """
130
+ try:
131
+ return await asyncio.to_thread(webbrowser.open, url)
132
+ except webbrowser.Error:
133
+ return False
@@ -21,13 +21,12 @@ import base64
21
21
  import binascii
22
22
  import json
23
23
  import time
24
- import webbrowser
25
- from typing import Any, Protocol
24
+ from typing import Any
26
25
  from urllib.parse import urlencode
27
26
 
28
27
  import httpx
29
28
 
30
- from hx.auth.oauth.callback import CallbackError, CallbackResult, LoopbackCallback, parse_redirect
29
+ from hx.auth.oauth.browser import LoginInteraction, OAuthError, authorize_in_browser
31
30
  from hx.auth.oauth.pkce import PKCE, generate_pkce, random_state
32
31
  from hx.auth.store import OAuthCredential
33
32
  from hx.net import async_client
@@ -65,28 +64,6 @@ ORIGINATOR = "hx"
65
64
  HTTP_TIMEOUT = 30.0
66
65
 
67
66
 
68
- class OAuthError(Exception):
69
- pass
70
-
71
-
72
- class LoginInteraction(Protocol):
73
- """How a login flow talks to whoever started it (CLI prompt or TUI modal)."""
74
-
75
- def show_url(self, url: str, instructions: str) -> None: ...
76
-
77
- def show_device_code(self, user_code: str, verification_uri: str) -> None: ...
78
-
79
- def progress(self, message: str) -> None: ...
80
-
81
- async def prompt_paste(self, message: str) -> str:
82
- """Return a pasted redirect URL or code.
83
-
84
- May never return - the browser callback usually wins the race - so
85
- implementations must tolerate cancellation.
86
- """
87
- ...
88
-
89
-
90
67
  def authorize_url(pkce: PKCE, state: str, *, originator: str = ORIGINATOR) -> str:
91
68
  params = {
92
69
  "response_type": "code",
@@ -250,90 +227,20 @@ async def refresh(credential: OAuthCredential) -> OAuthCredential:
250
227
 
251
228
 
252
229
  async def login_browser(interaction: LoginInteraction) -> OAuthCredential:
253
- """PKCE flow through the local browser, with a paste fallback.
254
-
255
- The callback server and the paste prompt race each other: over SSH the
256
- browser opens on the wrong machine and can never reach the loopback port,
257
- so pasting the final redirect URL has to work just as well.
258
- """
230
+ """PKCE flow through the local browser, with a paste fallback."""
259
231
  pkce = generate_pkce()
260
232
  state = random_state()
261
- url = authorize_url(pkce, state)
262
-
263
- callback = LoopbackCallback(CALLBACK_PORT, CALLBACK_PATH, state=state)
264
- callback.start()
265
- try:
266
- interaction.show_url(
267
- url,
268
- "Complete the sign-in in your browser. On a remote machine, paste the "
269
- "final redirect URL here instead.",
270
- )
271
- if not callback.listening:
272
- interaction.progress(
273
- f"Port {CALLBACK_PORT} is busy, so the browser cannot hand the code back. "
274
- "Paste the redirect URL here instead."
275
- )
276
- elif not await _open_browser(url):
277
- interaction.progress("Could not open a browser - open the URL above manually.")
278
-
279
- result = await _race_callback_and_paste(callback, interaction)
280
- finally:
281
- try:
282
- await callback.aclose()
283
- except asyncio.CancelledError:
284
- # Cancelled mid-teardown: finish the job on this thread rather than
285
- # leaving the port bound for the life of the process, which would
286
- # cost the *next* login its browser callback.
287
- callback.close()
288
- raise
289
-
290
- if result.state is not None and result.state != state:
291
- raise OAuthError("OAuth state mismatch - discard this login and try again.")
292
-
233
+ result = await authorize_in_browser(
234
+ authorize_url(pkce, state),
235
+ port=CALLBACK_PORT,
236
+ path=CALLBACK_PATH,
237
+ state=state,
238
+ interaction=interaction,
239
+ )
293
240
  interaction.progress("Exchanging the authorization code…")
294
241
  return await _exchange(result.code, pkce.verifier, REDIRECT_URI)
295
242
 
296
243
 
297
- async def _race_callback_and_paste(
298
- callback: LoopbackCallback,
299
- interaction: LoginInteraction,
300
- ) -> CallbackResult:
301
- paste_task = asyncio.ensure_future(
302
- interaction.prompt_paste("Paste the redirect URL or authorization code:")
303
- )
304
- wait_task = asyncio.ensure_future(callback.wait())
305
- try:
306
- done, _ = await asyncio.wait({paste_task, wait_task}, return_when=asyncio.FIRST_COMPLETED)
307
- # Prefer the loopback result: it is the one whose state we validated.
308
- if wait_task in done and not wait_task.cancelled():
309
- exc = wait_task.exception()
310
- if exc is None:
311
- return wait_task.result()
312
- if paste_task not in done:
313
- raise exc
314
- if paste_task in done:
315
- return parse_redirect(paste_task.result())
316
- raise CallbackError("Login did not complete.")
317
- finally:
318
- for task in (paste_task, wait_task):
319
- if not task.done():
320
- task.cancel()
321
-
322
-
323
- async def _open_browser(url: str) -> bool:
324
- """Open the URL without stalling the caller's event loop.
325
-
326
- ``webbrowser.open`` is not a quick handoff: on macOS it writes AppleScript
327
- to ``osascript`` and waits for it, which takes as long as the browser takes
328
- to come up. On the event loop that freezes the whole TUI - including the
329
- Escape that cancels the login and the field the user is meant to paste into.
330
- """
331
- try:
332
- return await asyncio.to_thread(webbrowser.open, url)
333
- except webbrowser.Error:
334
- return False
335
-
336
-
337
244
  async def login_device_code(interaction: LoginInteraction) -> OAuthCredential:
338
245
  """Headless flow: show a short code, poll until the user enters it elsewhere."""
339
246
  async with async_client(timeout=HTTP_TIMEOUT) as client: