comate-cli 0.1.10__tar.gz → 0.2.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 (94) hide show
  1. {comate_cli-0.1.10 → comate_cli-0.2.0}/PKG-INFO +1 -1
  2. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/mcp_cli.py +8 -8
  3. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/animations.py +21 -20
  4. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/app.py +7 -4
  5. comate_cli-0.2.0/comate_cli/terminal_agent/custom_slash_commands.py +494 -0
  6. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/event_renderer.py +181 -130
  7. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/logo.py +7 -2
  8. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/question_view.py +22 -14
  9. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/rpc_stdio.py +1 -1
  10. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/slash_commands.py +80 -7
  11. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/status_bar.py +1 -2
  12. comate_cli-0.2.0/comate_cli/terminal_agent/tips.py +15 -0
  13. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tool_view.py +36 -4
  14. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui.py +241 -107
  15. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/commands.py +69 -7
  16. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/history_sync.py +2 -2
  17. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/input_behavior.py +27 -1
  18. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/key_bindings.py +57 -19
  19. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/render_panels.py +59 -4
  20. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +10 -1
  21. {comate_cli-0.1.10 → comate_cli-0.2.0}/pyproject.toml +1 -1
  22. comate_cli-0.2.0/tests/test_compact_command_semantics.py +184 -0
  23. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_completion_status_panel.py +88 -4
  24. comate_cli-0.2.0/tests/test_custom_slash_commands.py +232 -0
  25. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_event_renderer.py +129 -51
  26. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_history_sync.py +5 -5
  27. comate_cli-0.2.0/tests/test_interrupt_exit_semantics.py +300 -0
  28. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_logo.py +14 -0
  29. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_mcp_cli.py +18 -0
  30. comate_cli-0.2.0/tests/test_question_key_bindings.py +181 -0
  31. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_question_view.py +117 -0
  32. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_rpc_stdio_bridge.py +40 -0
  33. comate_cli-0.2.0/tests/test_slash_argument_hint.py +52 -0
  34. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_slash_registry.py +17 -3
  35. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_status_bar.py +22 -0
  36. comate_cli-0.2.0/tests/test_task_panel_key_bindings.py +195 -0
  37. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_tool_view.py +1 -1
  38. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_tui_elapsed_status.py +1 -1
  39. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_tui_paste_placeholder.py +54 -0
  40. comate_cli-0.2.0/uv.lock +2243 -0
  41. comate_cli-0.1.10/tests/test_compact_command_semantics.py +0 -92
  42. comate_cli-0.1.10/uv.lock +0 -2259
  43. {comate_cli-0.1.10 → comate_cli-0.2.0}/.gitignore +0 -0
  44. {comate_cli-0.1.10 → comate_cli-0.2.0}/README.md +0 -0
  45. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/__init__.py +0 -0
  46. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/__main__.py +0 -0
  47. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/main.py +0 -0
  48. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/__init__.py +0 -0
  49. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/assistant_render.py +0 -0
  50. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/env_utils.py +0 -0
  51. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/error_display.py +0 -0
  52. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/fragment_utils.py +0 -0
  53. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/history_printer.py +0 -0
  54. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/input_geometry.py +0 -0
  55. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
  56. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/logging_adapter.py +0 -0
  57. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/markdown_render.py +0 -0
  58. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/mention_completer.py +0 -0
  59. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/message_style.py +0 -0
  60. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/models.py +0 -0
  61. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/preflight.py +0 -0
  62. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/resume_selector.py +0 -0
  63. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/rewind_store.py +0 -0
  64. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/rpc_protocol.py +0 -0
  65. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/selection_menu.py +0 -0
  66. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/session_view.py +0 -0
  67. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/startup.py +0 -0
  68. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/text_effects.py +0 -0
  69. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
  70. {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
  71. {comate_cli-0.1.10 → comate_cli-0.2.0}/test_memory.md +0 -0
  72. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/conftest.py +0 -0
  73. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_app_preflight_gate.py +0 -0
  74. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_app_shutdown.py +0 -0
  75. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_app_usage_line.py +0 -0
  76. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_cli_project_root.py +0 -0
  77. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_completion_context_activation.py +0 -0
  78. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_context_command.py +0 -0
  79. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_input_behavior.py +0 -0
  80. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_layout_coordinator.py +0 -0
  81. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_main_args.py +0 -0
  82. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_mcp_slash_command.py +0 -0
  83. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_mention_completer.py +0 -0
  84. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_preflight.py +0 -0
  85. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_preflight_copilot.py +0 -0
  86. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_resume_selector.py +0 -0
  87. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_rewind_command_semantics.py +0 -0
  88. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_rewind_store.py +0 -0
  89. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_rpc_protocol.py +0 -0
  90. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_selection_menu.py +0 -0
  91. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_skills_slash_command.py +0 -0
  92. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_slash_completer.py +0 -0
  93. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_tui_mcp_init_gate.py +0 -0
  94. {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_tui_split_invariance.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: comate-cli
3
- Version: 0.1.10
3
+ Version: 0.2.0
4
4
  Summary: Comate terminal CLI built on comate-agent-sdk
5
5
  Project-URL: Homepage, https://github.com/AndyLee1024/agent-sdk
6
6
  Project-URL: Repository, https://github.com/AndyLee1024/agent-sdk
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import argparse
4
4
  import asyncio
5
5
  import sys
6
- from pathlib import Path
7
6
  from typing import Any
8
7
 
9
8
  from comate_agent_sdk.mcp import (
@@ -15,6 +14,7 @@ from comate_agent_sdk.mcp import (
15
14
  write_mcp_servers_to_path,
16
15
  )
17
16
  from comate_agent_sdk.mcp.types import McpServerConfig
17
+ from comate_agent_sdk.utils.paths import PathInput
18
18
 
19
19
 
20
20
  class McpCliError(ValueError):
@@ -191,7 +191,7 @@ def _build_add_server_config(args: argparse.Namespace) -> McpServerConfig:
191
191
  return cfg # type: ignore[return-value]
192
192
 
193
193
 
194
- def _load_servers_by_scope(*, scope: str, project_root: Path | None) -> dict[str, McpServerConfig]:
194
+ def _load_servers_by_scope(*, scope: str, project_root: PathInput | None) -> dict[str, McpServerConfig]:
195
195
  if scope == "effective":
196
196
  return load_effective_servers(project_root=project_root)
197
197
  return load_scope_servers(scope=scope, project_root=project_root) # type: ignore[arg-type]
@@ -200,7 +200,7 @@ def _load_servers_by_scope(*, scope: str, project_root: Path | None) -> dict[str
200
200
  def _read_effective_server_with_source(
201
201
  *,
202
202
  name: str,
203
- project_root: Path | None,
203
+ project_root: PathInput | None,
204
204
  ) -> tuple[McpServerConfig, str] | None:
205
205
  user_servers = load_scope_servers(scope="user", project_root=project_root)
206
206
  project_servers = load_scope_servers(scope="project", project_root=project_root)
@@ -230,7 +230,7 @@ def _format_server_endpoint(cfg: McpServerConfig) -> str:
230
230
  return " ".join([command, *str_args]).strip()
231
231
 
232
232
 
233
- def _cmd_add(args: argparse.Namespace, *, project_root: Path | None) -> None:
233
+ def _cmd_add(args: argparse.Namespace, *, project_root: PathInput | None) -> None:
234
234
  scope = str(args.scope)
235
235
  name = str(args.name).strip()
236
236
  if not name:
@@ -250,7 +250,7 @@ def _cmd_add(args: argparse.Namespace, *, project_root: Path | None) -> None:
250
250
  )
251
251
 
252
252
 
253
- def _cmd_remove(args: argparse.Namespace, *, project_root: Path | None) -> None:
253
+ def _cmd_remove(args: argparse.Namespace, *, project_root: PathInput | None) -> None:
254
254
  scope = str(args.scope)
255
255
  name = str(args.name).strip()
256
256
  if not name:
@@ -271,7 +271,7 @@ def _cmd_remove(args: argparse.Namespace, *, project_root: Path | None) -> None:
271
271
  )
272
272
 
273
273
 
274
- def _cmd_list(args: argparse.Namespace, *, project_root: Path | None) -> None:
274
+ def _cmd_list(args: argparse.Namespace, *, project_root: PathInput | None) -> None:
275
275
  scope = str(args.scope)
276
276
  servers = _load_servers_by_scope(scope=scope, project_root=project_root)
277
277
  if not servers:
@@ -295,7 +295,7 @@ def _cmd_list(args: argparse.Namespace, *, project_root: Path | None) -> None:
295
295
  sys.stdout.write(f"{alias}: {endpoint} ({server_type}) - {status}\n")
296
296
 
297
297
 
298
- def _cmd_get(args: argparse.Namespace, *, project_root: Path | None) -> None:
298
+ def _cmd_get(args: argparse.Namespace, *, project_root: PathInput | None) -> None:
299
299
  name = str(args.name).strip()
300
300
  scope = str(args.scope)
301
301
  if not name:
@@ -363,7 +363,7 @@ def _cmd_get(args: argparse.Namespace, *, project_root: Path | None) -> None:
363
363
  sys.stdout.write("\n".join(lines) + "\n")
364
364
 
365
365
 
366
- def run_mcp_command(argv: list[str], *, project_root: Path | None = None) -> None:
366
+ def run_mcp_command(argv: list[str], *, project_root: PathInput | None = None) -> None:
367
367
  parser = _build_parser()
368
368
  parsed, extra_args = parser.parse_known_args(argv)
369
369
  command = str(getattr(parsed, "command", "") or "").strip()
@@ -11,26 +11,27 @@ from rich.text import Text
11
11
  from comate_agent_sdk.agent.events import StopEvent, TextEvent, ToolCallEvent, ToolResultEvent, UserQuestionEvent
12
12
 
13
13
  DEFAULT_STATUS_PHRASES: tuple[str, ...] = (
14
- "Vibing...",
15
- "Thinking...",
16
- "Reasoning...",
17
- "Planning next move...",
18
- "Reading context...",
19
- "Connecting dots...",
20
- "Synthesizing signal...",
21
- "Spotting edge cases...",
22
- "Checking assumptions...",
23
- "Tracing dependencies...",
24
- "Drafting response...",
25
- "Polishing details...",
26
- "Validating flow...",
27
- "Cross-checking facts...",
28
- "Refining intent...",
29
- "Mapping tools...",
30
- "Building confidence...",
31
- "Stitching answer...",
32
- "Finalizing output...",
33
- "Almost there...",
14
+ "Embellishing…",
15
+ "Vibing…",
16
+ "Thinking…",
17
+ "Reasoning…",
18
+ "Planning next move…",
19
+ "Reading context…",
20
+ "Connecting dots…",
21
+ "Synthesizing signal…",
22
+ "Spotting edge cases…",
23
+ "Checking assumptions…",
24
+ "Tracing dependencies…",
25
+ "Drafting response…",
26
+ "Polishing details…",
27
+ "Validating flow…",
28
+ "Cross-checking facts…",
29
+ "Refining intent…",
30
+ "Mapping tools…",
31
+ "Building confidence…",
32
+ "Stitching answer…",
33
+ "Finalizing output…",
34
+ "Almost there…",
34
35
  )
35
36
 
36
37
  BREATH_DOT_COLORS: tuple[str, ...] = (
@@ -5,6 +5,7 @@ import importlib.metadata
5
5
  import locale
6
6
  import logging
7
7
  import os
8
+ import random
8
9
  import signal
9
10
  import threading
10
11
  import time
@@ -21,6 +22,7 @@ from comate_agent_sdk.tools import tool
21
22
 
22
23
  from comate_cli.terminal_agent.event_renderer import EventRenderer
23
24
  from comate_cli.terminal_agent.logo import print_logo
25
+ from comate_cli.terminal_agent.tips import TIPS
24
26
  from comate_cli.terminal_agent.preflight import run_preflight_if_needed
25
27
  from comate_cli.terminal_agent.resume_selector import select_resume_session_id
26
28
  from comate_cli.terminal_agent.rpc_stdio import StdioRPCBridge
@@ -67,8 +69,8 @@ async def _check_update() -> str | None:
67
69
  return None
68
70
 
69
71
  if _is_chinese_locale():
70
- url = f"https://mirrors.cloud.tencent.com/pypi/pypi/{package}/json"
71
- source_label = "tencent"
72
+ url = f"https://mirrors.tuna.tsinghua.edu.cn/pypi/{package}/json"
73
+ source_label = "tuna"
72
74
  else:
73
75
  url = f"https://pypi.org/pypi/{package}/json"
74
76
  source_label = "pypi"
@@ -88,9 +90,9 @@ async def _check_update() -> str | None:
88
90
  from packaging.version import Version
89
91
  if Version(latest) > Version(current):
90
92
  return (
91
- f"[dim]💡 New version available: [bold cyan]{latest}[/] "
93
+ f"[dim] New version available: [bold cyan]{latest}[/] "
92
94
  f"(current: {current}) "
93
- f"Run [bold]uv tool upgrade {package}[/] to upgrade.[/]"
95
+ f"Run [bold green]uv tool upgrade {package}[/] to upgrade.[/]"
94
96
  )
95
97
  except Exception:
96
98
  pass
@@ -282,6 +284,7 @@ async def run(
282
284
  return
283
285
 
284
286
  print_logo(console, project_root=project_root)
287
+ console.print(f"[dim]💡 Tip: {random.choice(TIPS)}[/dim]")
285
288
 
286
289
  if resume_select and not resume_session_id:
287
290
  selected_session_id = await select_resume_session_id(console, cwd=project_root)
@@ -0,0 +1,494 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import re
6
+ import shlex
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ import yaml
12
+
13
+ from comate_agent_sdk.utils.paths import PathInput, normalize_path_input
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ DEFAULT_ITEM_MAX_BYTES = 32 * 1024
18
+ DEFAULT_TOTAL_MAX_BYTES = 128 * 1024
19
+ DEFAULT_BASH_TIMEOUT_SECONDS = 10.0
20
+
21
+ _COMMAND_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$")
22
+ _ARG_PLACEHOLDER_PATTERN = re.compile(r"\$ARGUMENTS\[(\d+)\]|\$(\d+)|\$ARGUMENTS")
23
+ _BASH_PATTERN = re.compile(r"!\`([^`\n]+)\`")
24
+ _FILE_REF_PATTERN = re.compile(r"(?<!\S)@([^\s]+)")
25
+ _MARKER_PREFIX = "<<__COMATE_CUSTOM_BLOCK_"
26
+ _MARKER_SUFFIX = "__>>"
27
+
28
+
29
+ class CustomSlashExpandError(RuntimeError):
30
+ pass
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class CustomSlashCommand:
35
+ name: str
36
+ description: str
37
+ template: str
38
+ source_scope: Literal["project", "user"]
39
+ namespace: str
40
+ source_path: Path
41
+ argument_hint: str | None = None
42
+
43
+ def scope_label(self) -> str:
44
+ if self.namespace:
45
+ return f"{self.source_scope}:{self.namespace}"
46
+ return self.source_scope
47
+
48
+ def description_with_scope(self) -> str:
49
+ return f"{self.description} [{self.scope_label()}]"
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class CustomSlashLoadResult:
54
+ commands: tuple[CustomSlashCommand, ...]
55
+ warnings: tuple[str, ...]
56
+
57
+
58
+ def discover_custom_slash_commands(
59
+ *,
60
+ project_root: PathInput,
61
+ builtin_names: set[str],
62
+ user_root: PathInput | None = None,
63
+ ) -> CustomSlashLoadResult:
64
+ resolved_project_root = normalize_path_input(project_root, field_name="project_root")
65
+ resolved_user_root = normalize_path_input(
66
+ user_root if user_root is not None else Path.home(),
67
+ field_name="user_root",
68
+ )
69
+
70
+ project_dir = resolved_project_root / ".agent" / "commands"
71
+ user_dir = resolved_user_root / ".agent" / "commands"
72
+
73
+ commands: list[CustomSlashCommand] = []
74
+ warnings: list[str] = []
75
+ loaded_by_name: dict[str, CustomSlashCommand] = {}
76
+
77
+ for scope, root in (("project", project_dir), ("user", user_dir)):
78
+ if not root.is_dir():
79
+ continue
80
+
81
+ per_scope_names: set[str] = set()
82
+ for file_path in sorted(root.rglob("*.md")):
83
+ parsed = _parse_custom_command_file(file_path=file_path, scope=scope, root_dir=root)
84
+ if isinstance(parsed, str):
85
+ warnings.append(parsed)
86
+ continue
87
+
88
+ name = parsed.name
89
+ if name in builtin_names:
90
+ warnings.append(
91
+ f"Skipped custom command '/{name}' from {file_path}: conflicts with builtin command name."
92
+ )
93
+ continue
94
+
95
+ if name in per_scope_names:
96
+ warnings.append(
97
+ f"Skipped custom command '/{name}' from {file_path}: duplicate name in {scope} scope."
98
+ )
99
+ continue
100
+ per_scope_names.add(name)
101
+
102
+ if name in loaded_by_name:
103
+ existing = loaded_by_name[name]
104
+ warnings.append(
105
+ f"Skipped custom command '/{name}' from {file_path}: already provided by "
106
+ f"{existing.source_scope} scope ({existing.source_path})."
107
+ )
108
+ continue
109
+
110
+ loaded_by_name[name] = parsed
111
+ commands.append(parsed)
112
+
113
+ commands.sort(key=lambda item: item.name.lower())
114
+ return CustomSlashLoadResult(commands=tuple(commands), warnings=tuple(warnings))
115
+
116
+
117
+ def _parse_custom_command_file(
118
+ *,
119
+ file_path: Path,
120
+ scope: Literal["project", "user"],
121
+ root_dir: Path,
122
+ ) -> CustomSlashCommand | str:
123
+ command_name = file_path.stem.strip()
124
+ if not command_name or _COMMAND_NAME_PATTERN.fullmatch(command_name) is None:
125
+ return (
126
+ f"Skipped custom command from {file_path}: invalid filename '{file_path.stem}'. "
127
+ "Only [a-zA-Z0-9_-] is allowed."
128
+ )
129
+
130
+ try:
131
+ content = file_path.read_text(encoding="utf-8")
132
+ except OSError as exc:
133
+ return f"Skipped custom command '/{command_name}' from {file_path}: read failed ({exc})."
134
+
135
+ if not content.startswith("---"):
136
+ return (
137
+ f"Skipped custom command '/{command_name}' from {file_path}: missing YAML frontmatter."
138
+ )
139
+
140
+ parts = content.split("---", 2)
141
+ if len(parts) < 3:
142
+ return (
143
+ f"Skipped custom command '/{command_name}' from {file_path}: invalid frontmatter format."
144
+ )
145
+
146
+ try:
147
+ frontmatter = yaml.safe_load(parts[1]) or {}
148
+ except Exception as exc:
149
+ return (
150
+ f"Skipped custom command '/{command_name}' from {file_path}: frontmatter parse failed ({exc})."
151
+ )
152
+
153
+ if not isinstance(frontmatter, dict):
154
+ return (
155
+ f"Skipped custom command '/{command_name}' from {file_path}: frontmatter must be a map."
156
+ )
157
+
158
+ raw_description = frontmatter.get("description")
159
+ description = str(raw_description).strip() if isinstance(raw_description, str) else ""
160
+ if not description:
161
+ return (
162
+ f"Skipped custom command '/{command_name}' from {file_path}: missing required 'description'."
163
+ )
164
+
165
+ raw_argument_hint = frontmatter.get("argument-hint", frontmatter.get("argument_hint"))
166
+ argument_hint = str(raw_argument_hint).strip() if isinstance(raw_argument_hint, str) else ""
167
+ if not argument_hint:
168
+ argument_hint = _extract_argument_hint(parts[1]) or ""
169
+ if not argument_hint:
170
+ argument_hint = None
171
+
172
+ template = parts[2].strip()
173
+ if not template:
174
+ return (
175
+ f"Skipped custom command '/{command_name}' from {file_path}: empty command template body."
176
+ )
177
+
178
+ namespace_path = file_path.parent.relative_to(root_dir).as_posix()
179
+ namespace = "" if namespace_path in {"", "."} else namespace_path
180
+
181
+ return CustomSlashCommand(
182
+ name=command_name,
183
+ description=description,
184
+ template=template,
185
+ source_scope=scope,
186
+ namespace=namespace,
187
+ source_path=file_path,
188
+ argument_hint=argument_hint,
189
+ )
190
+
191
+
192
+ async def render_custom_slash_prompt(
193
+ *,
194
+ command: CustomSlashCommand,
195
+ raw_args: str,
196
+ project_root: PathInput,
197
+ item_max_bytes: int = DEFAULT_ITEM_MAX_BYTES,
198
+ total_max_bytes: int = DEFAULT_TOTAL_MAX_BYTES,
199
+ bash_timeout_seconds: float = DEFAULT_BASH_TIMEOUT_SECONDS,
200
+ ) -> str:
201
+ resolved_project_root = normalize_path_input(project_root, field_name="project_root")
202
+ normalized_args = raw_args.strip()
203
+ try:
204
+ arg_tokens = shlex.split(normalized_args) if normalized_args else []
205
+ except ValueError as exc:
206
+ raise CustomSlashExpandError(f"Failed to parse arguments: {exc}") from exc
207
+
208
+ replaced_text, used_arg_placeholder = _replace_argument_placeholders(
209
+ template=command.template,
210
+ raw_args=normalized_args,
211
+ arg_tokens=arg_tokens,
212
+ )
213
+ if normalized_args and not used_arg_placeholder:
214
+ replaced_text = f"{replaced_text.rstrip()}\n\nARGUMENTS: {normalized_args}"
215
+
216
+ marker_map: dict[str, str] = {}
217
+ total_inserted_bytes = 0
218
+
219
+ after_bash = await _replace_bash_markers(
220
+ text=replaced_text,
221
+ marker_map=marker_map,
222
+ project_root=resolved_project_root,
223
+ item_max_bytes=item_max_bytes,
224
+ total_max_bytes=total_max_bytes,
225
+ total_inserted_bytes=total_inserted_bytes,
226
+ bash_timeout_seconds=bash_timeout_seconds,
227
+ )
228
+ total_inserted_bytes = _marker_payload_size(marker_map)
229
+
230
+ after_file_ref = _replace_file_reference_markers(
231
+ text=after_bash,
232
+ marker_map=marker_map,
233
+ project_root=resolved_project_root,
234
+ item_max_bytes=item_max_bytes,
235
+ total_max_bytes=total_max_bytes,
236
+ total_inserted_bytes=total_inserted_bytes,
237
+ )
238
+
239
+ rendered = after_file_ref
240
+ for marker, block in marker_map.items():
241
+ rendered = rendered.replace(marker, block)
242
+
243
+ return rendered
244
+
245
+
246
+ def _replace_argument_placeholders(
247
+ *,
248
+ template: str,
249
+ raw_args: str,
250
+ arg_tokens: list[str],
251
+ ) -> tuple[str, bool]:
252
+ used_placeholder = False
253
+
254
+ def _replace(match: re.Match[str]) -> str:
255
+ nonlocal used_placeholder
256
+ used_placeholder = True
257
+
258
+ indexed_argument = match.group(1)
259
+ shorthand_argument = match.group(2)
260
+
261
+ if indexed_argument is not None:
262
+ index = int(indexed_argument)
263
+ if index >= len(arg_tokens):
264
+ raise CustomSlashExpandError(
265
+ f"Missing required argument at position {index + 1} for placeholder "
266
+ f"$ARGUMENTS[{index}]."
267
+ )
268
+ return arg_tokens[index]
269
+
270
+ if shorthand_argument is not None:
271
+ index = int(shorthand_argument) - 1
272
+ if index < 0 or index >= len(arg_tokens):
273
+ raise CustomSlashExpandError(
274
+ f"Missing required argument for placeholder ${shorthand_argument}."
275
+ )
276
+ return arg_tokens[index]
277
+
278
+ return raw_args
279
+
280
+ try:
281
+ rendered = _ARG_PLACEHOLDER_PATTERN.sub(_replace, template)
282
+ except CustomSlashExpandError:
283
+ raise
284
+
285
+ return rendered, used_placeholder
286
+
287
+
288
+ async def _replace_bash_markers(
289
+ *,
290
+ text: str,
291
+ marker_map: dict[str, str],
292
+ project_root: Path,
293
+ item_max_bytes: int,
294
+ total_max_bytes: int,
295
+ total_inserted_bytes: int,
296
+ bash_timeout_seconds: float,
297
+ ) -> str:
298
+ if not text:
299
+ return text
300
+
301
+ parts: list[str] = []
302
+ cursor = 0
303
+ marker_index = len(marker_map)
304
+
305
+ for match in _BASH_PATTERN.finditer(text):
306
+ parts.append(text[cursor : match.start()])
307
+ command = match.group(1).strip()
308
+ if not command:
309
+ raise CustomSlashExpandError("Empty bash command in !`...` block.")
310
+
311
+ output = await _run_bash_command(
312
+ command=command,
313
+ cwd=project_root,
314
+ timeout_seconds=bash_timeout_seconds,
315
+ )
316
+ output_bytes = len(output.encode("utf-8"))
317
+ if output_bytes > item_max_bytes:
318
+ raise CustomSlashExpandError(
319
+ f"Bash output exceeds {item_max_bytes} bytes for command: {command}"
320
+ )
321
+
322
+ block = _format_bash_output_block(command=command, output=output)
323
+ block_bytes = len(block.encode("utf-8"))
324
+ total_inserted_bytes += block_bytes
325
+ if total_inserted_bytes > total_max_bytes:
326
+ raise CustomSlashExpandError(
327
+ f"Expanded custom command exceeds total limit ({total_max_bytes} bytes)."
328
+ )
329
+
330
+ marker = f"{_MARKER_PREFIX}{marker_index}{_MARKER_SUFFIX}"
331
+ marker_map[marker] = block
332
+ marker_index += 1
333
+ parts.append(marker)
334
+ cursor = match.end()
335
+
336
+ parts.append(text[cursor:])
337
+ return "".join(parts)
338
+
339
+
340
+ def _replace_file_reference_markers(
341
+ *,
342
+ text: str,
343
+ marker_map: dict[str, str],
344
+ project_root: Path,
345
+ item_max_bytes: int,
346
+ total_max_bytes: int,
347
+ total_inserted_bytes: int,
348
+ ) -> str:
349
+ if not text:
350
+ return text
351
+
352
+ resolved_project_root = project_root.expanduser().resolve()
353
+ parts: list[str] = []
354
+ cursor = 0
355
+ marker_index = len(marker_map)
356
+
357
+ for match in _FILE_REF_PATTERN.finditer(text):
358
+ parts.append(text[cursor : match.start()])
359
+ raw_path = match.group(1).strip()
360
+ if not raw_path:
361
+ raise CustomSlashExpandError("Empty file reference after '@'.")
362
+ if raw_path.startswith("~"):
363
+ raise CustomSlashExpandError(f"File reference must be relative to project root: @{raw_path}")
364
+
365
+ target = Path(raw_path)
366
+ if target.is_absolute():
367
+ raise CustomSlashExpandError(f"Absolute file reference is not allowed: @{raw_path}")
368
+
369
+ candidate = (resolved_project_root / target).resolve()
370
+ if not _is_path_under_root(candidate, resolved_project_root):
371
+ raise CustomSlashExpandError(f"File reference escapes project root: @{raw_path}")
372
+ if not candidate.is_file():
373
+ raise CustomSlashExpandError(f"File not found for reference: @{raw_path}")
374
+
375
+ try:
376
+ payload = candidate.read_bytes()
377
+ except OSError as exc:
378
+ raise CustomSlashExpandError(f"Failed to read file for reference @{raw_path}: {exc}") from exc
379
+
380
+ if b"\x00" in payload:
381
+ raise CustomSlashExpandError(f"Binary file is not supported for reference: @{raw_path}")
382
+ if len(payload) > item_max_bytes:
383
+ raise CustomSlashExpandError(
384
+ f"Referenced file exceeds {item_max_bytes} bytes: @{raw_path}"
385
+ )
386
+
387
+ try:
388
+ content = payload.decode("utf-8")
389
+ except UnicodeDecodeError as exc:
390
+ raise CustomSlashExpandError(
391
+ f"Referenced file must be UTF-8 text: @{raw_path}"
392
+ ) from exc
393
+
394
+ block = _format_file_reference_block(path=raw_path, content=content)
395
+ block_bytes = len(block.encode("utf-8"))
396
+ total_inserted_bytes += block_bytes
397
+ if total_inserted_bytes > total_max_bytes:
398
+ raise CustomSlashExpandError(
399
+ f"Expanded custom command exceeds total limit ({total_max_bytes} bytes)."
400
+ )
401
+
402
+ marker = f"{_MARKER_PREFIX}{marker_index}{_MARKER_SUFFIX}"
403
+ marker_map[marker] = block
404
+ marker_index += 1
405
+ parts.append(marker)
406
+ cursor = match.end()
407
+
408
+ parts.append(text[cursor:])
409
+ return "".join(parts)
410
+
411
+
412
+ async def _run_bash_command(
413
+ *,
414
+ command: str,
415
+ cwd: Path,
416
+ timeout_seconds: float,
417
+ ) -> str:
418
+ process = await asyncio.create_subprocess_exec(
419
+ "/bin/bash",
420
+ "-lc",
421
+ command,
422
+ cwd=str(cwd),
423
+ stdout=asyncio.subprocess.PIPE,
424
+ stderr=asyncio.subprocess.PIPE,
425
+ )
426
+
427
+ try:
428
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout_seconds)
429
+ except asyncio.TimeoutError as exc:
430
+ process.kill()
431
+ await process.communicate()
432
+ raise CustomSlashExpandError(
433
+ f"Bash command timed out after {timeout_seconds:.1f}s: {command}"
434
+ ) from exc
435
+
436
+ stdout_text = stdout.decode("utf-8", errors="replace")
437
+ stderr_text = stderr.decode("utf-8", errors="replace")
438
+ merged_output = stdout_text.strip()
439
+ if stderr_text.strip():
440
+ merged_output = f"{merged_output}\n{stderr_text.strip()}".strip()
441
+ if not merged_output:
442
+ merged_output = "(no output)"
443
+
444
+ if process.returncode != 0:
445
+ raise CustomSlashExpandError(
446
+ f"Bash command failed (exit {process.returncode}): {command}\n{merged_output}"
447
+ )
448
+
449
+ return merged_output
450
+
451
+
452
+ def _format_bash_output_block(*, command: str, output: str) -> str:
453
+ return (
454
+ "### Bash Command Output\n"
455
+ f"`{command}`\n"
456
+ "```text\n"
457
+ f"{output}\n"
458
+ "```"
459
+ )
460
+
461
+
462
+ def _format_file_reference_block(*, path: str, content: str) -> str:
463
+ return (
464
+ "### File Content\n"
465
+ f"`{path}`\n"
466
+ "```text\n"
467
+ f"{content}\n"
468
+ "```"
469
+ )
470
+
471
+
472
+ def _is_path_under_root(path: Path, root: Path) -> bool:
473
+ try:
474
+ path.relative_to(root)
475
+ return True
476
+ except ValueError:
477
+ return False
478
+
479
+
480
+ def _marker_payload_size(marker_map: dict[str, str]) -> int:
481
+ return sum(len(item.encode("utf-8")) for item in marker_map.values())
482
+
483
+
484
+ def _extract_argument_hint(frontmatter_text: str) -> str | None:
485
+ match = re.search(
486
+ r"(?mi)^\s*argument[-_]hint\s*:\s*(.+?)\s*$",
487
+ frontmatter_text,
488
+ )
489
+ if match is None:
490
+ return None
491
+ raw = match.group(1).strip()
492
+ if raw.startswith(("'", '"')) and raw.endswith(("'", '"')) and len(raw) >= 2:
493
+ raw = raw[1:-1].strip()
494
+ return raw or None