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.
- {hx_cli-0.2.16 → hx_cli-0.2.18}/CHANGELOG.md +68 -1
- {hx_cli-0.2.16 → hx_cli-0.2.18}/PKG-INFO +4 -1
- {hx_cli-0.2.16 → hx_cli-0.2.18}/README.md +3 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/__init__.py +1 -1
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/cli.py +5 -2
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/loop.py +37 -13
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/mcp/client.py +2 -5
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/permissions/engine.py +37 -5
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/permissions/parser.py +76 -10
- hx_cli-0.2.18/src/hx/text.py +12 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/anchors.py +3 -4
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/bash.py +65 -7
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/edit.py +154 -40
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/glob.py +4 -2
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/grep.py +3 -2
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/output.py +2 -1
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/read.py +18 -4
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/symbols.py +4 -3
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/task.py +2 -1
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/websearch.py +2 -1
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/write.py +66 -6
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/commands.py +9 -8
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/renderers.py +26 -14
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/blocks.py +13 -1
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/permission.py +15 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/pickers.py +3 -3
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/status.py +42 -7
- hx_cli-0.2.18/tests/e2e/test_edits.py +302 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_print.py +1 -0
- hx_cli-0.2.18/tests/e2e/test_shell.py +184 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_tools.py +85 -1
- {hx_cli-0.2.16 → hx_cli-0.2.18}/.github/workflows/ci.yml +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/.gitignore +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/examples/hooks/check.sh +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/install.sh +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/pyproject.toml +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/agents/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/agents/definitions.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/agents/subagent.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/claude_code.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/oauth/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/oauth/browser.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/oauth/callback.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/oauth/codex.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/oauth/devin.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/oauth/pkce.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/resolve.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/auth/store.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/config.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/checkpoints.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/compaction.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/context.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/events.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/images.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/lateinject.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/messages.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/session.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/title.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/core/usage.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/docs.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/frontmatter.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/git.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/hooks/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/hooks/engine.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/hooks/spec.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/keys.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/mcp/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/mcp/manager.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/mcp/oauth.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/net.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/paths.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/permissions/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/permissions/sandbox.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/base.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/claude_code.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/claude_code_catalogue.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/claude_code_cli.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/claude_code_transcript.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/codex.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/codex_catalogue.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/devin.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/devin_catalogue.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/devin_wire.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/models.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/openrouter.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/protowire.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/registry.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/providers/responses_codec.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/skills/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/skills/loader.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/skills/runtime.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/ansi.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/buffer.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/component.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/demo.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/editor.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/keydecode.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/loop.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/markdown.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/modifiers.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/primitives.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/sanitize.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/screen.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/syntax.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/terminal.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/undo.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/width.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/term/word_nav.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/base.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/registry.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tools/todo.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/trace.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/app.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/clipboard.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/format.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/fuzzy.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/glyphs.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/killring.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/limits.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/paint.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/roles.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/runtime.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/theme.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/theme_json.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/themes/ansi.json +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/themes/dark.json +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/themes/light.json +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/dialog.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/login.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/prompt.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/src/hx/tui/views/transcript.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/conftest.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/__init__.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/conftest.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/fake_claude.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/fixtures/echo_server.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/report.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/stub.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/terminal.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_chat.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_claude_code.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_cli.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_commands.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_extend.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_input.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/e2e/test_queue.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/test_live.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/test_live_claude_code.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/test_live_codex.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/test_live_devin.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/test_live_tavily.py +0 -0
- {hx_cli-0.2.16 → hx_cli-0.2.18}/tests/tls_proxy.py +0 -0
- {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.
|
|
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.
|
|
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`.
|
|
@@ -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
|
|
@@ -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
|
|
1051
|
-
|
|
1052
|
-
|
|
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 =
|
|
1068
|
+
path = resolve_path(str(raw_path), cwd)
|
|
1055
1069
|
try:
|
|
1056
|
-
before = path
|
|
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)}
|
|
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
|
|
223
|
-
#
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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):
|