eosh 0.2.0__tar.gz → 0.3.0__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 (160) hide show
  1. {eosh-0.2.0/src/eosh.egg-info → eosh-0.3.0}/PKG-INFO +186 -197
  2. {eosh-0.2.0 → eosh-0.3.0}/README.md +178 -189
  3. {eosh-0.2.0 → eosh-0.3.0}/README.pypi.md +181 -192
  4. eosh-0.3.0/addons/awsut/__init__.py +14 -0
  5. eosh-0.3.0/addons/awsut/agentcore/__init__.py +31 -0
  6. {eosh-0.2.0/src/eosh/recipes/_awsut_agentcore → eosh-0.3.0/addons/awsut/agentcore}/harness.py +8 -8
  7. {eosh-0.2.0/src/eosh/recipes/_awsut_agentcore → eosh-0.3.0/addons/awsut/agentcore}/memory.py +10 -10
  8. {eosh-0.2.0/src/eosh/recipes/_awsut_agentcore → eosh-0.3.0/addons/awsut/agentcore}/render.py +12 -12
  9. eosh-0.2.0/src/eosh/recipes/awsut.py → eosh-0.3.0/addons/awsut/cli.py +31 -37
  10. eosh-0.2.0/src/eosh/recipes/_awsut_common.py → eosh-0.3.0/addons/awsut/common.py +12 -10
  11. eosh-0.3.0/addons/awsut/sagemaker/__init__.py +29 -0
  12. {eosh-0.2.0/src/eosh/recipes/_awsut_sagemaker → eosh-0.3.0/addons/awsut/sagemaker}/hub.py +4 -4
  13. {eosh-0.2.0/src/eosh/recipes/_awsut_sagemaker → eosh-0.3.0/addons/awsut/sagemaker}/hyperpod.py +17 -14
  14. {eosh-0.2.0/src/eosh/recipes/_awsut_sagemaker → eosh-0.3.0/addons/awsut/sagemaker}/jobs.py +18 -15
  15. {eosh-0.2.0/src/eosh/recipes/_awsut_sagemaker → eosh-0.3.0/addons/awsut/sagemaker}/render.py +3 -3
  16. {eosh-0.2.0/src/eosh/recipes/_awsut_sagemaker → eosh-0.3.0/addons/awsut/sagemaker}/studio.py +7 -7
  17. {eosh-0.2.0 → eosh-0.3.0}/pyproject.toml +15 -7
  18. eosh-0.3.0/setup.py +23 -0
  19. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/__init__.py +15 -14
  20. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/_config.py +132 -75
  21. eosh-0.3.0/src/eosh/_job_leader.py +115 -0
  22. eosh-0.3.0/src/eosh/colors.py +129 -0
  23. eosh-0.3.0/src/eosh/command_context.py +267 -0
  24. eosh-0.3.0/src/eosh/commands.py +634 -0
  25. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/completion.py +114 -246
  26. eosh-0.3.0/src/eosh/context.py +271 -0
  27. eosh-0.3.0/src/eosh/decorators/__init__.py +38 -0
  28. eosh-0.3.0/src/eosh/decorators/quiet.py +42 -0
  29. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/decorators/retry.py +6 -6
  30. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/decorators/time.py +6 -6
  31. eosh-0.3.0/src/eosh/decorators/watch.py +147 -0
  32. eosh-0.3.0/src/eosh/execution.py +99 -0
  33. eosh-0.3.0/src/eosh/history.py +190 -0
  34. eosh-0.3.0/src/eosh/hooks.py +151 -0
  35. eosh-0.3.0/src/eosh/job.py +411 -0
  36. eosh-0.3.0/src/eosh/keys.py +419 -0
  37. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/lineedit.py +399 -708
  38. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/notify.py +29 -14
  39. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/paths.py +1 -1
  40. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/pipeline.py +282 -138
  41. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/process.py +70 -142
  42. eosh-0.3.0/src/eosh/recipes/__init__.py +222 -0
  43. eosh-0.3.0/src/eosh/recipes/aws.py +383 -0
  44. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/chmod.py +0 -3
  45. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/chown.py +2 -4
  46. eosh-0.3.0/src/eosh/recipes/cobra.py +87 -0
  47. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/cp.py +0 -3
  48. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/curl.py +0 -3
  49. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/git.py +1 -1
  50. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/kill.py +2 -2
  51. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/ls.py +1 -1
  52. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/lsof.py +0 -3
  53. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/mv.py +0 -3
  54. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/ps.py +1 -4
  55. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/rm.py +0 -3
  56. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/rsync.py +0 -3
  57. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/scp.py +0 -3
  58. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/tar.py +0 -3
  59. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/terraform.py +1 -5
  60. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/top.py +0 -3
  61. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/unzip.py +0 -3
  62. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/zip.py +0 -3
  63. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/scrollbar.py +12 -16
  64. eosh-0.3.0/src/eosh/shell.py +2435 -0
  65. eosh-0.3.0/src/eosh/shell_integration.py +315 -0
  66. eosh-0.3.0/src/eosh/slots.py +544 -0
  67. eosh-0.3.0/src/eosh/switcher.py +362 -0
  68. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/terminal.py +76 -1
  69. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/tui.py +55 -314
  70. eosh-0.3.0/src/eosh/user_errors.py +55 -0
  71. eosh-0.3.0/src/eosh/variables.py +292 -0
  72. {eosh-0.2.0 → eosh-0.3.0/src/eosh.egg-info}/PKG-INFO +186 -197
  73. {eosh-0.2.0 → eosh-0.3.0}/src/eosh.egg-info/SOURCES.txt +40 -20
  74. {eosh-0.2.0 → eosh-0.3.0}/src/eosh.egg-info/entry_points.txt +3 -0
  75. {eosh-0.2.0 → eosh-0.3.0}/src/eosh.egg-info/requires.txt +2 -2
  76. eosh-0.3.0/src/eosh.egg-info/top_level.txt +2 -0
  77. eosh-0.3.0/tests/test_alias_expansion.py +223 -0
  78. {eosh-0.2.0 → eosh-0.3.0}/tests/test_aws_recipe.py +22 -26
  79. eosh-0.3.0/tests/test_cobra_completion.py +277 -0
  80. eosh-0.3.0/tests/test_colors.py +91 -0
  81. eosh-0.3.0/tests/test_command_context.py +426 -0
  82. eosh-0.3.0/tests/test_command_input.py +151 -0
  83. {eosh-0.2.0 → eosh-0.3.0}/tests/test_commands.py +123 -96
  84. {eosh-0.2.0 → eosh-0.3.0}/tests/test_completion.py +14 -19
  85. {eosh-0.2.0 → eosh-0.3.0}/tests/test_completion_picker.py +67 -39
  86. eosh-0.3.0/tests/test_confirm.py +81 -0
  87. {eosh-0.2.0 → eosh-0.3.0}/tests/test_context.py +109 -48
  88. {eosh-0.2.0 → eosh-0.3.0}/tests/test_decorators.py +338 -414
  89. {eosh-0.2.0 → eosh-0.3.0}/tests/test_env_prefix.py +26 -0
  90. eosh-0.3.0/tests/test_execution.py +84 -0
  91. eosh-0.3.0/tests/test_forwarding.py +139 -0
  92. eosh-0.3.0/tests/test_history.py +259 -0
  93. eosh-0.3.0/tests/test_hooks.py +208 -0
  94. eosh-0.3.0/tests/test_job.py +408 -0
  95. eosh-0.3.0/tests/test_keys.py +385 -0
  96. {eosh-0.2.0 → eosh-0.3.0}/tests/test_notify.py +51 -83
  97. {eosh-0.2.0 → eosh-0.3.0}/tests/test_parsing.py +1 -17
  98. {eosh-0.2.0 → eosh-0.3.0}/tests/test_piped_python_commands.py +114 -27
  99. {eosh-0.2.0 → eosh-0.3.0}/tests/test_pipeline.py +8 -8
  100. eosh-0.3.0/tests/test_process.py +57 -0
  101. eosh-0.3.0/tests/test_recipe_missing.py +70 -0
  102. eosh-0.3.0/tests/test_recipes.py +145 -0
  103. eosh-0.3.0/tests/test_run_handler.py +105 -0
  104. eosh-0.3.0/tests/test_s3_completion.py +161 -0
  105. {eosh-0.2.0 → eosh-0.3.0}/tests/test_scrollbar.py +2 -9
  106. eosh-0.3.0/tests/test_sequence_park.py +29 -0
  107. eosh-0.3.0/tests/test_shell_integration.py +362 -0
  108. {eosh-0.2.0 → eosh-0.3.0}/tests/test_source_bash.py +16 -16
  109. {eosh-0.2.0 → eosh-0.3.0}/tests/test_subcommands.py +35 -38
  110. {eosh-0.2.0 → eosh-0.3.0}/tests/test_tar_recipe.py +5 -6
  111. eosh-0.3.0/tests/test_terminal_query.py +98 -0
  112. eosh-0.3.0/tests/test_user_config.py +164 -0
  113. {eosh-0.2.0 → eosh-0.3.0}/tests/test_variables.py +200 -42
  114. eosh-0.2.0/src/eosh/colors.py +0 -63
  115. eosh-0.2.0/src/eosh/commands.py +0 -785
  116. eosh-0.2.0/src/eosh/context.py +0 -189
  117. eosh-0.2.0/src/eosh/decorators/__init__.py +0 -263
  118. eosh-0.2.0/src/eosh/decorators/bg.py +0 -39
  119. eosh-0.2.0/src/eosh/decorators/quiet.py +0 -39
  120. eosh-0.2.0/src/eosh/decorators/watch.py +0 -660
  121. eosh-0.2.0/src/eosh/history.py +0 -36
  122. eosh-0.2.0/src/eosh/recipes/__init__.py +0 -208
  123. eosh-0.2.0/src/eosh/recipes/_awsut_agentcore/__init__.py +0 -46
  124. eosh-0.2.0/src/eosh/recipes/_awsut_sagemaker/__init__.py +0 -36
  125. eosh-0.2.0/src/eosh/recipes/_missing.py +0 -168
  126. eosh-0.2.0/src/eosh/recipes/aws.py +0 -239
  127. eosh-0.2.0/src/eosh/shell.py +0 -4253
  128. eosh-0.2.0/src/eosh/variables.py +0 -253
  129. eosh-0.2.0/src/eosh.egg-info/top_level.txt +0 -1
  130. eosh-0.2.0/tests/test_alias_expansion.py +0 -143
  131. eosh-0.2.0/tests/test_awsut_credentials.py +0 -171
  132. eosh-0.2.0/tests/test_awsut_whoami.py +0 -317
  133. eosh-0.2.0/tests/test_cobra_completion.py +0 -293
  134. eosh-0.2.0/tests/test_history_completion.py +0 -634
  135. eosh-0.2.0/tests/test_lineedit_history.py +0 -184
  136. eosh-0.2.0/tests/test_passthrough_input.py +0 -135
  137. eosh-0.2.0/tests/test_process.py +0 -116
  138. eosh-0.2.0/tests/test_recipe_missing.py +0 -144
  139. eosh-0.2.0/tests/test_recipes.py +0 -246
  140. eosh-0.2.0/tests/test_sagemaker_recipe.py +0 -2412
  141. eosh-0.2.0/tests/test_user_config.py +0 -27
  142. {eosh-0.2.0 → eosh-0.3.0}/LICENSE +0 -0
  143. {eosh-0.2.0 → eosh-0.3.0}/setup.cfg +0 -0
  144. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/__main__.py +0 -0
  145. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/completion_cache.py +0 -0
  146. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/parsing.py +0 -0
  147. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/prompt.py +0 -0
  148. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/df.py +0 -0
  149. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/du.py +0 -0
  150. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/find.py +0 -0
  151. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/grep.py +0 -0
  152. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/make.py +0 -0
  153. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/ssh.py +0 -0
  154. {eosh-0.2.0 → eosh-0.3.0}/src/eosh/recipes/tail.py +0 -0
  155. {eosh-0.2.0 → eosh-0.3.0}/src/eosh.egg-info/dependency_links.txt +0 -0
  156. {eosh-0.2.0 → eosh-0.3.0}/tests/test_argcomplete_fallback.py +0 -0
  157. {eosh-0.2.0 → eosh-0.3.0}/tests/test_completion_cache.py +0 -0
  158. {eosh-0.2.0 → eosh-0.3.0}/tests/test_make_recipe.py +0 -0
  159. {eosh-0.2.0 → eosh-0.3.0}/tests/test_redirects.py +0 -0
  160. {eosh-0.2.0 → eosh-0.3.0}/tests/test_shell_continuation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: eosh
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A lightweight but powerful terminal shell with rich TAB completion and context switching
5
5
  Author: craftware
