kapsel-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. kapsel/__init__.py +6 -0
  2. kapsel/cli.py +162 -0
  3. kapsel/completion/__init__.py +23 -0
  4. kapsel/completion/carapace_engine.py +236 -0
  5. kapsel/completion/completer.py +251 -0
  6. kapsel/completion/fig_engine.py +215 -0
  7. kapsel/completion/fig_schema.py +135 -0
  8. kapsel/completion/importer.py +92 -0
  9. kapsel/completion/kps/__init__.py +18 -0
  10. kapsel/completion/kps/builtins/__init__.py +92 -0
  11. kapsel/completion/kps/builtins/add.py +121 -0
  12. kapsel/completion/kps/builtins/config.py +184 -0
  13. kapsel/completion/kps/builtins/datadir.py +108 -0
  14. kapsel/completion/kps/builtins/help.py +270 -0
  15. kapsel/completion/kps/builtins/language.py +83 -0
  16. kapsel/completion/kps/builtins/status.py +121 -0
  17. kapsel/completion/kps/builtins/toggle.py +34 -0
  18. kapsel/completion/kps/dispatcher.py +65 -0
  19. kapsel/completion/kps/registry.py +101 -0
  20. kapsel/core/__init__.py +28 -0
  21. kapsel/core/detector.py +162 -0
  22. kapsel/core/engine.py +159 -0
  23. kapsel/core/executor.py +200 -0
  24. kapsel/core/i18n.py +31 -0
  25. kapsel/core/plugin/__init__.py +17 -0
  26. kapsel/core/plugin/base.py +53 -0
  27. kapsel/core/plugin/catalog.py +187 -0
  28. kapsel/core/plugin/context.py +71 -0
  29. kapsel/core/plugin/hooks.py +34 -0
  30. kapsel/core/plugin/manager.py +173 -0
  31. kapsel/i18n.py +196 -0
  32. kapsel/locales/de/LC_MESSAGES/kapsel.mo +0 -0
  33. kapsel/locales/de/LC_MESSAGES/kapsel.po +215 -0
  34. kapsel/locales/de/help.yaml +101 -0
  35. kapsel/locales/en/help.yaml +101 -0
  36. kapsel/locales/es/LC_MESSAGES/kapsel.mo +0 -0
  37. kapsel/locales/es/LC_MESSAGES/kapsel.po +213 -0
  38. kapsel/locales/es/help.yaml +101 -0
  39. kapsel/locales/fr/LC_MESSAGES/kapsel.mo +0 -0
  40. kapsel/locales/fr/LC_MESSAGES/kapsel.po +213 -0
  41. kapsel/locales/fr/help.yaml +101 -0
  42. kapsel/locales/ja/LC_MESSAGES/kapsel.mo +0 -0
  43. kapsel/locales/ja/LC_MESSAGES/kapsel.po +211 -0
  44. kapsel/locales/ja/help.yaml +101 -0
  45. kapsel/locales/kapsel.pot +210 -0
  46. kapsel/locales/ru/LC_MESSAGES/kapsel.mo +0 -0
  47. kapsel/locales/ru/LC_MESSAGES/kapsel.po +212 -0
  48. kapsel/locales/ru/help.yaml +101 -0
  49. kapsel/locales/zh_CN/LC_MESSAGES/kapsel.mo +0 -0
  50. kapsel/locales/zh_CN/LC_MESSAGES/kapsel.po +211 -0
  51. kapsel/locales/zh_CN/help.yaml +101 -0
  52. kapsel/storage/__init__.py +17 -0
  53. kapsel/storage/config.py +362 -0
  54. kapsel/storage/history.py +166 -0
  55. kapsel/storage/logger.py +75 -0
  56. kapsel/storage/migrate.py +119 -0
  57. kapsel/ui/__init__.py +21 -0
  58. kapsel/ui/banner.py +121 -0
  59. kapsel/ui/card.py +109 -0
  60. kapsel/ui/prompt.py +391 -0
  61. kapsel/ui/theme.py +59 -0
  62. kapsel_cli-0.1.0.dist-info/METADATA +397 -0
  63. kapsel_cli-0.1.0.dist-info/RECORD +66 -0
  64. kapsel_cli-0.1.0.dist-info/WHEEL +5 -0
  65. kapsel_cli-0.1.0.dist-info/entry_points.txt +3 -0
  66. kapsel_cli-0.1.0.dist-info/top_level.txt +1 -0
