kash-shell 0.3.8__py3-none-any.whl → 0.3.10__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 (154) hide show
  1. kash/actions/__init__.py +4 -4
  2. kash/actions/core/markdownify.py +5 -2
  3. kash/actions/core/readability.py +5 -2
  4. kash/actions/core/render_as_html.py +18 -0
  5. kash/actions/core/webpage_config.py +12 -4
  6. kash/commands/__init__.py +8 -20
  7. kash/commands/base/basic_file_commands.py +15 -0
  8. kash/commands/base/debug_commands.py +15 -2
  9. kash/commands/base/general_commands.py +27 -18
  10. kash/commands/base/logs_commands.py +1 -4
  11. kash/commands/base/model_commands.py +8 -8
  12. kash/commands/base/search_command.py +3 -2
  13. kash/commands/base/show_command.py +5 -3
  14. kash/commands/extras/parse_uv_lock.py +186 -0
  15. kash/commands/help/doc_commands.py +2 -31
  16. kash/commands/help/welcome.py +33 -0
  17. kash/commands/workspace/selection_commands.py +11 -6
  18. kash/commands/workspace/workspace_commands.py +19 -16
  19. kash/config/colors.py +2 -0
  20. kash/config/env_settings.py +72 -0
  21. kash/config/init.py +2 -2
  22. kash/config/logger.py +61 -59
  23. kash/config/logger_basic.py +12 -5
  24. kash/config/server_config.py +6 -6
  25. kash/config/settings.py +117 -67
  26. kash/config/setup.py +35 -9
  27. kash/config/suppress_warnings.py +30 -12
  28. kash/config/text_styles.py +3 -13
  29. kash/docs/load_api_docs.py +2 -1
  30. kash/docs/markdown/topics/a2_installation.md +7 -3
  31. kash/docs/markdown/topics/a3_getting_started.md +3 -2
  32. kash/docs/markdown/warning.md +3 -8
  33. kash/docs/markdown/welcome.md +4 -0
  34. kash/docs_base/load_recipe_snippets.py +1 -1
  35. kash/docs_base/recipes/{general_system_commands.ksh → general_system_commands.sh} +1 -1
  36. kash/{concepts → embeddings}/cosine.py +2 -1
  37. kash/embeddings/text_similarity.py +57 -0
  38. kash/exec/__init__.py +20 -3
  39. kash/exec/action_decorators.py +18 -4
  40. kash/exec/action_exec.py +41 -23
  41. kash/exec/action_registry.py +13 -48
  42. kash/exec/command_registry.py +2 -1
  43. kash/exec/fetch_url_metadata.py +4 -6
  44. kash/exec/importing.py +56 -0
  45. kash/exec/llm_transforms.py +6 -6
  46. kash/exec/precondition_registry.py +2 -1
  47. kash/exec/preconditions.py +16 -1
  48. kash/exec/shell_callable_action.py +33 -19
  49. kash/file_storage/file_store.py +23 -14
  50. kash/file_storage/item_file_format.py +13 -3
  51. kash/file_storage/metadata_dirs.py +11 -2
  52. kash/help/assistant.py +2 -2
  53. kash/help/assistant_instructions.py +2 -1
  54. kash/help/help_embeddings.py +2 -2
  55. kash/help/help_printing.py +14 -10
  56. kash/help/tldr_help.py +5 -3
  57. kash/llm_utils/clean_headings.py +1 -1
  58. kash/llm_utils/llm_api_keys.py +4 -4
  59. kash/llm_utils/llm_completion.py +2 -2
  60. kash/llm_utils/llm_features.py +68 -0
  61. kash/llm_utils/llm_messages.py +1 -2
  62. kash/llm_utils/llm_names.py +1 -1
  63. kash/llm_utils/llms.py +17 -12
  64. kash/local_server/__init__.py +5 -2
  65. kash/local_server/local_server.py +56 -46
  66. kash/local_server/local_server_commands.py +15 -15
  67. kash/local_server/local_server_routes.py +2 -2
  68. kash/local_server/local_url_formatters.py +1 -1
  69. kash/mcp/__init__.py +5 -2
  70. kash/mcp/mcp_cli.py +54 -17
  71. kash/mcp/mcp_server_commands.py +5 -6
  72. kash/mcp/mcp_server_routes.py +14 -11
  73. kash/mcp/mcp_server_sse.py +61 -34
  74. kash/mcp/mcp_server_stdio.py +0 -8
  75. kash/media_base/audio_processing.py +81 -7
  76. kash/media_base/media_cache.py +18 -18
  77. kash/media_base/media_services.py +1 -1
  78. kash/media_base/media_tools.py +6 -6
  79. kash/media_base/services/local_file_media.py +2 -2
  80. kash/media_base/{speech_transcription.py → transcription_deepgram.py} +25 -109
  81. kash/media_base/transcription_format.py +73 -0
  82. kash/media_base/transcription_whisper.py +38 -0
  83. kash/model/__init__.py +73 -5
  84. kash/model/actions_model.py +38 -4
  85. kash/model/concept_model.py +30 -0
  86. kash/model/items_model.py +56 -13
  87. kash/model/params_model.py +24 -0
  88. kash/shell/completions/completion_scoring.py +37 -5
  89. kash/shell/output/kerm_codes.py +1 -2
  90. kash/shell/output/shell_formatting.py +14 -4
  91. kash/shell/shell_main.py +2 -2
  92. kash/shell/utils/exception_printing.py +6 -0
  93. kash/shell/utils/native_utils.py +26 -20
  94. kash/text_handling/custom_sliding_transforms.py +12 -4
  95. kash/text_handling/doc_normalization.py +6 -2
  96. kash/text_handling/markdown_render.py +117 -0
  97. kash/text_handling/markdown_utils.py +204 -0
  98. kash/utils/common/import_utils.py +12 -3
  99. kash/utils/common/type_utils.py +0 -29
  100. kash/utils/common/url.py +80 -28
  101. kash/utils/errors.py +6 -0
  102. kash/utils/file_utils/{dir_size.py → dir_info.py} +25 -4
  103. kash/utils/file_utils/file_ext.py +2 -3
  104. kash/utils/file_utils/file_formats.py +28 -2
  105. kash/utils/file_utils/file_formats_model.py +50 -19
  106. kash/utils/file_utils/filename_parsing.py +10 -4
  107. kash/web_content/dir_store.py +1 -2
  108. kash/web_content/file_cache_utils.py +37 -10
  109. kash/web_content/file_processing.py +68 -0
  110. kash/web_content/local_file_cache.py +12 -9
  111. kash/web_content/web_extract.py +8 -3
  112. kash/web_content/web_fetch.py +12 -4
  113. kash/web_gen/tabbed_webpage.py +5 -2
  114. kash/web_gen/templates/base_styles.css.jinja +120 -14
  115. kash/web_gen/templates/base_webpage.html.jinja +60 -13
  116. kash/web_gen/templates/content_styles.css.jinja +4 -2
  117. kash/web_gen/templates/item_view.html.jinja +2 -2
  118. kash/web_gen/templates/tabbed_webpage.html.jinja +1 -2
  119. kash/workspaces/__init__.py +15 -2
  120. kash/workspaces/selections.py +18 -3
  121. kash/workspaces/source_items.py +4 -2
  122. kash/workspaces/workspace_output.py +11 -4
  123. kash/workspaces/workspaces.py +5 -11
  124. kash/xonsh_custom/command_nl_utils.py +40 -19
  125. kash/xonsh_custom/custom_shell.py +44 -12
  126. kash/xonsh_custom/customize_prompt.py +39 -21
  127. kash/xonsh_custom/load_into_xonsh.py +26 -27
  128. kash/xonsh_custom/shell_load_commands.py +2 -2
  129. kash/xonsh_custom/xonsh_completers.py +2 -249
  130. kash/xonsh_custom/xonsh_keybindings.py +282 -0
  131. kash/xonsh_custom/xonsh_modern_tools.py +3 -3
  132. kash/xontrib/kash_extension.py +5 -6
  133. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/METADATA +26 -12
  134. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/RECORD +140 -140
  135. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/entry_points.txt +1 -1
  136. kash/concepts/concept_formats.py +0 -23
  137. kash/concepts/text_similarity.py +0 -112
  138. kash/shell/clideps/api_keys.py +0 -99
  139. kash/shell/clideps/dotenv_setup.py +0 -114
  140. kash/shell/clideps/dotenv_utils.py +0 -89
  141. kash/shell/clideps/pkg_deps.py +0 -232
  142. kash/shell/clideps/platforms.py +0 -11
  143. kash/shell/clideps/terminal_features.py +0 -56
  144. kash/shell/utils/osc_utils.py +0 -95
  145. kash/shell/utils/terminal_images.py +0 -133
  146. kash/text_handling/markdown_util.py +0 -167
  147. kash/utils/common/atomic_var.py +0 -158
  148. kash/utils/common/string_replace.py +0 -93
  149. kash/utils/common/string_template.py +0 -101
  150. /kash/docs_base/recipes/{python_dev_commands.ksh → python_dev_commands.sh} +0 -0
  151. /kash/docs_base/recipes/{tldr_standard_commands.ksh → tldr_standard_commands.sh} +0 -0
  152. /kash/{concepts → embeddings}/embeddings.py +0 -0
  153. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/WHEEL +0 -0
  154. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,30 @@