6
6
  License-Expression: MIT
@@ -24,16 +24,16 @@ Classifier: Topic :: Terminals
24
24
  Requires-Python: >=3.12
25
25
  Description-Content-Type: text/markdown
26
26
  License-File: LICENSE
27
- Provides-Extra: aws
28
- Requires-Dist: boto3; extra == "aws"
29
- Requires-Dist: pexpect; extra == "aws"
27
+ Provides-Extra: awsut
28
+ Requires-Dist: boto3; extra == "awsut"
29
+ Requires-Dist: pexpect; extra == "awsut"
30
30
  Provides-Extra: dev
31
31
  Requires-Dist: pytest>=8; extra == "dev"
32
- Requires-Dist: eosh[aws]; extra == "dev"
32
+ Requires-Dist: eosh[awsut]; extra == "dev"
33
33
  Dynamic: license-file
34
34
 
35
35
  <!-- Generated from README.md by scripts/gen_pypi_readme.py; relative
36
- links rewritten against the v0.2.0 tag for PyPI. Do not edit
36
+ links rewritten against the v0.3.0 tag for PyPI. Do not edit
37
37
  or commit. -->
38
38
 
39
39
  # Eolith Shell
@@ -41,23 +41,23 @@ Dynamic: license-file
41
41
  **Eolith Shell** (`eosh`) is a lightweight but powerful terminal shell environment with rich tab completion and context switching.
