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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kapsel Status Command.
|
|
3
|
+
Renders comprehensive runtime environment, shell sniffing, and sandbox status dashboard.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import platform
|
|
10
|
+
import sys
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
from kapsel import __version__
|
|
19
|
+
from kapsel.core.detector import detector
|
|
20
|
+
from kapsel.i18n import _
|
|
21
|
+
from kapsel.storage.config import load_config
|
|
22
|
+
from kapsel.storage.logger import get_kapsel_dir
|
|
23
|
+
from kapsel.ui.banner import ensure_utf8_io
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def handle_status(args: Optional[List[str]] = None, console: Optional[Console] = None) -> int:
|
|
27
|
+
"""Renders the detailed Kapsel environment and runtime status dashboard."""
|
|
28
|
+
ensure_utf8_io()
|
|
29
|
+
con = console or Console(legacy_windows=False)
|
|
30
|
+
|
|
31
|
+
shell_name, shell_path = detector.detect_shell()
|
|
32
|
+
is_elevated, elevated_label = detector.is_elevated()
|
|
33
|
+
cwd_raw = Path.cwd()
|
|
34
|
+
cwd_fmt = detector.format_cwd(cwd_raw)
|
|
35
|
+
branch = detector.get_git_branch(cwd_raw)
|
|
36
|
+
cfg = load_config()
|
|
37
|
+
sandbox_dir = get_kapsel_dir()
|
|
38
|
+
|
|
39
|
+
# Status Grid
|
|
40
|
+
grid = Table.grid(expand=True, padding=(0, 2))
|
|
41
|
+
grid.add_column(style="bold #00f0ff", justify="right", width=18)
|
|
42
|
+
grid.add_column(style="#e4e4e7")
|
|
43
|
+
grid.add_column(style="bold #a855f7", justify="right", width=18)
|
|
44
|
+
grid.add_column(style="#e4e4e7")
|
|
45
|
+
|
|
46
|
+
admin_str = _("Admin")
|
|
47
|
+
priv_badge = (
|
|
48
|
+
f"[bold #10b981][{elevated_label}][/]"
|
|
49
|
+
if not is_elevated
|
|
50
|
+
else f"[bold #f59e0b][{elevated_label} ({admin_str})][/]"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
none_str = _("None")
|
|
54
|
+
grid.add_row(
|
|
55
|
+
f"🖥️ {_('Platform:')}",
|
|
56
|
+
f"{platform.system()} {platform.release()} ({platform.machine()})",
|
|
57
|
+
f"🐚 {_('Host Shell:')}",
|
|
58
|
+
f"[bold #38bdf8]{shell_name}[/] [dim]({shell_path})[/]",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
grid.add_row(
|
|
62
|
+
f"⚡ {_('Privilege:')}",
|
|
63
|
+
priv_badge,
|
|
64
|
+
f"🌿 {_('Git Branch:')}",
|
|
65
|
+
f"[bold #10b981]{branch}[/]" if branch else f"[dim]{none_str}[/]",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
grid.add_row(
|
|
69
|
+
f"📂 {_('Working Dir:')}",
|
|
70
|
+
f"[dim]{cwd_fmt}[/]",
|
|
71
|
+
f"💊 {_('Kapsel Version:')}",
|
|
72
|
+
f"[bold #00f0ff]v{__version__}[/] (Python {sys.version.split()[0]})",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
theme_name = cfg.theme.get("name", "cyber_dark") if isinstance(cfg.theme, dict) else str(cfg.theme)
|
|
76
|
+
border_status = _("On") if cfg.enable_card_border else _("Off")
|
|
77
|
+
grid.add_row(
|
|
78
|
+
f"📦 {_('Sandbox Dir:')}",
|
|
79
|
+
f"[dim]{sandbox_dir}[/]",
|
|
80
|
+
f"🎨 {_('Active Theme:')}",
|
|
81
|
+
f"[bold #a855f7]{theme_name}[/] [dim](Border: {border_status})[/]",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Completion & Plugins info
|
|
85
|
+
from kapsel.completion.kps.registry import get_kps_registry
|
|
86
|
+
registry = get_kps_registry()
|
|
87
|
+
commands = registry.list_commands()
|
|
88
|
+
|
|
89
|
+
from kapsel.completion.carapace_engine import get_carapace_engine
|
|
90
|
+
carapace_eng = get_carapace_engine()
|
|
91
|
+
if carapace_eng.is_available():
|
|
92
|
+
tools_count = len(carapace_eng.get_supported_tools())
|
|
93
|
+
completer_label = f"[bold #10b981]{tools_count}+[/] Carapace specs"
|
|
94
|
+
else:
|
|
95
|
+
specs_dir = Path(__file__).resolve().parent.parent.parent / "specs"
|
|
96
|
+
spec_count = len(list(specs_dir.glob("*.json"))) if specs_dir.exists() else 0
|
|
97
|
+
completer_label = f"[dim]{spec_count} Fig specs[/]"
|
|
98
|
+
|
|
99
|
+
is_active = os.environ.get("KAPSEL_ACTIVE") == "1"
|
|
100
|
+
active_str = _("Active")
|
|
101
|
+
standby_str = _("Standby")
|
|
102
|
+
grid.add_row(
|
|
103
|
+
f"🎯 {_('Completer:')}",
|
|
104
|
+
completer_label,
|
|
105
|
+
f"⚙️ {_('Session Mode:')}",
|
|
106
|
+
f"[bold #10b981]🟢 {active_str}[/]" if is_active else f"[dim]⚪ {standby_str}[/]",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
title_str = _("💊 KAPSEL System & Environment Status")
|
|
110
|
+
panel = Panel(
|
|
111
|
+
grid,
|
|
112
|
+
title=f"[bold #00f0ff]{title_str}[/]",
|
|
113
|
+
title_align="left",
|
|
114
|
+
border_style="#0891b2",
|
|
115
|
+
padding=(1, 2),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
con.print()
|
|
119
|
+
con.print(panel)
|
|
120
|
+
con.print()
|
|
121
|
+
return 0
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kapsel Toggle Command: 'kapsel toggle'.
|
|
3
|
+
Toggles Kapsel as the default terminal environment.
|
|
4
|
+
Typing it once opens the interactive Kapsel capsule shell.
|
|
5
|
+
Typing it a second time closes the shell and returns to the host terminal.
|
|
6
|
+
All comments and descriptions are in English.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
|
|
14
|
+
from kapsel.i18n import _
|
|
15
|
+
from kapsel.ui.banner import ensure_utf8_io
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def handle_toggle_command(args: Optional[List[str]] = None, console: Optional[Console] = None) -> int:
|
|
19
|
+
"""
|
|
20
|
+
Handles 'kapsel toggle' command.
|
|
21
|
+
Reports the toggle mechanism or current status.
|
|
22
|
+
"""
|
|
23
|
+
ensure_utf8_io()
|
|
24
|
+
con = console or Console(legacy_windows=False)
|
|
25
|
+
|
|
26
|
+
is_active = os.environ.get("KAPSEL_ACTIVE") == "1"
|
|
27
|
+
|
|
28
|
+
if is_active:
|
|
29
|
+
msg = _("🔌 Kapsel session is active. Type 'kapsel toggle' or 'exit' to quit.")
|
|
30
|
+
con.print(f"[bold #00f0ff]{msg}[/]")
|
|
31
|
+
else:
|
|
32
|
+
msg = _("✔ Kapsel session is ready. Run 'kapsel toggle' to launch.")
|
|
33
|
+
con.print(f"[bold #10b981]{msg}[/]")
|
|
34
|
+
return 0
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kapsel Command Dispatcher.
|
|
3
|
+
Dispatches 'kapsel <cmd>' and 'kps <cmd>' uniformly.
|
|
4
|
+
Unified architecture ensures both prefixes share the exact same command pipeline.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from kapsel.completion.kps.registry import get_kps_registry
|
|
11
|
+
from kapsel.i18n import _
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def dispatch_kps(command_line: str, console: Optional[Console] = None) -> Optional[int]:
|
|
15
|
+
"""
|
|
16
|
+
Dispatches command lines starting with 'kapsel' or 'kps'.
|
|
17
|
+
Both 'kapsel <cmd>' and 'kps <cmd>' route to the unified command registry.
|
|
18
|
+
Returns exit code (int) if handled, or None if not recognized or not a kapsel/kps command.
|
|
19
|
+
"""
|
|
20
|
+
stripped = command_line.strip()
|
|
21
|
+
if not stripped:
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
con = console or Console(legacy_windows=False)
|
|
25
|
+
registry = get_kps_registry()
|
|
26
|
+
|
|
27
|
+
# Determine command prefix and subcommand string
|
|
28
|
+
if stripped.startswith("kapsel "):
|
|
29
|
+
sub = stripped[7:].strip()
|
|
30
|
+
elif stripped == "kapsel":
|
|
31
|
+
sub = ""
|
|
32
|
+
elif stripped.startswith("kps "):
|
|
33
|
+
sub = stripped[4:].strip()
|
|
34
|
+
elif stripped == "kps":
|
|
35
|
+
sub = ""
|
|
36
|
+
else:
|
|
37
|
+
# Not a kapsel or kps command: do not intercept
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
# Handle bare 'kapsel' or 'kps' -> Show comprehensive manual and commands
|
|
41
|
+
if not sub:
|
|
42
|
+
help_cmd = registry.get("help")
|
|
43
|
+
if help_cmd:
|
|
44
|
+
return help_cmd.handler([], con)
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
parts = sub.split()
|
|
48
|
+
cmd_name = parts[0].lower()
|
|
49
|
+
args = parts[1:]
|
|
50
|
+
|
|
51
|
+
# Check alias shortcuts
|
|
52
|
+
if cmd_name in ("?", "-h", "--help"):
|
|
53
|
+
cmd_name = "help"
|
|
54
|
+
elif cmd_name == "info":
|
|
55
|
+
cmd_name = "status"
|
|
56
|
+
|
|
57
|
+
# Route to unified command registry
|
|
58
|
+
cmd = registry.get(cmd_name)
|
|
59
|
+
if cmd:
|
|
60
|
+
return cmd.handler(args, con)
|
|
61
|
+
|
|
62
|
+
msg = _("kapsel: unknown command '{cmd}'. See 'kapsel help'.").format(cmd=cmd_name)
|
|
63
|
+
con.print(f"[bold #f43f5e]{msg}[/]")
|
|
64
|
+
return 1
|
|
65
|
+
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kapsel Command Registry.
|
|
3
|
+
Unified Single Source of Truth for system management ('kapsel <cmd>')
|
|
4
|
+
and feature extension ('kps <cmd>') commands.
|
|
5
|
+
Used by autocompletion engines and command line dispatchers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class KpsCommand:
|
|
15
|
+
"""
|
|
16
|
+
Represents a registered command in the Kapsel ecosystem.
|
|
17
|
+
Shared uniformly across 'kapsel <cmd>' and 'kps <cmd>'.
|
|
18
|
+
"""
|
|
19
|
+
name: str
|
|
20
|
+
help_text: str
|
|
21
|
+
handler: Callable[[List[str], Optional[Console]], Optional[int]]
|
|
22
|
+
subcommands: Dict[str, str] = field(default_factory=dict)
|
|
23
|
+
usage: Optional[str] = None
|
|
24
|
+
plugin_id: Optional[str] = None
|
|
25
|
+
scope: str = "default"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class KpsCommandRegistry:
|
|
29
|
+
"""Central registry storing all Kapsel commands uniformly."""
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
# Keyed by command name (lowercase) to provide a unified command pipeline
|
|
33
|
+
self._commands: Dict[str, KpsCommand] = {}
|
|
34
|
+
|
|
35
|
+
def register(
|
|
36
|
+
self,
|
|
37
|
+
name: str,
|
|
38
|
+
handler: Callable[[List[str], Optional[Console]], Optional[int]],
|
|
39
|
+
help_text: str,
|
|
40
|
+
subcommands: Optional[Dict[str, str]] = None,
|
|
41
|
+
usage: Optional[str] = None,
|
|
42
|
+
plugin_id: Optional[str] = None,
|
|
43
|
+
scope: str = "default",
|
|
44
|
+
) -> KpsCommand:
|
|
45
|
+
"""Registers a command into the unified registry."""
|
|
46
|
+
clean_name = name.lower().strip()
|
|
47
|
+
cmd = KpsCommand(
|
|
48
|
+
name=clean_name,
|
|
49
|
+
help_text=help_text,
|
|
50
|
+
handler=handler,
|
|
51
|
+
subcommands=subcommands or {},
|
|
52
|
+
usage=usage,
|
|
53
|
+
plugin_id=plugin_id,
|
|
54
|
+
scope=scope,
|
|
55
|
+
)
|
|
56
|
+
self._commands[clean_name] = cmd
|
|
57
|
+
return cmd
|
|
58
|
+
|
|
59
|
+
def get(self, name: str, scope: Optional[str] = None) -> Optional[KpsCommand]:
|
|
60
|
+
"""Retrieves a command by name from the unified registry."""
|
|
61
|
+
clean_name = name.lower().strip()
|
|
62
|
+
return self._commands.get(clean_name)
|
|
63
|
+
|
|
64
|
+
def list_commands(self) -> List[KpsCommand]:
|
|
65
|
+
"""Returns all registered commands sorted by name."""
|
|
66
|
+
return sorted(self._commands.values(), key=lambda c: c.name)
|
|
67
|
+
|
|
68
|
+
def list_system_commands(self) -> List[KpsCommand]:
|
|
69
|
+
"""Backward-compatibility alias returning all core built-in commands."""
|
|
70
|
+
return sorted(
|
|
71
|
+
[c for c in self._commands.values() if not c.plugin_id],
|
|
72
|
+
key=lambda c: c.name,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def list_feature_commands(self) -> List[KpsCommand]:
|
|
76
|
+
"""Backward-compatibility alias returning all plugin-provided commands."""
|
|
77
|
+
return sorted(
|
|
78
|
+
[c for c in self._commands.values() if c.plugin_id],
|
|
79
|
+
key=lambda c: c.name,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def remove_by_plugin(self, plugin_id: str) -> None:
|
|
83
|
+
"""Removes all commands registered by a specific plugin."""
|
|
84
|
+
self._commands = {
|
|
85
|
+
k: v for k, v in self._commands.items() if v.plugin_id != plugin_id
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Global registry singleton
|
|
90
|
+
_GLOBAL_REGISTRY: Optional[KpsCommandRegistry] = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_kps_registry() -> KpsCommandRegistry:
|
|
94
|
+
"""Gets or initializes the global command registry."""
|
|
95
|
+
global _GLOBAL_REGISTRY
|
|
96
|
+
if _GLOBAL_REGISTRY is None:
|
|
97
|
+
_GLOBAL_REGISTRY = KpsCommandRegistry()
|
|
98
|
+
# Auto-register core built-in commands
|
|
99
|
+
from kapsel.completion.kps.builtins import register_builtins
|
|
100
|
+
register_builtins(_GLOBAL_REGISTRY)
|
|
101
|
+
return _GLOBAL_REGISTRY
|
kapsel/core/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kapsel core engine package.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from kapsel.core.detector import EnvironmentDetector, detector
|
|
6
|
+
from kapsel.core.engine import DispatchResult, DualStateEngine
|
|
7
|
+
from kapsel.core.executor import CommandExecutor, ExecutionSummary
|
|
8
|
+
from kapsel.core.plugin import (
|
|
9
|
+
HookType,
|
|
10
|
+
KapselPlugin,
|
|
11
|
+
PluginContext,
|
|
12
|
+
PluginManager,
|
|
13
|
+
PluginManifest,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"EnvironmentDetector",
|
|
18
|
+
"detector",
|
|
19
|
+
"CommandExecutor",
|
|
20
|
+
"ExecutionSummary",
|
|
21
|
+
"DualStateEngine",
|
|
22
|
+
"DispatchResult",
|
|
23
|
+
"KapselPlugin",
|
|
24
|
+
"PluginManifest",
|
|
25
|
+
"PluginContext",
|
|
26
|
+
"HookType",
|
|
27
|
+
"PluginManager",
|
|
28
|
+
]
|
kapsel/core/detector.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kapsel environment and shell sniffing detector.
|
|
3
|
+
Identifies host shell, elevation level, current working directory, and Git branch.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import ctypes
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import platform
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Optional, Tuple
|
|
12
|
+
|
|
13
|
+
import psutil
|
|
14
|
+
import shellingham
|
|
15
|
+
|
|
16
|
+
from kapsel.storage.logger import logger
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EnvironmentDetector:
|
|
20
|
+
"""Sniffs host shell, user privileges, Git state, and directory paths."""
|
|
21
|
+
|
|
22
|
+
KNOWN_SHELLS = {
|
|
23
|
+
"pwsh": "pwsh",
|
|
24
|
+
"pwsh.exe": "pwsh",
|
|
25
|
+
"powershell": "powershell",
|
|
26
|
+
"powershell.exe": "powershell",
|
|
27
|
+
"cmd": "cmd",
|
|
28
|
+
"cmd.exe": "cmd",
|
|
29
|
+
"bash": "bash",
|
|
30
|
+
"bash.exe": "bash",
|
|
31
|
+
"zsh": "zsh",
|
|
32
|
+
"fish": "fish",
|
|
33
|
+
"sh": "sh",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self._cached_shell: Optional[str] = None
|
|
38
|
+
self._cached_shell_path: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
def detect_shell(self) -> Tuple[str, str]:
|
|
41
|
+
"""
|
|
42
|
+
Detects the outer host shell name and executable path.
|
|
43
|
+
Returns (shell_name, executable_path), e.g. ('pwsh', 'C:\\...\\pwsh.exe').
|
|
44
|
+
"""
|
|
45
|
+
if self._cached_shell and self._cached_shell_path:
|
|
46
|
+
return self._cached_shell, self._cached_shell_path
|
|
47
|
+
|
|
48
|
+
# 1. Try psutil process tree lookup (inspect parents of current process)
|
|
49
|
+
try:
|
|
50
|
+
current_proc = psutil.Process(os.getpid())
|
|
51
|
+
for parent in current_proc.parents():
|
|
52
|
+
name = parent.name().lower()
|
|
53
|
+
if name in self.KNOWN_SHELLS:
|
|
54
|
+
canonical = self.KNOWN_SHELLS[name]
|
|
55
|
+
try:
|
|
56
|
+
exe = parent.exe()
|
|
57
|
+
except Exception:
|
|
58
|
+
exe = name
|
|
59
|
+
self._cached_shell = canonical
|
|
60
|
+
self._cached_shell_path = exe
|
|
61
|
+
logger.debug(f"Detected host shell from parent process: {canonical} ({exe})")
|
|
62
|
+
return canonical, exe
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.debug(f"Process tree shell detection failed: {e}")
|
|
65
|
+
|
|
66
|
+
# 2. Try shellingham
|
|
67
|
+
try:
|
|
68
|
+
detected = shellingham.detect_shell()
|
|
69
|
+
if detected:
|
|
70
|
+
raw_name, raw_path = detected
|
|
71
|
+
canonical = self.KNOWN_SHELLS.get(raw_name.lower(), raw_name.lower())
|
|
72
|
+
self._cached_shell = canonical
|
|
73
|
+
self._cached_shell_path = raw_path
|
|
74
|
+
logger.debug(f"Detected host shell from shellingham: {canonical} ({raw_path})")
|
|
75
|
+
return canonical, raw_path
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.debug(f"Shellingham detection failed: {e}")
|
|
78
|
+
|
|
79
|
+
# 3. Environment variable fallback
|
|
80
|
+
if platform.system() == "Windows":
|
|
81
|
+
comspec = os.environ.get("COMSPEC", "cmd.exe")
|
|
82
|
+
# If PSModulePath is set and contains pwsh
|
|
83
|
+
ps_module_path = os.environ.get("PSModulePath", "")
|
|
84
|
+
if "PowerShell\\7" in ps_module_path or "pwsh" in ps_module_path.lower():
|
|
85
|
+
self._cached_shell = "pwsh"
|
|
86
|
+
self._cached_shell_path = "pwsh.exe"
|
|
87
|
+
elif "WindowsPowerShell" in ps_module_path:
|
|
88
|
+
self._cached_shell = "powershell"
|
|
89
|
+
self._cached_shell_path = "powershell.exe"
|
|
90
|
+
else:
|
|
91
|
+
self._cached_shell = "cmd"
|
|
92
|
+
self._cached_shell_path = comspec
|
|
93
|
+
else:
|
|
94
|
+
shell_env = os.environ.get("SHELL", "/bin/sh")
|
|
95
|
+
base_name = Path(shell_env).name.lower()
|
|
96
|
+
self._cached_shell = self.KNOWN_SHELLS.get(base_name, base_name)
|
|
97
|
+
self._cached_shell_path = shell_env
|
|
98
|
+
|
|
99
|
+
logger.debug(f"Fallback shell detection: {self._cached_shell} ({self._cached_shell_path})")
|
|
100
|
+
return self._cached_shell, self._cached_shell_path
|
|
101
|
+
|
|
102
|
+
def is_elevated(self) -> Tuple[bool, str]:
|
|
103
|
+
"""
|
|
104
|
+
Check if running with elevated privileges (Admin on Windows, Root on Unix).
|
|
105
|
+
Returns (is_elevated, label), e.g. (True, "Admin") or (False, "User").
|
|
106
|
+
"""
|
|
107
|
+
try:
|
|
108
|
+
if platform.system() == "Windows":
|
|
109
|
+
is_admin = bool(ctypes.windll.shell32.IsUserAnAdmin())
|
|
110
|
+
return is_admin, "Admin" if is_admin else "User"
|
|
111
|
+
else:
|
|
112
|
+
is_root = (os.geteuid() == 0)
|
|
113
|
+
return is_root, "Root" if is_root else "User"
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.debug(f"Elevation detection failed: {e}")
|
|
116
|
+
return False, "User"
|
|
117
|
+
|
|
118
|
+
def get_git_branch(self, path: Optional[Path] = None) -> Optional[str]:
|
|
119
|
+
"""
|
|
120
|
+
Quickly inspects .git/HEAD in current or ancestor directories to extract the active branch.
|
|
121
|
+
Does not spawn external git processes, ensuring near-instant performance.
|
|
122
|
+
"""
|
|
123
|
+
target = (path or Path.cwd()).resolve()
|
|
124
|
+
for directory in [target] + list(target.parents):
|
|
125
|
+
git_dir = directory / ".git"
|
|
126
|
+
if git_dir.is_file():
|
|
127
|
+
# Git worktree / submodule pointer
|
|
128
|
+
try:
|
|
129
|
+
content = git_dir.read_text(encoding="utf-8").strip()
|
|
130
|
+
if content.startswith("gitdir:"):
|
|
131
|
+
ref_path = (directory / content.split(":", 1)[1].strip()).resolve()
|
|
132
|
+
git_dir = ref_path
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
|
|
136
|
+
if git_dir.is_dir():
|
|
137
|
+
head_file = git_dir / "HEAD"
|
|
138
|
+
if head_file.exists():
|
|
139
|
+
try:
|
|
140
|
+
content = head_file.read_text(encoding="utf-8").strip()
|
|
141
|
+
if content.startswith("ref: refs/heads/"):
|
|
142
|
+
return content.replace("ref: refs/heads/", "").strip()
|
|
143
|
+
elif content:
|
|
144
|
+
return content[:7] # Detached commit hash
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def format_cwd(self, path: Optional[Path] = None) -> str:
|
|
150
|
+
"""
|
|
151
|
+
Formats current working directory, abbreviating home directory to '~'.
|
|
152
|
+
"""
|
|
153
|
+
p = (path or Path.cwd()).resolve()
|
|
154
|
+
home = Path.home().resolve()
|
|
155
|
+
try:
|
|
156
|
+
rel = p.relative_to(home)
|
|
157
|
+
return f"~/{rel.as_posix()}" if str(rel) != "." else "~"
|
|
158
|
+
except ValueError:
|
|
159
|
+
return str(p)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
detector = EnvironmentDetector()
|
kapsel/core/engine.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kapsel Core Engine orchestrator.
|
|
3
|
+
Manages state transitions, plugin lifecycle, command dispatching, and execution history.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from kapsel.completion.kps.dispatcher import dispatch_kps
|
|
12
|
+
from kapsel.completion.kps.registry import KpsCommandRegistry, get_kps_registry
|
|
13
|
+
from kapsel.core.detector import EnvironmentDetector, detector
|
|
14
|
+
from kapsel.core.executor import CommandExecutor, ExecutionSummary
|
|
15
|
+
from kapsel.core.plugin.hooks import HookType
|
|
16
|
+
from kapsel.i18n import _
|
|
17
|
+
from kapsel.core.plugin.manager import PluginManager
|
|
18
|
+
from kapsel.storage.config import KapselConfig, load_config
|
|
19
|
+
from kapsel.storage.history import HistoryManager
|
|
20
|
+
from kapsel.storage.logger import logger
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class DispatchResult:
|
|
25
|
+
mode: str # "kps" or "native"
|
|
26
|
+
raw_input: str
|
|
27
|
+
translated_cmd: Optional[str]
|
|
28
|
+
execution: ExecutionSummary
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DualStateEngine:
|
|
32
|
+
"""The central brain of Kapsel orchestrating dual-state execution, plugins, and state persistence."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
config: Optional[KapselConfig] = None,
|
|
37
|
+
kps_registry: Optional[KpsCommandRegistry] = None,
|
|
38
|
+
history_mgr: Optional[HistoryManager] = None,
|
|
39
|
+
env_detector: Optional[EnvironmentDetector] = None,
|
|
40
|
+
):
|
|
41
|
+
self.config = config or load_config()
|
|
42
|
+
self.history_mgr = history_mgr or HistoryManager()
|
|
43
|
+
self.detector = env_detector or detector
|
|
44
|
+
self.kps_registry = kps_registry or get_kps_registry()
|
|
45
|
+
|
|
46
|
+
# Detect host shell
|
|
47
|
+
self.shell_name, self.shell_path = self.detector.detect_shell()
|
|
48
|
+
self.is_elevated, self.elevated_label = self.detector.is_elevated()
|
|
49
|
+
|
|
50
|
+
# Initialize executor
|
|
51
|
+
self.executor = CommandExecutor(self.shell_name, self.shell_path)
|
|
52
|
+
|
|
53
|
+
# Initialize Plugin Subsystem
|
|
54
|
+
self.plugin_manager = PluginManager(
|
|
55
|
+
config=self.config,
|
|
56
|
+
kps_command_register_fn=self.kps_registry.register,
|
|
57
|
+
env_detector=self.detector,
|
|
58
|
+
)
|
|
59
|
+
self.plugin_manager.load_all_plugins()
|
|
60
|
+
self.plugin_manager.trigger_hook(HookType.ON_READY)
|
|
61
|
+
|
|
62
|
+
def reload(self) -> None:
|
|
63
|
+
"""Reload configuration and refresh plugins."""
|
|
64
|
+
self.config = load_config()
|
|
65
|
+
self.plugin_manager.unload_all()
|
|
66
|
+
self.plugin_manager.load_all_plugins()
|
|
67
|
+
|
|
68
|
+
def is_kapsel_mode(self, text: str) -> bool:
|
|
69
|
+
"""Determines if the current text line represents Kapsel Command Mode."""
|
|
70
|
+
stripped = text.strip()
|
|
71
|
+
return (
|
|
72
|
+
stripped in ("kapsel", "kps")
|
|
73
|
+
or stripped.startswith("kapsel ")
|
|
74
|
+
or stripped.startswith("kps ")
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
is_kps_mode = is_kapsel_mode
|
|
78
|
+
|
|
79
|
+
def dispatch(self, user_input: str) -> DispatchResult:
|
|
80
|
+
"""
|
|
81
|
+
Dispatches input line either to Plugin Filter, Kapsel Builtin, or Native execution.
|
|
82
|
+
Records execution duration, status, and history in SQLite history.db.
|
|
83
|
+
"""
|
|
84
|
+
stripped = user_input.strip()
|
|
85
|
+
cwd_str = str(Path.cwd())
|
|
86
|
+
start_time = time.time()
|
|
87
|
+
|
|
88
|
+
# 1. Plugin Pre-execution filter (e.g. mapping plugin intercepts and translates)
|
|
89
|
+
is_filtered, transformed_cmd = self.plugin_manager.filter_command(user_input)
|
|
90
|
+
|
|
91
|
+
if is_filtered:
|
|
92
|
+
mode = "kapsel"
|
|
93
|
+
cmd_to_run = transformed_cmd
|
|
94
|
+
exec_summary = self.executor.execute(cmd_to_run)
|
|
95
|
+
translated_cmd = cmd_to_run
|
|
96
|
+
|
|
97
|
+
elif self.is_kapsel_mode(user_input):
|
|
98
|
+
mode = "kapsel"
|
|
99
|
+
translated_cmd = None
|
|
100
|
+
|
|
101
|
+
# Try dispatching to core/plugin kps/kapsel subcommands
|
|
102
|
+
t0 = time.perf_counter()
|
|
103
|
+
handled_code = dispatch_kps(user_input)
|
|
104
|
+
t1 = time.perf_counter()
|
|
105
|
+
duration_ms = int((t1 - t0) * 1000)
|
|
106
|
+
|
|
107
|
+
if handled_code is not None:
|
|
108
|
+
exec_summary = ExecutionSummary(
|
|
109
|
+
command=user_input,
|
|
110
|
+
exit_code=handled_code,
|
|
111
|
+
duration_ms=duration_ms,
|
|
112
|
+
duration_str=f"{duration_ms}ms",
|
|
113
|
+
success=(handled_code == 0),
|
|
114
|
+
is_builtin=True,
|
|
115
|
+
)
|
|
116
|
+
else:
|
|
117
|
+
# Not a recognized builtin command
|
|
118
|
+
msg = _("kapsel: unknown command '{cmd}'. See 'kapsel help'.").format(cmd=user_input)
|
|
119
|
+
print(msg, file=sys.stderr)
|
|
120
|
+
exec_summary = ExecutionSummary(
|
|
121
|
+
command=user_input,
|
|
122
|
+
exit_code=1,
|
|
123
|
+
duration_ms=0,
|
|
124
|
+
duration_str="0ms",
|
|
125
|
+
success=False,
|
|
126
|
+
is_builtin=True,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
else:
|
|
130
|
+
mode = "native"
|
|
131
|
+
translated_cmd = None
|
|
132
|
+
exec_summary = self.executor.execute(user_input)
|
|
133
|
+
|
|
134
|
+
# Record to history database
|
|
135
|
+
self.history_mgr.add_record(
|
|
136
|
+
command=user_input,
|
|
137
|
+
translated_cmd=translated_cmd,
|
|
138
|
+
mode=mode,
|
|
139
|
+
cwd=cwd_str,
|
|
140
|
+
shell=self.shell_name,
|
|
141
|
+
timestamp=start_time,
|
|
142
|
+
duration_ms=exec_summary.duration_ms,
|
|
143
|
+
exit_code=exec_summary.exit_code,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Trigger plugin post-execution hook
|
|
147
|
+
self.plugin_manager.trigger_hook(
|
|
148
|
+
HookType.ON_AFTER_EXECUTE,
|
|
149
|
+
command=user_input,
|
|
150
|
+
exit_code=exec_summary.exit_code,
|
|
151
|
+
duration_ms=exec_summary.duration_ms,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return DispatchResult(
|
|
155
|
+
mode=mode,
|
|
156
|
+
raw_input=user_input,
|
|
157
|
+
translated_cmd=translated_cmd,
|
|
158
|
+
execution=exec_summary,
|
|
159
|
+
)
|