1
+ from typing import NewType
2
+
3
+ from kash.utils.lang_utils.capitalization import capitalize_cms
4
+
5
+ Concept = NewType("Concept", str)
6
+
7
+
8
+ def canonicalize_concept(concept: str, capitalize: bool = True) -> Concept:
9
+ """
10
+ Convert a concept string (general name, person, etc.) to a canonical form.
11
+ Drop any extraneous Markdown bullets. Drop any quoted phrases (e.g. book titles etc)
12
+ for consistency.
13
+ """
14
+ concept = concept.strip("-* ")
15
+ for quote in ['"', "'"]:
16
+ if concept.startswith(quote) and concept.endswith(quote):
17
+ concept = concept[1:-1]
18
+ if capitalize:
19
+ return Concept(capitalize_cms(concept))
20
+ else:
21
+ return Concept(concept)
22
+
23
+
24
+ def normalize_concepts(
25
+ concepts: list[str], sort_dedup: bool = True, capitalize: bool = True
26
+ ) -> list[Concept]:
27
+ if sort_dedup:
28
+ return sorted(set(canonicalize_concept(concept, capitalize) for concept in concepts))
29
+ else:
30
+ return [canonicalize_concept(concept, capitalize) for concept in concepts]
kash/model/items_model.py CHANGED
@@ -9,17 +9,22 @@ from pathlib import Path
9
9
  from typing import TYPE_CHECKING, Any, TypeVar
