hx-cli 0.2.0__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.
- {hx_cli-0.2.0 → hx_cli-0.2.2}/CHANGELOG.md +53 -1
- {hx_cli-0.2.0 → hx_cli-0.2.2}/PKG-INFO +44 -7
- {hx_cli-0.2.0 → hx_cli-0.2.2}/README.md +42 -5
- {hx_cli-0.2.0 → hx_cli-0.2.2}/install.sh +1 -1
- {hx_cli-0.2.0 → hx_cli-0.2.2}/pyproject.toml +1 -1
- hx_cli-0.2.2/src/hx/__init__.py +5 -0
- hx_cli-0.2.2/src/hx/auth/oauth/browser.py +133 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/auth/oauth/codex.py +10 -103
- hx_cli-0.2.2/src/hx/auth/oauth/devin.py +169 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/auth/resolve.py +8 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/cli.py +33 -15
- hx_cli-0.2.2/src/hx/providers/devin.py +589 -0
- hx_cli-0.2.2/src/hx/providers/devin_catalogue.py +301 -0
- hx_cli-0.2.2/src/hx/providers/devin_wire.py +512 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/providers/models.py +205 -92
- hx_cli-0.2.2/src/hx/providers/protowire.py +225 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/providers/registry.py +71 -2
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/loop.py +46 -5
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/markdown.py +85 -6
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/primitives.py +3 -2
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/terminal.py +82 -8
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/commands.py +15 -14
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/paint.py +14 -3
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/roles.py +1 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/runtime.py +90 -6
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/theme_json.py +6 -1
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/themes/ansi.json +1 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/themes/dark.json +3 -1
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/themes/light.json +3 -1
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/views/blocks.py +23 -6
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/views/login.py +50 -29
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/views/status.py +1 -3
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/auth/test_codex_oauth.py +4 -4
- hx_cli-0.2.2/tests/auth/test_devin_oauth.py +119 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/conftest.py +8 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/providers/test_catalogue.py +10 -6
- hx_cli-0.2.2/tests/providers/test_devin.py +453 -0
- hx_cli-0.2.2/tests/providers/test_devin_catalogue.py +206 -0
- hx_cli-0.2.2/tests/providers/test_protowire.py +100 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/providers/test_registry.py +35 -2
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/term/test_loop.py +75 -1
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/term/test_markdown.py +62 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/term/test_terminal.py +86 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/test_cli.py +1 -1
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/test_install.py +1 -1
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/test_live_codex.py +8 -2
- hx_cli-0.2.2/tests/test_live_devin.py +130 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/test_login.py +58 -1
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/views/test_blocks.py +17 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/views/test_runtime.py +77 -2
- hx_cli-0.2.0/src/hx/__init__.py +0 -5
- {hx_cli-0.2.0 → hx_cli-0.2.2}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/.gitignore +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/config.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/core/context.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/core/loop.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/core/messages.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/docs.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/git.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/keys.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/net.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/paths.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/permissions/engine.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/permissions/parser.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/providers/fake.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/ansi.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tools/bash.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tools/edit.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tools/glob.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tools/grep.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tools/output.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tools/read.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tools/task.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tools/websearch.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tools/write.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/glyphs.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/renderers.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/views/pickers.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/agents/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/agents/test_subagent.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/auth/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/auth/test_pkce.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/auth/test_resolve.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/auth/test_store.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/core/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/core/test_checkpoints.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/core/test_compaction.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/core/test_context.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/core/test_lateinject.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/core/test_loop.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/core/test_rewind.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/core/test_session.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/core/test_settings_layers.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/core/test_settings_migration.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/core/test_steering.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/core/test_title.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/core/test_usage.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/mcp/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/mcp/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/mcp/test_manager.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/permissions/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/permissions/test_engine.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/permissions/test_parser.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/permissions/test_sandbox.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/providers/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/providers/test_codex.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/providers/test_codex_catalogue.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/providers/test_openrouter.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/providers/test_responses_codec.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/skills/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/skills/test_loader.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/term/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/term/conftest.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/term/test_ansi.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/term/test_keydecode.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/term/test_primitives.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/term/test_sanitize.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/term/test_screen.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/term/test_width.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/test_docs.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/test_git.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/test_integration.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/test_live.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/test_net.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/test_paths.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/test_readme.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tools/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tools/test_background.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tools/test_bash.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tools/test_files.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tools/test_output.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tools/test_todo.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tools/test_websearch.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/support.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/test_autocomplete.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/test_clipboard.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/test_effort.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/test_keys.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/test_renderers.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/test_session.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/test_theme_json.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/views/__init__.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/views/test_permission.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/views/test_pickers.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/views/test_prompt.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/views/test_status.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/tests/tui/views/test_transcript.py +0 -0
- {hx_cli-0.2.0 → hx_cli-0.2.2}/uv.lock +0 -0
|
@@ -28,6 +28,56 @@ 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
|
+
|
|
63
|
+
## [0.2.1] - 2026-09-14
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- Code blocks in a reply are drawn as a block - the code on a band of its own
|
|
68
|
+
colour, syntax highlighted, with the language named at the top - instead of
|
|
69
|
+
printing the ``` fences back out as text. Themes set the band with the new
|
|
70
|
+
`mdCodeBlockBg` role, and a theme that leaves colour to the terminal, as
|
|
71
|
+
`ansi` does, gets a rule above and below instead.
|
|
72
|
+
|
|
73
|
+
### Fixed
|
|
74
|
+
|
|
75
|
+
- The model's reasoning is shown again. It was collapsed to a bare `Thinking…`
|
|
76
|
+
with no key wired to open it, so the text never reached the screen. `ctrl+o`
|
|
77
|
+
now folds and unfolds it, the same key that expands a tool call.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
31
81
|
## [0.2.0] - 2026-09-14
|
|
32
82
|
|
|
33
83
|
### Changed
|
|
@@ -390,7 +440,9 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
|
|
|
390
440
|
- `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
|
|
391
441
|
self-update, and `install.sh` bootstrapping uv with a pinned Python.
|
|
392
442
|
|
|
393
|
-
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.
|
|
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
|
|
445
|
+
[0.2.1]: https://github.com/aletisunil/hx/compare/v0.2.0...v0.2.1
|
|
394
446
|
[0.2.0]: https://github.com/aletisunil/hx/compare/v0.1.9...v0.2.0
|
|
395
447
|
[0.1.9]: https://github.com/aletisunil/hx/compare/v0.1.8...v0.1.9
|
|
396
448
|
[0.1.8]: https://github.com/aletisunil/hx/compare/v0.1.7...v0.1.8
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hx-cli
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary: HX -
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 -
|
|
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,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
|
|
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.
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
interaction
|
|
267
|
-
|
|
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:
|