42
42
 
43
43
 
44
- ![Eolith Shell demo: TAB completion with descriptions, the multi-select flag picker, and switching contexts while a build keeps running](https://raw.githubusercontent.com/crftwr/eosh/v0.2.0/doc/images/demo.gif)
44
+ ![Eolith Shell demo: TAB completion with descriptions, flag completion, and switching contexts while a build keeps running](https://raw.githubusercontent.com/crftwr/eosh/v0.3.0/doc/images/demo.gif)
45
45
 
46
46
  ## Features
47
47
 
48
48
  - **Rich tab completion** — per-argument completers with descriptions, inline picker UI
49
- - **Multi-select flag picker** — TAB on flags opens a Space-to-toggle checkbox list
50
- - **Context switching** — named environments with variables and working directories, with push/pop and `Ctrl+]` live switching
49
+ - **Flag completion** — TAB on `-` lists every flag with its description; a flag that takes a value goes straight on to completing that value
50
+ - **Context switching** — named environments with variables and working directories, switched by name or live with `Ctrl+]`
51
51
  - **PTY process multiplexing** — run processes in contexts and switch between them without killing them
52
52
  - **Pipelines and redirections** — `|`, `>`, `>>`, `<`, `2>`, `2>&1`, `;`, `&&`, `||`, globs (`*`, `?`, `**`), and `\`-line-continuation
53
- - **Pipeline decorators** — wrap any pipeline with `@watch`, `@time`, `@retry`, `@quiet`, or `@bg`; authoring your own is a few lines of Python
53
+ - **Pipeline decorators** — wrap any pipeline with `@watch`, `@time`, `@retry` or `@quiet`; authoring your own is a few lines of Python
54
54
  - **Custom commands** — define Python functions as shell commands with full completion support
55
55
  - **Python-backed variables** — `var aws_region=us-east-1` can drive multiple `os.environ` keys via a `Var` subclass; `$NAME` expansion is symmetric
56
56
  - **Completion recipes** — opt-in TAB completion for `git`, `make`, `ssh`, `aws`, and more
57
- - **Protocol fallbacks** — automatic completion for cobra-based tools (`docker`, `kubectl`, `helm`, `gh`, …) and argcomplete-based Python CLIs (`pipx`, `conda`, `pre-commit`, `tox`, …) — no recipe needed
57
+ - **Protocol fallbacks** — completion for cobra-based tools (`docker`, `kubectl`, `helm`, `gh`, …, opt-in by name) and, automatically, argcomplete-based Python CLIs (`pipx`, `conda`, `pre-commit`, `tox`, …) — no hand-written recipe needed
58
58
  - **System command fallback** — anything not a registered command runs through the system shell
59
59
  - **Cross-platform** — interactive shell, completion, pipelines, redirects, contexts, and history all work on POSIX and Windows; PTY-backed multiplexing of running native processes is POSIX-only
60
- - **History** — persistent history with up/down navigation, `Ctrl+R` search, and past command lines offered as multi-argument TAB candidates, scoped to the context and directory you're in
60
+ - **History** — shared by every eosh window (SQLite): fish-style suggestions of lines you ran in this directory, `Ctrl+R` search that starts from what you've typed, per-context up/down navigation, and a `history` command
61
61
  - **Desktop notifications** — an OS notification when a command that took more than 10 seconds finishes, including ones running in a backgrounded context; native backends only, no extra dependencies
62
62
 
63
63
  ## Installation
@@ -66,14 +66,14 @@ Requires Python 3.12+. The core has no dependencies beyond the standard library.
66
66
 
67
67
  ```bash
68
68
  pip install eosh # or: pipx install eosh / uv tool install eosh
69
- pip install "eosh[aws]" # + the `awsut` recipe (boto3, pexpect)
70
- uv tool install "eosh[aws]" # same, as an isolated uv tool
69
+ pip install "eosh[awsut]" # + the `awsut` add-on (boto3, pexpect)
70
+ uv tool install "eosh[awsut]" # same, as an isolated uv tool
71
71
  ```
72
72
 
73
73
  On first launch `eosh` writes a starter `~/.eosh/config.py` that enables
74
- every built-in recipe. Without the `[aws]` extra, `awsut` is skipped and
75
- everything else loads normally; running `awsut` then prints the install
76
- command for the environment eosh lives in (`uv tool`, `pipx`, or pip).
74
+ every built-in recipe and bundled add-on. Without the `[awsut]` extra,
75
+ `awsut` is skipped and everything else loads normally; running `awsut` then
76
+ says which module is missing and which extra provides it.
77
77
 
78
78
  `uv tool` and `pipx` install eosh into its own virtualenv, so a
79
79
  `pip install boto3` in another shell does not reach it — add packages with
@@ -108,22 +108,29 @@ eosh
108
108
  | `alias [NAME[=EXPANSION] ...]` | List aliases, show one, or define a shorthand for a command |
109
109
  | `unalias NAME [NAME ...]` | Remove aliases |
110
110
  | `source-bash [FILE [ARG ...]]` | Run a bash script (or a pasted block) and import its variables and cwd |
111
+ | `confirm [MESSAGE]` | Ask `MESSAGE [y/N]` (default `Continue?`); exit 0 on `y`, 1 otherwise — `confirm 'Destroy prod?' && terraform destroy` |
112
+ | `history [-n N] [--here] [KEYWORD ...]` | List past command lines (every window, every context), filtered by keywords or this directory |
111
113
  | `reload` | Reload `~/.eosh/config.py` without restarting |
114
+ | `config edit` | Open `~/.eosh/config.py` in `$VISUAL` / `$EDITOR`, then reload it |
112
115
  | `exit` | Exit the shell |
113
116
 
114
117
  Any command not listed above is passed through to the system shell (e.g., `ls`, `git`, `grep`).
115
118
 
116
119
  ### Aliases
117
120
 
118
- An alias replaces the first word of a command line. Quote the expansion when it contains spaces:
121
+ An alias replaces a command's name with text, as in bash: at the start of a line, after `;` `&&` `||` `|`, and as a decorator's body. The text can be anything a line can hold — pipes, `;` / `&&`, redirects, `$VAR`, decorators. Quote the expansion when it contains spaces:
119
122
 
120
123
  ```
121
124
  eosh> alias hp='awsut sagemaker hyperpod'
122
125
  eosh> hp list # runs: awsut sagemaker hyperpod list
126
+ eosh> alias gl='git log --oneline | head -20'
127
+ eosh> alias deploy='confirm "Deploy to prod?" && make deploy'
123
128
  eosh> alias # list all aliases
124
129
  eosh> unalias hp
125
130
  ```
126
131
 
132
+ `\hp` or `'hp'` runs the command itself, not the alias. An alias may use another (`alias ll='ls -l'` with `alias ls='ls -G'` runs `ls -G -l`), but never itself, so `alias ls='ls -G'` is safe.
133
+
127
134
  ### Importing Variables from Bash
128
135
 
129
136
  `source-bash` runs a script in a real `bash` — so `export`, `$(…)`, loops, heredocs and conditionals all work — and imports the environment and working directory it leaves behind, the way bash's own `source` would:
@@ -141,26 +148,27 @@ eosh> source-bash -c 'export A=$(date +%s)'
141
148
  Contexts let you define named environments with variables that are exported to `os.environ` and a remembered working directory.
142
149
 
143
150
  ```
144
- eosh> context push prod
145
- Pushed context 'prod'
151
+ eosh> context new prod
152
+ Created context 'prod'
146
153
  [prod] eosh> var ACCOUNT=123456 REGION=us-east-1
147
- [prod] eosh> context push staging
148
- Pushed context 'staging'
154
+ [prod] eosh> context new staging
155
+ Created context 'staging'
149
156
  [staging] eosh> var ACCOUNT=789012 REGION=us-west-2
150
- [staging] eosh> context pop
151
- Popped 'staging', now in 'prod'
152
- [prod] eosh> context list
153
- * prod {'ACCOUNT': '123456', 'REGION': 'us-east-1'}
154
- staging {'ACCOUNT': '789012', 'REGION': 'us-west-2'}
157
+ [staging] eosh> context list
158
+ * staging {'ACCOUNT': '789012', 'REGION': 'us-west-2'}
159
+ prod {'ACCOUNT': '123456', 'REGION': 'us-east-1'}
160
+ default
161
+ [staging] eosh> context close
162
+ Closed 'staging', now in 'prod'
155
163
  ```
156
164
 
157
165
  Context subcommands:
158
166
 
159
167
  | Subcommand | Description |
160
168
  |------------|-------------|
161
- | `context push <name>` | Create a new context (inheriting current vars) and switch to it |
162
- | `context pop` | Return to the previous context and remove the current one |
163
- | `context switch <name>` | Switch to an existing context without modifying the stack |
169
+ | `context new <name>` | Create a context (inheriting the current one's variables and history) and switch to it |
170
+ | `context close [name]` | Remove a context (default: the current one); the most recently used remaining one becomes current |
171
+ | `context switch <name>` | Switch to an existing context |
164
172
  | `context list` | Show all contexts with their state and variables |
165
173
  | `context kill <name>` | Send SIGTERM to the running process in a context |
166
174
 
@@ -168,10 +176,10 @@ Context subcommands:
168
176
 
169
177
  Press `Ctrl+]` at the shell prompt (or while a process is running) to open a TUI context picker:
170
178
 
171
- - Arrow keys or `Ctrl+P/N` to navigate
179
+ - Arrow keys or `Ctrl+P` to navigate
172
180
  - **Enter** to switch to the selected context
173
181
  - **Esc** / `Ctrl+C` to cancel
174
- - Select `+ new context` to create a new one
182
+ - `Ctrl+N` creates a new context, `Ctrl+D` deletes the highlighted one, `Ctrl+R` renames it
175
183
 
176
184
  If the target context has a running process, switching to it resumes that process immediately. The original process keeps running in the background — visible as `[bg:1]` in the prompt.
177
185
 
@@ -181,41 +189,28 @@ Press TAB to complete:
181
189
  - Command names (registered commands + system PATH executables)
182
190
  - File/directory paths (default fallback)
183
191
  - Custom per-argument completions defined by commands
184
- - Past command lines from history (see below)
185
-
186
- **History completion** — TAB also offers past command lines that start with
187
- what you've typed so far. Only the part that would be *added* is listed, like
188
- any other candidate, tagged `history` and shown first:
189
-
190
- ```
191
- eosh> git commit <TAB>
192
- ┌────────────────────────────────────────────────┐
193
- │ -m "fix typo" history │
194
- │ --amend --no-edit history │
195
- │ doc/ │
196
- │ src/ │
197
- └────────────────────────────────────────────────┘
198
- ```
199
-
200
- Accepting one inserts it at the cursor verbatim, so a single suggestion can fill
201
- in several arguments at once. Matching is against the entire typed line —
202
- including pipelines (`ls | grep fo<TAB>`) — and draws on the current context's
203
- history, the same list `↑`/`↓` walks (`Ctrl+R` searches every context). A
204
- history candidate is never inserted without being shown in the picker first, and
205
- a unique ordinary completion still applies on the first TAB as before.
206
-
207
- Candidates are also scoped to the **directory** you're in: eosh records where
208
- each command was run (`~/.eosh/history.dirs`) and offers only the lines you ran
209
- here, so another checkout's `make deploy` stays out of the way. Nothing matching
210
- run here means no history rows — the picker just shows the ordinary candidates.
211
- `↑`/`↓` and `Ctrl+R` are not directory-scoped, so lines from elsewhere are still
212
- one key away.
213
-
214
- **Flag completion** — when flags are available, TAB opens a multi-select checkbox picker:
215
- - Navigate with arrows; **Space** toggles a flag; **Enter** confirms
216
- - Type a letter to jump to the next flag starting with that letter
217
- - Short boolean flags are combined: selecting `-a` and `-l` inserts `-al`
218
- - Flags that take a value (e.g. `-d N`) insert `flag ` then open a value picker or show an inline hint
192
+
193
+ ### History
194
+
195
+ As you type, the most recent line you ran **in this directory** that starts with what you've typed appears dimmed after the cursor. **→** (or Ctrl+E / End) accepts it, and **Alt+F** accepts one word. Otherwise keep typing:
196
+
197
+ ```
198
+ ~/proj> git commit -m "fix typo"
199
+ ^^^^^^^^^^^^^^^^^^ typed "git co"; the rest is the suggestion
200
+ ```
201
+
202
+ - **Ctrl+R** searches every line you've run, anywhere. It starts filtered by what's already typed, and shows where and when each line last ran.
203
+ - **`history`** lists past lines: `history docker run`, `history -n 100 --here`.
204
+ - **↑ / ↓** walk the current context's own list.
205
+
206
+ History is shared by every eosh window you have open (`~/.eosh/history.db`, SQLite). A line run in one window is suggested in another at once, and shells running side by side never overwrite each other's history. See [doc/history.md](https://github.com/crftwr/eosh/blob/v0.3.0/doc/history.md).
207
+
208
+ **Flag completion** — TAB on `-` lists the command's flags as ordinary picker
209
+ rows, each with its description:
210
+ - Type to narrow, Down/Up and **Enter** to pick — one flag per TAB
211
+ - A flag that takes a value is shown as `-d <N>`; picking it inserts `-d ` and
212
+ goes straight on to completing the value (a directory picker for `tar -C`,
213
+ say). When the value has no completer, the status bar shows what to type.
219
214
 
220
215
  ### Pipelines, Redirects, and Sequencing
221
216
 
@@ -252,7 +247,6 @@ A **decorator** is a token of the form `@name [flags]` at the start of a line th
252
247
  @time {make && ./run-tests}
253
248
  @retry -n 5 --delay 2 curl https://flaky.example.com/
254
249
  @quiet pytest -q
255
- @bg {tail -f /var/log/system.log} # run in a fresh background context
256
250
  ```
257
251
 
258
252
  **Scope rule.** If the wrapped pipeline contains `|`, `;`, `&&`, `||`, or a redirect, it must be enclosed in `{...}`. Single-command bodies don't need braces. This makes the decorator's scope visible at a glance and side-steps the `watch -n 5 ls | grep abc` ambiguity that POSIX `watch` is famous for.
@@ -261,17 +255,18 @@ A **decorator** is a token of the form `@name [flags]` at the start of a line th
261
255
 
262
256
  | Decorator | Description |
263
257
  |-----------|-------------|
264
- | `@watch [-n SEC] [--no-clear]` | Repeatedly run a pipeline until interrupted |
258
+ | `@watch [-n SEC] [--no-clear]` | Re-run a pipeline on a timer, showing its latest output (`q` quits) |
265
259
  | `@time` | Print elapsed wall/user/sys time after the pipeline finishes |
266
260
  | `@retry [-n N] [--delay SEC]` | Re-run the pipeline on non-zero exit, up to `N` attempts |
267
261
  | `@quiet [--stderr]` | Discard stdout (and stderr with `--stderr`); still propagates the exit code |
268
- | `@bg [name]` | Run the pipeline in a fresh background context (resumable via `Ctrl+]`) |
269
262
 
270
263
  See the [Custom Decorators](#custom-decorators) section below for authoring your own.
271
264
 
272
265
  ## Customization
273
266
 
274
- Create `~/.eosh/config.py` to define custom commands and completers. This file is plain Python that imports from eosh. Use `reload` to apply changes without restarting.
267
+ Create `~/.eosh/config.py` to define custom commands and completers. This file is plain Python that imports from eosh. Use `reload` to apply changes without restarting, or `config edit` to open it in your editor and reload when you quit. `reload` starts from a clean slate: everything the previous run registered (commands, decorators, aliases, variables, the prompt, a custom notifier) is dropped before the config runs again.
268
+
269
+ A config can't replace a built-in (`cd`, `help`, `@watch`, the `notify` variable, …) by accident: registering one of those names prints a `config warning:` line and keeps the built-in. Pass `override=True` (`registry.command("cd", override=True)`, `var_registry.register(v, override=True)`) when you mean it; `help` lists your commands apart from the built-ins, and removing the override and running `reload` brings the built-in back.
275
270
 
276
271
  ```python
277
272
  # ~/.eosh/config.py
@@ -284,7 +279,7 @@ enable("make", "git", "ssh")
284
279
 
285
280
  class InstanceCompleter(Completer):
286
281
  def complete(self, ctx):
287
- account = ctx.args[0] if ctx.args else ctx.shell_context.get_variable("ACCOUNT")
282
+ account = ctx.args[0] if ctx.args else ctx.shell_context.get_var("ACCOUNT")
288
283
  # fetch instances for account...
289
284
  return [Completion(value="i-abc123", description="web-server-1")]
290
285
 
@@ -304,37 +299,25 @@ def connect(account, region, instance_id):
304
299
 
305
300
  ### Python-Backed Variables
306
301
 
307
- A `Var` subclass mirrors the `CommandRegistry` pattern: subclass `Var`, register an instance with `var_registry`, and the built-in `var` command (and bare `NAME=VALUE` assignment) dispatches through your class. `$NAME` / `${NAME}` expansion uses the same lookup, so a Python-backed variable is read- and write-symmetric with `os.environ`.
302
+ Register a variable with `var_registry`, and the built-in `var` command, bare `NAME=VALUE` assignment and `$NAME` / `${NAME}` expansion all go through it — a Python-backed variable is read- and write-symmetric with `os.environ`. An `EnvVar` names one or more environment keys (the shell writes all of them, per context). For a value that should stay out of child processes, subclass `PyVar` (still per-context) or `GlobalVar` (one value for the whole shell).
308
303
 
309
304
  ```python
310
305
  # ~/.eosh/config.py
311
- import os
312
- from eosh import Var, EnvVar, var_registry
306
+ from eosh import EnvVar, var_registry
313
307
  from eosh.completion import ChoiceCompleter, CallbackCompleter
314
308
 
315
- class AwsRegionVar(Var):
316
- name = "aws_region"
317
- description = "AWS region — sets AWS_REGION + AWS_DEFAULT_REGION"
318
-
319
- def get(self):
320
- return os.environ.get("AWS_REGION")
321
-
322
- def set(self, value):
323
- os.environ["AWS_REGION"] = value
324
- os.environ["AWS_DEFAULT_REGION"] = value
325
-
326
- @property
327
- def value_completer(self):
328
- return ChoiceCompleter(["us-east-1", "us-west-2", "eu-west-1"])
329
-
330
- var_registry.register(AwsRegionVar())
331
- var_registry.register(
332
- EnvVar("aws_profile", "AWS_PROFILE",
333
- completer=CallbackCompleter(lambda: ["default", "prod", "staging"]))
334
- )
309
+ var_registry.register(EnvVar(
310
+ "aws_region", keys=["AWS_REGION", "AWS_DEFAULT_REGION"],
311
+ completer=ChoiceCompleter(["us-east-1", "us-west-2", "eu-west-1"]),
312
+ description="AWS region — sets AWS_REGION + AWS_DEFAULT_REGION",
313
+ ))
314
+ var_registry.register(EnvVar(
315
+ "aws_profile", keys="AWS_PROFILE",
316
+ completer=CallbackCompleter(lambda: ["default", "prod", "staging"]),
317
+ ))
335
318
  ```
336
319
 
337
- Use `EnvVar(name, env_var, completer=...)` for a single-key passthrough; subclass `Var` directly when one logical name needs to drive multiple `os.environ` keys (or any other side effect). With the variables above:
320
+ With the variables above:
338
321
 
339
322
  ```
340
323
  eosh> var aws_region=us-west-2
@@ -345,19 +328,18 @@ eosh> aws ec2 describe-instances --region $aws_region
345
328
 
346
329
  ### Custom Decorators
347
330
 
348
- To author your own decorator, decorate a function with `decorator_registry.decorator(...)`. The function receives the wrapped `Pipeline` as its first positional argument and the parsed flag namespace as kwargs; call `pipeline.run()` to execute the body and return the exit code.
331
+ A decorator is a command named `@name`: register it with `registry.command("@name", ...)`, exactly as you would a command. The function receives the body as its first positional argument (a `Sequence`: the pipelines inside the braces, joined by `;` / `&&` / `||`) and the parsed flag namespace as kwargs; call `body.run()` to execute it and return the exit code.
349
332
 
350
333
  ```python
351
334
  # ~/.eosh/config.py
352
335
  import sys
353
336
  import time
354
- from eosh.commands import arg
355
- from eosh.decorators import registry as decorator_registry
356
- from eosh.pipeline import Pipeline
337
+ from eosh.commands import arg, registry
338
+ from eosh.pipeline import Sequence
357
339
 
358
- @decorator_registry.decorator(
359
- name="repeat",
360
- help="Run the pipeline N times, stopping early on the first failure.",
340
+ @registry.command(
341
+ "@repeat",
342
+ help="Run the body N times, stopping early on the first failure.",
361
343
  params=[
362
344
  arg("-n", "--count", type=int, default=3, metavar="N",
363
345
  help="number of iterations (default 3)"),
@@ -365,11 +347,11 @@ from eosh.pipeline import Pipeline
365
347
  help="seconds to sleep between iterations"),
366
348
  ],
367
349
  )
368
- def repeat(pipeline: Pipeline, *, count: int, delay: float) -> int:
350
+ def repeat(body: Sequence, *, count: int, delay: float) -> int:
369
351
  last = 0
370
352
  for i in range(1, count + 1):
371
353
  sys.stderr.write(f"@repeat: iteration {i}/{count}\n")
372
- last = pipeline.run()
354
+ last = body.run()
373
355
  if last != 0:
374
356
  return last
375
357
  if delay > 0 and i < count:
@@ -384,40 +366,33 @@ eosh> @repeat -n 5 --delay 1 ls
384
366
  eosh> @repeat -n 3 {make && ./run-tests}
385
367
  ```
386
368
 
387
- To share decorators across machines or teammates, drop a module under `~/.eosh/decorators/<name>.py` that defines `register()` (same shape as the built-ins) and call `enable()` from `config.py`:
388
-
389
- ```python
390
- # ~/.eosh/config.py
391
- from eosh.decorators import add_decorator_path, enable as enable_decorators
392
-
393
- add_decorator_path("/team/shared/decorators") # optional extra directory
394
- enable_decorators("repeat") # found in ~/.eosh/decorators/
395
- # or /team/shared/decorators/
396
- ```
369
+ To share decorators across machines or teammates, put them in a module and import it from `config.py` — see [Your Own Recipes](#your-own-recipes).
397
370
 
398
- ### Spawning Interactive Subprocesses
371
+ ### Talking to the User: `ctx`
399
372
 
400
- If a custom command needs to spawn a subprocess that reads from the user (SSH-like sessions, TUIs, MFA prompts, anything that calls `getpass`), wrap the call with `passthrough_run` — *not* `subprocess.run`:
373
+ A command that asks questions, offers a choice, or launches an interactive program declares `pass_context=True`. Its handler then receives a `CommandContext` as its first argument:
401
374
 
402
375
  ```python
403
- from eosh import passthrough_run
404
-
405
- @registry.command(name="my_ssm", ...)
406
- def my_ssm():
407
- passthrough_run(["aws", "ssm", "start-session", "--target", "i-abc123"])
376
+ @registry.command("deploy", params=[arg("env")], pass_context=True)
377
+ def deploy(ctx, env):
378
+ target = ctx.choose(["web", "worker", "scheduler"], title="Deploy which target?")
379
+ if target is None or not ctx.confirm(f"Deploy {target} to {env}?"):
380
+ return 1
381
+ return ctx.run_interactive(["make", "deploy", f"TARGET={target}"])
408
382
  ```
409
383
 
410
- Plain `subprocess.run` would have the main shell thread and the subprocess both reading from the real terminal, splitting keystrokes between them; `passthrough_run` allocates a slot-owned PTY for the child so input flows cleanly.
384
+ | Method | Does |
385
+ |---|---|
386
+ | `ctx.input(prompt)` / `ctx.input_block(prompt)` | read a line / a pasted block (up to a blank line) |
387
+ | `ctx.confirm(prompt, default=False)` | yes/no question; `[y/N]` is appended |
388
+ | `ctx.choose(items, title="")` | inline picker; returns the item, or `None` on Esc |
389
+ | `ctx.run_interactive(argv)` | run `ssh`, an MFA prompt, a TUI… on a terminal of its own |
390
+ | `ctx.get_var(name)` / `ctx.set_var(name, value)` / `ctx.unset_var(name)` | the context's variables, as `$name` / `var name=value` |
391
+ | `ctx.context_name`, `ctx.cwd` | where the command is running |
411
392
 
412
- For reading a single line of input back from the user, use `passthrough_input(prompt)` instead of plain `input()`:
393
+ Use these instead of `input()` and `subprocess.run` for anything that reads the keyboard. A Python command runs on a background thread while the shell reads every key (that is what lets Ctrl+] switch away from it), so `input()` or an interactive `subprocess.run` would split keystrokes with the shell. Non-interactive subprocesses (`capture_output=True`, `pexpect.popen_spawn`, …) are fine as they are.
413
394
 
414
- ```python
415
- from eosh import passthrough_input
416
-
417
- answer = passthrough_input("Continue? [y/N] ")
418
- ```
419
-
420
- Outside a Python command thread, both helpers fall back to the obvious thing (`subprocess.run` and `input`), so the same code is safe in either context. Non-interactive subprocesses (`capture_output=True`, pipeline stages with explicit pipes, `pexpect.popen_spawn`, …) don't need wrapping.
395
+ `ctx` belongs to the context the command **started in**. A command you send to the background with Ctrl+] still reads and sets that context's variables, so a script started in `prod` can't change `staging`.
421
396
 
422
397
  ### Desktop Notifications
423
398
 
@@ -429,7 +404,7 @@ When a command runs for at least 10 seconds, eosh posts an OS notification as it
429
404
  ✓ eosh — 1h 05m [bg-1] terraform apply
430
405
  ```
431
406
 
432
- Commands running in a context you backgrounded with `Ctrl+]` (or with `@bg`) notify too, tagged with the context name. Interactive programs — editors, pagers, `top`, `ssh`, `tmux`, sub-shells — are skipped, since sitting in them for an hour isn't work finishing.
407
+ Commands running in a context you backgrounded with `Ctrl+]` notify too, tagged with the context name. Interactive programs — editors, pagers, `top`, `ssh`, `tmux`, sub-shells — are skipped, since sitting in them for an hour isn't work finishing.
433
408
 
434
409
  Backends are whatever the platform already ships: `osascript` on macOS, `notify-send` on Linux, a PowerShell toast on Windows, and the terminal bell as a fallback. Nothing to install.
435
410
 
@@ -453,7 +428,44 @@ notify.SKIP_COMMANDS.add("psql")
453
428
  notify.set_notifier(lambda title, message: post_to_slack(f"{title}\n{message}"))
454
429
  ```