10
10
 
11
11
  from frontmatter_format import from_yaml_string, new_yaml
12
- from prettyfmt import abbrev_obj, abbrev_on_words, abbrev_phrase_in_middle, sanitize_title
12
+ from prettyfmt import (
13
+ abbrev_obj,
14
+ abbrev_on_words,
15
+ abbrev_phrase_in_middle,
16
+ sanitize_title,
17
+ slugify_snake,
18
+ )
13
19
  from pydantic.dataclasses import dataclass
14
- from slugify import slugify
15
20
  from strif import abbrev_str, format_iso_timestamp
16
21
 
17
- from kash.concepts.concept_formats import canonicalize_concept
18
22
  from kash.config.logger import get_logger
23
+ from kash.model.concept_model import canonicalize_concept
19
24
  from kash.model.media_model import MediaMetadata
20
25
  from kash.model.operations_model import OperationSummary, Source
21
26
  from kash.model.paths_model import StorePath, fmt_store_path
22
- from kash.text_handling.markdown_util import markdown_to_html
27
+ from kash.text_handling.markdown_render import markdown_to_html
23
28
  from kash.utils.common.format_utils import fmt_loc, html_to_plaintext, plaintext_to_html
24
29
  from kash.utils.common.url import Locator, Url
25
30
  from kash.utils.errors import FileFormatError
@@ -28,6 +33,7 @@ from kash.utils.file_utils.file_formats_model import FileExt, Format
28
33
 
29
34
  if TYPE_CHECKING:
30
35
  from kash.model.actions_model import ExecContext
36
+ from kash.workspaces import Workspace
31
37
 
32
38
  log = get_logger(__name__)
33
39
 
@@ -77,13 +83,14 @@ class ItemType(Enum):
77
83
  Format.yaml: ItemType.doc,
78
84
  Format.diff: ItemType.doc,
79
85
  Format.python: ItemType.extension,
80
- Format.kash_script: ItemType.extension,
81
86
  Format.json: ItemType.doc,
82
87
  Format.csv: ItemType.doc,
83
88
  Format.log: ItemType.log,
84
89
  Format.pdf: ItemType.resource,
85
90
  Format.jpeg: ItemType.asset,
86
91
  Format.png: ItemType.asset,
92
+ Format.gif: ItemType.asset,
93
+ Format.svg: ItemType.asset,
87
94
  Format.docx: ItemType.resource,
88
95
  Format.mp3: ItemType.resource,
89
96
  Format.m4a: ItemType.resource,
@@ -304,7 +311,11 @@ class Item:
304
311
  key: value for key, value in item_dict.items() if key not in all_fields
305
312
  }
306
313
  if unexpected_metadata:
