hx-cli 0.2.15__tar.gz → 0.2.17__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.15 → hx_cli-0.2.17}/CHANGELOG.md +60 -1
- {hx_cli-0.2.15 → hx_cli-0.2.17}/PKG-INFO +4 -1
- {hx_cli-0.2.15 → hx_cli-0.2.17}/README.md +3 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/cli.py +5 -2
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/loop.py +58 -23
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/permissions/engine.py +34 -5
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/permissions/parser.py +76 -10
- hx_cli-0.2.17/src/hx/text.py +12 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/anchors.py +3 -4
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/bash.py +65 -7
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/edit.py +91 -41
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/glob.py +2 -1
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/grep.py +2 -1
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/output.py +2 -1
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/read.py +15 -2
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/task.py +2 -1
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/websearch.py +2 -1
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/write.py +38 -6
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/commands.py +4 -3
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/glyphs.py +4 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/renderers.py +9 -3
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/runtime.py +41 -14
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/blocks.py +18 -7
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/status.py +42 -7
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/transcript.py +8 -4
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/terminal.py +5 -1
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_chat.py +3 -2
- hx_cli-0.2.17/tests/e2e/test_edits.py +221 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_print.py +1 -0
- hx_cli-0.2.17/tests/e2e/test_shell.py +184 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_tools.py +183 -1
- {hx_cli-0.2.15 → hx_cli-0.2.17}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/.gitignore +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/install.sh +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/pyproject.toml +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/claude_code.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/config.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/context.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/messages.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/docs.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/git.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/keys.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/mcp/client.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/net.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/paths.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/claude_code.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/claude_code_catalogue.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/claude_code_cli.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/claude_code_transcript.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/registry.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/ansi.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/symbols.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/trace.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/permission.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/pickers.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/conftest.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/fake_claude.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/stub.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_claude_code.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_cli.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_commands.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_extend.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_input.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/e2e/test_queue.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/test_live.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/test_live_claude_code.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/tests/tls_proxy.py +0 -0
- {hx_cli-0.2.15 → hx_cli-0.2.17}/uv.lock +0 -0
|
@@ -28,6 +28,63 @@ the project follows [semantic versioning](https://semver.org/).
|
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
+
## [0.2.17] - 2026-10-18
|
|
32
|
+
|
|
33
|
+
### Security
|
|
34
|
+
|
|
35
|
+
- The API keys HX itself signs in with (`HX_OPENROUTER_API_KEY`, `OPENROUTER_API_KEY`, and the
|
|
36
|
+
Tavily and Devin keys) are no longer passed to the commands it runs. An auto-approved `env`
|
|
37
|
+
used to put them straight into the model's context.
|
|
38
|
+
- `xxd -r`, `xxd in out`, `uniq in out` and `sort --compress-program` now ask before they run.
|
|
39
|
+
They write files or run a program, but they were auto-approved as read-only commands.
|
|
40
|
+
- A long option cut short now counts as the full option, as getopt_long and git read it.
|
|
41
|
+
`sort --compress=sh`, `sort --out=f`, `git grep --open-files-in=sh` and `git branch --del`
|
|
42
|
+
were auto-approved as read-only. So were `xxd in -p` and macOS `uniq in -c`, which write a
|
|
43
|
+
file named after the flag.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- A command that reads stdin (`cat`, `read`, `ssh host ls`) no longer breaks the shell. It used
|
|
48
|
+
to swallow every later command, which then reported success without running. Commands now
|
|
49
|
+
get end-of-file on stdin, and a command with an unclosed quote fails on its own.
|
|
50
|
+
- Background jobs no longer read from the terminal, so a dev server cannot take keystrokes
|
|
51
|
+
from the prompt.
|
|
52
|
+
- Background jobs get `HX=1` and `TERM=dumb`, the same as other commands. A tool that colours
|
|
53
|
+
its output for a real terminal used to write escape codes into the job's log.
|
|
54
|
+
- Non-ASCII command output no longer shows `�` where a read split a character.
|
|
55
|
+
- An edit to a CRLF file keeps CRLF on every line. Each edit used to rewrite the whole file
|
|
56
|
+
with LF, and the diff did not show it. `old_string` with plain newlines now matches there.
|
|
57
|
+
- A hashline replacement that ends with a newline no longer adds a blank line after it.
|
|
58
|
+
- Edit and Write keep a file's mode, so a script stays executable, and write through a
|
|
59
|
+
symlink instead of replacing it with a regular file.
|
|
60
|
+
- With `--cwd`, or `hx resume` from another directory, the approval shows the diff of the
|
|
61
|
+
file the edit will change. It used to read a relative path from the shell's directory.
|
|
62
|
+
- An edit's `+N -M` and its diff now include changed lines that start with `--` or `++`,
|
|
63
|
+
such as SQL comments.
|
|
64
|
+
- "Always allow" on a command HX cannot split into parts, such as a heredoc with an
|
|
65
|
+
apostrophe, is now honoured in later sessions instead of asking again.
|
|
66
|
+
- Counts of one read in the singular: "read 1 line", "1 result", "1 match", "1 tool".
|
|
67
|
+
- Write reports the number of lines the way Read numbers them. A file ending in a newline
|
|
68
|
+
was reported one line longer.
|
|
69
|
+
- The status bar shortens a long project path from its start, so the project's name and
|
|
70
|
+
branch stay visible. Before the first request, the cache field reads "no cache".
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## [0.2.16] - 2026-10-13
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
|
|
78
|
+
- The "Working…" status and the tool spinners now keep moving while a tool runs. They
|
|
79
|
+
used to vanish or freeze from the moment the model asked for a tool until its next reply,
|
|
80
|
+
so a long command looked like an idle session. The clock now counts the whole turn, not
|
|
81
|
+
only the time since the last model reply.
|
|
82
|
+
- A tool call waiting on an approval shows `?` and does not spin, and its "Took" counts
|
|
83
|
+
only the run, not the time you spent deciding. A denied call never ran, so it shows no
|
|
84
|
+
"Took" at all.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
31
88
|
## [0.2.15] - 2026-10-04
|
|
32
89
|
|
|
33
90
|
### Changed
|
|
@@ -901,7 +958,9 @@ First release, published to PyPI as [`hx-cli`](https://pypi.org/project/hx-cli/)
|
|
|
901
958
|
- `/configure` and `hx auth` for the OpenRouter key, `hx upgrade` for
|
|
902
959
|
self-update, and `install.sh` bootstrapping uv with a pinned Python.
|
|
903
960
|
|
|
904
|
-
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.
|
|
961
|
+
[Unreleased]: https://github.com/aletisunil/hx/compare/v0.2.17...HEAD
|
|
962
|
+
[0.2.17]: https://github.com/aletisunil/hx/compare/v0.2.16...v0.2.17
|
|
963
|
+
[0.2.16]: https://github.com/aletisunil/hx/compare/v0.2.15...v0.2.16
|
|
905
964
|
[0.2.15]: https://github.com/aletisunil/hx/compare/v0.2.14...v0.2.15
|
|
906
965
|
[0.2.14]: https://github.com/aletisunil/hx/compare/v0.2.13...v0.2.14
|
|
907
966
|
[0.2.13]: https://github.com/aletisunil/hx/compare/v0.2.12...v0.2.13
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hx-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.17
|
|
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`.
|
|
@@ -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 =
|
|
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}
|
|
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
|
|
@@ -75,6 +77,30 @@ class _Steered(Exception):
|
|
|
75
77
|
"""Internal: this stream was cancelled to deliver a steer, not to stop."""
|
|
76
78
|
|
|
77
79
|
|
|
80
|
+
class _Stopwatch:
|
|
81
|
+
"""How long one tool call has run, for its "Took".
|
|
82
|
+
|
|
83
|
+
Started once the call is cleared to run, and zero until then. The time a
|
|
84
|
+
person spent on an approval is not the tool's: a three-second command
|
|
85
|
+
answered after a minute's thought said it took a minute, and a denied one
|
|
86
|
+
that never ran said it took as long as the user took to say no.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
__slots__ = ("_started",)
|
|
90
|
+
|
|
91
|
+
def __init__(self) -> None:
|
|
92
|
+
self._started: float | None = None
|
|
93
|
+
|
|
94
|
+
def start(self) -> None:
|
|
95
|
+
self._started = time.monotonic()
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def ms(self) -> float:
|
|
99
|
+
if self._started is None:
|
|
100
|
+
return 0.0
|
|
101
|
+
return (time.monotonic() - self._started) * 1000
|
|
102
|
+
|
|
103
|
+
|
|
78
104
|
@dataclass(slots=True)
|
|
79
105
|
class TurnResult:
|
|
80
106
|
stop_reason: StopReason
|
|
@@ -712,7 +738,7 @@ class AgentLoop:
|
|
|
712
738
|
self, call: ToolUseBlock, results: dict[str, ToolResultBlock]
|
|
713
739
|
) -> ToolResultBlock:
|
|
714
740
|
"""Run one call. Interrupted, it leaves its answer in ``results``."""
|
|
715
|
-
|
|
741
|
+
clock = _Stopwatch()
|
|
716
742
|
|
|
717
743
|
# PreToolUse runs before the call is announced. A hook that rewrites the
|
|
718
744
|
# input must not leave the transcript showing a command that never ran,
|
|
@@ -730,7 +756,7 @@ class AgentLoop:
|
|
|
730
756
|
)
|
|
731
757
|
streamed: list[str] = []
|
|
732
758
|
try:
|
|
733
|
-
return await self._run_announced(call, pre,
|
|
759
|
+
return await self._run_announced(call, pre, clock, streamed, results)
|
|
734
760
|
except asyncio.CancelledError:
|
|
735
761
|
if call.id in results:
|
|
736
762
|
# The tool had finished and only its hooks were cut off.
|
|
@@ -743,7 +769,7 @@ class AgentLoop:
|
|
|
743
769
|
ToolCallFinished(
|
|
744
770
|
tool_use_id=call.id,
|
|
745
771
|
is_error=True,
|
|
746
|
-
duration_ms=
|
|
772
|
+
duration_ms=clock.ms,
|
|
747
773
|
summary="interrupted",
|
|
748
774
|
interrupted=True,
|
|
749
775
|
)
|
|
@@ -754,7 +780,7 @@ class AgentLoop:
|
|
|
754
780
|
self,
|
|
755
781
|
call: ToolUseBlock,
|
|
756
782
|
pre: HookOutcome,
|
|
757
|
-
|
|
783
|
+
clock: _Stopwatch,
|
|
758
784
|
streamed: list[str],
|
|
759
785
|
results: dict[str, ToolResultBlock],
|
|
760
786
|
) -> ToolResultBlock:
|
|
@@ -772,7 +798,7 @@ class AgentLoop:
|
|
|
772
798
|
ToolCallFinished(
|
|
773
799
|
tool_use_id=call.id,
|
|
774
800
|
is_error=True,
|
|
775
|
-
duration_ms=
|
|
801
|
+
duration_ms=clock.ms,
|
|
776
802
|
summary="blocked by hook",
|
|
777
803
|
detail=refusal.content,
|
|
778
804
|
)
|
|
@@ -785,12 +811,13 @@ class AgentLoop:
|
|
|
785
811
|
ToolCallFinished(
|
|
786
812
|
tool_use_id=call.id,
|
|
787
813
|
is_error=True,
|
|
788
|
-
duration_ms=
|
|
814
|
+
duration_ms=clock.ms,
|
|
789
815
|
summary="denied",
|
|
790
816
|
detail=denied.content,
|
|
791
817
|
)
|
|
792
818
|
)
|
|
793
819
|
return denied
|
|
820
|
+
clock.start()
|
|
794
821
|
|
|
795
822
|
def progress(chunk: str) -> None:
|
|
796
823
|
streamed.append(chunk)
|
|
@@ -812,12 +839,12 @@ class AgentLoop:
|
|
|
812
839
|
except asyncio.CancelledError:
|
|
813
840
|
# The tool finished and its effects are real: its own result stands,
|
|
814
841
|
# rather than telling the model a write that happened never did.
|
|
815
|
-
results[call.id] = self._finish(call, result, HookOutcome(),
|
|
842
|
+
results[call.id] = self._finish(call, result, HookOutcome(), clock)
|
|
816
843
|
raise
|
|
817
|
-
return self._finish(call, result, post,
|
|
844
|
+
return self._finish(call, result, post, clock)
|
|
818
845
|
|
|
819
846
|
def _finish(
|
|
820
|
-
self, call: ToolUseBlock, result: ToolResult, post: HookOutcome,
|
|
847
|
+
self, call: ToolUseBlock, result: ToolResult, post: HookOutcome, clock: _Stopwatch
|
|
821
848
|
) -> ToolResultBlock:
|
|
822
849
|
"""Announce a call the tool finished, and its result for the model."""
|
|
823
850
|
content = result.content
|
|
@@ -830,7 +857,7 @@ class AgentLoop:
|
|
|
830
857
|
ToolCallFinished(
|
|
831
858
|
tool_use_id=call.id,
|
|
832
859
|
is_error=result.is_error or post.blocked,
|
|
833
|
-
duration_ms=
|
|
860
|
+
duration_ms=clock.ms,
|
|
834
861
|
summary=result.summary or ("error" if result.is_error else "done"),
|
|
835
862
|
detail=content if result.is_error or post.blocked else "",
|
|
836
863
|
metadata=dict(result.metadata),
|
|
@@ -852,7 +879,7 @@ class AgentLoop:
|
|
|
852
879
|
from hx.permissions.engine import PermissionRequest
|
|
853
880
|
|
|
854
881
|
tool = self.tools.get(call.name)
|
|
855
|
-
detail, detail_kind = _permission_detail(call)
|
|
882
|
+
detail, detail_kind = _permission_detail(call, self.settings.cwd)
|
|
856
883
|
request = PermissionRequest(
|
|
857
884
|
tool_name=call.name,
|
|
858
885
|
specifier=tool.permission_specifier(call.input),
|
|
@@ -863,6 +890,7 @@ class AgentLoop:
|
|
|
863
890
|
detail=detail,
|
|
864
891
|
detail_kind=detail_kind,
|
|
865
892
|
origin=self.origin,
|
|
893
|
+
tool_use_id=call.id,
|
|
866
894
|
)
|
|
867
895
|
allowed, reason = await self.permissions.request(
|
|
868
896
|
request, on_ask=lambda: self._announce_ask(call.id, request)
|
|
@@ -1006,7 +1034,7 @@ def _parse_tool_input(raw: str | None) -> dict[str, Any]:
|
|
|
1006
1034
|
return parsed if isinstance(parsed, dict) else {}
|
|
1007
1035
|
|
|
1008
1036
|
|
|
1009
|
-
def _permission_detail(call: ToolUseBlock) -> tuple[str, str]:
|
|
1037
|
+
def _permission_detail(call: ToolUseBlock, cwd: Path) -> tuple[str, str]:
|
|
1010
1038
|
"""What the approval shows, and what kind of thing it is.
|
|
1011
1039
|
|
|
1012
1040
|
For an edit this previews the change against the file on disk, so the user
|
|
@@ -1016,18 +1044,29 @@ def _permission_detail(call: ToolUseBlock) -> tuple[str, str]:
|
|
|
1016
1044
|
the text meant a file whose first line was ``---`` - YAML front matter, a
|
|
1017
1045
|
markdown rule - was treated as a diff and run through a painter that
|
|
1018
1046
|
strips exactly those lines, leaving the user approving a blank space.
|
|
1047
|
+
|
|
1048
|
+
The path is resolved against the session's directory, the way the tool
|
|
1049
|
+
resolves it. HX never changes its own working directory, so under
|
|
1050
|
+
``--cwd`` - or a session resumed from somewhere else - a relative path
|
|
1051
|
+
read from the process directory previewed a different file, or none, and
|
|
1052
|
+
the user approved "no change" for an edit that changed something.
|
|
1019
1053
|
"""
|
|
1020
1054
|
if call.name == "Bash":
|
|
1021
1055
|
return str(call.input.get("command", "")), "command"
|
|
1022
1056
|
|
|
1023
1057
|
if call.name in {"Edit", "Write"} and (raw_path := call.input.get("file_path")):
|
|
1024
|
-
from
|
|
1025
|
-
|
|
1026
|
-
|
|
1058
|
+
from hx.tools.edit import (
|
|
1059
|
+
apply_edits,
|
|
1060
|
+
apply_hashline,
|
|
1061
|
+
parse_edits,
|
|
1062
|
+
parse_hashline,
|
|
1063
|
+
unified_diff,
|
|
1064
|
+
)
|
|
1065
|
+
from hx.tools.read import read_exact, resolve_path
|
|
1027
1066
|
|
|
1028
|
-
path =
|
|
1067
|
+
path = resolve_path(str(raw_path), cwd)
|
|
1029
1068
|
try:
|
|
1030
|
-
before = path
|
|
1069
|
+
before = read_exact(path) if path.is_file() else ""
|
|
1031
1070
|
if call.name == "Write":
|
|
1032
1071
|
after = str(call.input.get("content", ""))
|
|
1033
1072
|
elif call.input.get("hashline"):
|
|
@@ -1036,11 +1075,7 @@ def _permission_detail(call: ToolUseBlock) -> tuple[str, str]:
|
|
|
1036
1075
|
# filename and the user approves an edit they never saw.
|
|
1037
1076
|
after = apply_hashline(before, parse_hashline(call.input))
|
|
1038
1077
|
else:
|
|
1039
|
-
after = before
|
|
1040
|
-
for edit in parse_edits(call.input):
|
|
1041
|
-
after = after.replace(
|
|
1042
|
-
edit.old_string, edit.new_string, -1 if edit.replace_all else 1
|
|
1043
|
-
)
|
|
1078
|
+
after = apply_edits(before, parse_edits(call.input))
|
|
1044
1079
|
diff = unified_diff(before, after, str(path))
|
|
1045
1080
|
return (diff, "diff") if diff else (f"{path} (no change)", "text")
|
|
1046
1081
|
except Exception:
|
|
@@ -1077,7 +1112,7 @@ scroll is a prompt they stop reading."""
|
|
|
1077
1112
|
|
|
1078
1113
|
def _one_value(value: Any, limit: int) -> str:
|
|
1079
1114
|
if isinstance(value, list):
|
|
1080
|
-
return f"[{len(value)}
|
|
1115
|
+
return f"[{count(len(value), 'item')}]"
|
|
1081
1116
|
if isinstance(value, dict):
|
|
1082
1117
|
return "{…}"
|
|
1083
1118
|
if isinstance(value, str) and len(value) > limit:
|
|
@@ -78,6 +78,9 @@ class PermissionRequest:
|
|
|
78
78
|
"""
|
|
79
79
|
origin: str | None = None
|
|
80
80
|
"""Which subagent asked, when it was not the main conversation."""
|
|
81
|
+
tool_use_id: str | None = None
|
|
82
|
+
"""The call waiting on this, so the UI can show that call as waiting
|
|
83
|
+
rather than running. None for a request no tool call made - a `!` line."""
|
|
81
84
|
|
|
82
85
|
def all_specifiers(self) -> tuple[str, ...]:
|
|
83
86
|
"""Everything a rule has to be matched against for this one call."""
|
|
@@ -141,6 +144,12 @@ class PermissionEngine:
|
|
|
141
144
|
if request.tool_name == "Bash" and request.specifier:
|
|
142
145
|
parsed = parse(request.specifier)
|
|
143
146
|
if parsed.unparseable:
|
|
147
|
+
# A rule holding the whole command verbatim grants exactly what it
|
|
148
|
+
# names, so it covers what cannot be decomposed - it is what "always
|
|
149
|
+
# allow" saves for such a command. Returning before looking made
|
|
150
|
+
# that saved rule ask again in every later session. Ask still wins.
|
|
151
|
+
if not self._match(request, Decision.ASK) and (exact := self._exact_allow(request)):
|
|
152
|
+
return PermissionOutcome(Decision.ALLOW, "matched an allow rule", exact)
|
|
144
153
|
return PermissionOutcome(
|
|
145
154
|
Decision.ASK,
|
|
146
155
|
"command could not be decomposed; approving it blind is not safe",
|
|
@@ -216,11 +225,11 @@ class PermissionEngine:
|
|
|
216
225
|
return None
|
|
217
226
|
|
|
218
227
|
# A rule holding the whole command verbatim: exact, so it grants no more
|
|
219
|
-
# than the string it names. This is what
|
|
220
|
-
#
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
228
|
+
# than the string it names. This is what keeps hand-written full-command
|
|
229
|
+
# rules working; :meth:`evaluate` also consults it for a command that
|
|
230
|
+
# cannot be decomposed.
|
|
231
|
+
if exact := self._exact_allow(request):
|
|
232
|
+
return exact
|
|
224
233
|
|
|
225
234
|
parsed = parse(request.specifier)
|
|
226
235
|
if not parsed.segments or parsed.unparseable:
|
|
@@ -241,6 +250,26 @@ class PermissionEngine:
|
|
|
241
250
|
first = first or covering
|
|
242
251
|
return first
|
|
243
252
|
|
|
253
|
+
def _exact_allow(self, request: PermissionRequest) -> Rule | None:
|
|
254
|
+
"""An allow rule whose specifier is this request's, character for character.
|
|
255
|
+
|
|
256
|
+
Compared stripped, as both sides are by the time they mean anything: a
|
|
257
|
+
rule's specifier is stripped as it is parsed, and Bash strips a command
|
|
258
|
+
before running it.
|
|
259
|
+
"""
|
|
260
|
+
specifier = (request.specifier or "").strip()
|
|
261
|
+
return next(
|
|
262
|
+
(
|
|
263
|
+
rule
|
|
264
|
+
for rule in self.rules
|
|
265
|
+
if rule.decision is Decision.ALLOW
|
|
266
|
+
and rule.tool in {request.tool_name, "*"}
|
|
267
|
+
and rule.specifier is not None
|
|
268
|
+
and rule.specifier == specifier
|
|
269
|
+
),
|
|
270
|
+
None,
|
|
271
|
+
)
|
|
272
|
+
|
|
244
273
|
def _match_allow_each(
|
|
245
274
|
self,
|
|
246
275
|
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
|
-
|
|
146
|
-
|
|
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
|
|
719
|
-
|
|
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
|
-
|
|
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``
|
|
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
|
-
|
|
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):
|