hx-cli 0.2.16__tar.gz → 0.2.18__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. {hx_cli-0.2.16 → hx_cli-0.2.18}/CHANGELOG.md +68 -1
  2. {hx_cli-0.2.16 → hx_cli-0.2.18}/PKG-INFO +4 -1
  3. {hx_cli-0.2.16 → hx_cli-0.2.18}/README.md +3 -0
  4. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/__init__.py +1 -1
  5. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/cli.py +5 -2
  6. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/loop.py +37 -13
  7. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/mcp/client.py +2 -5
  8. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/permissions/engine.py +37 -5
  9. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/permissions/parser.py +76 -10
  10. hx_cli-0.2.18/src/hx/text.py +12 -0
  11. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/anchors.py +3 -4
  12. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/bash.py +65 -7
  13. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/edit.py +154 -40
  14. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/glob.py +4 -2
  15. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/grep.py +3 -2
  16. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/output.py +2 -1
  17. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/read.py +18 -4
  18. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/symbols.py +4 -3
  19. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/task.py +2 -1
  20. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/websearch.py +2 -1
  21. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/write.py +66 -6
  22. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/commands.py +9 -8
  23. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/renderers.py +26 -14
  24. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/blocks.py +13 -1
  25. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/permission.py +15 -0
  26. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/pickers.py +3 -3
  27. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/status.py +42 -7
  28. hx_cli-0.2.18/tests/e2e/test_edits.py +302 -0
  29. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_print.py +1 -0
  30. hx_cli-0.2.18/tests/e2e/test_shell.py +184 -0
  31. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_tools.py +85 -1
  32. {hx_cli-0.2.16 → hx_cli-0.2.18}/.github/workflows/ci.yml +0 -0
  33. {hx_cli-0.2.16 → hx_cli-0.2.18}/.gitignore +0 -0
  34. {hx_cli-0.2.16 → hx_cli-0.2.18}/examples/hooks/check.sh +0 -0
  35. {hx_cli-0.2.16 → hx_cli-0.2.18}/install.sh +0 -0
  36. {hx_cli-0.2.16 → hx_cli-0.2.18}/pyproject.toml +0 -0
  37. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/agents/__init__.py +0 -0
  38. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/agents/definitions.py +0 -0
  39. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/agents/subagent.py +0 -0
  40. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/__init__.py +0 -0
  41. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/claude_code.py +0 -0
  42. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/oauth/__init__.py +0 -0
  43. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/oauth/browser.py +0 -0
  44. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/oauth/callback.py +0 -0
  45. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/oauth/codex.py +0 -0
  46. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/oauth/devin.py +0 -0
  47. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/oauth/pkce.py +0 -0
  48. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/resolve.py +0 -0
  49. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/store.py +0 -0
  50. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/config.py +0 -0
  51. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/__init__.py +0 -0
  52. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/checkpoints.py +0 -0
  53. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/compaction.py +0 -0
  54. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/context.py +0 -0
  55. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/events.py +0 -0
  56. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/images.py +0 -0
  57. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/lateinject.py +0 -0
  58. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/messages.py +0 -0
  59. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/session.py +0 -0
  60. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/title.py +0 -0
  61. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/usage.py +0 -0
  62. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/docs.py +0 -0
  63. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/frontmatter.py +0 -0
  64. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/git.py +0 -0
  65. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/hooks/__init__.py +0 -0
  66. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/hooks/engine.py +0 -0
  67. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/hooks/spec.py +0 -0
  68. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/keys.py +0 -0
  69. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/mcp/__init__.py +0 -0
  70. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/mcp/manager.py +0 -0
  71. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/mcp/oauth.py +0 -0
  72. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/net.py +0 -0
  73. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/paths.py +0 -0
  74. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/permissions/__init__.py +0 -0
  75. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/permissions/sandbox.py +0 -0
  76. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/__init__.py +0 -0
  77. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/base.py +0 -0
  78. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/claude_code.py +0 -0
  79. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/claude_code_catalogue.py +0 -0
  80. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/claude_code_cli.py +0 -0
  81. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/claude_code_transcript.py +0 -0
  82. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/codex.py +0 -0
  83. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/codex_catalogue.py +0 -0
  84. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/devin.py +0 -0
  85. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/devin_catalogue.py +0 -0
  86. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/devin_wire.py +0 -0
  87. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/models.py +0 -0
  88. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/openrouter.py +0 -0
  89. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/protowire.py +0 -0
  90. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/registry.py +0 -0
  91. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/responses_codec.py +0 -0
  92. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/skills/__init__.py +0 -0
  93. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/skills/loader.py +0 -0
  94. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/skills/runtime.py +0 -0
  95. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/__init__.py +0 -0
  96. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/ansi.py +0 -0
  97. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/buffer.py +0 -0
  98. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/component.py +0 -0
  99. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/demo.py +0 -0
  100. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/editor.py +0 -0
  101. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/keydecode.py +0 -0
  102. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/loop.py +0 -0
  103. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/markdown.py +0 -0
  104. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/modifiers.py +0 -0
  105. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/primitives.py +0 -0
  106. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/sanitize.py +0 -0
  107. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/screen.py +0 -0
  108. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/syntax.py +0 -0
  109. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/terminal.py +0 -0
  110. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/undo.py +0 -0
  111. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/width.py +0 -0
  112. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/word_nav.py +0 -0
  113. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/__init__.py +0 -0
  114. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/base.py +0 -0
  115. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/registry.py +0 -0
  116. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/todo.py +0 -0
  117. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/trace.py +0 -0
  118. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/__init__.py +0 -0
  119. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/app.py +0 -0
  120. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/clipboard.py +0 -0
  121. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/format.py +0 -0
  122. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/fuzzy.py +0 -0
  123. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/glyphs.py +0 -0
  124. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/killring.py +0 -0
  125. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/limits.py +0 -0
  126. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/paint.py +0 -0
  127. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/roles.py +0 -0
  128. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/runtime.py +0 -0
  129. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/theme.py +0 -0
  130. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/theme_json.py +0 -0
  131. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/themes/ansi.json +0 -0
  132. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/themes/dark.json +0 -0
  133. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/themes/light.json +0 -0
  134. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/__init__.py +0 -0
  135. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/dialog.py +0 -0
  136. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/login.py +0 -0
  137. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/prompt.py +0 -0
  138. {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/transcript.py +0 -0
  139. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/__init__.py +0 -0
  140. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/conftest.py +0 -0
  141. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/__init__.py +0 -0
  142. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/conftest.py +0 -0
  143. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/fake_claude.py +0 -0
  144. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/fixtures/echo_server.py +0 -0
  145. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/report.py +0 -0
  146. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/stub.py +0 -0
  147. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/terminal.py +0 -0
  148. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_chat.py +0 -0
  149. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_claude_code.py +0 -0
  150. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_cli.py +0 -0
  151. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_commands.py +0 -0
  152. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_extend.py +0 -0
  153. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_input.py +0 -0
  154. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_queue.py +0 -0
  155. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/test_live.py +0 -0
  156. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/test_live_claude_code.py +0 -0
  157. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/test_live_codex.py +0 -0
  158. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/test_live_devin.py +0 -0
  159. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/test_live_tavily.py +0 -0
  160. {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/tls_proxy.py +0 -0
  161. {hx_cli-0.2.16 → hx_cli-0.2.18}/uv.lock +0 -0
@@ -28,6 +28,71 @@ the project follows [semantic versioning](https://semver.org/).
28
28
 
29
29
  ---
30
30
 
31
+ ## [0.2.18] - 2026-10-18
32
+
33
+ ### Security
34
+
35
+ - Edit and Write no longer leave a private file's new content readable by other users while
36
+ they write it. The temporary file now gets the file's own mode before any content goes in.
37
+
38
+ ### Fixed
39
+
40
+ - An edit through a symlink says which file it changes. The approval reads "config.yml is a
41
+ link to ~/shared/config.yml", and so does the answered approval and the result the model
42
+ gets. A link into a missing directory is refused in words, not with a raw error.
43
+ - The transcript names a symlink by its own name, not by the name of its target.
44
+ - A failed tool's error wraps in the transcript instead of being cut at the window's edge, so
45
+ a reason that comes after a long path stays readable.
46
+ - An edit to a CRLF file that differs only in line endings is refused as identical. It used to
47
+ be reported as applied, with nothing changed.
48
+ - Counts of one read in the singular when a limit leaves something out: "1 more line",
49
+ "1 more match", "1 more result". So do the other counts that still had "1 lines" forms.
50
+
51
+ ---
52
+
53
+ ## [0.2.17] - 2026-10-18
54
+
55
+ ### Security
56
+
57
+ - The API keys HX itself signs in with (`HX_OPENROUTER_API_KEY`, `OPENROUTER_API_KEY`, and the
58
+ Tavily and Devin keys) are no longer passed to the commands it runs. An auto-approved `env`
59
+ used to put them straight into the model's context.
60
+ - `xxd -r`, `xxd in out`, `uniq in out` and `sort --compress-program` now ask before they run.
61
+ They write files or run a program, but they were auto-approved as read-only commands.
62
+ - A long option cut short now counts as the full option, as getopt_long and git read it.
63
+ `sort --compress=sh`, `sort --out=f`, `git grep --open-files-in=sh` and `git branch --del`
64
+ were auto-approved as read-only. So were `xxd in -p` and macOS `uniq in -c`, which write a
65
+ file named after the flag.
66
+
67
+ ### Fixed
68
+
69
+ - A command that reads stdin (`cat`, `read`, `ssh host ls`) no longer breaks the shell. It used
70
+ to swallow every later command, which then reported success without running. Commands now
71
+ get end-of-file on stdin, and a command with an unclosed quote fails on its own.
72
+ - Background jobs no longer read from the terminal, so a dev server cannot take keystrokes
73
+ from the prompt.
74
+ - Background jobs get `HX=1` and `TERM=dumb`, the same as other commands. A tool that colours
75
+ its output for a real terminal used to write escape codes into the job's log.
76
+ - Non-ASCII command output no longer shows `�` where a read split a character.
77
+ - An edit to a CRLF file keeps CRLF on every line. Each edit used to rewrite the whole file
78
+ with LF, and the diff did not show it. `old_string` with plain newlines now matches there.
79
+ - A hashline replacement that ends with a newline no longer adds a blank line after it.
80
+ - Edit and Write keep a file's mode, so a script stays executable, and write through a
81
+ symlink instead of replacing it with a regular file.
82
+ - With `--cwd`, or `hx resume` from another directory, the approval shows the diff of the
83
+ file the edit will change. It used to read a relative path from the shell's directory.
84
+ - An edit's `+N -M` and its diff now include changed lines that start with `--` or `++`,
85
+ such as SQL comments.
86
+ - "Always allow" on a command HX cannot split into parts, such as a heredoc with an
87
+ apostrophe, is now honoured in later sessions instead of asking again.
88
+ - Counts of one read in the singular: "read 1 line", "1 result", "1 match", "1 tool".
89
+ - Write reports the number of lines the way Read numbers them. A file ending in a newline
90
+ was reported one line longer.
91
+ - The status bar shortens a long project path from its start, so the project's name and
92
+ branch stay visible. Before the first request, the cache field reads "no cache".
93
+
94
+ ---
95
+
31
96
  ## [0.2.16] - 2026-10-13
32
97
 
33
98
  ### Fixed
@@ -915,7 +980,9 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
915
980
  - `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
916
981
  self-update, and `install.sh` bootstrapping uv with a pinned Python.
917
982
 
918
- [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.16...HEAD
983
+ [Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.18...HEAD
984
+ [0.2.18]: https://github.com/aletisunil/hx/compare/v0.2.17...v0.2.18
985
+ [0.2.17]: https://github.com/aletisunil/hx/compare/v0.2.16...v0.2.17
919
986
  [0.2.16]: https://github.com/aletisunil/hx/compare/v0.2.15...v0.2.16
920
987
  [0.2.15]: https://github.com/aletisunil/hx/compare/v0.2.14...v0.2.15
921
988
  [0.2.14]: https://github.com/aletisunil/hx/compare/v0.2.13...v0.2.14
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hx-cli
3
- Version: 0.2.16
3
+ Version: 0.2.18
4
4
  Summary: HX - an agent harness for the terminal
5
5
  Project-URL: Homepage, https://hx.sunilaleti.dev
6
6
  Project-URL: Documentation, https://hx.sunilaleti.dev
@@ -871,6 +871,9 @@ temp dir, credential paths (`~/.ssh`, `~/.aws`, and HX's own `auth.json`) are
871
871
  unreadable and unwritable - even inside the project, and even before they
872
872
  exist - and outbound network is off. If neither backend is present the
873
873
  status bar says `no-sandbox` rather than implying protection that is not there.
874
+ The API keys HX signs in with (`HX_OPENROUTER_API_KEY`, `OPENROUTER_API_KEY`,
875
+ and the Tavily and Devin keys) are removed from the environment of every
876
+ command it runs.
874
877
 
875
878
  Modes cycle with shift+tab: `plan` (read-only — mutating tools are not even
876
879
  offered to the model), `default`, `acceptEdits`, `bypass`.
@@ -830,6 +830,9 @@ temp dir, credential paths (`~/.ssh`, `~/.aws`, and HX's own `auth.json`) are
830
830
  unreadable and unwritable - even inside the project, and even before they
831
831
  exist - and outbound network is off. If neither backend is present the
832
832
  status bar says `no-sandbox` rather than implying protection that is not there.
833
+ The API keys HX signs in with (`HX_OPENROUTER_API_KEY`, `OPENROUTER_API_KEY`,
834
+ and the Tavily and Devin keys) are removed from the environment of every
835
+ command it runs.
833
836
 
834
837
  Modes cycle with shift+tab: `plan` (read-only — mutating tools are not even
835
838
  offered to the model), `default`, `acceptEdits`, `bypass`.
@@ -1,5 +1,5 @@
1
1
  """HX - an agent harness for the terminal."""
2
2
 
3
- __version__ = "0.2.16"
3
+ __version__ = "0.2.18"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -27,6 +27,7 @@ from typing import Any
27
27
 
28
28
  from hx import __version__
29
29
  from hx.paths import auth_file
30
+ from hx.text import count
30
31
 
31
32
  USAGE = """\
32
33
  hx - an agent harness for the terminal
@@ -1033,7 +1034,9 @@ def run_mcp_command(args: list[str]) -> int:
1033
1034
  print(f"{status.name:<20} {status.error} - run `hx mcp login {status.name}`")
1034
1035
  continue
1035
1036
  mark = "ok" if status.connected else "unavailable"
1036
- detail = f" - {status.error}" if status.error else f" ({status.tool_count} tools)"
1037
+ detail = (
1038
+ f" - {status.error}" if status.error else f" ({count(status.tool_count, 'tool')})"
1039
+ )
1037
1040
  print(f"{status.name:<20} {mark}{detail}")
1038
1041
  return 0
1039
1042
 
@@ -1118,7 +1121,7 @@ def _mcp_login(config: Any) -> int:
1118
1121
 
1119
1122
  status = asyncio.run(_probe_servers([config]))[0]
1120
1123
  if status.connected:
1121
- print(f"Signed in to {config.name}: {status.tool_count} tools available.")
1124
+ print(f"Signed in to {config.name}: {count(status.tool_count, 'tool')} available.")
1122
1125
  return 0
1123
1126
  print(f"Signed in, but {config.name} is still unavailable: {status.error}", file=sys.stderr)
1124
1127
  return 1
@@ -52,10 +52,12 @@ from hx.core.session import Environment
52
52
  from hx.core.usage import TurnUsage, compute_cost
53
53
  from hx.hooks.spec import HookOutcome
54
54
  from hx.providers.base import ProviderError, ProviderRequest, StreamDelta, StreamEnd
55
+ from hx.text import count
55
56
  from hx.tools.base import ToolContext, ToolResult
56
57
 
57
58
  if TYPE_CHECKING:
58
59
  from collections.abc import Awaitable, Callable, Sequence
60
+ from pathlib import Path
59
61
 
60
62
  from hx.config import Settings
61
63
  from hx.core.compaction import Compactor
@@ -877,7 +879,7 @@ class AgentLoop:
877
879
  from hx.permissions.engine import PermissionRequest
878
880
 
879
881
  tool = self.tools.get(call.name)
880
- detail, detail_kind = _permission_detail(call)
882
+ detail, detail_kind = _permission_detail(call, self.settings.cwd)
881
883
  request = PermissionRequest(
882
884
  tool_name=call.name,
883
885
  specifier=tool.permission_specifier(call.input),
@@ -887,6 +889,7 @@ class AgentLoop:
887
889
  description=f"{call.name}({_brief(call.input)})",
888
890
  detail=detail,
889
891
  detail_kind=detail_kind,
892
+ link_target=_link_target(call, self.settings.cwd),
890
893
  origin=self.origin,
891
894
  tool_use_id=call.id,
892
895
  )
@@ -1032,7 +1035,7 @@ def _parse_tool_input(raw: str | None) -> dict[str, Any]:
1032
1035
  return parsed if isinstance(parsed, dict) else {}
1033
1036
 
1034
1037
 
1035
- def _permission_detail(call: ToolUseBlock) -> tuple[str, str]:
1038
+ def _permission_detail(call: ToolUseBlock, cwd: Path) -> tuple[str, str]:
1036
1039
  """What the approval shows, and what kind of thing it is.
1037
1040
 
1038
1041
  For an edit this previews the change against the file on disk, so the user
@@ -1042,18 +1045,29 @@ def _permission_detail(call: ToolUseBlock) -> tuple[str, str]:
1042
1045
  the text meant a file whose first line was ``---`` - YAML front matter, a
1043
1046
  markdown rule - was treated as a diff and run through a painter that
1044
1047
  strips exactly those lines, leaving the user approving a blank space.
1048
+
1049
+ The path is resolved against the session's directory, the way the tool
1050
+ resolves it. HX never changes its own working directory, so under
1051
+ ``--cwd`` - or a session resumed from somewhere else - a relative path
1052
+ read from the process directory previewed a different file, or none, and
1053
+ the user approved "no change" for an edit that changed something.
1045
1054
  """
1046
1055
  if call.name == "Bash":
1047
1056
  return str(call.input.get("command", "")), "command"
1048
1057
 
1049
1058
  if call.name in {"Edit", "Write"} and (raw_path := call.input.get("file_path")):
1050
- from pathlib import Path
1051
-
1052
- from hx.tools.edit import apply_hashline, parse_edits, parse_hashline, unified_diff
1059
+ from hx.tools.edit import (
1060
+ apply_edits,
1061
+ apply_hashline,
1062
+ parse_edits,
1063
+ parse_hashline,
1064
+ unified_diff,
1065
+ )
1066
+ from hx.tools.read import read_exact, resolve_path
1053
1067
 
1054
- path = Path(str(raw_path))
1068
+ path = resolve_path(str(raw_path), cwd)
1055
1069
  try:
1056
- before = path.read_text(encoding="utf-8") if path.is_file() else ""
1070
+ before = read_exact(path) if path.is_file() else ""
1057
1071
  if call.name == "Write":
1058
1072
  after = str(call.input.get("content", ""))
1059
1073
  elif call.input.get("hashline"):
@@ -1062,11 +1076,7 @@ def _permission_detail(call: ToolUseBlock) -> tuple[str, str]:
1062
1076
  # filename and the user approves an edit they never saw.
1063
1077
  after = apply_hashline(before, parse_hashline(call.input))
1064
1078
  else:
1065
- after = before
1066
- for edit in parse_edits(call.input):
1067
- after = after.replace(
1068
- edit.old_string, edit.new_string, -1 if edit.replace_all else 1
1069
- )
1079
+ after = apply_edits(before, parse_edits(call.input))
1070
1080
  diff = unified_diff(before, after, str(path))
1071
1081
  return (diff, "diff") if diff else (f"{path} (no change)", "text")
1072
1082
  except Exception:
@@ -1076,6 +1086,20 @@ def _permission_detail(call: ToolUseBlock) -> tuple[str, str]:
1076
1086
  return _detailed(call.input), "text"
1077
1087
 
1078
1088
 
1089
+ def _link_target(call: ToolUseBlock, cwd: Path) -> str | None:
1090
+ """Where an Edit or Write lands when the path it names is a symlink."""
1091
+ if call.name not in {"Edit", "Write"} or not (raw_path := call.input.get("file_path")):
1092
+ return None
1093
+ from hx.tools.read import resolve_path
1094
+ from hx.tools.write import link_target
1095
+
1096
+ try:
1097
+ target = link_target(resolve_path(str(raw_path), cwd))
1098
+ except (OSError, RuntimeError): # a link loop, or a directory we cannot see into
1099
+ return None
1100
+ return str(target) if target is not None else None
1101
+
1102
+
1079
1103
  def _detailed(params: dict[str, Any], limit: int = 400) -> str:
1080
1104
  """What the approval prompt shows for a tool with no preview of its own.
1081
1105
 
@@ -1103,7 +1127,7 @@ scroll is a prompt they stop reading."""
1103
1127
 
1104
1128
  def _one_value(value: Any, limit: int) -> str:
1105
1129
  if isinstance(value, list):
1106
- return f"[{len(value)} items]"
1130
+ return f"[{count(len(value), 'item')}]"
1107
1131
  if isinstance(value, dict):
1108
1132
  return "{…}"
1109
1133
  if isinstance(value, str) and len(value) > limit:
@@ -20,6 +20,7 @@ import httpx
20
20
 
21
21
  from hx import __version__
22
22
  from hx.net import async_client
23
+ from hx.text import count
23
24
 
24
25
  if TYPE_CHECKING:
25
26
  from hx.mcp.oauth import OAuthSession
@@ -531,11 +532,7 @@ class MCPClient:
531
532
  if result.get("isError"):
532
533
  raise MCPToolError(text or f"{name} failed")
533
534
  if not text:
534
- text = (
535
- f"({len(images)} image{'s' if len(images) != 1 else ''})"
536
- if images
537
- else "(no content)"
538
- )
535
+ text = f"({count(len(images), 'image')})" if images else "(no content)"
539
536
  return MCPToolOutput(text=text, images=images)
540
537
 
541
538
  async def _request(
@@ -76,6 +76,12 @@ class PermissionRequest:
76
76
  that painter strips exactly those lines, so the user was asked to approve a
77
77
  blank space.
78
78
  """
79
+ link_target: str | None = None
80
+ """The file an edit really changes, when the path it names is a symlink.
81
+
82
+ The link can point anywhere, out of the project included, and the diff
83
+ alone does not say where it lands.
84
+ """
79
85
  origin: str | None = None
80
86
  """Which subagent asked, when it was not the main conversation."""
81
87
  tool_use_id: str | None = None
@@ -144,6 +150,12 @@ class PermissionEngine:
144
150
  if request.tool_name == "Bash" and request.specifier:
145
151
  parsed = parse(request.specifier)
146
152
  if parsed.unparseable:
153
+ # A rule holding the whole command verbatim grants exactly what it
154
+ # names, so it covers what cannot be decomposed - it is what "always
155
+ # allow" saves for such a command. Returning before looking made
156
+ # that saved rule ask again in every later session. Ask still wins.
157
+ if not self._match(request, Decision.ASK) and (exact := self._exact_allow(request)):
158
+ return PermissionOutcome(Decision.ALLOW, "matched an allow rule", exact)
147
159
  return PermissionOutcome(
148
160
  Decision.ASK,
149
161
  "command could not be decomposed; approving it blind is not safe",
@@ -219,11 +231,11 @@ class PermissionEngine:
219
231
  return None
220
232
 
221
233
  # A rule holding the whole command verbatim: exact, so it grants no more
222
- # than the string it names. This is what covers a command that cannot be
223
- # decomposed, and what keeps hand-written full-command rules working.
224
- for rule in candidates:
225
- if rule.specifier == request.specifier:
226
- return rule
234
+ # than the string it names. This is what keeps hand-written full-command
235
+ # rules working; :meth:`evaluate` also consults it for a command that
236
+ # cannot be decomposed.
237
+ if exact := self._exact_allow(request):
238
+ return exact
227
239
 
228
240
  parsed = parse(request.specifier)
229
241
  if not parsed.segments or parsed.unparseable:
@@ -244,6 +256,26 @@ class PermissionEngine:
244
256
  first = first or covering
245
257
  return first
246
258
 
259
+ def _exact_allow(self, request: PermissionRequest) -> Rule | None:
260
+ """An allow rule whose specifier is this request's, character for character.
261
+
262
+ Compared stripped, as both sides are by the time they mean anything: a
263
+ rule's specifier is stripped as it is parsed, and Bash strips a command
264
+ before running it.
265
+ """
266
+ specifier = (request.specifier or "").strip()
267
+ return next(
268
+ (
269
+ rule
270
+ for rule in self.rules
271
+ if rule.decision is Decision.ALLOW
272
+ and rule.tool in {request.tool_name, "*"}
273
+ and rule.specifier is not None
274
+ and rule.specifier == specifier
275
+ ),
276
+ None,
277
+ )
278
+
247
279
  def _match_allow_each(
248
280
  self,
249
281
  request: PermissionRequest,
@@ -132,18 +132,37 @@ MUTATING_FLAGS: dict[str, frozenset[str]] = {
132
132
  {"-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprintf", "-fls"}
133
133
  ),
134
134
  "fd": frozenset({"-x", "--exec", "-X", "--exec-batch"}),
135
- "sort": frozenset({"-o", "--output"}),
135
+ "sort": frozenset({"-o", "--output", "--compress-program"}),
136
136
  "tree": frozenset({"-o"}),
137
137
  "date": frozenset({"-s", "--set"}),
138
138
  "rg": frozenset({"--pre"}),
139
139
  "yq": frozenset({"-i", "--inplace"}),
140
+ # Any word starting -r reverts: xxd reads `-r`, `-rp` and `-revert` alike.
141
+ "xxd": frozenset({"-r"}),
140
142
  }
141
143
  """Flags that turn a read-only command into a writing or executing one.
142
144
 
143
145
  ``sed`` edits in place under ``-i``, ``find`` runs arbitrary programs under
144
- ``-exec`` and unlinks under ``-delete``, ``sort -o`` overwrites its own input.
145
- Treating the executable name alone as read-only allowed all three through
146
- unprompted.
146
+ ``-exec`` and unlinks under ``-delete``, ``sort -o`` overwrites its own input,
147
+ ``sort --compress-program`` runs whatever it names and ``xxd -r`` turns a hex
148
+ dump back into bytes - into a file, given one. Treating the executable name
149
+ alone as read-only allowed them all through unprompted.
150
+ """
151
+
152
+ OUTPUT_OPERAND_COMMANDS: dict[str, frozenset[str]] = {
153
+ # uniq [OPTION]... [INPUT [OUTPUT]]
154
+ "uniq": frozenset({"-f", "--skip-fields", "-s", "--skip-chars", "-w", "--check-chars"}),
155
+ # xxd [options] [infile [outfile]]
156
+ "xxd": frozenset(
157
+ {"-c", "-cols", "-g", "-groupsize", "-l", "-len", "-n", "-name", "-o", "-offset"}
158
+ | {"-s", "-seek", "-R"}
159
+ ),
160
+ }
161
+ """Commands that write a file named by their second operand, with no flag at all.
162
+
163
+ ``uniq in out`` and ``xxd in out`` overwrite ``out``. The value is the options
164
+ that take a separate argument, so ``uniq -f 1 in`` is read as one operand, not
165
+ two.
147
166
  """
148
167
 
149
168
  GIT_READ_ONLY_SUBCOMMANDS = frozenset(
@@ -602,6 +621,8 @@ def is_read_only(segment: CommandSegment) -> bool:
602
621
  return not _env_runs_a_command(segment.args)
603
622
  if segment.executable in PROGRAM_TEXT_COMMANDS and _program_text_writes(segment):
604
623
  return False
624
+ if segment.executable in OUTPUT_OPERAND_COMMANDS and _names_an_output(segment):
625
+ return False
605
626
  return not _has_flag(
606
627
  segment.args,
607
628
  MUTATING_FLAGS.get(segment.executable, frozenset()),
@@ -640,6 +661,31 @@ grounds to call the command read-only: ``awk -f prog.awk`` runs whatever
640
661
  """
641
662
 
642
663
 
664
+ def _names_an_output(segment: CommandSegment) -> bool:
665
+ """True when the command was given an output file as its second operand.
666
+
667
+ Counting errs towards two: an option this does not know to take a value
668
+ makes its value look like an operand, which prompts - never the reverse.
669
+ Options end at the first operand, as for xxd and BSD uniq: there ``xxd in
670
+ -p`` and ``uniq in -c`` write files named ``-p`` and ``-c``. GNU uniq reads
671
+ that ``-c`` as an option, and the command only prompts where it need not.
672
+ """
673
+ takes_value = OUTPUT_OPERAND_COMMANDS[segment.executable]
674
+ operands = 0
675
+ options_done = False
676
+ args = iter(segment.args)
677
+ for arg in args:
678
+ if not options_done and arg == "--":
679
+ options_done = True
680
+ elif not options_done and arg.startswith("-") and arg != "-":
681
+ if arg in takes_value:
682
+ next(args, None)
683
+ else:
684
+ operands += 1
685
+ options_done = True
686
+ return operands >= 2
687
+
688
+
643
689
  def _program_text_writes(segment: CommandSegment) -> bool:
644
690
  """True when the program handed to ``awk``/``sed`` can write or execute."""
645
691
  if _has_option(segment.args, PROGRAM_FILE_OPTIONS[segment.executable]):
@@ -710,13 +756,15 @@ def _split_option(arg: str, options: tuple[str, ...]) -> tuple[str, str | None]
710
756
  Three spellings reach the same option and a shell accepts all three:
711
757
  separate (``-e`` then the script), ``=``-attached (``--expression=…``) and,
712
758
  for short options, packed into the same word (``-e'w out'``). Missing the
713
- last one let a writing script through unread.
759
+ last one let a writing script through unread. A long option may also be cut
760
+ short (``--expr=…``) - see :func:`_abbreviates`.
714
761
  """
715
762
  if arg in options:
716
763
  return arg, None
717
764
  name, sep, value = arg.partition("=")
718
- if sep and name in options:
719
- return name, value
765
+ option = next((o for o in options if name == o or _abbreviates(name, o)), None)
766
+ if option is not None:
767
+ return option, value if sep else None
720
768
  if len(arg) > 2 and arg.startswith("-") and not arg.startswith("--") and arg[:2] in options:
721
769
  return arg[:2], arg[2:]
722
770
  return None
@@ -745,12 +793,14 @@ def _option_values(args: tuple[str, ...], options: tuple[str, ...]) -> list[str]
745
793
  return values
746
794
 
747
795
 
748
- ATTACHED_VALUE_COMMANDS = frozenset({"date"})
796
+ ATTACHED_VALUE_COMMANDS = frozenset({"date", "xxd"})
749
797
  """Commands whose short options swallow the rest of their word as a value.
750
798
 
751
799
  ``date -Iseconds`` is one option and its argument, not a cluster of five, so
752
800
  only its first letter may be read as a flag - otherwise the ``s`` of
753
801
  ``seconds`` matches ``date -s`` and a pure formatting call prompts every time.
802
+ ``xxd`` reads only the start of each word as well: ``-groupsize`` is not a
803
+ ``-r`` for the r inside it.
754
804
  """
755
805
 
756
806
 
@@ -764,7 +814,8 @@ def _has_flag(
764
814
  clustered (``sed -ni``). Only two-character flags take part in the attached
765
815
  and clustered readings - ``find``'s options are single-dash words, and
766
816
  scanning ``-depth`` for the letters of ``-delete`` would refuse commands that
767
- write nothing.
817
+ write nothing. A long flag also matches when cut short, as
818
+ :func:`_abbreviates` describes.
768
819
 
769
820
  ``cluster_first_only`` narrows the clustered reading to the first letter, for
770
821
  commands where the rest of the word is that option's value rather than more
@@ -775,7 +826,8 @@ def _has_flag(
775
826
  for arg in args:
776
827
  if arg == "--":
777
828
  break # everything after this is an operand, not an option
778
- if arg.split("=", 1)[0] in flags:
829
+ name = arg.split("=", 1)[0]
830
+ if name in flags or any(_abbreviates(name, flag) for flag in flags):
779
831
  return True
780
832
  if not arg.startswith("-") or arg.startswith("--"):
781
833
  continue
@@ -787,6 +839,20 @@ def _has_flag(
787
839
  return False
788
840
 
789
841
 
842
+ def _abbreviates(name: str, option: str) -> bool:
843
+ """True when ``name`` is the long ``option`` cut short: ``--compress`` for
844
+ ``--compress-program``.
845
+
846
+ getopt_long and git's option parser take any unambiguous prefix of a long
847
+ option as the option itself, so matching only the full spelling let ``sort
848
+ --compress=sh`` and ``git grep --open-files-in=sh`` run a program unprompted.
849
+ Every prefix counts, ambiguous or not: an ambiguous one is an error, and one
850
+ that is itself another whole option only prompts where it need not - never
851
+ the reverse.
852
+ """
853
+ return len(name) > 2 and name.startswith("--") and option.startswith(name) and name != option
854
+
855
+
790
856
  def _leading_letters(text: str) -> str:
791
857
  """The run of option letters at the head of a cluster - ``i`` of ``-i.bak``."""
792
858
  end = 0
@@ -0,0 +1,12 @@
1
+ """Wording shared by the tools, the TUI and the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def count(number: int, singular: str, plural: str | None = None) -> str:
7
+ """``1 line``, ``2 lines``: a number with its noun agreeing.
8
+
9
+ ``plural`` is for the nouns an ``s`` does not make plural - ``match``.
10
+ """
11
+ noun = singular if number == 1 else (plural or f"{singular}s")
12
+ return f"{number} {noun}"
@@ -112,13 +112,12 @@ def resolve(lines: list[str], anchor: str) -> int:
112
112
  return matches[0]
113
113
 
114
114
 
115
- def replace_span(lines: list[str], start: int, end: int, replacement: str) -> list[str]:
116
- """Replace ``lines[start:end + 1]`` with ``replacement`` split into lines.
115
+ def replace_span(lines: list[str], start: int, end: int, replacement: list[str]) -> list[str]:
116
+ """Replace ``lines[start:end + 1]`` with the ``replacement`` lines.
117
117
 
118
118
  An empty replacement deletes the span rather than inserting a blank line.
119
119
  """
120
- new_lines = replacement.split("\n") if replacement else []
121
- return lines[:start] + new_lines + lines[end + 1 :]
120
+ return lines[:start] + replacement + lines[end + 1 :]
122
121
 
123
122
 
124
123
  class AnchorError(Exception):