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.
- {comate_cli-0.1.10 → comate_cli-0.2.0}/PKG-INFO +1 -1
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/mcp_cli.py +8 -8
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/animations.py +21 -20
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/app.py +7 -4
- comate_cli-0.2.0/comate_cli/terminal_agent/custom_slash_commands.py +494 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/event_renderer.py +181 -130
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/logo.py +7 -2
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/question_view.py +22 -14
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/rpc_stdio.py +1 -1
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/slash_commands.py +80 -7
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/status_bar.py +1 -2
- comate_cli-0.2.0/comate_cli/terminal_agent/tips.py +15 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tool_view.py +36 -4
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui.py +241 -107
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/commands.py +69 -7
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/history_sync.py +2 -2
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/input_behavior.py +27 -1
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/key_bindings.py +57 -19
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/render_panels.py +59 -4
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/slash_command_registry.py +10 -1
- {comate_cli-0.1.10 → comate_cli-0.2.0}/pyproject.toml +1 -1
- comate_cli-0.2.0/tests/test_compact_command_semantics.py +184 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_completion_status_panel.py +88 -4
- comate_cli-0.2.0/tests/test_custom_slash_commands.py +232 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_event_renderer.py +129 -51
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_history_sync.py +5 -5
- comate_cli-0.2.0/tests/test_interrupt_exit_semantics.py +300 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_logo.py +14 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_mcp_cli.py +18 -0
- comate_cli-0.2.0/tests/test_question_key_bindings.py +181 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_question_view.py +117 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_rpc_stdio_bridge.py +40 -0
- comate_cli-0.2.0/tests/test_slash_argument_hint.py +52 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_slash_registry.py +17 -3
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_status_bar.py +22 -0
- comate_cli-0.2.0/tests/test_task_panel_key_bindings.py +195 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_tool_view.py +1 -1
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_tui_elapsed_status.py +1 -1
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_tui_paste_placeholder.py +54 -0
- comate_cli-0.2.0/uv.lock +2243 -0
- comate_cli-0.1.10/tests/test_compact_command_semantics.py +0 -92
- comate_cli-0.1.10/uv.lock +0 -2259
- {comate_cli-0.1.10 → comate_cli-0.2.0}/.gitignore +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/README.md +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/__init__.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/__main__.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/main.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/__init__.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/assistant_render.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/env_utils.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/error_display.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/fragment_utils.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/history_printer.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/input_geometry.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/layout_coordinator.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/logging_adapter.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/markdown_render.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/mention_completer.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/message_style.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/models.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/preflight.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/resume_selector.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/rewind_store.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/rpc_protocol.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/selection_menu.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/session_view.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/startup.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/text_effects.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/__init__.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/comate_cli/terminal_agent/tui_parts/ui_mode.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/test_memory.md +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/conftest.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_app_preflight_gate.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_app_shutdown.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_app_usage_line.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_cli_project_root.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_completion_context_activation.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_context_command.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_input_behavior.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_layout_coordinator.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_main_args.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_mcp_slash_command.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_mention_completer.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_preflight.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_preflight_copilot.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_resume_selector.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_rewind_command_semantics.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_rewind_store.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_rpc_protocol.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_selection_menu.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_skills_slash_command.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_slash_completer.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_tui_mcp_init_gate.py +0 -0
- {comate_cli-0.1.10 → comate_cli-0.2.0}/tests/test_tui_split_invariance.py +0 -0
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
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.
|
|
71
|
-
source_label = "
|
|
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]
|
|
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
|