kapsel-cli 0.6.8__tar.gz → 0.7.2__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.
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/PKG-INFO +1 -1
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/__init__.py +1 -1
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/cli.py +12 -11
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/carapace_installer.py +9 -15
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/dispatcher.py +11 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/block/runner.py +103 -8
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/executor.py +11 -1
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/update_check.py +3 -12
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/ui/prompt.py +6 -16
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel_cli.egg-info/PKG-INFO +1 -1
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel_cli.egg-info/SOURCES.txt +5 -1
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/pyproject.toml +1 -1
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_carapace_installer.py +5 -5
- kapsel_cli-0.7.2/tests/test_ctrl_b_roaming.py +69 -0
- kapsel_cli-0.7.2/tests/test_kp_enhanced.py +111 -0
- kapsel_cli-0.7.2/tests/test_multiline_passthrough.py +83 -0
- kapsel_cli-0.7.2/tests/test_plugin_install_batch.py +95 -0
- kapsel_cli-0.7.2/tests/test_plugin_kssh.py +91 -0
- kapsel_cli-0.6.8/tests/test_kssh_input_bar.py +0 -92
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/README.md +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/__main__.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/__init__.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/carapace_engine.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/completer.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/__init__.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/__init__.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/add.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/completion.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/config.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/datadir.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/dir_nav.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/help.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/language.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/plugin_switch.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/restart.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/search.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/status.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/toggle.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/builtins/upgrade.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/kps/registry.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/completion/spec_manager.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/__init__.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/block/__init__.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/block/model.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/block/registry.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/detector.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/engine.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/i18n.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/plugin/__init__.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/plugin/base.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/plugin/catalog.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/plugin/context.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/plugin/fetcher.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/plugin/hooks.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/plugin/manager.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/tools/__init__.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/tools/installer.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/core/tools/registry.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/i18n.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/de/LC_MESSAGES/kapsel.mo +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/de/LC_MESSAGES/kapsel.po +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/de/help.yaml +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/de/tips.yaml +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/en/help.yaml +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/en/tips.yaml +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/es/LC_MESSAGES/kapsel.mo +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/es/LC_MESSAGES/kapsel.po +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/es/help.yaml +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/es/tips.yaml +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/fr/LC_MESSAGES/kapsel.mo +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/fr/LC_MESSAGES/kapsel.po +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/fr/help.yaml +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/fr/tips.yaml +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ja/LC_MESSAGES/kapsel.mo +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ja/LC_MESSAGES/kapsel.po +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ja/help.yaml +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ja/tips.yaml +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/kapsel.pot +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ru/LC_MESSAGES/kapsel.mo +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ru/LC_MESSAGES/kapsel.po +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ru/help.yaml +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/ru/tips.yaml +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/zh_CN/LC_MESSAGES/kapsel.mo +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/zh_CN/LC_MESSAGES/kapsel.po +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/zh_CN/help.yaml +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/locales/zh_CN/tips.yaml +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/storage/__init__.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/storage/config.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/storage/history.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/storage/logger.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/storage/migrate.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/ui/__init__.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/ui/banner.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/ui/card.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/ui/theme.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/ui/tips.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel/ui/viewport.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel_cli.egg-info/dependency_links.txt +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel_cli.egg-info/entry_points.txt +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel_cli.egg-info/requires.txt +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/kapsel_cli.egg-info/top_level.txt +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/setup.cfg +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_add_concurrent.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_ai_spec_sanitizer.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_block_system.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_config.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_dir_nav.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_history.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_i18n.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_ai.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_alias.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_autopilot.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_backup.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_commit.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_fetcher.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_help.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_init.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_install.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_lang.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_mend.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_portal.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_preview.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_queue.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_template.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_plugin_trans.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_restart_and_pypi.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_search_and_completions.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_spec_manager.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_toggle.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_tools_registry.py +0 -0
- {kapsel_cli-0.6.8 → kapsel_cli-0.7.2}/tests/test_upgrade.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kapsel-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Next-generation cross-platform terminal capsule & ergonomic shell multiplexer with context-aware command translation and 1,000+ CLI completions.
|
|
5
5
|
Author-email: MrEiu <k648888@vip.qq.com>
|
|
6
6
|
License: MIT
|
|
@@ -102,17 +102,18 @@ def main(args: Optional[List[str]] = None) -> int:
|
|
|
102
102
|
if not stripped:
|
|
103
103
|
continue
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
105
|
+
if "\n" not in stripped:
|
|
106
|
+
normalized = " ".join(stripped.lower().split())
|
|
107
|
+
if normalized in ("exit", "quit", "bye"):
|
|
108
|
+
engine.history_mgr.record_command(command=user_input, mode="builtin")
|
|
109
|
+
os.environ.pop("KAPSEL_ACTIVE", None)
|
|
110
|
+
console.print("[dim]Exited Kapsel.[/]")
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
if normalized in ("restart", "kapsel restart", "reload"):
|
|
114
|
+
engine.history_mgr.record_command(command=user_input, mode="builtin")
|
|
115
|
+
from kapsel.completion.kps.builtins.restart import handle_restart
|
|
116
|
+
return handle_restart(console=console)
|
|
116
117
|
|
|
117
118
|
try:
|
|
118
119
|
result = engine.dispatch(user_input)
|
|
@@ -192,9 +192,10 @@ def install_carapace(
|
|
|
192
192
|
|
|
193
193
|
def ensure_carapace_installed(console: Optional[Console] = None) -> bool:
|
|
194
194
|
"""
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
195
|
+
Checks if Carapace completion engine is available.
|
|
196
|
+
If not installed, prints a lightweight non-blocking notice and enters immediately.
|
|
197
|
+
Never blocks terminal startup with network downloads.
|
|
198
|
+
Users can install it anytime on demand using 'kapsel setup-completion'.
|
|
198
199
|
"""
|
|
199
200
|
from kapsel.completion.carapace_engine import get_carapace_engine
|
|
200
201
|
|
|
@@ -203,16 +204,9 @@ def ensure_carapace_installed(console: Optional[Console] = None) -> bool:
|
|
|
203
204
|
return True
|
|
204
205
|
|
|
205
206
|
con = console or Console(legacy_windows=False)
|
|
206
|
-
con.print(
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
except Exception as e:
|
|
212
|
-
logger.warning(f"Failed to auto-bootstrap Carapace: {e}")
|
|
213
|
-
con.print(
|
|
214
|
-
f"[yellow]Notice: Could not automatically setup Carapace ({e}). "
|
|
215
|
-
f"Continuing in basic autocompletion mode.[/]\n"
|
|
216
|
-
)
|
|
217
|
-
return False
|
|
207
|
+
con.print(
|
|
208
|
+
"[dim]ℹ Notice: Carapace completion engine not detected. Running in standard autocompletion mode.[/]\n"
|
|
209
|
+
"[dim] (Optional: run '[bold cyan]kapsel setup-completion[/]' to enable 1,000+ tool completions)[/]\n"
|
|
210
|
+
)
|
|
211
|
+
return False
|
|
218
212
|
|
|
@@ -133,6 +133,7 @@ def _handle_kp_command(
|
|
|
133
133
|
con.print("[dim]Executes multiple commands or pasted blocks asynchronously in the background.[/]\n")
|
|
134
134
|
con.print("[bold white]Usage:[/]")
|
|
135
135
|
con.print(" [bold #a855f7]kp <commands...>[/] Execute commands sequentially in background (atomic, non-blocking)")
|
|
136
|
+
con.print(" [bold #a855f7]kp -s <commands...>[/] Execute commands sequentially in background (explicit sequential)")
|
|
136
137
|
con.print(" [bold #a855f7]kp -c <commands...>[/] Execute commands concurrently in background parallel tracks")
|
|
137
138
|
con.print(" [bold #a855f7]kp attach [id][/] Attach to running block as interactive sub-terminal (二级终端)")
|
|
138
139
|
con.print(" [bold #a855f7]kp status[/] List session blocks, running status, PID, and exit codes")
|
|
@@ -140,6 +141,7 @@ def _handle_kp_command(
|
|
|
140
141
|
con.print(" [bold #a855f7]kp stop <id>[/] Terminate a running background block\n")
|
|
141
142
|
con.print("[bold white]Examples:[/]")
|
|
142
143
|
con.print(" kp npm run dev (Runs dev server in background, prompt stays clean)")
|
|
144
|
+
con.print(" kp -s git pull\\npnpm build (Runs tasks sequentially in background)")
|
|
143
145
|
con.print(" kp -c pnpm build:app\\npnpm build:docs (Runs build tasks concurrently in parallel)\n")
|
|
144
146
|
return 0
|
|
145
147
|
|
|
@@ -262,6 +264,15 @@ def _handle_kp_command(
|
|
|
262
264
|
elif sub.startswith("-c\n") or sub.startswith("--concurrent\n"):
|
|
263
265
|
is_concurrent = True
|
|
264
266
|
body = sub.split("\n", 1)[1]
|
|
267
|
+
elif sub.startswith("-s ") or sub.startswith("--sequential "):
|
|
268
|
+
is_concurrent = False
|
|
269
|
+
body = sub.split(" ", 1)[1]
|
|
270
|
+
elif sub == "-s" or sub == "--sequential":
|
|
271
|
+
con.print("[bold #f43f5e]Error:[/] Please provide commands to execute with 'kp -s'.\n")
|
|
272
|
+
return 1
|
|
273
|
+
elif sub.startswith("-s\n") or sub.startswith("--sequential\n"):
|
|
274
|
+
is_concurrent = False
|
|
275
|
+
body = sub.split("\n", 1)[1]
|
|
265
276
|
|
|
266
277
|
commands = split_commands(body)
|
|
267
278
|
if not commands:
|
|
@@ -20,19 +20,114 @@ from kapsel.core.block.model import BlockStatus, CommandBlock
|
|
|
20
20
|
from kapsel.core.block.registry import get_block_registry
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def _normalize_continuation_spaces(text: str) -> str:
|
|
24
|
+
"""Replaces unquoted newlines (from continuation lines) with spaces and collapses indentation."""
|
|
25
|
+
if "\n" not in text:
|
|
26
|
+
return text
|
|
27
|
+
|
|
28
|
+
result = []
|
|
29
|
+
in_single = False
|
|
30
|
+
in_double = False
|
|
31
|
+
i = 0
|
|
32
|
+
while i < len(text):
|
|
33
|
+
ch = text[i]
|
|
34
|
+
if (ch == "\\" or ch == "`") and not in_single and i + 1 < len(text):
|
|
35
|
+
result.append(ch)
|
|
36
|
+
result.append(text[i + 1])
|
|
37
|
+
i += 2
|
|
38
|
+
continue
|
|
39
|
+
if ch == "'" and not in_double:
|
|
40
|
+
in_single = not in_single
|
|
41
|
+
result.append(ch)
|
|
42
|
+
elif ch == '"' and not in_single:
|
|
43
|
+
in_double = not in_double
|
|
44
|
+
result.append(ch)
|
|
45
|
+
elif ch == "\n" and not in_single and not in_double:
|
|
46
|
+
if result and result[-1] != " ":
|
|
47
|
+
result.append(" ")
|
|
48
|
+
while i + 1 < len(text) and text[i + 1] in (" ", "\t"):
|
|
49
|
+
i += 1
|
|
50
|
+
else:
|
|
51
|
+
result.append(ch)
|
|
52
|
+
i += 1
|
|
53
|
+
|
|
54
|
+
return "".join(result).strip()
|
|
55
|
+
|
|
56
|
+
|
|
23
57
|
def split_commands(raw_text: str) -> List[str]:
|
|
24
58
|
"""
|
|
25
|
-
Parses multi-line or delimited text into individual commands
|
|
26
|
-
|
|
59
|
+
Parses multi-line or delimited text into individual executable commands.
|
|
60
|
+
- Preserves newlines within unclosed quotes ('', "").
|
|
61
|
+
- Handles line continuation characters ('\\' in POSIX/Bash, '`' in PowerShell)
|
|
62
|
+
by joining them into a single command with continuation characters stripped.
|
|
63
|
+
- Handles trailing pipeline ('|') and logical ('&&', '||') continuations.
|
|
64
|
+
- Ignores empty whitespace lines.
|
|
65
|
+
- Does NOT filter '#' lines (leaves comments/commands intact for the shell).
|
|
27
66
|
"""
|
|
28
|
-
|
|
29
|
-
|
|
67
|
+
if not raw_text or not raw_text.strip():
|
|
68
|
+
return []
|
|
69
|
+
|
|
70
|
+
lines = raw_text.splitlines()
|
|
71
|
+
commands: List[str] = []
|
|
72
|
+
current_parts: List[str] = []
|
|
73
|
+
in_single_quote = False
|
|
74
|
+
in_double_quote = False
|
|
75
|
+
|
|
30
76
|
for line in lines:
|
|
31
|
-
|
|
32
|
-
if not
|
|
77
|
+
stripped = line.strip()
|
|
78
|
+
if not stripped and not current_parts and not in_single_quote and not in_double_quote:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
# Count quotes in this line, ignoring escaped quotes
|
|
82
|
+
i = 0
|
|
83
|
+
while i < len(line):
|
|
84
|
+
ch = line[i]
|
|
85
|
+
if (ch == "\\" or ch == "`") and not in_single_quote and i + 1 < len(line):
|
|
86
|
+
i += 2
|
|
87
|
+
continue
|
|
88
|
+
if ch == "'" and not in_double_quote:
|
|
89
|
+
in_single_quote = not in_single_quote
|
|
90
|
+
elif ch == '"' and not in_single_quote:
|
|
91
|
+
in_double_quote = not in_double_quote
|
|
92
|
+
i += 1
|
|
93
|
+
|
|
94
|
+
# If currently inside an open quote, line continues unconditionally
|
|
95
|
+
if in_single_quote or in_double_quote:
|
|
96
|
+
current_parts.append(line)
|
|
33
97
|
continue
|
|
34
|
-
|
|
35
|
-
|
|
98
|
+
|
|
99
|
+
# Outside quotes: check if line ends with a continuation token
|
|
100
|
+
r_stripped = line.rstrip()
|
|
101
|
+
is_continuation = False
|
|
102
|
+
continuation_clean = line
|
|
103
|
+
|
|
104
|
+
if r_stripped.endswith("\\"):
|
|
105
|
+
is_continuation = True
|
|
106
|
+
continuation_clean = r_stripped[:-1].rstrip()
|
|
107
|
+
elif r_stripped.endswith("`"):
|
|
108
|
+
is_continuation = True
|
|
109
|
+
continuation_clean = r_stripped[:-1].rstrip()
|
|
110
|
+
elif r_stripped.endswith("|") or r_stripped.endswith("&&") or r_stripped.endswith("||"):
|
|
111
|
+
is_continuation = True
|
|
112
|
+
continuation_clean = r_stripped
|
|
113
|
+
|
|
114
|
+
if is_continuation:
|
|
115
|
+
current_parts.append(continuation_clean)
|
|
116
|
+
else:
|
|
117
|
+
current_parts.append(line)
|
|
118
|
+
full_cmd = "\n".join(current_parts).strip()
|
|
119
|
+
full_cmd = _normalize_continuation_spaces(full_cmd)
|
|
120
|
+
if full_cmd:
|
|
121
|
+
commands.append(full_cmd)
|
|
122
|
+
current_parts = []
|
|
123
|
+
|
|
124
|
+
if current_parts:
|
|
125
|
+
leftover = "\n".join(current_parts).strip()
|
|
126
|
+
leftover = _normalize_continuation_spaces(leftover)
|
|
127
|
+
if leftover:
|
|
128
|
+
commands.append(leftover)
|
|
129
|
+
|
|
130
|
+
return commands
|
|
36
131
|
|
|
37
132
|
|
|
38
133
|
def _run_single_command_process(cmd: str, cwd: str) -> Tuple[int, str, int]:
|
|
@@ -81,6 +81,11 @@ class CommandExecutor:
|
|
|
81
81
|
|
|
82
82
|
def _handle_builtin(self, cmd: str) -> Optional[ExecutionSummary]:
|
|
83
83
|
"""Intercepts built-ins like cd, clear, cls."""
|
|
84
|
+
# Multi-line commands/scripts must bypass single-line built-in interception
|
|
85
|
+
# to prevent cmd.split() from collapsing lines into space-separated arguments.
|
|
86
|
+
if "\n" in cmd:
|
|
87
|
+
return None
|
|
88
|
+
|
|
84
89
|
parts = cmd.split()
|
|
85
90
|
if not parts:
|
|
86
91
|
return None
|
|
@@ -220,7 +225,12 @@ class CommandExecutor:
|
|
|
220
225
|
args = [exe, "-NoLogo", "-Command", cmd]
|
|
221
226
|
elif shell == "cmd":
|
|
222
227
|
exe = self.shell_path if self.shell_path and Path(self.shell_path).exists() else "cmd.exe"
|
|
223
|
-
|
|
228
|
+
effective_cmd = (
|
|
229
|
+
" & ".join(line.strip() for line in cmd.splitlines() if line.strip())
|
|
230
|
+
if "\n" in cmd
|
|
231
|
+
else cmd
|
|
232
|
+
)
|
|
233
|
+
args = [exe, "/c", effective_cmd]
|
|
224
234
|
else:
|
|
225
235
|
# Unix shells (bash, zsh, fish, sh)
|
|
226
236
|
exe = self.shell_path if self.shell_path and Path(self.shell_path).exists() else "/bin/sh"
|
|
@@ -125,16 +125,7 @@ def get_pypi_upgrade_notice() -> Optional[str]:
|
|
|
125
125
|
return latest_ver
|
|
126
126
|
return None
|
|
127
127
|
|
|
128
|
-
# If no cache exists, launch
|
|
129
|
-
|
|
130
|
-
worker.start()
|
|
131
|
-
worker.join(timeout=0.6)
|
|
132
|
-
|
|
133
|
-
# Re-check cache after fast join
|
|
134
|
-
cached_after = _read_cached_info()
|
|
135
|
-
if cached_after is not None:
|
|
136
|
-
latest_ver = str(cached_after.get("latest_version", ""))
|
|
137
|
-
if parse_semver(latest_ver) > parse_semver(CURRENT_KAPSEL_VERSION):
|
|
138
|
-
return latest_ver
|
|
139
|
-
|
|
128
|
+
# If no cache exists, launch worker asynchronously in the background without blocking startup
|
|
129
|
+
threading.Thread(target=_background_fetch_worker, daemon=True).start()
|
|
140
130
|
return None
|
|
131
|
+
|
|
@@ -187,12 +187,13 @@ class BlockRoamingController:
|
|
|
187
187
|
|
|
188
188
|
def enter_roaming(self, buffer) -> None:
|
|
189
189
|
blocks = self.get_blocks()
|
|
190
|
+
self.is_active = True
|
|
191
|
+
self.saved_buffer_text = buffer.text
|
|
190
192
|
if not blocks:
|
|
193
|
+
self.current_index = -1
|
|
191
194
|
self.status_msg = "No command blocks in session history."
|
|
192
195
|
return
|
|
193
196
|
|
|
194
|
-
self.is_active = True
|
|
195
|
-
self.saved_buffer_text = buffer.text
|
|
196
197
|
self.current_index = len(blocks) - 1
|
|
197
198
|
self.status_msg = ""
|
|
198
199
|
self._sync_buffer_to_block(buffer)
|
|
@@ -467,21 +468,10 @@ def create_key_bindings(
|
|
|
467
468
|
@kb.add("c-b")
|
|
468
469
|
def _(event: KeyPressEvent) -> None:
|
|
469
470
|
"""
|
|
470
|
-
Ctrl+B:
|
|
471
|
-
|
|
472
|
-
Pressing Enter on a running block inside the navigator enters the live sub-terminal.
|
|
471
|
+
Ctrl+B: Toggles inline Block Roaming Mode directly in the prompt line,
|
|
472
|
+
without launching a disruptive full-screen viewport.
|
|
473
473
|
"""
|
|
474
|
-
|
|
475
|
-
from kapsel.ui.viewport import run_block_viewport_async
|
|
476
|
-
|
|
477
|
-
async def _launch() -> None:
|
|
478
|
-
async with in_terminal():
|
|
479
|
-
cmd = await run_block_viewport_async()
|
|
480
|
-
if cmd is not None:
|
|
481
|
-
event.current_buffer.text = cmd
|
|
482
|
-
event.current_buffer.cursor_position = len(cmd)
|
|
483
|
-
|
|
484
|
-
event.app.create_background_task(_launch())
|
|
474
|
+
roam_ctrl.toggle(event.current_buffer)
|
|
485
475
|
|
|
486
476
|
@kb.add("up", filter=is_roaming)
|
|
487
477
|
@kb.add("k", filter=is_roaming)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kapsel-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Next-generation cross-platform terminal capsule & ergonomic shell multiplexer with context-aware command translation and 1,000+ CLI completions.
|
|
5
5
|
Author-email: MrEiu <k648888@vip.qq.com>
|
|
6
6
|
License: MIT
|
|
@@ -96,10 +96,12 @@ tests/test_ai_spec_sanitizer.py
|
|
|
96
96
|
tests/test_block_system.py
|
|
97
97
|
tests/test_carapace_installer.py
|
|
98
98
|
tests/test_config.py
|
|
99
|
+
tests/test_ctrl_b_roaming.py
|
|
99
100
|
tests/test_dir_nav.py
|
|
100
101
|
tests/test_history.py
|
|
101
102
|
tests/test_i18n.py
|
|
102
|
-
tests/
|
|
103
|
+
tests/test_kp_enhanced.py
|
|
104
|
+
tests/test_multiline_passthrough.py
|
|
103
105
|
tests/test_plugin_ai.py
|
|
104
106
|
tests/test_plugin_alias.py
|
|
105
107
|
tests/test_plugin_autopilot.py
|
|
@@ -109,6 +111,8 @@ tests/test_plugin_fetcher.py
|
|
|
109
111
|
tests/test_plugin_help.py
|
|
110
112
|
tests/test_plugin_init.py
|
|
111
113
|
tests/test_plugin_install.py
|
|
114
|
+
tests/test_plugin_install_batch.py
|
|
115
|
+
tests/test_plugin_kssh.py
|
|
112
116
|
tests/test_plugin_lang.py
|
|
113
117
|
tests/test_plugin_mend.py
|
|
114
118
|
tests/test_plugin_portal.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kapsel-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.2"
|
|
8
8
|
description = "Next-generation cross-platform terminal capsule & ergonomic shell multiplexer with context-aware command translation and 1,000+ CLI completions."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -45,16 +45,16 @@ def test_ensure_carapace_installed_when_available():
|
|
|
45
45
|
mock_install.assert_not_called()
|
|
46
46
|
|
|
47
47
|
|
|
48
|
-
def
|
|
49
|
-
"""Verifies that ensure_carapace_installed
|
|
48
|
+
def test_ensure_carapace_installed_when_missing_does_not_block():
|
|
49
|
+
"""Verifies that ensure_carapace_installed does not trigger blocking network install when missing."""
|
|
50
50
|
mock_engine = MagicMock()
|
|
51
51
|
mock_engine.is_available.return_value = False
|
|
52
52
|
|
|
53
53
|
with patch("kapsel.completion.carapace_engine.get_carapace_engine", return_value=mock_engine), \
|
|
54
|
-
patch("kapsel.completion.carapace_installer.install_carapace"
|
|
54
|
+
patch("kapsel.completion.carapace_installer.install_carapace") as mock_install:
|
|
55
55
|
res = ensure_carapace_installed()
|
|
56
|
-
assert res is
|
|
57
|
-
mock_install.
|
|
56
|
+
assert res is False
|
|
57
|
+
mock_install.assert_not_called()
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
def test_kps_registry_has_setup_completion():
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for Ctrl+B inline Block Roaming Mode.
|
|
3
|
+
Ensures Ctrl+B toggles inline roaming toolbar rather than launching full-screen viewport.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from unittest.mock import MagicMock
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from kapsel.core.block.model import BlockStatus, CommandBlock
|
|
10
|
+
from kapsel.core.block.registry import get_block_registry
|
|
11
|
+
from kapsel.storage.config import KapselConfig
|
|
12
|
+
from kapsel.ui.prompt import BlockRoamingController, create_key_bindings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_roam_ctrl_toggle_enters_and_exits():
|
|
16
|
+
"""Toggling roam_ctrl activates inline roaming and then cleanly exits and restores buffer."""
|
|
17
|
+
reg = get_block_registry()
|
|
18
|
+
reg.clear_for_test() if hasattr(reg, "clear_for_test") else None
|
|
19
|
+
|
|
20
|
+
# Register a sample block
|
|
21
|
+
reg.add_block(command="echo test_block", exit_code=0, duration_ms=10)
|
|
22
|
+
|
|
23
|
+
roam = BlockRoamingController()
|
|
24
|
+
dummy_buffer = MagicMock()
|
|
25
|
+
dummy_buffer.text = "typing something"
|
|
26
|
+
dummy_buffer.cursor_position = len(dummy_buffer.text)
|
|
27
|
+
|
|
28
|
+
# 1. Toggle ON: enters roaming mode
|
|
29
|
+
roam.toggle(dummy_buffer)
|
|
30
|
+
assert roam.is_active is True
|
|
31
|
+
assert roam.saved_buffer_text == "typing something"
|
|
32
|
+
assert dummy_buffer.text == "echo test_block"
|
|
33
|
+
|
|
34
|
+
# 2. Toggle OFF: exits roaming mode and restores buffer text
|
|
35
|
+
roam.toggle(dummy_buffer)
|
|
36
|
+
assert roam.is_active is False
|
|
37
|
+
assert dummy_buffer.text == "typing something"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_ctrl_b_keybinding_triggers_toggle():
|
|
41
|
+
"""Ctrl+B keybinding must trigger roam_ctrl.toggle without launching full-screen viewport."""
|
|
42
|
+
config = KapselConfig()
|
|
43
|
+
roam = BlockRoamingController()
|
|
44
|
+
kb = create_key_bindings(config, roaming=roam)
|
|
45
|
+
|
|
46
|
+
# Find the c-b binding
|
|
47
|
+
cb_binding = None
|
|
48
|
+
for binding in kb.bindings:
|
|
49
|
+
# Check if keys contain 'c-b'
|
|
50
|
+
keys_str = [str(k) for k in binding.keys]
|
|
51
|
+
if any("c-b" in k or k == "c-b" or "c-b" in str(binding.keys) for k in keys_str):
|
|
52
|
+
cb_binding = binding
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
assert cb_binding is not None, "Ctrl+B key binding should exist"
|
|
56
|
+
|
|
57
|
+
# Simulate pressing Ctrl+B
|
|
58
|
+
event = MagicMock()
|
|
59
|
+
event.current_buffer = MagicMock()
|
|
60
|
+
event.current_buffer.text = "current input"
|
|
61
|
+
|
|
62
|
+
# Roaming should initially be inactive
|
|
63
|
+
assert roam.is_active is False
|
|
64
|
+
|
|
65
|
+
# Call the c-b handler
|
|
66
|
+
cb_binding.call(event)
|
|
67
|
+
|
|
68
|
+
# Roaming should now be active directly in line (no full-screen launch)
|
|
69
|
+
assert roam.is_active is True
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for enhanced kp block runner:
|
|
3
|
+
- Robust command splitting (line continuations, unclosed quotes, keeping # lines)
|
|
4
|
+
- Concurrent (-c) vs Sequential (-s) block dispatching
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from kapsel.core.block.runner import split_commands
|
|
9
|
+
from kapsel.completion.kps.dispatcher import dispatch_kps
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_split_commands_retains_hash_lines():
|
|
13
|
+
"""Lines starting with # must NOT be filtered out, leaving them for the shell."""
|
|
14
|
+
text = "echo hello\n# this is a comment or root command\necho world"
|
|
15
|
+
cmds = split_commands(text)
|
|
16
|
+
assert cmds == ["echo hello", "# this is a comment or root command", "echo world"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_split_commands_backslash_continuation():
|
|
20
|
+
"""POSIX/Bash trailing backslash joins lines and removes the backslash."""
|
|
21
|
+
text = """docker run -d \\
|
|
22
|
+
--name web \\
|
|
23
|
+
-p 80:80 \\
|
|
24
|
+
nginx"""
|
|
25
|
+
cmds = split_commands(text)
|
|
26
|
+
assert len(cmds) == 1
|
|
27
|
+
assert cmds[0] == "docker run -d --name web -p 80:80 nginx"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_split_commands_powershell_backtick_continuation():
|
|
31
|
+
"""PowerShell trailing backtick joins lines and removes the backtick."""
|
|
32
|
+
text = """Get-ChildItem `
|
|
33
|
+
-Filter *.py `
|
|
34
|
+
-Recurse"""
|
|
35
|
+
cmds = split_commands(text)
|
|
36
|
+
assert len(cmds) == 1
|
|
37
|
+
assert cmds[0] == "Get-ChildItem -Filter *.py -Recurse"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_split_commands_pipeline_and_logical_continuation():
|
|
41
|
+
"""Trailing |, &&, and || lines automatically join with the subsequent line."""
|
|
42
|
+
text = """cat file.txt |
|
|
43
|
+
grep foo &&
|
|
44
|
+
wc -l"""
|
|
45
|
+
cmds = split_commands(text)
|
|
46
|
+
assert len(cmds) == 1
|
|
47
|
+
assert cmds[0] == "cat file.txt | grep foo && wc -l"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_split_commands_multiline_quotes_preserved():
|
|
51
|
+
"""Newlines inside unclosed quotes are preserved as string data."""
|
|
52
|
+
text = """git commit -m "first line
|
|
53
|
+
second line"
|
|
54
|
+
git push"""
|
|
55
|
+
cmds = split_commands(text)
|
|
56
|
+
assert len(cmds) == 2
|
|
57
|
+
assert cmds[0] == 'git commit -m "first line\nsecond line"'
|
|
58
|
+
assert cmds[1] == "git push"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_dispatch_kp_sequential_flag(monkeypatch):
|
|
62
|
+
"""'kp -s <cmd>' must dispatch with is_concurrent=False."""
|
|
63
|
+
captured = {}
|
|
64
|
+
|
|
65
|
+
def mock_async(commands, is_concurrent, **kwargs):
|
|
66
|
+
captured["commands"] = commands
|
|
67
|
+
captured["is_concurrent"] = is_concurrent
|
|
68
|
+
|
|
69
|
+
monkeypatch.setattr("kapsel.core.block.runner.execute_block_async", mock_async)
|
|
70
|
+
code = dispatch_kps("kp -s echo 1\necho 2")
|
|
71
|
+
assert code == 0
|
|
72
|
+
assert captured["is_concurrent"] is False
|
|
73
|
+
assert captured["commands"] == ["echo 1", "echo 2"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_dispatch_kp_concurrent_flag(monkeypatch):
|
|
77
|
+
"""'kp -c <cmd>' must dispatch with is_concurrent=True."""
|
|
78
|
+
captured = {}
|
|
79
|
+
|
|
80
|
+
def mock_async(commands, is_concurrent, **kwargs):
|
|
81
|
+
captured["commands"] = commands
|
|
82
|
+
captured["is_concurrent"] = is_concurrent
|
|
83
|
+
|
|
84
|
+
monkeypatch.setattr("kapsel.core.block.runner.execute_block_async", mock_async)
|
|
85
|
+
code = dispatch_kps("kp -c echo 1\necho 2")
|
|
86
|
+
assert code == 0
|
|
87
|
+
assert captured["is_concurrent"] is True
|
|
88
|
+
assert captured["commands"] == ["echo 1", "echo 2"]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_dispatch_kp_default_sequential(monkeypatch):
|
|
92
|
+
"""'kp <cmd>' without flag defaults to is_concurrent=False."""
|
|
93
|
+
captured = {}
|
|
94
|
+
|
|
95
|
+
def mock_async(commands, is_concurrent, **kwargs):
|
|
96
|
+
captured["commands"] = commands
|
|
97
|
+
captured["is_concurrent"] = is_concurrent
|
|
98
|
+
|
|
99
|
+
monkeypatch.setattr("kapsel.core.block.runner.execute_block_async", mock_async)
|
|
100
|
+
code = dispatch_kps("kp echo 1\necho 2")
|
|
101
|
+
assert code == 0
|
|
102
|
+
assert captured["is_concurrent"] is False
|
|
103
|
+
assert captured["commands"] == ["echo 1", "echo 2"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_dispatch_kp_empty_flag_errors():
|
|
107
|
+
"""'kp -s' or 'kp -c' alone with no commands returns error code 1."""
|
|
108
|
+
assert dispatch_kps("kp -s") == 1
|
|
109
|
+
assert dispatch_kps("kp -c") == 1
|
|
110
|
+
assert dispatch_kps("kp --sequential") == 1
|
|
111
|
+
assert dispatch_kps("kp --concurrent") == 1
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for multi-line command pass-through and built-in isolation in Kapsel.
|
|
3
|
+
Ensures multi-line commands are not broken or space-collapsed by single-line built-in handlers.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from kapsel.core.executor import CommandExecutor, ExecutionSummary
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_builtin_single_line_cd_intercepted():
|
|
14
|
+
"""Single-line 'cd' command must be intercepted by _handle_builtin."""
|
|
15
|
+
executor = CommandExecutor(shell_name="pwsh")
|
|
16
|
+
res = executor._handle_builtin("cd .")
|
|
17
|
+
assert res is not None
|
|
18
|
+
assert isinstance(res, ExecutionSummary)
|
|
19
|
+
assert res.is_builtin is True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_builtin_multiline_bypasses_interception():
|
|
23
|
+
"""
|
|
24
|
+
Multi-line pasted commands (e.g. containing newlines) must bypass _handle_builtin
|
|
25
|
+
even if the first line starts with 'cd' or another builtin, preventing cmd.split()
|
|
26
|
+
from collapsing subsequent lines into space-delimited arguments.
|
|
27
|
+
"""
|
|
28
|
+
executor = CommandExecutor(shell_name="pwsh")
|
|
29
|
+
multiline_cmd = "cd mydir\nnpm install\nnpm run dev"
|
|
30
|
+
res = executor._handle_builtin(multiline_cmd)
|
|
31
|
+
assert res is None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_builtin_touch_multiline_bypasses_interception():
|
|
35
|
+
"""Multi-line text starting with touch must not be collapsed by touch handler."""
|
|
36
|
+
executor = CommandExecutor(shell_name="pwsh")
|
|
37
|
+
multiline_touch = "touch file.txt\necho hello"
|
|
38
|
+
res = executor._handle_builtin(multiline_touch)
|
|
39
|
+
assert res is None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_multiline_cmd_exe_formatting(monkeypatch):
|
|
43
|
+
"""
|
|
44
|
+
In cmd.exe shell, multi-line commands should have non-empty lines joined
|
|
45
|
+
with ' & ' so cmd.exe /c executes them sequentially instead of truncating at newline.
|
|
46
|
+
"""
|
|
47
|
+
executor = CommandExecutor(shell_name="cmd")
|
|
48
|
+
captured_args = []
|
|
49
|
+
|
|
50
|
+
def mock_run(args, **kwargs):
|
|
51
|
+
captured_args.extend(args)
|
|
52
|
+
class DummyProc:
|
|
53
|
+
returncode = 0
|
|
54
|
+
return DummyProc()
|
|
55
|
+
|
|
56
|
+
monkeypatch.setattr("subprocess.run", mock_run)
|
|
57
|
+
executor._run_in_host_shell("echo 1\necho 2\n\necho 3")
|
|
58
|
+
|
|
59
|
+
assert "cmd.exe" in captured_args[0]
|
|
60
|
+
assert captured_args[1] == "/c"
|
|
61
|
+
assert captured_args[2] == "echo 1 & echo 2 & echo 3"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_multiline_pwsh_raw_passthrough(monkeypatch):
|
|
65
|
+
"""
|
|
66
|
+
In PowerShell or POSIX shells, multi-line scripts should pass through intact
|
|
67
|
+
as raw text to preserve native multi-line syntax (loops, here-docs, etc.).
|
|
68
|
+
"""
|
|
69
|
+
executor = CommandExecutor(shell_name="pwsh")
|
|
70
|
+
captured_args = []
|
|
71
|
+
|
|
72
|
+
def mock_run(args, **kwargs):
|
|
73
|
+
captured_args.extend(args)
|
|
74
|
+
class DummyProc:
|
|
75
|
+
returncode = 0
|
|
76
|
+
return DummyProc()
|
|
77
|
+
|
|
78
|
+
monkeypatch.setattr("subprocess.run", mock_run)
|
|
79
|
+
raw_script = "Write-Host 'line1'\nWrite-Host 'line2'"
|
|
80
|
+
executor._run_in_host_shell(raw_script)
|
|
81
|
+
|
|
82
|
+
assert "-Command" in captured_args
|
|
83
|
+
assert captured_args[-1] == raw_script
|