307
- log.info("Skipping unexpected metadata on item: %s%s", info_prefix, unexpected_metadata)
314
+ log.info(
315
+ "Skipping unexpected metadata on item: %s%s",
316
+ info_prefix,
317
+ unexpected_metadata,
318
+ )
308
319
 
309
320
  result = cls(
310
321
  type=type_,
@@ -322,7 +333,10 @@ class Item:
322
333
 
323
334
  @classmethod
324
335
  def from_external_path(
325
- cls, path: Path | str, item_type: ItemType | None = None, title: str | None = None
336
+ cls,
337
+ path: Path | str,
338
+ item_type: ItemType | None = None,
339
+ title: str | None = None,
326
340
  ) -> Item:
327
341
  """
328
342
  Create a resource Item for a file with a format inferred from the file extension
@@ -394,6 +408,19 @@ class Item:
394
408
  if self.type.expects_body and self.format.has_body and not self.body:
395
409
  raise ValueError(f"Item type `{self.type.value}` is text but has no body: {self}")
396
410
 
411
+ def absolute_path(self, ws: "Workspace | None" = None) -> Path: # noqa: UP037
412
+ """
413
+ Get the absolute path to the item. Throws `ValueError` if the item has no
414
+ store path. If no workspace is provided, uses the current workspace.
415
+ """
416
+ from kash.workspaces import current_ws
417
+
418
+ if not self.store_path:
419
+ raise ValueError("Item has no store path")
420
+ if not ws:
421
+ ws = current_ws()
422
+ return ws.base_dir / self.store_path
423
+
397
424
  @property
398
425
  def is_binary(self) -> bool:
399
426
  return bool(self.format and self.format.is_binary)
@@ -535,13 +562,20 @@ class Item:
535
562
 
536
563
  return body_text[:max_len]
537
564
 
565
+ @property
566
+ def has_body(self) -> bool:
567
+ """
568
+ True if the item has a non-empty body.
569
+ """
570
+ return bool(self.body and self.body.strip())
571
+
538
572
  def slug_name(self, max_len: int = SLUG_MAX_LEN) -> str:
539
573
  """
540
574
  Get a readable slugified version of the title or filename or content
541
575
  appropriate for this item. May not be unique.
542
576
  """
543
577
  title = self.abbrev_title(max_len=max_len)
544
- slug = slugify(title, max_length=max_len, separator="_")
578
+ slug = slugify_snake(title)
545
579
  return slug
546
580
 
547
581
  def abbrev_description(self, max_len: int = 1000) -> str:
@@ -584,8 +618,8 @@ class Item:
584
618
  # Python files cannot have more than one . in them.
585
619
  return f"{FileExt.py.value}"
586
620
  elif self.type == ItemType.script:
587
- # Same for kash scripts.
588
- return f"{self.type.value}.{FileExt.ksh.value}"
621
+ # Same for kash/xonsh scripts.
622
+ return f"{self.type.value}.{FileExt.xsh.value}"
589
623
  else:
590
624
  return f"{self.type.value}.{self.get_file_ext().value}"
591
625
 
@@ -675,6 +709,10 @@ class Item:
675
709
  updates = other_updates.copy()
676
710
  updates["type"] = type
677
711
 
712
+ # If format was specified and user didn't specify file_ext, then infer it.
713
+ if "file_ext" not in other_updates and "format" in other_updates:
714
+ updates["file_ext"] = other_updates["format"].file_ext
715
+
678
716
  # External resource paths only make sense for resources, so clear them out if new item
679
717
  # is not a resource.
680
718
  new_type = updates.get("type") or self.type
@@ -685,15 +723,20 @@ class Item:
685
723
  if derived_from:
686
724
  new_item.update_relations(derived_from=derived_from)
687
725
 
688
- # Fall back to action title template if we have it and it wasn't explicitly set.
726
+ # Fall back to action title template if we have it and title wasn't explicitly set.
689
727
  if "title" not in other_updates:
728
+ prev_title = self.title or (Path(self.store_path).stem if self.store_path else UNTITLED)
690
729
  if self.context:
691
730
  action = self.context.action
692
731
  new_item.title = action.title_template.format(
693
- title=self.title or UNTITLED, action_name=action.name
732
+ title=prev_title, action_name=action.name
694
733
  )
695
734
  else:
696
- log.warning("Deriving an item without action context, will omit title: %s", self)
735
+ log.warning(
736
+ "Deriving an item without action context so keeping previous title: %s",
737
+ self,
738
+ )
739
+ new_item.title = f"{prev_title} (derived copy)"
697
740
 
698
741
  return new_item
699
742
 
@@ -294,6 +294,18 @@ COMMON_ACTION_PARAMS: dict[str, Param] = {
294
294
  type=DocSelection,
295
295
  default_value=DocSelection.full,
296
296
  ),
297
+ "s3_bucket": Param(
298
+ "s3_bucket",
299
+ "The S3 bucket to upload to.",
300
+ type=str,
301
+ default_value=None,
302
+ ),
303
+ "s3_prefix": Param(
304
+ "s3_prefix",
305
+ "The S3 prefix to upload to (with or without a trailing slash).",
306
+ type=str,
307
+ default_value=None,
308
+ ),
297
309
  }
298
310
 
299
311
  # Extra parameters that are available when an action is invoked from the shell.
@@ -305,6 +317,18 @@ RUNTIME_ACTION_PARAMS: dict[str, Param] = {
305
317
  "it produces an output item that already exists.",
306
318
  type=bool,
307
319
  ),
320
+ "refetch": Param(
321
+ "refetch",
322
+ "Forcing re-fetching of any content, not using media or content caches.",
323
+ type=bool,
324
+ default_value=False,
325
+ ),
326
+ "no_format": Param(
327
+ "no_format",
328
+ "Do not auto-format (normalize) Markdown outputs.",
329
+ type=bool,
330
+ default_value=False,
331
+ ),
308
332
  }
309
333
 
310
334
 
@@ -7,6 +7,7 @@ from datetime import UTC, datetime
7
7
  from pathlib import Path
8
8
  from typing import TypeVar
9
9
 
10
+ import rich
10
11
  from strif import abbrev_str
11
12
  from thefuzz import fuzz
12
13
 
@@ -22,7 +23,7 @@ log = get_logger(__name__)
22
23
  T = TypeVar("T")
23
24
 
24
25
  # Scores less than this can be dropped early.
25
- MIN_CUTOFF = Score(60)
26
+ MIN_CUTOFF = Score(70)
26
27
 
27
28
 
28
29
  def linear_boost(score: Score, min_score: Score) -> Score:
@@ -171,10 +172,29 @@ def score_phrase(prefix: str, text: str) -> Score:
171
172
  Could experiment with this more but it's a rough attempt to balance
172
173
  full matches and prefix matches.
173
174
  """
174
- return Score(
175
- 0.4 * fuzz.token_set_ratio(prefix, text)
176
- + 0.4 * fuzz.partial_ratio(prefix, text)
177
- + 0.2 * fuzz.token_sort_ratio(prefix, text)
175
+ if len(prefix) > 5:
176
+ return Score(
177
+ 0.4 * fuzz.ratio(prefix, text)
178
+ + 0.3 * fuzz.token_set_ratio(prefix, text)
179
+ + 0.3 * fuzz.partial_ratio(prefix, text),
180
+ )
181
+ else:
182
+ return Score(0.6 * fuzz.ratio(prefix, text) + 0.4 * fuzz.token_set_ratio(prefix, text))
183
+
184
+
185
+ def print_all_scores(prefix: str, text: str):
186
+ rich.inspect(
187
+ {
188
+ "ratio": fuzz.ratio(prefix, text),
189
+ "partial_ratio": fuzz.partial_ratio(prefix, text),
190
+ "token_sort_ratio": fuzz.token_sort_ratio(prefix, text),
191
+ "token_set_ratio": fuzz.token_set_ratio(prefix, text),
192
+ "partial_token_set_ratio": fuzz.partial_token_set_ratio(prefix, text),
193
+ "final": score_phrase(prefix, text),
194
+ },
195
+ methods=False,
196
+ docs=False,
197
+ sort=False,
178
198
  )
179
199
 
180
200
 
@@ -281,3 +301,15 @@ def score_snippet(query: str, snippet: CommentedCommand) -> Score:
281
301
  )
282
302
  # Bias a little toward command matches.
283
303
  return Score(max(command_score, 0.7 * comment_score))
304
+
305
+
306
+ ## Tests
307
+
308
+
309
+ def test_score_phrase():
310
+ assert score_phrase("hello world", "hello world") == Score(100)
311
+ assert Score(90) >= score_phrase("hello world", "hello there world") >= Score(80)
312
+ assert Score(70) >= score_phrase("hello world", "hello there there") >= Score(60)
313
+ assert Score(85) >= score_phrase("duf", "df") >= Score(75)
314
+ assert Score(70) >= score_phrase("wik", "awk") >= Score(60)
315
+ assert Score(60) >= score_phrase("wiki", "awk") >= Score(50)
@@ -72,13 +72,12 @@ from html import escape
72
72
  from typing import Annotated, Literal, Self, TypeAlias
73
73
  from urllib.parse import parse_qs, quote, urlencode, urlparse
74
74
 
75
+ from clideps.terminal.osc_utils import OscStr, osc8_link, osc8_link_codes, osc8_link_rich, osc_code
75
76
  from prompt_toolkit.formatted_text import FormattedText
76
77
  from pydantic import BaseModel, Field, TypeAdapter, model_validator
77
78
  from rich.style import Style
78
79
  from rich.text import Text
79
80
 
80
- from kash.shell.utils.osc_utils import OscStr, osc8_link, osc8_link_codes, osc8_link_rich, osc_code
81
-
82
81
  KC_VERSION = 0
83
82
  """Version of the Kerm codes format. Update when we make breaking changes."""
84
83
 
@@ -8,10 +8,7 @@ from flowmark import Wrap, fill_text
8
8
  from rich.console import Group
9
9
  from rich.text import Text
10
10
 
11
- from kash.config.text_styles import (
12
- STYLE_HINT,
13
- format_success_emoji,
14
- )
11
+ from kash.config.text_styles import COLOR_FAILURE, COLOR_SUCCESS, STYLE_HINT
15
12
  from kash.shell.output.kmarkdown import KMarkdown
16
13
 
17
14
 
@@ -84,6 +81,19 @@ def format_paragraphs(*paragraphs: str | Text | Group) -> Group:
84
81
  return Group(*text)
85
82
 
86
83
 
84
+ EMOJI_TRUE = "✔︎"
85
+
86
+ EMOJI_FALSE = "✘"
87
+
88
+
89
+ def success_emoji(value: bool, success_only: bool = False) -> str:
90
+ return EMOJI_TRUE if value else " " if success_only else EMOJI_FALSE
91
+
92
+
93
+ def format_success_emoji(value: bool, success_only: bool = False) -> Text:
94
+ return Text(success_emoji(value, success_only), style=COLOR_SUCCESS if value else COLOR_FAILURE)
95
+
96
+
87
97
  def format_success(message: str | Text) -> Text:
88
98
  return Text.assemble(format_success_emoji(True), message)
89
99
 
kash/shell/shell_main.py CHANGED
@@ -16,12 +16,12 @@ import threading
16
16
  import xonsh.main
17
17
  from strif import quote_if_needed
18
18
 
19
- from kash.config.setup import setup
19
+ from kash.config.setup import kash_setup
20
20
  from kash.shell.utils.argparse_utils import WrappedColorFormatter
21
21
  from kash.shell.version import get_full_version_name, get_version
22
22
  from kash.xonsh_custom.custom_shell import install_to_xonshrc, start_shell
23
23
 
24
- setup(rich_logging=True) # Set up logging first.
24
+ kash_setup(rich_logging=True) # Set up logging first.
25
25
 
26
26
 
27
27
  __version__ = get_version()
@@ -46,5 +46,11 @@ def wrap_with_exception_printing(func: Callable[..., R]) -> Callable[[list[str]]
46
46
  log.error(f"[{COLOR_ERROR}]Command error:[/{COLOR_ERROR}] %s", summarize_traceback(e))
47
47
  log.info("Command error details: %s", e, exc_info=True)
48
48
  return None
49
+ except Exception as e:
50
+ # Note xonsh can log exceptions but it will be in the xonsh call stack, which is
51
+ # useless for the user. Better to log all unexpected exception call stack
52
+ # here to our logs.
53
+ log.info("Command error details: %s", e, exc_info=True)
54
+ raise
49
55
 
50
56
  return command
@@ -11,15 +11,15 @@ import webbrowser
11
11
  from enum import Enum
12
12
  from pathlib import Path
13
13
 
14
+ from clideps.pkgs.pkg_check import pkg_check
15
+ from clideps.pkgs.platform_checks import Platform, get_platform
16
+ from clideps.terminal.terminal_images import terminal_show_image
14
17
  from flowmark import Wrap
15
18
  from funlog import log_calls
16
19
 
17
20
  from kash.config.logger import get_logger
18
- from kash.config.text_styles import BAT_STYLE, BAT_THEME, COLOR_ERROR
19
- from kash.shell.clideps.pkg_deps import Pkg, pkg_check
20
- from kash.shell.clideps.platforms import PLATFORM, Platform
21
+ from kash.config.text_styles import BAT_STYLE, BAT_STYLE_PLAIN, BAT_THEME, COLOR_ERROR
21
22
  from kash.shell.output.shell_output import cprint
22
- from kash.shell.utils.terminal_images import terminal_show_image
23
23
  from kash.utils.common.format_utils import fmt_loc
24
24
  from kash.utils.common.url import as_file_url, is_file_url, is_url
25
25
  from kash.utils.errors import FileNotFound, SetupError
@@ -49,11 +49,11 @@ def file_size_check(
49
49
  def native_open(filename: str | Path):
50
50
  filename = str(filename)
51
51
  log.message("Opening file: %s", filename)
52
- if PLATFORM == Platform.Darwin:
52
+ if get_platform() == Platform.Darwin:
53
53
  subprocess.run(["open", filename])
54
- elif PLATFORM == Platform.Linux:
54
+ elif get_platform() == Platform.Linux:
55
55
  subprocess.run(["xdg-open", filename])
56
- elif PLATFORM == Platform.Windows:
56
+ elif get_platform() == Platform.Windows:
57
57
  subprocess.run(["start", shlex.quote(filename)], shell=True)
58
58
  else:
59
59
  raise NotImplementedError("Unsupported platform")
@@ -110,12 +110,14 @@ def _detect_view_mode(file_or_url: str) -> ViewMode:
110
110
  def view_file_native(
111
111
  file_or_url: str | Path,
112
112
  view_mode: ViewMode = ViewMode.auto,
113
+ plain: bool = False,
113
114
  ):
114
115
  """
115
116
  Open a file or URL in the console or a native app. If `view_mode` is auto,
116
117
  automatically determine whether to use console, web browser, or the user's
117
118
  preferred native application. For images, also tries terminal-based image
118
- display.
119
+ display. The `--plain` flag will disable line numbers, grid, etc. in `bat`
120
+ and force `ViewMode.console`.
119
121
  """
120
122
  file_or_url = str(file_or_url)
121
123
  path = None
@@ -124,6 +126,9 @@ def view_file_native(
124
126
  if not path.exists():
125
127
  raise FileNotFound(fmt_loc(path))
126
128
 
129
+ if plain:
130
+ view_mode = ViewMode.console
131
+
127
132
  if view_mode == ViewMode.auto:
128
133
  view_mode = _detect_view_mode(file_or_url)
129
134
 
@@ -133,7 +138,7 @@ def view_file_native(
133
138
  webbrowser.open(url)
134
139
  elif view_mode == ViewMode.console and path:
135
140
  file_size, min_lines = file_size_check(path)
136
- view_file_console(path, use_pager=min_lines > 40 or file_size > 20 * 1024)
141
+ view_file_console(path, use_pager=min_lines > 40 or file_size > 20 * 1024, plain=plain)
137
142
  elif view_mode == ViewMode.terminal_image and path:
138
143
  try:
139
144
  terminal_show_image(path)
@@ -187,11 +192,11 @@ def tail_file(
187
192
  if follow:
188
193
  max_lines = follow_max_lines
189
194
 
190
- pkg_check().require(Pkg.tail)
191
- pkg_check().warn_if_missing(Pkg.bat)
195
+ pkg_check().require("tail")
196
+ pkg_check().warn_if_missing("bat")
192
197
 
193
198
  if follow:
194
- if pkg_check().has(Pkg.bat):
199
+ if pkg_check().is_found("bat"):
195
200
  # Follow the file in real-time.
196
201
  command = (
197
202
  f"tail -{max_lines} -f {all_paths_str} | "
@@ -202,8 +207,8 @@ def tail_file(
202
207
  command = f"tail -f {all_paths_str}"
203
208
  cprint("Following file: `%s`", command, text_wrap=Wrap.NONE)
204
209
  else:
205
- pkg_check().require(Pkg.less)
206
- if pkg_check().has(Pkg.bat, Pkg.less):
210
+ pkg_check().require("less")
211
+ if pkg_check().is_found("bat"):
207
212
  command = (
208
213
  f"tail -{max_lines} {all_paths_str} | "
209
214
  f"bat --paging=never --color=always --style=plain --theme={BAT_THEME} -l log | "
@@ -216,7 +221,7 @@ def tail_file(
216
221
  subprocess.run(command, shell=True, check=True)
217
222
 
218
223
 
219
- def view_file_console(filename: str | Path, use_pager: bool = True):
224
+ def view_file_console(filename: str | Path, use_pager: bool = True, plain: bool = False):
220
225
  """
221
226
  Displays a file in the console with pagination and syntax highlighting.
222
227
  """
@@ -226,18 +231,19 @@ def view_file_console(filename: str | Path, use_pager: bool = True):
226
231
  # TODO: Visualize YAML frontmatter with different syntax/style than Markdown content.
227
232
 
228
233
  is_text = file_format_info(filename).is_text
234
+ bat_style = BAT_STYLE_PLAIN if plain else BAT_STYLE
229
235
  if is_text:
230
- pkg_check().require(Pkg.less)
231
- if pkg_check().has(Pkg.bat):
236
+ pkg_check().require("less")
237
+ if pkg_check().is_found("bat"):
232
238
  pager_str = "--pager=always --pager=less " if use_pager else ""
233
- command = f"bat {pager_str}--color=always --style={BAT_STYLE} --theme={BAT_THEME} {quoted_filename}"
239
+ command = f"bat {pager_str}--color=always --style={bat_style} --theme={BAT_THEME} {quoted_filename}"
234
240
  else:
235
- pkg_check().require(Pkg.pygmentize)
241
+ pkg_check().require("pygmentize")
236
242
  command = f"pygmentize -g {quoted_filename}"
237
243
  if use_pager:
238
244
  command = f"{command} | less -R"
239
245
  else:
240
- pkg_check().require(Pkg.hexyl)
246
+ pkg_check().require("hexyl")
241
247
  command = f"hexyl {quoted_filename}"
242
248
  if use_pager:
243
249
  command = f"{command} | less -R"
@@ -1,10 +1,17 @@
1
1
  from collections.abc import Callable
2
2
  from math import ceil
3
3
 
4
- from chopdiff.docs import DiffFilter, Paragraph, TextDoc, TextUnit, diff_docs, join_wordtoks
4
+ from chopdiff.docs import (
5
+ DIFF_FILTER_NONE,
6
+ DiffFilter,
7
+ Paragraph,
8
+ TextDoc,
9
+ TextUnit,
10
+ diff_docs,
11
+ join_wordtoks,
12
+ )
5
13
  from chopdiff.transforms import (
6
14
  WindowSettings,
7
- accept_all,
8
15
  remove_window_br,
9
16
  sliding_para_window,
10
17
  sliding_window_transform,
@@ -31,7 +38,7 @@ def filtered_transform(
31
38
  doc: TextDoc,
32
39
  transform_func: TextDocTransform,
33
40
  windowing: WindowSettings | None,
34
- diff_filter: DiffFilter = accept_all,
41
+ diff_filter: DiffFilter | None = None,
35
42
  ) -> TextDoc:
36
43
  """
37
44
  Apply a transform with sliding window across the input doc, enforcing the changes it's
@@ -39,7 +46,7 @@ def filtered_transform(
39
46
 
40
47
  If windowing is None, apply the transform to the entire document at once.
41
48
  """
42
- has_filter = diff_filter != accept_all
49
+ has_filter = bool(diff_filter and diff_filter != DIFF_FILTER_NONE)
43
50
 
44
51
  if not windowing or not windowing.size:
45
52
  transformed_doc = transform_func(doc)
@@ -52,6 +59,7 @@ def filtered_transform(
52
59
  transformed_doc = transform_func(input_doc)
53
60
 
54
61
  if has_filter:
62
+ assert diff_filter
55
63
  # Check the transform did what it should have.
56
64
  diff = diff_docs(input_doc, transformed_doc)
57
65
  accepted_diff, rejected_diff = diff.filter(diff_filter)
@@ -21,7 +21,11 @@ def normalize_formatting_ansi(text: str, format: Format | None, width=DEFAULT_WR
21
21
  text, width=width, word_splitter=simple_word_splitter, len_fn=ansi_cell_len
22
22
  )
23
23
  elif format == Format.markdown or format == Format.md_html:
24
- return fill_markdown(text, line_wrapper=line_wrap_by_sentence(len_fn=ansi_cell_len))
24
+ return fill_markdown(
25
+ text,
26
+ line_wrapper=line_wrap_by_sentence(len_fn=ansi_cell_len),
27
+ cleanups=True, # Safe cleanups like unbolding section headers.
28
+ )
25
29
  elif format == Format.html:
26
30
  # We don't currently auto-format HTML as we sometimes use HTML with specifically chosen line breaks.
27
31
  return text
@@ -52,7 +56,7 @@ def normalize_text_file(
52
56
 
53
57
 
54
58
  def test_osc8_link():
55
- from kash.shell.utils.osc_utils import osc8_link
59
+ from clideps.terminal.osc_utils import osc8_link
56
60
 
57
61
  link = osc8_link("https://example.com/" + "x" * 50, "Example")
58
62
  assert ansi_cell_len(link) == 7