kash-shell 0.3.0__py3-none-any.whl

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 (269) hide show
  1. kash/__init__.py +2 -0
  2. kash/__main__.py +4 -0
  3. kash/actions/__init__.py +55 -0
  4. kash/actions/core/assistant_chat.py +45 -0
  5. kash/actions/core/chat.py +90 -0
  6. kash/actions/core/format_markdown_template.py +92 -0
  7. kash/actions/core/markdownify.py +29 -0
  8. kash/actions/core/readability.py +27 -0
  9. kash/actions/core/show_webpage.py +28 -0
  10. kash/actions/core/strip_html.py +28 -0
  11. kash/actions/core/summarize_as_bullets.py +53 -0
  12. kash/actions/core/webpage_config.py +21 -0
  13. kash/actions/core/webpage_generate.py +29 -0
  14. kash/actions/meta/write_instructions.py +39 -0
  15. kash/actions/meta/write_new_action.py +157 -0
  16. kash/commands/__init__.py +21 -0
  17. kash/commands/base/basic_file_commands.py +183 -0
  18. kash/commands/base/browser_commands.py +62 -0
  19. kash/commands/base/debug_commands.py +214 -0
  20. kash/commands/base/diff_commands.py +90 -0
  21. kash/commands/base/files_command.py +408 -0
  22. kash/commands/base/general_commands.py +104 -0
  23. kash/commands/base/global_state_commands.py +41 -0
  24. kash/commands/base/logs_commands.py +92 -0
  25. kash/commands/base/reformat_command.py +54 -0
  26. kash/commands/base/search_command.py +65 -0
  27. kash/commands/base/show_command.py +69 -0
  28. kash/commands/extras/utils_commands.py +27 -0
  29. kash/commands/help/assistant_commands.py +97 -0
  30. kash/commands/help/doc_commands.py +226 -0
  31. kash/commands/help/help_commands.py +133 -0
  32. kash/commands/workspace/selection_commands.py +200 -0
  33. kash/commands/workspace/workspace_commands.py +640 -0
  34. kash/concepts/concept_formats.py +23 -0
  35. kash/concepts/embeddings.py +130 -0
  36. kash/concepts/text_similarity.py +108 -0
  37. kash/config/__init__.py +4 -0
  38. kash/config/api_keys.py +84 -0
  39. kash/config/capture_output.py +77 -0
  40. kash/config/colors.py +279 -0
  41. kash/config/init.py +18 -0
  42. kash/config/lazy_imports.py +22 -0
  43. kash/config/logger.py +355 -0
  44. kash/config/logger_basic.py +35 -0
  45. kash/config/logo.txt +4 -0
  46. kash/config/logo_fancy.txt +4 -0
  47. kash/config/server_config.py +51 -0
  48. kash/config/settings.py +196 -0
  49. kash/config/setup.py +51 -0
  50. kash/config/suppress_warnings.py +27 -0
  51. kash/config/text_styles.py +426 -0
  52. kash/docs/__init__.py +0 -0
  53. kash/docs/all_docs.py +58 -0
  54. kash/docs/load_actions_info.py +28 -0
  55. kash/docs/load_api_docs.py +13 -0
  56. kash/docs/load_help_topics.py +47 -0
  57. kash/docs/load_source_code.py +125 -0
  58. kash/docs/markdown/api_docs_template.md +42 -0
  59. kash/docs/markdown/assistant_instructions_template.md +114 -0
  60. kash/docs/markdown/readme_template.md +26 -0
  61. kash/docs/markdown/topics/a1_what_is_kash.md +76 -0
  62. kash/docs/markdown/topics/a2_progress.md +96 -0
  63. kash/docs/markdown/topics/a3_installation.md +119 -0
  64. kash/docs/markdown/topics/a4_getting_started.md +300 -0
  65. kash/docs/markdown/topics/a5_tips_for_use_with_other_tools.md +83 -0
  66. kash/docs/markdown/topics/b0_philosophy_of_kash.md +177 -0
  67. kash/docs/markdown/topics/b1_kash_overview.md +124 -0
  68. kash/docs/markdown/topics/b2_workspace_and_file_formats.md +61 -0
  69. kash/docs/markdown/topics/b3_modern_shell_tool_recommendations.md +83 -0
  70. kash/docs/markdown/topics/b4_faq.md +166 -0
  71. kash/docs/markdown/warning.md +7 -0
  72. kash/docs/markdown/welcome.md +9 -0
  73. kash/docs_base/docs_base.py +85 -0
  74. kash/docs_base/load_custom_command_info.py +27 -0
  75. kash/docs_base/load_faqs.py +48 -0
  76. kash/docs_base/load_recipe_snippets.py +48 -0
  77. kash/docs_base/recipes/general_system_commands.ksh +10 -0
  78. kash/docs_base/recipes/python_dev_commands.ksh +7 -0
  79. kash/docs_base/recipes/tldr_standard_commands.ksh +2144 -0
  80. kash/errors.py +176 -0
  81. kash/exec/__init__.py +16 -0
  82. kash/exec/action_decorators.py +412 -0
  83. kash/exec/action_exec.py +457 -0
  84. kash/exec/action_registry.py +123 -0
  85. kash/exec/combiners.py +127 -0
  86. kash/exec/command_exec.py +34 -0
  87. kash/exec/command_registry.py +72 -0
  88. kash/exec/fetch_url_metadata.py +71 -0
  89. kash/exec/history.py +44 -0
  90. kash/exec/llm_transforms.py +121 -0
  91. kash/exec/precondition_checks.py +71 -0
  92. kash/exec/precondition_registry.py +43 -0
  93. kash/exec/preconditions.py +152 -0
  94. kash/exec/resolve_args.py +123 -0
  95. kash/exec/shell_callable_action.py +90 -0
  96. kash/exec_model/__init__.py +0 -0
  97. kash/exec_model/args_model.py +93 -0
  98. kash/exec_model/commands_model.py +163 -0
  99. kash/exec_model/script_model.py +161 -0
  100. kash/exec_model/shell_model.py +21 -0
  101. kash/file_storage/__init__.py +0 -0
  102. kash/file_storage/file_store.py +642 -0
  103. kash/file_storage/item_file_format.py +152 -0
  104. kash/file_storage/metadata_dirs.py +108 -0
  105. kash/file_storage/mtime_cache.py +108 -0
  106. kash/file_storage/persisted_yaml.py +37 -0
  107. kash/file_storage/store_cache_warmer.py +37 -0
  108. kash/file_storage/store_filenames.py +53 -0
  109. kash/form_input/__init__.py +0 -0
  110. kash/form_input/prompt_input.py +44 -0
  111. kash/help/__init__.py +0 -0
  112. kash/help/assistant.py +324 -0
  113. kash/help/assistant_instructions.py +68 -0
  114. kash/help/assistant_output.py +43 -0
  115. kash/help/docstring_utils.py +111 -0
  116. kash/help/function_param_info.py +44 -0
  117. kash/help/help_embeddings.py +85 -0
  118. kash/help/help_lookups.py +60 -0
  119. kash/help/help_pages.py +122 -0
  120. kash/help/help_printing.py +169 -0
  121. kash/help/help_types.py +247 -0
  122. kash/help/recommended_commands.py +143 -0
  123. kash/help/tldr_help.py +296 -0
  124. kash/llm_utils/__init__.py +0 -0
  125. kash/llm_utils/chat_format.py +413 -0
  126. kash/llm_utils/clean_headings.py +65 -0
  127. kash/llm_utils/fuzzy_parsing.py +119 -0
  128. kash/llm_utils/language_models.py +178 -0
  129. kash/llm_utils/llm_completion.py +172 -0
  130. kash/llm_utils/llm_messages.py +36 -0
  131. kash/local_server/__init__.py +2 -0
  132. kash/local_server/local_server.py +183 -0
  133. kash/local_server/local_server_commands.py +55 -0
  134. kash/local_server/local_server_routes.py +306 -0
  135. kash/local_server/local_url_formatters.py +169 -0
  136. kash/local_server/port_tools.py +67 -0
  137. kash/local_server/rich_html_template.py +12 -0
  138. kash/mcp/__init__.py +2 -0
  139. kash/mcp/mcp_main.py +67 -0
  140. kash/mcp/mcp_server_commands.py +57 -0
  141. kash/mcp/mcp_server_routes.py +256 -0
  142. kash/mcp/mcp_server_sse.py +143 -0
  143. kash/mcp/mcp_server_stdio.py +45 -0
  144. kash/media_base/__init__.py +0 -0
  145. kash/media_base/audio_processing.py +27 -0
  146. kash/media_base/media_cache.py +178 -0
  147. kash/media_base/media_services.py +112 -0
  148. kash/media_base/media_tools.py +48 -0
  149. kash/media_base/services/local_file_media.py +165 -0
  150. kash/media_base/speech_transcription.py +224 -0
  151. kash/media_base/timestamp_citations.py +80 -0
  152. kash/model/__init__.py +73 -0
  153. kash/model/actions_model.py +633 -0
  154. kash/model/assistant_response_model.py +87 -0
  155. kash/model/compound_actions_model.py +188 -0
  156. kash/model/graph_model.py +92 -0
  157. kash/model/items_model.py +821 -0
  158. kash/model/language_list.py +39 -0
  159. kash/model/llm_actions_model.py +63 -0
  160. kash/model/media_model.py +124 -0
  161. kash/model/operations_model.py +176 -0
  162. kash/model/params_model.py +435 -0
  163. kash/model/paths_model.py +458 -0
  164. kash/model/preconditions_model.py +98 -0
  165. kash/shell/__init__.py +0 -0
  166. kash/shell/completions/completion_scoring.py +280 -0
  167. kash/shell/completions/completion_types.py +154 -0
  168. kash/shell/completions/shell_completions.py +277 -0
  169. kash/shell/file_icons/color_for_format.py +70 -0
  170. kash/shell/file_icons/nerd_icons.py +946 -0
  171. kash/shell/output/__init__.py +0 -0
  172. kash/shell/output/kerm_code_utils.py +59 -0
  173. kash/shell/output/kerm_codes.py +588 -0
  174. kash/shell/output/kmarkdown.py +117 -0
  175. kash/shell/output/shell_output.py +477 -0
  176. kash/shell/ui/__init__.py +0 -0
  177. kash/shell/ui/shell_results.py +118 -0
  178. kash/shell/ui/shell_syntax.py +26 -0
  179. kash/shell/utils/exception_printing.py +50 -0
  180. kash/shell/utils/native_utils.py +240 -0
  181. kash/shell/utils/osc_utils.py +95 -0
  182. kash/shell/utils/shell_function_wrapper.py +204 -0
  183. kash/shell/utils/sys_tool_deps.py +289 -0
  184. kash/shell/utils/terminal_images.py +133 -0
  185. kash/shell_main.py +67 -0
  186. kash/text_handling/custom_sliding_transforms.py +266 -0
  187. kash/text_handling/doc_normalization.py +64 -0
  188. kash/text_handling/markdown_util.py +167 -0
  189. kash/text_handling/unified_diffs.py +138 -0
  190. kash/utils/__init__.py +4 -0
  191. kash/utils/common/__init__.py +4 -0
  192. kash/utils/common/atomic_var.py +147 -0
  193. kash/utils/common/format_utils.py +81 -0
  194. kash/utils/common/function_inspect.py +178 -0
  195. kash/utils/common/import_utils.py +89 -0
  196. kash/utils/common/lazyobject.py +144 -0
  197. kash/utils/common/obj_replace.py +78 -0
  198. kash/utils/common/parse_key_vals.py +85 -0
  199. kash/utils/common/parse_shell_args.py +348 -0
  200. kash/utils/common/stack_traces.py +49 -0
  201. kash/utils/common/string_replace.py +93 -0
  202. kash/utils/common/string_template.py +101 -0
  203. kash/utils/common/task_stack.py +162 -0
  204. kash/utils/common/type_utils.py +137 -0
  205. kash/utils/common/uniquifier.py +95 -0
  206. kash/utils/common/url.py +155 -0
  207. kash/utils/file_utils/__init__.py +3 -0
  208. kash/utils/file_utils/dir_size.py +48 -0
  209. kash/utils/file_utils/file_ext.py +86 -0
  210. kash/utils/file_utils/file_formats.py +134 -0
  211. kash/utils/file_utils/file_formats_model.py +408 -0
  212. kash/utils/file_utils/file_sort_filter.py +235 -0
  213. kash/utils/file_utils/file_walk.py +163 -0
  214. kash/utils/file_utils/filename_parsing.py +99 -0
  215. kash/utils/file_utils/git_tools.py +19 -0
  216. kash/utils/file_utils/ignore_files.py +166 -0
  217. kash/utils/file_utils/path_utils.py +36 -0
  218. kash/utils/lang_utils/__init__.py +0 -0
  219. kash/utils/lang_utils/capitalization.py +128 -0
  220. kash/utils/lang_utils/inflection.py +18 -0
  221. kash/utils/rich_custom/__init__.py +3 -0
  222. kash/utils/rich_custom/ansi_cell_len.py +72 -0
  223. kash/utils/rich_custom/rich_char_transform.py +89 -0
  224. kash/utils/rich_custom/rich_indent.py +69 -0
  225. kash/utils/rich_custom/rich_markdown_fork.py +771 -0
  226. kash/version.py +31 -0
  227. kash/web_content/canon_url.py +24 -0
  228. kash/web_content/dir_store.py +103 -0
  229. kash/web_content/file_cache_utils.py +117 -0
  230. kash/web_content/local_file_cache.py +247 -0
  231. kash/web_content/web_extract.py +55 -0
  232. kash/web_content/web_extract_justext.py +86 -0
  233. kash/web_content/web_extract_readabilipy.py +23 -0
  234. kash/web_content/web_fetch.py +101 -0
  235. kash/web_content/web_page_model.py +28 -0
  236. kash/web_gen/__init__.py +4 -0
  237. kash/web_gen/tabbed_webpage.py +149 -0
  238. kash/web_gen/template_render.py +29 -0
  239. kash/web_gen/templates/base_styles.css.jinja +192 -0
  240. kash/web_gen/templates/base_webpage.html.jinja +124 -0
  241. kash/web_gen/templates/content_styles.css.jinja +194 -0
  242. kash/web_gen/templates/explain_view.html.jinja +49 -0
  243. kash/web_gen/templates/item_view.html.jinja +294 -0
  244. kash/web_gen/templates/tabbed_webpage.html.jinja +49 -0
  245. kash/workspaces/__init__.py +13 -0
  246. kash/workspaces/param_state.py +24 -0
  247. kash/workspaces/selections.py +333 -0
  248. kash/workspaces/source_items.py +88 -0
  249. kash/workspaces/workspace_importing.py +56 -0
  250. kash/workspaces/workspace_names.py +33 -0
  251. kash/workspaces/workspace_output.py +154 -0
  252. kash/workspaces/workspace_registry.py +78 -0
  253. kash/workspaces/workspaces.py +197 -0
  254. kash/xonsh_custom/custom_shell.py +366 -0
  255. kash/xonsh_custom/customize_prompt.py +197 -0
  256. kash/xonsh_custom/customize_xonsh.py +112 -0
  257. kash/xonsh_custom/shell_load_commands.py +152 -0
  258. kash/xonsh_custom/shell_which.py +64 -0
  259. kash/xonsh_custom/xonsh_completers.py +715 -0
  260. kash/xonsh_custom/xonsh_env.py +28 -0
  261. kash/xonsh_custom/xonsh_modern_tools.py +56 -0
  262. kash/xonsh_custom/xonsh_ranking_completer.py +152 -0
  263. kash/xontrib/fnm.py +120 -0
  264. kash/xontrib/kash_extension.py +61 -0
  265. kash_shell-0.3.0.dist-info/METADATA +757 -0
  266. kash_shell-0.3.0.dist-info/RECORD +269 -0
  267. kash_shell-0.3.0.dist-info/WHEEL +4 -0
  268. kash_shell-0.3.0.dist-info/entry_points.txt +3 -0
  269. kash_shell-0.3.0.dist-info/licenses/LICENSE +664 -0
