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.
- kapsel/__init__.py +6 -0
- kapsel/cli.py +162 -0
- kapsel/completion/__init__.py +23 -0
- kapsel/completion/carapace_engine.py +236 -0
- kapsel/completion/completer.py +251 -0
- kapsel/completion/fig_engine.py +215 -0
- kapsel/completion/fig_schema.py +135 -0
- kapsel/completion/importer.py +92 -0
- kapsel/completion/kps/__init__.py +18 -0
- kapsel/completion/kps/builtins/__init__.py +92 -0
- kapsel/completion/kps/builtins/add.py +121 -0
- kapsel/completion/kps/builtins/config.py +184 -0
- kapsel/completion/kps/builtins/datadir.py +108 -0
- kapsel/completion/kps/builtins/help.py +270 -0
- kapsel/completion/kps/builtins/language.py +83 -0
- kapsel/completion/kps/builtins/status.py +121 -0
- kapsel/completion/kps/builtins/toggle.py +34 -0
- kapsel/completion/kps/dispatcher.py +65 -0
- kapsel/completion/kps/registry.py +101 -0
- kapsel/core/__init__.py +28 -0
- kapsel/core/detector.py +162 -0
- kapsel/core/engine.py +159 -0
- kapsel/core/executor.py +200 -0
- kapsel/core/i18n.py +31 -0
- kapsel/core/plugin/__init__.py +17 -0
- kapsel/core/plugin/base.py +53 -0
- kapsel/core/plugin/catalog.py +187 -0
- kapsel/core/plugin/context.py +71 -0
- kapsel/core/plugin/hooks.py +34 -0
- kapsel/core/plugin/manager.py +173 -0
- kapsel/i18n.py +196 -0
- kapsel/locales/de/LC_MESSAGES/kapsel.mo +0 -0
- kapsel/locales/de/LC_MESSAGES/kapsel.po +215 -0
- kapsel/locales/de/help.yaml +101 -0
- kapsel/locales/en/help.yaml +101 -0
- kapsel/locales/es/LC_MESSAGES/kapsel.mo +0 -0
- kapsel/locales/es/LC_MESSAGES/kapsel.po +213 -0
- kapsel/locales/es/help.yaml +101 -0
- kapsel/locales/fr/LC_MESSAGES/kapsel.mo +0 -0
- kapsel/locales/fr/LC_MESSAGES/kapsel.po +213 -0
- kapsel/locales/fr/help.yaml +101 -0
- kapsel/locales/ja/LC_MESSAGES/kapsel.mo +0 -0
- kapsel/locales/ja/LC_MESSAGES/kapsel.po +211 -0
- kapsel/locales/ja/help.yaml +101 -0
- kapsel/locales/kapsel.pot +210 -0
- kapsel/locales/ru/LC_MESSAGES/kapsel.mo +0 -0
- kapsel/locales/ru/LC_MESSAGES/kapsel.po +212 -0
- kapsel/locales/ru/help.yaml +101 -0
- kapsel/locales/zh_CN/LC_MESSAGES/kapsel.mo +0 -0
- kapsel/locales/zh_CN/LC_MESSAGES/kapsel.po +211 -0
- kapsel/locales/zh_CN/help.yaml +101 -0
- kapsel/storage/__init__.py +17 -0
- kapsel/storage/config.py +362 -0
- kapsel/storage/history.py +166 -0
- kapsel/storage/logger.py +75 -0
- kapsel/storage/migrate.py +119 -0
- kapsel/ui/__init__.py +21 -0
- kapsel/ui/banner.py +121 -0
- kapsel/ui/card.py +109 -0
- kapsel/ui/prompt.py +391 -0
- kapsel/ui/theme.py +59 -0
- kapsel_cli-0.1.0.dist-info/METADATA +397 -0
- kapsel_cli-0.1.0.dist-info/RECORD +66 -0
- kapsel_cli-0.1.0.dist-info/WHEEL +5 -0
- kapsel_cli-0.1.0.dist-info/entry_points.txt +3 -0
- kapsel_cli-0.1.0.dist-info/top_level.txt +1 -0
kapsel/__init__.py
ADDED
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
|
+
)
|