455
430
 
456
- See [doc/notifications.md](https://github.com/crftwr/eosh/blob/v0.2.0/doc/notifications.md) for the design and the known limits.
431
+ See [doc/notifications.md](https://github.com/crftwr/eosh/blob/v0.3.0/doc/notifications.md) for the design and the known limits.
432
+
433
+ ### Event Hooks
434
+
435
+ Run your own functions when something happens in the shell. Each event is a decorator from `eosh.hooks`:
436
+
437
+ ```python
438
+ # ~/.eosh/config.py
439
+ import os
440
+ from eosh import hooks
441
+
442
+ @hooks.on_context_switched
443
+ def title(old, new):
444
+ print(f"\x1b]2;[{new}] eosh\x07", end="", flush=True) # terminal title
445
+
446
+ @hooks.on_directory_changed
447
+ def per_project(old, new):
448
+ if os.path.exists(os.path.join(new, ".venv")):
449
+ print("(this project has a .venv)")
450
+
451
+ @hooks.on_command_not_found
452
+ def auto_cd(argv): # type a directory name to cd into it
453
+ if len(argv) == 1 and os.path.isdir(argv[0]):
454
+ os.chdir(argv[0])
455
+ return True # claimed: no "command not found"
456
+ return False
457
+ ```
458
+
459
+ | Event | Called as |
460
+ |---|---|
461
+ | `on_startup` / `on_exit` | `f()` |
462
+ | `on_directory_changed` | `f(old, new)` — `cd`, a context switch, anything that moves the cwd |
463
+ | `on_context_switched` | `f(old, new)` — Ctrl+], `context switch` / `new` / `close` |
464
+ | `on_command_starting` | `f(line)` — just before a line runs (observe only) |
465
+ | `on_command_finished` | `f(line, status, elapsed)` |
466
+ | `on_command_not_found` | `f(argv) -> bool` — return `True` to claim the command |
467
+
468
+ Hooks run in registration order. A hook that raises is reported with its traceback, and the next one still runs. `reload` forgets them all before re-running the config. See [doc/hooks.md](https://github.com/crftwr/eosh/blob/v0.3.0/doc/hooks.md) for the details and [doc/limitations.md](https://github.com/crftwr/eosh/blob/v0.3.0/doc/limitations.md#event-hooks--what-they-dont-see) for the known gaps.
457
469
 
458
470
  ### Prompt Customization
459
471
 
@@ -491,9 +503,7 @@ The function is called each time the prompt is displayed, so it reflects dynamic
491
503
  | `CallbackCompleter(func)` | Complete from a function's return value |
492
504
  | `FileCompleter()` | Complete filesystem paths (files and directories) |
493
505
  | `DirCompleter()` | Complete directory paths only |
494
- | `OptionsCompleter(options, args)` | Complete flags with multi-select TUI; `args` declares value-taking flags |
495
- | `ConditionalCompleter(mapping)` | Pick a sub-completer based on preceding args |
496
- | `HistoryCompleter(history_fn, limit, ran_here_fn)` | Continue the typed line from past command lines (may span several arguments), scoped to the ones run in the cwd |
506
+ | `OptionsCompleter(options, args)` | Complete flags, one picker row each; `args` declares value-taking flags |
497
507
 
498
508
  ### Completion Recipes
499
509
 
@@ -506,71 +516,52 @@ enable("git", "make", "ssh", "kill", "ls", "grep", "find", "du", "df", "tail", "
506
516
 
507
517
  Each recipe registers flag completion (via `OptionsCompleter`) and positional completions (subcommands, files, branches, etc.) for the named command.
508
518
 
509
- Two protocol fallbacks activate automatically — no recipe needed:
519
+ Two protocols cover whole families of tools without a per-tool recipe:
510
520
 
511
- - **Cobra-based tools** (`docker`, `kubectl`, `helm`, `gh`, `argocd`, …) — `CobraCompleter` drives their `__complete` subcommand, including live resource enumeration (running containers, k8s resources, GitHub issues, …). See [doc/cobra-fallback.md](https://github.com/crftwr/eosh/blob/v0.2.0/doc/cobra-fallback.md).
512
- - **argcomplete-based Python CLIs** (`pipx`, `conda`, `pre-commit`, `tox`, `pdm`, `httpie`, …) — `ArgcompleteCompleter` detects the `# PYTHON_ARGCOMPLETE_OK` marker and drives the argcomplete protocol. See [doc/argcomplete-fallback.md](https://github.com/crftwr/eosh/blob/v0.2.0/doc/argcomplete-fallback.md).
521
+ - **Cobra-based tools** (`docker`, `kubectl`, `helm`, `gh`, `argocd`, …) — `CobraCompleter` drives their `__complete` subcommand, including live resource enumeration (running containers, k8s resources, GitHub issues, …). Opt-in by name: `enable("cobra")` covers the well-known tools, and `enable_cobra("mytool")` adds your own. See [doc/cobra.md](https://github.com/crftwr/eosh/blob/v0.3.0/doc/cobra.md).
522
+ - **argcomplete-based Python CLIs** (automatic) (`pipx`, `conda`, `pre-commit`, `tox`, `pdm`, `httpie`, …) — `ArgcompleteCompleter` detects the `# PYTHON_ARGCOMPLETE_OK` marker and drives the argcomplete protocol. See [doc/argcomplete-fallback.md](https://github.com/crftwr/eosh/blob/v0.3.0/doc/argcomplete-fallback.md).
513
523
 
514
- #### User-Defined Recipes
524
+ #### Your Own Recipes
515
525
 
516
- You can write your own recipes and place them in `~/.eosh/recipes/` (or any directory you add to the search path). `enable()` checks the search path automatically after the built-ins, so the call site in `config.py` is identical:
526
+ A recipe is plain Python: register a command with no handler, and its `params` drive completion for the external tool. Write it in `config.py`, or in a module next to it — `~/.eosh` is on `sys.path` while `config.py` runs, and `reload` re-runs the modules imported from there:
517
527
 
518
528
  ```python
519
- from eosh.recipes import enable
520
- enable("git") # built-in
521
- enable("my_tool") # found in ~/.eosh/recipes/my_tool.py
522
- ```
523
-
524
- A recipe file must define a `register()` function:
525
-
526
- ```python
527
- # ~/.eosh/recipes/my_tool.py
529
+ # ~/.eosh/my_tools.py
528
530
  from eosh.commands import arg, registry
529
- from eosh.completion import CallbackCompleter, ChoiceCompleter
530
-
531
- def register():
532
- registry.command(
533
- "my-tool",
534
- help="my-tool — deploy/rollback/status helper",
535
- params=[
536
- arg("subcommand", choices=["deploy", "rollback", "status"]),
537
- arg("target", help="deploy target", completer=CallbackCompleter(_list_targets)),
538
- arg("-v", "--verbose", action="store_true", help="verbose"),
539
- arg("--dry-run", action="store_true", help="don't apply changes"),
540
- ],
541
- )
531
+ from eosh.completion import CallbackCompleter
542
532
 
543
- def _list_targets():
544
- # Return dynamic values (cached, fetched from an API, etc.)
545
- return ["web", "worker", "scheduler"]
533
+ registry.command(
534
+ "my-tool",
535
+ help="my-tool — deploy/rollback/status helper",
536
+ params=[
537
+ arg("subcommand", choices=["deploy", "rollback", "status"]),
538
+ arg("target", help="deploy target",
539
+ completer=CallbackCompleter(lambda: ["web", "worker", "scheduler"])),
540
+ arg("-v", "--verbose", action="store_true", help="verbose"),
541
+ arg("--dry-run", action="store_true", help="don't apply changes"),
542
+ ],
543
+ )
546
544
  ```
547
545
 
548
- #### Recipe Search Path
549
-
550
- The default search path contains only `~/.eosh/recipes/`. Call `add_recipe_path()` to add more directories — useful for sharing recipes across a team:
551
-
552
546
  ```python
553
- from eosh.recipes import add_recipe_path, enable
547
+ # ~/.eosh/config.py
548
+ import sys
549
+ sys.path.append("/team/shared/eosh") # optional: recipes shared by a team
550
+ from eosh.recipes import enable
554
551
 
555
- add_recipe_path("/team/shared/recipes") # checked after ~/.eosh/recipes/
556
- enable("my_tool") # found in whichever directory contains my_tool.py first
552
+ enable("git") # built-in
553
+ import my_tools # your own, from ~/.eosh
557
554
  ```
558
555
 
559
- Lookup order for every `enable()` call:
560
-
561
- 1. Built-in package (`eosh.recipes.<name>`) — always highest priority
562
- 2. `~/.eosh/recipes/<name>.py` — personal recipes
563
- 3. Additional paths in the order they were added via `add_recipe_path()`
564
-
565
- You can also read or modify `recipe_search_path` directly (it is a plain `list[Path]`).
556
+ A module you import may use third-party packages, but they must be installed
557
+ in eosh's own environment (see [Installation](#installation) for `uv tool` /
558
+ `pipx`).
566
559
 
567
- A recipe may import third-party packages, but they must be installed in
568
- eosh's own environment (see [Installation](#installation) for `uv tool` /
569
- `pipx`). When one is missing, `enable("*")` skips that recipe instead of
570
- failing the whole config and registers a placeholder command under the
571
- recipe's name (unless that name is already a command or an executable on
572
- `PATH`); running it says which module is missing and how to install it.
573
- Naming the recipe explicitly — `enable("my_tool")` — still raises.
560
+ `enable()` never stops your config: an unknown name, or a recipe whose
561
+ `register()` raises, is a `config warning:` line (the latter with its
562
+ traceback) and the remaining names still load. An error in a module of your
563
+ own — a typo in an import, a syntax error — is printed at startup with a
564
+ traceback through your files.
574
565
 
575
566
  ### Writing a Custom Completer
576
567
 
@@ -580,7 +571,7 @@ Subclass `Completer` and implement `complete()`. The `CompletionContext` gives y
580
571
  - `args` — previously completed arguments
581
572
  - `arg_index` — which argument position is being completed
582
573
  - `prefix` — partial text typed so far
583
- - `shell_context` — the active context (access variables with `.get_variable()`)
574
+ - `shell_context` — the active context, read-only: `.get_var(name)`, `.context_name`, `.cwd`
584
575
 
585
576
  To add completion to a system command without wrapping it, register a handler-less command — execution falls through to the real binary:
586
577
 
@@ -604,12 +595,12 @@ registry.command(
604
595
  | Key | Action |
605
596
  |-----|--------|
606
597
  | `Tab` | Open completion picker |
607
- | `Ctrl+R` | Search history |
598
+ | `Ctrl+R` | Search history, starting from what's typed |
608
599
  | `Ctrl+]` | Open context switcher |
609
600
  | `↑` / `↓` or `Ctrl+P/N` | Navigate history |
610
- | `Ctrl+A` / `Ctrl+E` | Move to start / end of line |
611
- | `Ctrl+B` / `Ctrl+F` | Move one character left / right |
612
- | `Alt+B` / `Alt+F` | Move one word left / right |
601
+ | `Ctrl+A` / `Ctrl+E` | Move to start / end of line (`Ctrl+E` at the end accepts the suggestion) |
602
+ | `Ctrl+B` / `Ctrl+F`, `←` / `→` | Move one character left / right (at the end, accept the suggestion) |
603
+ | `Alt+B` / `Alt+F` | Move one word left / right (`Alt+F` at the end accepts one word of the suggestion) |
613
604
  | `Ctrl+W` | Delete word before cursor |
614
605
  | `Ctrl+K` | Delete to end of line |
615
606
  | `Ctrl+U` | Delete to beginning of line |
@@ -621,10 +612,8 @@ registry.command(
621
612
  | Path | Purpose |
622
613
  |------|---------|
623
614
  | `~/.eosh/config.py` | User configuration |
624
- | `~/.eosh/history` | Command history |
625
- | `~/.eosh/history.dirs` | Directories each history line was run in (scopes history TAB candidates) |
626
- | `~/.eosh/recipes/<name>.py` | User-defined completion recipes (loaded by `enable("<name>")`) |
627
- | `~/.eosh/decorators/<name>.py` | User-defined pipeline decorators (loaded by `enable("<name>")`) |
615
+ | `~/.eosh/history.db` | Command history, shared by every eosh process (SQLite) |
616
+ | `~/.eosh/*.py` | Your own modules (recipes, commands, decorators) — on `sys.path` while `config.py` runs |
628
617
 
629
618
  ## Platform Support
630
619
 
@@ -634,4 +623,4 @@ The one POSIX-only feature is **PTY-backed multiplexing of a live external proce
634
623
 
635
624
  ## License
636
625
 
637
- MIT — see [LICENSE](https://github.com/crftwr/eosh/blob/v0.2.0/LICENSE).
626
+ MIT — see [LICENSE](https://github.com/crftwr/eosh/blob/v0.3.0/LICENSE).