@@ -0,0 +1,26 @@
1
+ import re
2
+
3
+ from kash.utils.common.parse_shell_args import shell_quote
4
+
5
+
6
+ def is_assist_request_str(line: str) -> str | None:
7
+ """
8
+ Is this a query to the assistant?
9
+ Checks for phrases ending in a ? or starting with a ?.
10
+ """
11
+ line = line.strip()
12
+ if re.search(r"\b\w+\?$", line) or line.startswith("?"):
13
+ return line.lstrip("?").strip()
14
+ return None
15
+
16
+
17
+ def assist_request_str(nl_req: str) -> str:
18
+ """
19
+ Command string to call the assistant with a natural language request.
20
+ """
21
+ nl_req = nl_req.lstrip("? ").rstrip()
22
+ # Quoting isn't necessary unless we have quote marks.
23
+ if "'" in nl_req or '"' in nl_req:
24
+ return f"? {shell_quote(nl_req, idempotent=True)}"
25
+ else:
26
+ return f"? {nl_req}"
@@ -0,0 +1,50 @@
1
+ from collections.abc import Callable
2
+ from functools import wraps
3
+ from typing import TypeVar
4
+
5
+ from kash.config.logger import get_logger
6
+ from kash.config.text_styles import COLOR_ERROR
7
+ from kash.errors import NONFATAL_EXCEPTIONS
8
+ from kash.shell.output.shell_output import PrintHooks
9
+
10
+ log = get_logger(__name__)
11
+
12
+
13
+ def summarize_traceback(exception: Exception) -> str:
14
+ exception_str = str(exception)
15
+ lines = exception_str.splitlines()
16
+ exc_type = type(exception).__name__
17
+ return f"{exc_type}: " + "\n".join(
18
+ [
19
+ line
20
+ for line in lines
21
+ if line.strip()
22
+ and not line.lstrip().startswith("Traceback")
23
+ # and not line.lstrip().startswith("File ")
24
+ and not line.lstrip().startswith("The above exception")
25
+ and not line.startswith(" ")
26
+ ]
27
+ + ["\nRun `logs` for details."]
28
+ )
29
+
30
+
31
+ R = TypeVar("R")
32
+
33
+
34
+ def wrap_with_exception_printing(func: Callable[..., R]) -> Callable[[list[str]], R | None]:
35
+ @wraps(func)
36
+ def command(*args) -> R | None:
37
+ try:
38
+ log.info(
39
+ "Command function call: %s(%s)",
40
+ func.__name__,
41
+ (", ".join(str(arg) for arg in args)),
42
+ )
43
+ return func(*args)
44
+ except NONFATAL_EXCEPTIONS as e:
45
+ PrintHooks.nonfatal_exception()
46
+ log.error(f"[{COLOR_ERROR}]Command error:[/{COLOR_ERROR}] %s", summarize_traceback(e))
47
+ log.info("Command error details: %s", e, exc_info=True)
48
+ return None
49
+
50
+ return command
@@ -0,0 +1,240 @@
1
+ """
2
+ Platform-specific tools and utilities.
3
+ """
4
+
5
+ import os
6
+ import shlex
7
+ import subprocess
8
+ import urllib.parse
9
+ import webbrowser
10
+ from enum import Enum
11
+ from pathlib import Path
12
+
13
+ from flowmark import Wrap
14
+ from funlog import log_calls
15
+
16
+ from kash.config.logger import get_logger
17
+ from kash.config.text_styles import BAT_STYLE, BAT_THEME, COLOR_ERROR
18
+ from kash.errors import FileNotFound, SetupError
19
+ from kash.shell.output.shell_output import cprint
20
+ from kash.shell.utils.sys_tool_deps import PLATFORM, Platform, SysTool, sys_tool_check
21
+ from kash.shell.utils.terminal_images import terminal_show_image
22
+ from kash.utils.common.format_utils import fmt_loc
23
+ from kash.utils.common.url import as_file_url, is_file_url, is_url
24
+ from kash.utils.file_utils.file_formats import is_full_html_page, read_partial_text
25
+ from kash.utils.file_utils.file_formats_model import file_format_info
26
+
27
+ log = get_logger(__name__)
28
+
29
+
30
+ def file_size_check(
31
+ filename: str | Path, max_lines: int = 100, max_bytes: int = 50 * 1024
32
+ ) -> tuple[int, int]:
33
+ """
34
+ Get the size and scan to get initial line count (up to max_lines) of a file.
35
+ """
36
+ filename = str(filename)
37
+ file_size = os.path.getsize(filename)
38
+ line_min = 0
39
+ with open(filename, "rb") as f:
40
+ for i, _line in enumerate(f):
41
+ if i >= max_lines or f.tell() > max_bytes:
42
+ break
43
+ line_min += 1
44
+ return file_size, line_min
45
+
46
+
47
+ def native_open(filename: str | Path):
48
+ filename = str(filename)
49
+ log.message("Opening file: %s", filename)
50
+ if PLATFORM == Platform.Darwin:
51
+ subprocess.run(["open", filename])
52
+ elif PLATFORM == Platform.Linux:
53
+ subprocess.run(["xdg-open", filename])
54
+ elif PLATFORM == Platform.Windows:
55
+ subprocess.run(["start", shlex.quote(filename)], shell=True)
56
+ else:
57
+ raise NotImplementedError("Unsupported platform")
58
+
59
+
60
+ def native_open_url(url_or_query: str):
61
+ """
62
+ Open a URL or query in the system's default browser.
63
+ """
64
+ log.message("Opening in browser: %s", url_or_query)
65
+ if is_url(url_or_query):
66
+ webbrowser.open(url_or_query)
67
+ else:
68
+ webbrowser.open(f"https://www.google.com/search?q={urllib.parse.quote(url_or_query)}")
69
+
70
+
71
+ class ViewMode(Enum):
72
+ auto = "auto"
73
+ console = "console"
74
+ browser = "browser"
75
+ native = "native"
76
+ terminal_image = "terminal_image"
77
+
78
+
79
+ @log_calls(level="info")
80
+ def _detect_view_mode(file_or_url: str) -> ViewMode:
81
+ # As a heuristic, we use the browser for URLs and for local files that are
82
+ # clearly full HTML pages (since HTML fragments are fine on console).
83
+ if is_url(file_or_url) and not is_file_url(file_or_url):
84
+ return ViewMode.browser
85
+
86
+ path = Path(file_or_url)
87
+ if path.is_file(): # File or symlink.
88
+ content = read_partial_text(path)
89
+ if content and is_full_html_page(content):
90
+ return ViewMode.browser
91
+
92
+ info = file_format_info(path)
93
+ log.info("File format detected: %s", info)
94
+
95
+ if info.is_text:
96
+ return ViewMode.console
97
+ if info.is_image:
98
+ log.info("Detected image file, will display in terminal")
99
+ return ViewMode.terminal_image
100
+ else:
101
+ return ViewMode.native
102
+ elif path.is_dir():
103
+ return ViewMode.native
104
+ else:
105
+ raise FileNotFound(fmt_loc(file_or_url))
106
+
107
+
108
+ def view_file_native(
109
+ file_or_url: str | Path,
110
+ view_mode: ViewMode = ViewMode.auto,
111
+ ):
112
+ """
113
+ Open a file or URL in the console or a native app. If `view_mode` is auto,
114
+ automatically determine whether to use console, web browser, or the user's
115
+ preferred native application. For images, also tries terminal-based image
116
+ display.
117
+ """
118
+ file_or_url = str(file_or_url)
119
+ path = None
120
+ if not is_url(file_or_url):
121
+ path = Path(file_or_url)
122
+ if not path.exists():
123
+ raise FileNotFound(fmt_loc(path))
124
+
125
+ if view_mode == ViewMode.auto:
126
+ view_mode = _detect_view_mode(file_or_url)
127
+
128
+ if view_mode == ViewMode.browser:
129
+ url = file_or_url if is_url(file_or_url) else as_file_url(file_or_url)
130
+ log.message("Opening URL in browser: %s", url)
131
+ webbrowser.open(url)
132
+ elif view_mode == ViewMode.console and path:
133
+ file_size, min_lines = file_size_check(path)
134
+ view_file_console(path, use_pager=min_lines > 40 or file_size > 20 * 1024)
135
+ elif view_mode == ViewMode.terminal_image and path:
136
+ try:
137
+ terminal_show_image(path)
138
+ except SetupError as e:
139
+ log.info("%s: %s", e, path)
140
+ native_open(path)
141
+ elif view_mode == ViewMode.native:
142
+ native_open(file_or_url)
143
+ else:
144
+ raise ValueError(f"Don't know how to view: {view_mode}: {file_or_url}")
145
+
146
+
147
+ def tail_file(
148
+ filename: str | Path,
149
+ follow: bool = False,
150
+ max_lines: int = 10000,
151
+ follow_max_lines: int = 200,
152
+ ):
153
+ """
154
+ Tail a log file. With colorization using bat if available, otherwise using less.
155
+ If follow is True, follows the file as it grows.
156
+
157
+ Uses bat if available. Note bat doesn't have efficient seek functionality like
158
+ `less +G` so we prefer to use bat with less. Use Ctrl-C to quit less (this is
159
+ enabled with `less -K`).
160
+ """
161
+ filename = str(filename)
162
+ quoted_filename = shlex.quote(filename)
163
+
164
+ if follow:
165
+ max_lines = follow_max_lines
166
+
167
+ sys_tool_check().require(SysTool.less)
168
+ sys_tool_check().warn_if_missing(SysTool.bat, SysTool.tail)
169
+
170
+ if follow:
171
+ if sys_tool_check().has(SysTool.bat, SysTool.tail, SysTool.less):
172
+ # Follow the file in real-time.
173
+ command = (
174
+ f"tail -{max_lines} -f {quoted_filename} | "
175
+ f"bat --paging=never --color=always --style=plain --theme={BAT_THEME} -l log | "
176
+ "less -K -R +F"
177
+ )
178
+ else:
179
+ command = f"tail -f {quoted_filename} | less -R +F"
180
+ cprint("Following file: `%s`", command, text_wrap=Wrap.NONE)
181
+ else:
182
+ if sys_tool_check().has(SysTool.bat, SysTool.tail, SysTool.less):
183
+ command = (
184
+ f"tail -{max_lines} {quoted_filename} | "
185
+ f"bat --paging=never --color=always --style=plain --theme={BAT_THEME} -l log | "
186
+ "less -K -R +G"
187
+ )
188
+ else:
189
+ command = f"less +G {quoted_filename}"
190
+ cprint("Tailing file: `%s`", command, text_wrap=Wrap.NONE)
191
+
192
+ subprocess.run(command, shell=True, check=True)
193
+
194
+
195
+ def view_file_console(filename: str | Path, use_pager: bool = True):
196
+ """
197
+ Displays a file in the console with pagination and syntax highlighting.
198
+ """
199
+ filename = str(filename)
200
+ quoted_filename = shlex.quote(filename)
201
+
202
+ # TODO: Visualize YAML frontmatter with different syntax/style than Markdown content.
203
+
204
+ is_text = file_format_info(filename).is_text
205
+ if is_text:
206
+ sys_tool_check().require(SysTool.less)
207
+ if sys_tool_check().has(SysTool.bat):
208
+ pager_str = "--pager=always --pager=less " if use_pager else ""
209
+ command = f"bat {pager_str}--color=always --style={BAT_STYLE} --theme={BAT_THEME} {quoted_filename}"
210
+ else:
211
+ sys_tool_check().require(SysTool.pygmentize)
212
+ command = f"pygmentize -g {quoted_filename}"
213
+ if use_pager:
214
+ command = f"{command} | less -R"
215
+ else:
216
+ sys_tool_check().require(SysTool.hexyl)
217
+ command = f"hexyl {quoted_filename}"
218
+ if use_pager:
219
+ command = f"{command} | less -R"
220
+
221
+ try:
222
+ subprocess.run(command, shell=True, check=True)
223
+ except subprocess.CalledProcessError as e:
224
+ cprint(f"Error displaying file: {e}", style=COLOR_ERROR, text_wrap=Wrap.NONE)
225
+
226
+
227
+ def edit_files(*filenames: str | Path):
228
+ """
229
+ Edit a file using the user's preferred editor.
230
+ """
231
+ from kash.config.settings import global_settings
232
+
233
+ editor = os.getenv("EDITOR", global_settings().default_editor)
234
+ subprocess.run([editor] + list(filenames))
235
+
236
+
237
+ def native_trash(*paths: str | Path):
238
+ from send2trash import send2trash
239
+
240
+ send2trash(list(Path(p) for p in paths))
@@ -0,0 +1,95 @@
1
+ import os
2
+ from functools import cache
3
+
4
+ from rich.style import Style
5
+ from rich.text import Text
6
+
7
+
8
+ @cache
9
+ def terminal_supports_osc8() -> bool:
10
+ """
11
+ Attempt to detect if the terminal supports OSC 8 hyperlinks.
12
+ """
13
+ term_program = os.environ.get("TERM_PROGRAM", "")
14
+ term = os.environ.get("TERM", "")
15
+
16
+ if term_program in ["iTerm.app", "WezTerm", "Hyper"]:
17
+ return True
18
+ if "konsole" in term_program.lower():
19
+ return True
20
+ if "kitty" in term or "xterm" in term:
21
+ return True
22
+ if "vscode" in term_program.lower():
23
+ return True
24
+
25
+ return False
26
+
27
+
28
+ # Constants for OSC control sequences
29
+ OSC_START = "\x1b]"
30
+ ST_CODE = "\x1b\\" # String Terminator
31
+ BEL_CODE = "\x07" # Bell character
32
+
33
+ OSC_HYPERLINK = "8"
34
+
35
+
36
+ class OscStr(str):
37
+ """
38
+ Marker class for strings that contain OSC codes.
39
+ """
40
+
41
+ pass
42
+
43
+
44
+ def osc_code(code: int | str, data: str) -> OscStr:
45
+ """
46
+ Return an extended OSC code.
47
+ """
48
+ return OscStr(f"{OSC_START}{code};{data}{ST_CODE}")
49
+
50
+
51
+ def osc8_link_codes(uri: str, metadata_str: str = "") -> tuple[str, str]:
52
+ safe_uri = uri.replace(";", "%3B") # Escape semicolons in the URL.
53
+ safe_metadata = metadata_str.replace(";", "%3B")
54
+ escape_start = f"{OSC_START}{OSC_HYPERLINK};{safe_metadata};{safe_uri}{ST_CODE}"
55
+ escape_end = f"{OSC_START}{OSC_HYPERLINK};;{ST_CODE}"
56
+ return escape_start, escape_end
57
+
58
+
59
+ def osc8_link(uri: str, text: str, metadata_str: str = "") -> OscStr:
60
+ r"""
61
+ Return a string with the OSC 8 hyperlink escape sequence.
62
+
63
+ Format: ESC ] 8 ; params ; URI ST text ESC ] 8 ; ; ST
64
+
65
+ ST (String Terminator) is either ESC \ or BEL but the former is more common.
66
+
67
+ Spec: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
68
+
69
+ :param uri: The URI or URL for the hyperlink.
70
+ :param text: The clickable text to display.
71
+ :param metadata_str: Optional metadata between the semicolons.
72
+ """
73
+ escape_start, escape_end = osc8_link_codes(uri, metadata_str)
74
+
75
+ return OscStr(f"{escape_start}{text}{escape_end}")
76
+
77
+
78
+ def osc8_link_graceful(uri: str, text: str, id: str = "") -> OscStr | str:
79
+ """
80
+ Generate clickable text for terminal emulators supporting OSC 8 with a fallback
81
+ for non-supporting terminals to make the link visible.
82
+ """
83
+ if terminal_supports_osc8():
84
+ metadata_str = f"id={id}" if id else ""
85
+ return osc8_link(uri, text, metadata_str)
86
+ else:
87
+ # Fallback for non-supporting terminals.
88
+ return f"{text} ({uri})"
89
+
90
+
91
+ def osc8_link_rich(uri: str, text: str, metadata_str: str = "", style: str | Style = "") -> Text:
92
+ """
93
+ Must use Text.from_ansi() for Rich to handle links correctly!
94
+ """
95
+ return Text.from_ansi(osc8_link(uri, text, metadata_str), style=style)
@@ -0,0 +1,204 @@
1
+ import types
2
+ from collections.abc import Callable, Mapping
3
+ from enum import Enum
4
+ from functools import wraps
5
+ from typing import Any, TypeVar, cast, get_args
6
+
7
+ from kash.config.logger import get_logger
8
+ from kash.errors import InvalidCommand
9
+ from kash.exec.command_registry import CommandFunction
10
+ from kash.help.help_printing import print_command_function_help
11
+ from kash.utils.common.function_inspect import FuncParam, inspect_function_params
12
+ from kash.utils.common.parse_shell_args import parse_shell_args
13
+
14
+ log = get_logger(__name__)
15
+
16
+
17
+ def _map_positional(
18
+ pos_args: list[str], pos_params: list[FuncParam], kw_params: list[FuncParam]
19
+ ) -> tuple[list[Any], int]:
20
+ """
21
+ Map parsed positional arguments to function parameters, ensuring the number of
22
+ arguments matches and converting types.
23
+ """
24
+ pos_values = []
25
+ i = 0
26
+ keywords_consumed = 0
27
+
28
+ for param in pos_params:
29
+ param_type = param.type or str
30
+ if param.is_varargs:
31
+ pos_values.extend([param_type(arg) for arg in pos_args[i:]])
32
+ return pos_values, 0 # All remaining args are consumed, so we can return early.
33
+ elif i < len(pos_args):
34
+ pos_values.append(param_type(pos_args[i]))
35
+ i += 1
36
+ else:
37
+ raise InvalidCommand(f"Missing positional argument: {param.name}")
38
+
39
+ # If there are remaining positional arguments, they will go toward keyword arguments.
40
+ for param in kw_params:
41
+ param_type = param.type or str
42
+ if not param.is_varargs and i < len(pos_args):
43
+ pos_values.append(param_type(pos_args[i]))
44
+ i += 1
45
+ keywords_consumed += 1
46
+
47
+ if i < len(pos_args):
48
+ raise InvalidCommand(
49
+ f"Too many arguments provided (expected {len(pos_params)}, got {len(pos_args)}): {pos_args}"
50
+ )
51
+
52
+ return pos_values, keywords_consumed
53
+
54
+
55
+ def _map_keyword(kw_args: Mapping[str, str | bool], kw_params: list[FuncParam]) -> dict[str, Any]:
56
+ """
57
+ Map parsed keyword arguments to function parameters, converting types and handling var
58
+ keyword arguments.
59
+ """
60
+
61
+ kw_values = {param.name: param.default for param in kw_params if not param.is_varargs}
62
+ var_kw_values = {}
63
+ var_kw_param = None
64
+
65
+ # Find the var keyword argument (**kwargs), if any.
66
+ var_kw_param = next((param for param in kw_params if param.is_varargs), None)
67
+
68
+ # Map the keyword arguments to the function parameters.
69
+ for key, value in kw_args.items():
70
+ matching_param = next((param for param in kw_params if param.name == key), None)
71
+ if matching_param:
72
+ matching_param_type = matching_param.type or str
73
+
74
+ # Handle UnionType (str | None) specially
75
+ if hasattr(types, "UnionType") and isinstance(matching_param_type, types.UnionType):
76
+ args = get_args(matching_param_type)
77
+ non_none_args = [arg for arg in args if arg is not type(None)]
78
+ if len(non_none_args) == 1 and isinstance(non_none_args[0], type):
79
+ matching_param_type = non_none_args[0]
80
+
81
+ if isinstance(value, bool) and not issubclass(matching_param_type, bool):
82
+ raise InvalidCommand(f"Option `--{key}` expects a value")
83
+ if not isinstance(value, bool) and issubclass(matching_param_type, bool):
84
+ raise InvalidCommand(f"Option `--{key}` is boolean and does not take a value")
85
+
86
+ try:
87
+ kw_values[key] = matching_param_type(value) # Convert value to type.
88
+ except Exception as e:
89
+ valid_values = ""
90
+ if isinstance(matching_param.type, type) and issubclass(matching_param.type, Enum):
91
+ valid_values = f" (valid values are: {', '.join('`' + v.name + '`' for v in matching_param.type)})"
92
+ raise InvalidCommand(
93
+ f"Invalid value for option `{key}`: {value}{valid_values}"
94
+ ) from e
95
+ elif var_kw_param:
96
+ var_kw_values[key] = value
97
+ else:
98
+ raise InvalidCommand(f"Unknown option `--{key}`")
99
+
100
+ if var_kw_param:
101
+ kw_values.update(var_kw_values)
102
+
103
+ return kw_values
104
+
105
+
106
+ R = TypeVar("R")
107
+
108
+
109
+ def wrap_for_shell_args(func: Callable[..., R]) -> Callable[[list[str]], R | None]:
110
+ """
111
+ Wrap a function to accept a list of string shell-style arguments, parse them, and
112
+ call the original function.
113
+ """
114
+ from kash.commands.help import help_commands
115
+
116
+ params = inspect_function_params(func)
117
+ pos_params = [p for p in params if p.is_positional]
118
+ kw_params = [p for p in params if p not in pos_params]
119
+
120
+ @wraps(func)
121
+ def wrapped(args: list[str]) -> R | None:
122
+ shell_args = parse_shell_args(args)
123
+
124
+ if shell_args.show_help:
125
+ print_command_function_help(cast(CommandFunction, func), verbose=True)
126
+ return None
127
+ elif shell_args.options.get("show_source", False):
128
+ help_commands.source_code(func.__name__)
129
+ return None
130
+
131
+ pos_values, kw_consumed = _map_positional(shell_args.args, pos_params, kw_params)
132
+
133
+ # If some positional arguments were used as keyword arguments, we need to remove
134
+ # them from the kw_params so they don't get passed twice.
135
+ remaining_kw_params = kw_params[kw_consumed:]
136
+
137
+ kw_values = _map_keyword(shell_args.options, remaining_kw_params)
138
+
139
+ if args:
140
+ log.info(
141
+ "Mapping shell args to function params: %s -> %s -> %s(*%s, **%s)",
142
+ args,
143
+ shell_args,
144
+ func.__name__,
145
+ pos_values,
146
+ kw_values,
147
+ )
148
+
149
+ return func(*pos_values, **kw_values)
150
+
151
+ return wrapped
152
+
153
+
154
+ ## Tests
155
+
156
+
157
+ def test_wrap_function():
158
+ def func1(
159
+ arg1: str, arg2: str, arg3: int, option_one: bool = False, option_two: str | None = None
160
+ ) -> list:
161
+ return [arg1, arg2, arg3, option_one, option_two]
162
+
163
+ def func2(*paths: str, summary: bool | None = False, iso_time: bool | None = False) -> list:
164
+ return [paths, summary, iso_time]
165
+
166
+ def func3(arg1: str, **keywords) -> list:
167
+ return [arg1, keywords]
168
+
169
+ def func4() -> list:
170
+ return []
171
+
172
+ wrapped_func1 = wrap_for_shell_args(func1)
173
+ wrapped_func2 = wrap_for_shell_args(func2)
174
+ wrapped_func3 = wrap_for_shell_args(func3)
175
+ wrapped_func4 = wrap_for_shell_args(func4)
176
+
177
+ print("\nwrapped:")
178
+ print(
179
+ wrapped_func1(["arg1_value", "arg2_value", "99", "--option_one", "--option_two=some_value"])
180
+ )
181
+ print(wrapped_func2(["--summary", "--iso_time", "path1", "path2", "path3"]))
182
+ print(wrapped_func3(["arg1_value", "--extra_param=some_value"]))
183
+
184
+ print(wrapped_func4([]))
185
+
186
+ assert wrapped_func1(
187
+ ["arg1_value", "arg2_value", "99", "--option_one", "--option_two=some_value"]
188
+ ) == [
189
+ "arg1_value",
190
+ "arg2_value",
191
+ 99,
192
+ True,
193
+ "some_value",
194
+ ]
195
+ assert wrapped_func2(["--summary", "--iso_time", "path1", "path2", "path3"]) == [
196
+ ("path1", "path2", "path3"),
197
+ True,
198
+ True,
199
+ ]
200
+ assert wrapped_func3(["arg1_value", "--extra_param=some_value"]) == [
201
+ "arg1_value",
202
+ {"extra_param": "some_value"},
203
+ ]
204
+ assert wrapped_func4([]) == []