kapsel/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ Kapsel: Cross-platform adaptive smart terminal capsule.
3
+ Wrap complexity, expose simplicity.
4
+ """
5
+
6
+ __version__ = "0.1.0"
kapsel/cli.py ADDED
@@ -0,0 +1,162 @@
1
+ """
2
+ Kapsel CLI entry point.
3
+ Provides both the interactive TUI capsule shell (`kapsel`) and one-shot translator tool (`kps`).
4
+ Includes 'kapsel toggle' to toggle Kapsel as the default terminal environment.
5
+ All comments and descriptions are in English.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ from typing import List, Optional
11
+
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+
15
+ from kapsel import __version__
16
+ from kapsel.completion.kps import dispatch_kps
17
+ from kapsel.core.engine import DualStateEngine
18
+ from kapsel.storage.logger import logger
19
+ from kapsel.ui.banner import ensure_utf8_io, render_banner
20
+ from kapsel.ui.card import render_execution_footer
21
+ from kapsel.ui.prompt import KapselPrompt
22
+
23
+
24
+ def main(args: Optional[List[str]] = None) -> int:
25
+ """Main interactive capsule shell loop and command runner."""
26
+ ensure_utf8_io()
27
+ if args is None:
28
+ args = sys.argv[1:]
29
+
30
+ # Handle quick flags
31
+ if args and args[0] in ("-v", "--version"):
32
+ print(f"Kapsel v{__version__}")
33
+ return 0
34
+
35
+ no_banner = "--no-banner" in args
36
+ clean_args = [a for a in args if a != "--no-banner"]
37
+
38
+ # Handle -c / --command
39
+ if clean_args and clean_args[0] in ("-c", "--command"):
40
+ cmd_str = " ".join(clean_args[1:])
41
+ engine = DualStateEngine()
42
+ result = engine.dispatch(cmd_str)
43
+ if engine.config.enable_card_border and not result.execution.is_builtin:
44
+ render_execution_footer(
45
+ summary=result.execution,
46
+ translation=result.translated_cmd,
47
+ config=engine.config,
48
+ )
49
+ return result.execution.exit_code
50
+
51
+ is_toggle_start = False
52
+ if clean_args and clean_args[0] == "toggle":
53
+ is_toggle_start = True
54
+ clean_args = clean_args[1:]
55
+
56
+ # If other positional subcommands passed (e.g. kapsel status, kapsel help, kapsel add, kapsel config, kapsel datadir)
57
+ if clean_args and not is_toggle_start:
58
+ engine = DualStateEngine()
59
+ full_cmd = "kapsel " + " ".join(clean_args)
60
+ result = engine.dispatch(full_cmd)
61
+ if engine.config.enable_card_border and not result.execution.is_builtin:
62
+ render_execution_footer(
63
+ summary=result.execution,
64
+ translation=result.translated_cmd,
65
+ config=engine.config,
66
+ )
67
+ return result.execution.exit_code
68
+
69
+ # Launch interactive shell (Kapsel Default Mode)
70
+ engine = DualStateEngine()
71
+ console = Console(legacy_windows=False)
72
+
73
+ os.environ["KAPSEL_ACTIVE"] = "1"
74
+
75
+ if is_toggle_start:
76
+ console.print("[bold #10b981]βœ” Kapsel active.[/] [dim]Type 'toggle' or 'exit' to quit.[/]\n")
77
+ elif engine.config.enable_banner and not no_banner:
78
+ render_banner()
79
+
80
+ prompt_session = KapselPrompt(engine)
81
+
82
+ # Interactive REPL loop
83
+ while True:
84
+ try:
85
+ user_input = prompt_session.prompt()
86
+ except KeyboardInterrupt:
87
+ # User pressed Ctrl+C, reset prompt without exiting
88
+ print()
89
+ continue
90
+ except EOFError:
91
+ # User pressed Ctrl+D, cleanly exit
92
+ os.environ.pop("KAPSEL_ACTIVE", None)
93
+ print("\nExiting Kapsel. Bye! πŸ’Š")
94
+ break
95
+
96
+ stripped = user_input.strip()
97
+ if not stripped:
98
+ continue
99
+
100
+ normalized = " ".join(stripped.lower().split())
101
+ if normalized in ("exit", "quit", "toggle", "kapsel toggle", "kps toggle"):
102
+ os.environ.pop("KAPSEL_ACTIVE", None)
103
+ console.print("[dim]Exited Kapsel.[/]")
104
+ break
105
+
106
+ try:
107
+ result = engine.dispatch(user_input)
108
+ if engine.config.enable_card_border and not result.execution.is_builtin:
109
+ render_execution_footer(
110
+ summary=result.execution,
111
+ translation=result.translated_cmd,
112
+ config=engine.config,
113
+ )
114
+ except Exception as e:
115
+ logger.exception(f"Unexpected error executing '{user_input}': {e}")
116
+ print(f"kapsel: unexpected error: {e}", file=sys.stderr)
117
+
118
+ return 0
119
+
120
+
121
+ def kps_cli() -> int:
122
+ """
123
+ Direct CLI entry point for 'kps' command.
124
+ Allows running single commands from external shells:
125
+ e.g. `kps status` or `kps rm -rf node_modules`
126
+ """
127
+ ensure_utf8_io()
128
+ argv = sys.argv[1:]
129
+ if not argv:
130
+ # If run as bare 'kps', launch the full interactive shell
131
+ return main()
132
+
133
+ if argv[0] in ("-v", "--version"):
134
+ print(f"Kapsel v{__version__}")
135
+ return 0
136
+
137
+ if argv[0] == "toggle":
138
+ return main(["toggle"])
139
+
140
+ # 1. Fast dispatch to registered built-in or plugin kps commands
141
+ cmd_line = " ".join(argv)
142
+ builtin_exit = dispatch_kps(cmd_line)
143
+ if builtin_exit is not None:
144
+ return builtin_exit
145
+
146
+ # 2. Otherwise dispatch through engine (executes plugin filters or reports error)
147
+ full_line = "kps " + cmd_line
148
+ engine = DualStateEngine()
149
+ result = engine.dispatch(full_line)
150
+
151
+ if engine.config.enable_card_border and not result.execution.is_builtin:
152
+ render_execution_footer(
153
+ summary=result.execution,
154
+ translation=result.translated_cmd,
155
+ config=engine.config,
156
+ )
157
+
158
+ return result.execution.exit_code
159
+
160
+
161
+ if __name__ == "__main__":
162
+ sys.exit(main())
@@ -0,0 +1,23 @@
1
+ """
2
+ Kapsel Completion Subpackage.
3
+ Fuses Fig.Spec declarative hierarchy with Kapsel's core command subsystem and plugin hooks.
4
+ """
5
+
6
+ from kapsel.completion.carapace_engine import CarapaceCandidate, CarapaceEngine, get_carapace_engine
7
+ from kapsel.completion.completer import DualStateCompleter
8
+ from kapsel.completion.fig_engine import FigCandidate, FigEngine, get_fig_engine
9
+ from kapsel.completion.fig_schema import FigArg, FigOption, FigSpec, FigSubcommand
10
+
11
+ __all__ = [
12
+ "DualStateCompleter",
13
+ "CarapaceEngine",
14
+ "get_carapace_engine",
15
+ "CarapaceCandidate",
16
+ "FigEngine",
17
+ "get_fig_engine",
18
+ "FigCandidate",
19
+ "FigSpec",
20
+ "FigSubcommand",
21
+ "FigOption",
22
+ "FigArg",
23
+ ]
@@ -0,0 +1,236 @@
1
+ """
2
+ Carapace Dynamic Completion Engine for Kapsel.
3
+ Bridges 'carapace-bin' to provide context-aware, multi-shell autocompletion
4
+ for over 1,000+ commands (git, docker, kubectl, cargo, npm, etc.) with millisecond response.
5
+ All comments and descriptions are in English.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import re
13
+ import shlex
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from typing import Dict, List, Optional, Set, Tuple
18
+
19
+ from kapsel.storage.config import get_kapsel_dir
20
+ from kapsel.storage.logger import logger
21
+
22
+
23
+ @dataclass
24
+ class CarapaceCandidate:
25
+ """Represents a single autocompletion candidate returned by Carapace."""
26
+ value: str
27
+ display: str
28
+ description: str
29
+ style: str = ""
30
+ tag: str = ""
31
+
32
+
33
+ _ANSI_ESCAPE_RE = re.compile(r"(\x1b|\x9b|`e)\[[0-?]*[ -/]*[@-~]")
34
+
35
+
36
+ def _strip_ansi(text: str) -> str:
37
+ """Removes ANSI color and style escape codes from text."""
38
+ if not text:
39
+ return ""
40
+ return _ANSI_ESCAPE_RE.sub("", text).strip()
41
+
42
+
43
+ def resolve_carapace_executable() -> Optional[str]:
44
+ """
45
+ Finds the carapace-bin executable in standard locations:
46
+ 1. System PATH
47
+ 2. Scoop shims / apps directory
48
+ 3. Kapsel local bin directory (~/.kapsel/bin/carapace)
49
+ 4. Cargo bin directory (~/.cargo/bin)
50
+ """
51
+ # 1. Direct Scoop app binary (avoids shim process wrapper overhead on Windows)
52
+ if sys.platform == "win32":
53
+ user_profile = Path(os.environ.get("USERPROFILE", Path.home()))
54
+ for candidate in [
55
+ user_profile / "scoop/apps/carapace-bin/current/carapace.exe",
56
+ user_profile / "scoop/shims/carapace.exe",
57
+ user_profile / ".cargo/bin/carapace.exe",
58
+ user_profile / "AppData/Local/Microsoft/WinGet/Links/carapace.exe",
59
+ ]:
60
+ if candidate.exists():
61
+ return str(candidate)
62
+
63
+ # 2. System PATH
64
+ which_p = shutil.which("carapace")
65
+ if which_p:
66
+ return which_p
67
+
68
+ # 3. Kapsel local bin
69
+ is_win = sys.platform == "win32"
70
+ local_bin = get_kapsel_dir() / "bin" / ("carapace.exe" if is_win else "carapace")
71
+ if local_bin.exists():
72
+ return str(local_bin)
73
+
74
+ return None
75
+
76
+
77
+ class CarapaceEngine:
78
+ """
79
+ High-performance completion engine delegating to Carapace (carapace-bin).
80
+ Caches the list of 1,000+ supported tools and invokes JSON export for dynamic context.
81
+ """
82
+
83
+ def __init__(self, executable: Optional[str] = None):
84
+ self.executable: Optional[str] = executable or resolve_carapace_executable()
85
+ self._supported_tools: Optional[Set[str]] = None
86
+ self._completion_cache: Dict[Tuple[str, Tuple[str, ...], str], List[CarapaceCandidate]] = {}
87
+
88
+ def is_available(self) -> bool:
89
+ """Returns True if carapace-bin is installed and executable."""
90
+ return self.executable is not None and Path(self.executable).exists()
91
+
92
+ def get_supported_tools(self) -> Set[str]:
93
+ """
94
+ Returns the set of tool names supported by Carapace.
95
+ Lazy-loads via 'carapace --list' and caches in memory.
96
+ """
97
+ if self._supported_tools is not None:
98
+ return self._supported_tools
99
+
100
+ if not self.is_available():
101
+ self._supported_tools = set()
102
+ return self._supported_tools
103
+
104
+ try:
105
+ res = subprocess.run(
106
+ [self.executable, "--list"],
107
+ capture_output=True,
108
+ text=True,
109
+ encoding="utf-8",
110
+ errors="replace",
111
+ timeout=2.0,
112
+ )
113
+ if res.returncode == 0 and res.stdout.strip():
114
+ data = json.loads(res.stdout)
115
+ if isinstance(data, dict):
116
+ self._supported_tools = set(data.keys())
117
+ return self._supported_tools
118
+ except Exception as e:
119
+ logger.warning(f"Failed to load Carapace tools list: {e}")
120
+
121
+ self._supported_tools = set()
122
+ return self._supported_tools
123
+
124
+ def has_completer_for(self, tool: str) -> bool:
125
+ """Checks if Carapace has a completion specification for the given tool."""
126
+ normalized = tool.lower()
127
+ if normalized.endswith(".exe"):
128
+ normalized = normalized[:-4]
129
+ return normalized in self.get_supported_tools()
130
+
131
+ def get_completions(self, text_line: str) -> Tuple[List[CarapaceCandidate], str]:
132
+ """
133
+ Retrieves completion candidates from Carapace for the given command line.
134
+ Returns a tuple of (candidates, current_word_prefix).
135
+ """
136
+ if not self.is_available() or not text_line.strip():
137
+ return [], ""
138
+
139
+ stripped = text_line.lstrip()
140
+ ends_with_space = text_line.endswith(" ")
141
+
142
+ # Tokenize line respecting spaces
143
+ try:
144
+ # We use a custom parser or shlex to extract tokens
145
+ words = shlex.split(stripped)
146
+ except ValueError:
147
+ # Unterminated quote, fall back to simple whitespace split
148
+ words = stripped.split()
149
+
150
+ if not words:
151
+ return [], ""
152
+
153
+ first_tool = words[0].lower()
154
+ if first_tool.endswith(".exe"):
155
+ first_tool = first_tool[:-4]
156
+
157
+ if not self.has_completer_for(first_tool):
158
+ return [], ""
159
+
160
+ # Determine the current word prefix being completed
161
+ if ends_with_space:
162
+ prefix = ""
163
+ args_for_carapace = words + [""]
164
+ else:
165
+ prefix = words[-1]
166
+ args_for_carapace = words
167
+
168
+ # Check in-memory cache
169
+ cwd_str = str(Path.cwd())
170
+ cache_key = (first_tool, tuple(args_for_carapace), cwd_str)
171
+ if cache_key in self._completion_cache:
172
+ return self._completion_cache[cache_key], prefix
173
+
174
+ # Execute: carapace <tool> export <tool> <arg1> <arg2> ...
175
+ cmd = [self.executable, first_tool, "export"] + args_for_carapace
176
+
177
+ try:
178
+ res = subprocess.run(
179
+ cmd,
180
+ capture_output=True,
181
+ text=True,
182
+ encoding="utf-8",
183
+ errors="replace",
184
+ timeout=0.8, # Robust timeout for process execution on Windows
185
+ cwd=cwd_str,
186
+ )
187
+ if res.returncode != 0 or not res.stdout.strip():
188
+ return [], prefix
189
+
190
+ data = json.loads(res.stdout)
191
+ values = data.get("values", [])
192
+ candidates: List[CarapaceCandidate] = []
193
+
194
+ for item in values:
195
+ if not isinstance(item, dict):
196
+ continue
197
+ val = item.get("value", "")
198
+ disp = item.get("display", val)
199
+ desc = _strip_ansi(item.get("description", ""))
200
+ style = item.get("style", "")
201
+ tag = item.get("tag", "")
202
+
203
+ candidates.append(
204
+ CarapaceCandidate(
205
+ value=val,
206
+ display=disp,
207
+ description=desc,
208
+ style=style,
209
+ tag=tag,
210
+ )
211
+ )
212
+
213
+ # Limit cache size to 256 entries
214
+ if len(self._completion_cache) > 256:
215
+ self._completion_cache.clear()
216
+ self._completion_cache[cache_key] = candidates
217
+
218
+ return candidates, prefix
219
+
220
+ except subprocess.TimeoutExpired:
221
+ logger.debug(f"Carapace completion timed out for: {text_line}")
222
+ return [], prefix
223
+ except Exception as e:
224
+ logger.debug(f"Error querying Carapace for '{text_line}': {e}")
225
+ return [], prefix
226
+
227
+
228
+ _CARAPACE_ENGINE: Optional[CarapaceEngine] = None
229
+
230
+
231
+ def get_carapace_engine() -> CarapaceEngine:
232
+ """Returns the singleton instance of CarapaceEngine."""
233
+ global _CARAPACE_ENGINE
234
+ if _CARAPACE_ENGINE is None:
235
+ _CARAPACE_ENGINE = CarapaceEngine()
236
+ return _CARAPACE_ENGINE
@@ -0,0 +1,251 @@
1
+ """
2
+ Kapsel Dual-State Completer (Carapace-Powered).
3
+ Seamlessly fuses Carapace dynamic multi-shell completion (1,000+ commands)
4
+ with Kapsel's core system management and plugin ecosystem.
5
+ All comments and descriptions are in English.
6
+ """
7
+
8
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
9
+
10
+ from prompt_toolkit.completion import CompleteEvent, Completer, Completion, PathCompleter
11
+ from prompt_toolkit.document import Document
12
+
13
+ from kapsel.completion.carapace_engine import CarapaceEngine, get_carapace_engine
14
+ from kapsel.completion.fig_engine import FigEngine, get_fig_engine
15
+ from kapsel.completion.kps.registry import KpsCommandRegistry, get_kps_registry
16
+ from kapsel.core.i18n import _
17
+
18
+
19
+ class DualStateCompleter(Completer):
20
+ """
21
+ Dual-State Carapace-Powered Completer:
22
+ - Native Mode: Deep multi-level context-aware autocompletion powered by Carapace
23
+ (1,000+ tools: git branches/tags, docker flags/containers, npm scripts, etc.).
24
+ - Kapsel Mode ('kapsel <cmd>' / 'kps <cmd>'): Unified capsule commands (help, status, config,
25
+ datadir, add, toggle, and plugin extensions).
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ carapace_engine: Optional[CarapaceEngine] = None,
31
+ fig_engine: Optional[FigEngine] = None,
32
+ kps_registry: Optional[KpsCommandRegistry] = None,
33
+ current_shell: str = "pwsh",
34
+ plugin_manager: Optional[Any] = None,
35
+ ):
36
+ self.carapace_engine = carapace_engine or get_carapace_engine()
37
+ self.fig_engine = fig_engine or get_fig_engine()
38
+ self.kps_registry = kps_registry or get_kps_registry()
39
+ self.current_shell = current_shell
40
+ self.plugin_manager = plugin_manager
41
+ self.path_completer = PathCompleter(expanduser=True)
42
+
43
+ def set_shell(self, shell: str) -> None:
44
+ self.current_shell = shell
45
+
46
+ def get_completions(
47
+ self, document: Document, complete_event: CompleteEvent
48
+ ) -> Iterable[Completion]:
49
+ text_before = document.text_before_cursor
50
+ stripped = text_before.lstrip()
51
+
52
+ # 1. User is typing 'kapsel' or 'kps' (offer transition without trailing space)
53
+ if stripped in ("k", "ka", "kap", "kaps", "kapse", "kapsel"):
54
+ yield Completion(
55
+ text="kapsel",
56
+ start_position=-len(stripped),
57
+ display="kapsel",
58
+ display_meta="Kapsel command (help, status, config, add, ...)",
59
+ )
60
+ yield Completion(
61
+ text="kps",
62
+ start_position=-len(stripped),
63
+ display="kps",
64
+ display_meta="Kapsel command alias (kps)",
65
+ )
66
+ return
67
+ if stripped in ("kp", "kps"):
68
+ yield Completion(
69
+ text="kps",
70
+ start_position=-len(stripped),
71
+ display="kps",
72
+ display_meta="Kapsel command alias (kps)",
73
+ )
74
+ return
75
+
76
+ # 2. Unified Kapsel Command Mode: 'kapsel <cmd>' or 'kps <cmd>'
77
+ if stripped.startswith("kapsel "):
78
+ sub = stripped[7:]
79
+ yield from self._complete_kapsel_mode(sub)
80
+ return
81
+
82
+ if stripped.startswith("kps "):
83
+ sub = stripped[4:]
84
+ yield from self._complete_kapsel_mode(sub)
85
+ return
86
+
87
+ # 3. Native Mode: Carapace (1000+ tools), Fig fallback, builtins, & paths
88
+ yield from self._complete_native_mode(stripped, document, complete_event)
89
+
90
+ def _complete_kapsel_mode(self, query: str) -> Iterable[Completion]:
91
+ """Completes commands under unified 'kapsel ' and 'kps ' pipeline."""
92
+ ends_with_space = query.endswith(" ")
93
+ words = query.split()
94
+
95
+ if ends_with_space:
96
+ prefix = ""
97
+ else:
98
+ prefix = words[-1] if words else ""
99
+
100
+ available_cmds = self.kps_registry.list_commands()
101
+
102
+ # A. Completing primary command name (e.g. 'kapsel conf', 'kps ai', etc.)
103
+ if len(words) == 0 or (len(words) == 1 and not ends_with_space):
104
+ for cmd in available_cmds:
105
+ if cmd.name.startswith(prefix.lower()):
106
+ icon = "πŸš€ " if cmd.plugin_id else "βš™οΈ "
107
+ yield Completion(
108
+ text=cmd.name,
109
+ start_position=-len(prefix),
110
+ display=cmd.name,
111
+ display_meta=f"{icon}{cmd.help_text}",
112
+ )
113
+
114
+ # B. Completing subcommands (e.g. 'kapsel config [edit|path|get|set]')
115
+ elif len(words) >= 1:
116
+ first_cmd_name = words[0].lower()
117
+ cmd = self.kps_registry.get(first_cmd_name)
118
+ if cmd and cmd.subcommands:
119
+ if len(words) == 1 and ends_with_space:
120
+ sub_prefix = ""
121
+ elif len(words) == 2 and not ends_with_space:
122
+ sub_prefix = words[1]
123
+ else:
124
+ sub_prefix = ""
125
+
126
+ for subcmd, subdesc in cmd.subcommands.items():
127
+ if subcmd.startswith(sub_prefix.lower()):
128
+ yield Completion(
129
+ text=subcmd,
130
+ start_position=-len(sub_prefix),
131
+ display=subcmd,
132
+ display_meta=f"πŸ”Ή {subdesc}",
133
+ )
134
+
135
+ # C. Plugin-provided dynamic completions (e.g. tldr cheat sheet caching)
136
+ if self.plugin_manager:
137
+ plugin_cands = self.plugin_manager.get_plugin_completions("kps " + query)
138
+ for cand in plugin_cands:
139
+ yield Completion(
140
+ text=cand.get("text", ""),
141
+ start_position=cand.get("start_position", -len(prefix)),
142
+ display=cand.get("display", cand.get("text", "")),
143
+ display_meta=cand.get("display_meta", "Plugin"),
144
+ )
145
+
146
+ # D. If user ran a native tool prefixed with kapsel/kps (e.g. 'kps git checkout')
147
+ if words:
148
+ first_tool = words[0].lower()
149
+ if self.carapace_engine.is_available() and self.carapace_engine.has_completer_for(first_tool):
150
+ yield from self._yield_carapace_completions(query)
151
+
152
+ def _complete_native_mode(
153
+ self, stripped: str, document: Document, complete_event: CompleteEvent
154
+ ) -> Iterable[Completion]:
155
+ parts = stripped.split()
156
+ first_tool = parts[0].lower() if parts else ""
157
+ if first_tool.endswith(".exe"):
158
+ first_tool = first_tool[:-4]
159
+
160
+ # A. Primary: Carapace Dynamic Completion (1,000+ commands with live context)
161
+ if parts and self.carapace_engine.is_available() and self.carapace_engine.has_completer_for(first_tool):
162
+ yield from self._yield_carapace_completions(stripped)
163
+ return
164
+
165
+ # B. Secondary: Fig.Spec fallback if tool was defined in Fig
166
+ if parts and self.fig_engine.has_spec_for_tool(first_tool):
167
+ yield from self._yield_fig_completions(stripped)
168
+ return
169
+
170
+ # C. Native Top-Level Builtins & High-Frequency Tools (when starting command line)
171
+ if len(parts) <= 1 and not stripped.endswith(" "):
172
+ curr_word = parts[0] if parts else ""
173
+ native_builtins = [
174
+ ("cd", _("Change directory")),
175
+ ("clear", _("Clear terminal screen")),
176
+ ("exit", _("Exit session")),
177
+ ("git", _("Git version control")),
178
+ ("docker", _("Docker container platform")),
179
+ ("scoop", _("Windows command-line installer")),
180
+ ("npm", _("Node.js package manager")),
181
+ ("cargo", _("Rust package manager")),
182
+ ("python", _("Python interpreter")),
183
+ ("kubectl", _("Kubernetes cluster CLI")),
184
+ ("pnpm", _("Fast disk space efficient package manager")),
185
+ ("yarn", _("Node.js package manager")),
186
+ ]
187
+ for cmd, desc in native_builtins:
188
+ if cmd.startswith(curr_word.lower()):
189
+ yield Completion(
190
+ text=cmd,
191
+ start_position=-len(curr_word),
192
+ display=cmd,
193
+ display_meta=desc,
194
+ )
195
+
196
+ # D. Plugin-provided completions for native mode (e.g. mapping plugins)
197
+ if self.plugin_manager:
198
+ plugin_cands = self.plugin_manager.get_plugin_completions(stripped)
199
+ for cand in plugin_cands:
200
+ yield Completion(
201
+ text=cand.get("text", ""),
202
+ start_position=cand.get("start_position", -len(parts[-1]) if parts else 0),
203
+ display=cand.get("display", cand.get("text", "")),
204
+ display_meta=cand.get("display_meta", "πŸ”Œ 插仢提供"),
205
+ )
206
+
207
+ # E. Filesystem Path Completion fallback
208
+ yield from self.path_completer.get_completions(document, complete_event)
209
+
210
+ def _yield_carapace_completions(self, text_line: str) -> Iterable[Completion]:
211
+ """Queries CarapaceEngine and yields structured, styled prompt_toolkit completions."""
212
+ candidates, prefix = self.carapace_engine.get_completions(text_line)
213
+ start_pos = -len(prefix)
214
+
215
+ for cand in candidates:
216
+ # Determine suitable icon based on argument type
217
+ if cand.value.startswith("-"):
218
+ icon = "🚩 "
219
+ elif "/" in cand.value or "\\" in cand.value:
220
+ icon = "πŸ“ "
221
+ elif cand.tag in ("heads", "local branches", "remote branches", "tags"):
222
+ icon = "🌿 "
223
+ elif cand.tag in ("containers", "images", "volumes", "networks"):
224
+ icon = "🐳 "
225
+ else:
226
+ icon = "πŸ“¦ "
227
+
228
+ tag_part = f"[{cand.tag}] " if cand.tag else ""
229
+ desc = f"{icon}{tag_part}{cand.description}".strip() if cand.description or cand.tag else f"{icon}{cand.value}"
230
+
231
+ yield Completion(
232
+ text=cand.value,
233
+ start_position=start_pos,
234
+ display=cand.display or cand.value,
235
+ display_meta=desc,
236
+ )
237
+
238
+ def _yield_fig_completions(self, text_line: str) -> Iterable[Completion]:
239
+ """Evaluates legacy Fig AST context and yields styled completions."""
240
+ completed, partial = self.fig_engine.tokenize_line(text_line)
241
+ candidates = self.fig_engine.get_completions(text_line)
242
+ start_pos = -len(partial) if partial else 0
243
+
244
+ for cand in candidates:
245
+ icon = "🚩 " if cand.kind == "option" else "πŸ“¦ "
246
+ yield Completion(
247
+ text=cand.insert_text,
248
+ start_position=start_pos,
249
+ display=cand.display_text,
250
+ display_meta=f"{icon}{cand.description}",
251
+ )