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/core/executor.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kapsel command execution subsystem.
|
|
3
|
+
Executes native and translated commands via host shell with timing and exit code capture.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from typing import Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from kapsel.storage.logger import logger
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ExecutionSummary:
|
|
19
|
+
command: str
|
|
20
|
+
exit_code: int
|
|
21
|
+
duration_ms: int
|
|
22
|
+
duration_str: str
|
|
23
|
+
success: bool
|
|
24
|
+
is_builtin: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CommandExecutor:
|
|
28
|
+
"""Executes commands seamlessly through the host shell with full TUI/TTY passthrough."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, shell_name: str = "pwsh", shell_path: Optional[str] = None):
|
|
31
|
+
self.shell_name = shell_name
|
|
32
|
+
self.shell_path = shell_path or shell_name
|
|
33
|
+
self._prev_dir: Optional[Path] = None
|
|
34
|
+
|
|
35
|
+
def set_shell(self, shell_name: str, shell_path: Optional[str]) -> None:
|
|
36
|
+
self.shell_name = shell_name
|
|
37
|
+
self.shell_path = shell_path or shell_name
|
|
38
|
+
|
|
39
|
+
def execute(self, cmd_string: str) -> ExecutionSummary:
|
|
40
|
+
"""
|
|
41
|
+
Executes a command string.
|
|
42
|
+
Handles built-ins (cd, exit, clear) and delegates other commands to host shell.
|
|
43
|
+
"""
|
|
44
|
+
cmd = cmd_string.strip()
|
|
45
|
+
if not cmd:
|
|
46
|
+
return ExecutionSummary(command="", exit_code=0, duration_ms=0, duration_str="0ms", success=True)
|
|
47
|
+
|
|
48
|
+
# 1. Check built-in commands
|
|
49
|
+
builtin_result = self._handle_builtin(cmd)
|
|
50
|
+
if builtin_result is not None:
|
|
51
|
+
return builtin_result
|
|
52
|
+
|
|
53
|
+
# 2. Host shell execution
|
|
54
|
+
t0 = time.perf_counter()
|
|
55
|
+
exit_code = 0
|
|
56
|
+
try:
|
|
57
|
+
exit_code = self._run_in_host_shell(cmd)
|
|
58
|
+
except KeyboardInterrupt:
|
|
59
|
+
print("\n^C")
|
|
60
|
+
exit_code = 130
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.error(f"Error executing command '{cmd}': {e}")
|
|
63
|
+
print(f"Kapsel: execution error: {e}", file=sys.stderr)
|
|
64
|
+
exit_code = 1
|
|
65
|
+
|
|
66
|
+
t1 = time.perf_counter()
|
|
67
|
+
duration_ms = int((t1 - t0) * 1000)
|
|
68
|
+
duration_str = self.format_duration(duration_ms)
|
|
69
|
+
|
|
70
|
+
return ExecutionSummary(
|
|
71
|
+
command=cmd,
|
|
72
|
+
exit_code=exit_code,
|
|
73
|
+
duration_ms=duration_ms,
|
|
74
|
+
duration_str=duration_str,
|
|
75
|
+
success=(exit_code == 0),
|
|
76
|
+
is_builtin=False,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def _handle_builtin(self, cmd: str) -> Optional[ExecutionSummary]:
|
|
80
|
+
"""Intercepts built-ins like cd, clear, cls."""
|
|
81
|
+
parts = cmd.split()
|
|
82
|
+
if not parts:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
primary = parts[0].lower()
|
|
86
|
+
|
|
87
|
+
# Builtin: cd
|
|
88
|
+
if primary == "cd":
|
|
89
|
+
t0 = time.perf_counter()
|
|
90
|
+
exit_code = self._execute_cd(parts[1:] if len(parts) > 1 else [])
|
|
91
|
+
t1 = time.perf_counter()
|
|
92
|
+
ms = int((t1 - t0) * 1000)
|
|
93
|
+
return ExecutionSummary(
|
|
94
|
+
command=cmd,
|
|
95
|
+
exit_code=exit_code,
|
|
96
|
+
duration_ms=ms,
|
|
97
|
+
duration_str=self.format_duration(ms),
|
|
98
|
+
success=(exit_code == 0),
|
|
99
|
+
is_builtin=True,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Check kapsel/kps prefixed commands
|
|
103
|
+
if primary in ("kapsel", "kps"):
|
|
104
|
+
from kapsel.completion.kps import dispatch_kps
|
|
105
|
+
builtin_code = dispatch_kps(cmd)
|
|
106
|
+
if builtin_code is not None:
|
|
107
|
+
return ExecutionSummary(
|
|
108
|
+
command=cmd,
|
|
109
|
+
exit_code=builtin_code,
|
|
110
|
+
duration_ms=0,
|
|
111
|
+
duration_str="0ms",
|
|
112
|
+
success=(builtin_code == 0),
|
|
113
|
+
is_builtin=True,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Builtin: clear / cls
|
|
117
|
+
if primary in ("clear", "cls"):
|
|
118
|
+
os.system("cls" if os.name == "nt" else "clear")
|
|
119
|
+
from kapsel.ui.banner import render_banner
|
|
120
|
+
render_banner()
|
|
121
|
+
return ExecutionSummary(
|
|
122
|
+
command=cmd,
|
|
123
|
+
exit_code=0,
|
|
124
|
+
duration_ms=0,
|
|
125
|
+
duration_str="0ms",
|
|
126
|
+
success=True,
|
|
127
|
+
is_builtin=True,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
def _execute_cd(self, args: list[str]) -> int:
|
|
133
|
+
current = Path.cwd()
|
|
134
|
+
if not args or args[0] in ("~", ""):
|
|
135
|
+
target = Path.home()
|
|
136
|
+
elif args[0] == "-":
|
|
137
|
+
if self._prev_dir:
|
|
138
|
+
target = self._prev_dir
|
|
139
|
+
print(target)
|
|
140
|
+
else:
|
|
141
|
+
print("kapsel: cd: OLDPWD not set", file=sys.stderr)
|
|
142
|
+
return 1
|
|
143
|
+
else:
|
|
144
|
+
# Handle quoted paths or joined arguments if spaces were present
|
|
145
|
+
raw_path = " ".join(args).strip("\"'")
|
|
146
|
+
target = Path(raw_path).expanduser()
|
|
147
|
+
if not target.is_absolute():
|
|
148
|
+
target = (current / target).resolve()
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
if not target.exists():
|
|
152
|
+
print(f"kapsel: cd: {target}: No such file or directory", file=sys.stderr)
|
|
153
|
+
return 1
|
|
154
|
+
if not target.is_dir():
|
|
155
|
+
print(f"kapsel: cd: {target}: Not a directory", file=sys.stderr)
|
|
156
|
+
return 1
|
|
157
|
+
|
|
158
|
+
os.chdir(target)
|
|
159
|
+
self._prev_dir = current
|
|
160
|
+
return 0
|
|
161
|
+
except Exception as e:
|
|
162
|
+
print(f"kapsel: cd: {e}", file=sys.stderr)
|
|
163
|
+
return 1
|
|
164
|
+
|
|
165
|
+
def _run_in_host_shell(self, cmd: str) -> int:
|
|
166
|
+
"""
|
|
167
|
+
Runs the command through the detected host shell binary.
|
|
168
|
+
Inherits stdin, stdout, stderr so interactive CLI programs (vim, fzf, git log) work seamlessly.
|
|
169
|
+
"""
|
|
170
|
+
shell = self.shell_name.lower()
|
|
171
|
+
args = []
|
|
172
|
+
|
|
173
|
+
if shell in ("pwsh", "powershell"):
|
|
174
|
+
# PowerShell: execute via -NoLogo -Command
|
|
175
|
+
exe = self.shell_path if self.shell_path and Path(self.shell_path).exists() else (
|
|
176
|
+
"pwsh.exe" if shell == "pwsh" else "powershell.exe"
|
|
177
|
+
)
|
|
178
|
+
args = [exe, "-NoLogo", "-Command", cmd]
|
|
179
|
+
elif shell == "cmd":
|
|
180
|
+
exe = self.shell_path if self.shell_path and Path(self.shell_path).exists() else "cmd.exe"
|
|
181
|
+
args = [exe, "/c", cmd]
|
|
182
|
+
else:
|
|
183
|
+
# Unix shells (bash, zsh, fish, sh)
|
|
184
|
+
exe = self.shell_path if self.shell_path and Path(self.shell_path).exists() else "/bin/sh"
|
|
185
|
+
args = [exe, "-c", cmd]
|
|
186
|
+
|
|
187
|
+
# Use subprocess.run without pipes so it directly controls terminal TTY / console
|
|
188
|
+
proc = subprocess.run(args, shell=False)
|
|
189
|
+
return proc.returncode
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def format_duration(ms: int) -> str:
|
|
193
|
+
if ms < 1000:
|
|
194
|
+
return f"{ms}ms"
|
|
195
|
+
secs = ms / 1000.0
|
|
196
|
+
if secs < 60:
|
|
197
|
+
return f"{secs:.2f}s"
|
|
198
|
+
mins = int(secs // 60)
|
|
199
|
+
rem_secs = secs % 60
|
|
200
|
+
return f"{mins}m {rem_secs:.1f}s"
|
kapsel/core/i18n.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backwards-compatibility forwarder for kapsel.i18n.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from kapsel.i18n import (
|
|
6
|
+
DEFAULT_LANGUAGE,
|
|
7
|
+
DOMAIN,
|
|
8
|
+
SUPPORTED_LANGUAGES,
|
|
9
|
+
_,
|
|
10
|
+
detect_system_language,
|
|
11
|
+
get_current_language,
|
|
12
|
+
get_locales_dir,
|
|
13
|
+
get_translation,
|
|
14
|
+
ngettext,
|
|
15
|
+
normalize_language_code,
|
|
16
|
+
set_current_language,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DEFAULT_LANGUAGE",
|
|
21
|
+
"DOMAIN",
|
|
22
|
+
"SUPPORTED_LANGUAGES",
|
|
23
|
+
"_",
|
|
24
|
+
"ngettext",
|
|
25
|
+
"detect_system_language",
|
|
26
|
+
"get_current_language",
|
|
27
|
+
"get_locales_dir",
|
|
28
|
+
"get_translation",
|
|
29
|
+
"normalize_language_code",
|
|
30
|
+
"set_current_language",
|
|
31
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kapsel Plugin System.
|
|
3
|
+
Exposes base classes, metadata schemas, hooks, and manager.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from kapsel.core.plugin.base import KapselPlugin, PluginManifest
|
|
7
|
+
from kapsel.core.plugin.context import PluginContext
|
|
8
|
+
from kapsel.core.plugin.hooks import HookType
|
|
9
|
+
from kapsel.core.plugin.manager import PluginManager
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"KapselPlugin",
|
|
13
|
+
"PluginManifest",
|
|
14
|
+
"PluginContext",
|
|
15
|
+
"HookType",
|
|
16
|
+
"PluginManager",
|
|
17
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kapsel Plugin System - Base Specification.
|
|
3
|
+
Defines the core abstract plugin interface and metadata models.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from kapsel.core.plugin.context import PluginContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class PluginManifest:
|
|
16
|
+
"""Metadata describing a Kapsel plugin."""
|
|
17
|
+
id: str
|
|
18
|
+
name: str
|
|
19
|
+
version: str
|
|
20
|
+
description: str = ""
|
|
21
|
+
author: str = ""
|
|
22
|
+
homepage: str = ""
|
|
23
|
+
min_kapsel_version: str = "0.1.0"
|
|
24
|
+
dependencies: List[str] = field(default_factory=list)
|
|
25
|
+
tags: List[str] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class KapselPlugin(ABC):
|
|
29
|
+
"""
|
|
30
|
+
Abstract base class that all Kapsel plugins must inherit from.
|
|
31
|
+
"""
|
|
32
|
+
manifest: PluginManifest
|
|
33
|
+
|
|
34
|
+
def __init__(self, manifest: Optional[PluginManifest] = None):
|
|
35
|
+
if manifest:
|
|
36
|
+
self.manifest = manifest
|
|
37
|
+
elif not hasattr(self, "manifest"):
|
|
38
|
+
raise ValueError(f"Plugin {self.__class__.__name__} must define a manifest.")
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def on_load(self, context: "PluginContext") -> None:
|
|
42
|
+
"""
|
|
43
|
+
Called when the plugin is loaded into the Kapsel engine.
|
|
44
|
+
Use this method to register kps commands, hooks, and initialize state.
|
|
45
|
+
"""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
def on_unload(self) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Called when the plugin is being unloaded or Kapsel is shutting down.
|
|
51
|
+
Clean up resources, open files, or network connections here.
|
|
52
|
+
"""
|
|
53
|
+
pass
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kapsel Plugin System - Catalog & Discovery Engine.
|
|
3
|
+
Manages repository subcommands dictionary and handles 'kapsel add update'.
|
|
4
|
+
All comments and descriptions are in English.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
from typing import Dict, Optional
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from kapsel.storage.config import get_kapsel_dir
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_CATALOG: Dict[str, str] = {
|
|
18
|
+
"update": "Scan and update available plugin catalog and completion dictionary",
|
|
19
|
+
"ai": "Terminal AI assistant and setup wizard (aichat)",
|
|
20
|
+
"alias": "Cross-platform Linux command alias mapper",
|
|
21
|
+
"autopilot": "Background task queue and autonomous execution (Pueue)",
|
|
22
|
+
"fuck": "Intelligent console command error correction (thefuck)",
|
|
23
|
+
"help": "Fast interactive command cheat sheets (tealdeer)",
|
|
24
|
+
"install": "Unified cross-platform package manager (mpm)",
|
|
25
|
+
"profile": "Dotfiles and workspace sync roaming (chezmoi)",
|
|
26
|
+
"rec": "Snippet recorder and interactive runner (pet)",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_catalog_paths():
|
|
31
|
+
"""Returns candidate paths for catalog.json in order of priority."""
|
|
32
|
+
paths = []
|
|
33
|
+
# 1. Local workspace plugins folder
|
|
34
|
+
workspace_catalog = Path.cwd() / "plugins" / "catalog.json"
|
|
35
|
+
paths.append(workspace_catalog)
|
|
36
|
+
|
|
37
|
+
# 2. Global runtime data directory
|
|
38
|
+
global_catalog = get_kapsel_dir() / "plugins_catalog.json"
|
|
39
|
+
paths.append(global_catalog)
|
|
40
|
+
|
|
41
|
+
global_in_plugins = get_kapsel_dir() / "plugins" / "catalog.json"
|
|
42
|
+
paths.append(global_in_plugins)
|
|
43
|
+
|
|
44
|
+
return paths
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_plugin_catalog() -> Dict[str, str]:
|
|
48
|
+
"""
|
|
49
|
+
Loads the subcommands dictionary from catalog.json in the repository or runtime data directory.
|
|
50
|
+
Falls back to scanning plugins/ or default catalog if not found.
|
|
51
|
+
"""
|
|
52
|
+
for candidate in _resolve_catalog_paths():
|
|
53
|
+
if candidate.exists() and candidate.is_file():
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(candidate.read_text(encoding="utf-8"))
|
|
56
|
+
if isinstance(data, dict):
|
|
57
|
+
# Ensure 'update' entry is always present
|
|
58
|
+
data.setdefault("update", "Scan and update available plugin catalog and completion dictionary")
|
|
59
|
+
return data
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
# If file not present yet, return default catalog
|
|
64
|
+
return dict(DEFAULT_CATALOG)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _extract_plugin_description(plugin_dir: Path) -> str:
|
|
68
|
+
"""Extracts a human-readable description for a plugin from manifest, pyproject.toml, or docstring."""
|
|
69
|
+
plugin_name = plugin_dir.name
|
|
70
|
+
|
|
71
|
+
# 1. Try reading manifest in plugin.py
|
|
72
|
+
plugin_py = plugin_dir / "plugin.py"
|
|
73
|
+
if plugin_py.exists():
|
|
74
|
+
try:
|
|
75
|
+
content = plugin_py.read_text(encoding="utf-8", errors="ignore")
|
|
76
|
+
m = re.search(r'description\s*=\s*["\']([^"\']+)["\']', content)
|
|
77
|
+
if m:
|
|
78
|
+
return m.group(1).strip()
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
# 2. Try pyproject.toml
|
|
83
|
+
pyproject = plugin_dir / "pyproject.toml"
|
|
84
|
+
if pyproject.exists():
|
|
85
|
+
try:
|
|
86
|
+
content = pyproject.read_text(encoding="utf-8", errors="ignore")
|
|
87
|
+
m = re.search(r'description\s*=\s*["\']([^"\']+)["\']', content)
|
|
88
|
+
if m:
|
|
89
|
+
return m.group(1).strip()
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
# 3. Try __init__.py docstring
|
|
94
|
+
init_py = plugin_dir / "__init__.py"
|
|
95
|
+
if init_py.exists():
|
|
96
|
+
try:
|
|
97
|
+
content = init_py.read_text(encoding="utf-8", errors="ignore")
|
|
98
|
+
doc_m = re.search(r'"""(.*?)"""', content, re.DOTALL)
|
|
99
|
+
if doc_m:
|
|
100
|
+
first_line = doc_m.group(1).strip().split("\n")[0].strip()
|
|
101
|
+
if first_line:
|
|
102
|
+
return first_line
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
return f"Plugin: {plugin_name}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def update_plugin_catalog(console: Optional[Console] = None) -> Dict[str, str]:
|
|
110
|
+
"""
|
|
111
|
+
Scans the repository plugins/ folder, extracts plugin metadata,
|
|
112
|
+
updates catalog.json, and refreshes the in-memory command registry.
|
|
113
|
+
"""
|
|
114
|
+
con = console or Console(legacy_windows=False)
|
|
115
|
+
catalog: Dict[str, str] = {
|
|
116
|
+
"update": "Scan and update available plugin catalog and completion dictionary"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Search directories to scan
|
|
120
|
+
scan_dirs = []
|
|
121
|
+
workspace_plugins = Path.cwd() / "plugins"
|
|
122
|
+
if workspace_plugins.exists() and workspace_plugins.is_dir():
|
|
123
|
+
scan_dirs.append(workspace_plugins)
|
|
124
|
+
|
|
125
|
+
global_plugins = get_kapsel_dir() / "plugins"
|
|
126
|
+
if global_plugins.exists() and global_plugins.is_dir() and global_plugins not in scan_dirs:
|
|
127
|
+
scan_dirs.append(global_plugins)
|
|
128
|
+
|
|
129
|
+
discovered = 0
|
|
130
|
+
for s_dir in scan_dirs:
|
|
131
|
+
for item in sorted(s_dir.iterdir()):
|
|
132
|
+
if item.is_dir() and not item.name.startswith((".", "_")):
|
|
133
|
+
p_name = item.name.lower()
|
|
134
|
+
if p_name not in catalog:
|
|
135
|
+
desc = _extract_plugin_description(item)
|
|
136
|
+
catalog[p_name] = desc
|
|
137
|
+
discovered += 1
|
|
138
|
+
|
|
139
|
+
# Save to workspace plugins/catalog.json if workspace plugins directory exists
|
|
140
|
+
if workspace_plugins.exists() and workspace_plugins.is_dir():
|
|
141
|
+
target_file = workspace_plugins / "catalog.json"
|
|
142
|
+
try:
|
|
143
|
+
target_file.write_text(json.dumps(catalog, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
con.print(f"[yellow]Warning: Failed to write {target_file}:[/] {e}")
|
|
146
|
+
|
|
147
|
+
# Also save to global data directory cache
|
|
148
|
+
global_cache = get_kapsel_dir() / "plugins_catalog.json"
|
|
149
|
+
try:
|
|
150
|
+
global_cache.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
global_cache.write_text(json.dumps(catalog, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
152
|
+
except Exception as e:
|
|
153
|
+
con.print(f"[yellow]Warning: Failed to write global cache {global_cache}:[/] {e}")
|
|
154
|
+
|
|
155
|
+
# Hot-reload in-memory registry subcommands for 'add' command
|
|
156
|
+
try:
|
|
157
|
+
from kapsel.completion.kps.registry import get_kps_registry
|
|
158
|
+
reg = get_kps_registry()
|
|
159
|
+
add_cmd = reg.get("add")
|
|
160
|
+
if add_cmd:
|
|
161
|
+
add_cmd.subcommands = dict(catalog)
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
return catalog
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def render_catalog_table(catalog: Dict[str, str], console: Console) -> None:
|
|
169
|
+
"""Renders the plugin catalog as a modern terminal table."""
|
|
170
|
+
table = Table(
|
|
171
|
+
title="[bold #00f0ff]📦 Kapsel Plugin Catalog (Subcommands Dictionary)[/]",
|
|
172
|
+
border_style="#0891b2",
|
|
173
|
+
header_style="bold #00f0ff",
|
|
174
|
+
show_header=True,
|
|
175
|
+
)
|
|
176
|
+
table.add_column("Command / Plugin", style="bold #a855f7", width=18)
|
|
177
|
+
table.add_column("Description", style="white")
|
|
178
|
+
|
|
179
|
+
for name, desc in catalog.items():
|
|
180
|
+
if name == "update":
|
|
181
|
+
table.add_row(f"[bold #10b981]{name}[/]", f"[dim italic]{desc}[/]")
|
|
182
|
+
else:
|
|
183
|
+
table.add_row(name, desc)
|
|
184
|
+
|
|
185
|
+
console.print()
|
|
186
|
+
console.print(table)
|
|
187
|
+
console.print(f"[dim]Total items: {len(catalog)} | Run [bold #00f0ff]kapsel add <plugin>[/] to enable any plugin.[/]\n")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kapsel Plugin System - Context API.
|
|
3
|
+
Provides controlled and safe access to host core services for plugins.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from kapsel.core.detector import EnvironmentDetector, detector
|
|
10
|
+
from kapsel.core.plugin.hooks import HookType
|
|
11
|
+
from kapsel.storage.config import KapselConfig
|
|
12
|
+
from kapsel.storage.logger import logger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PluginContext:
|
|
16
|
+
"""
|
|
17
|
+
The Host API context provided to a plugin during `on_load(context)`.
|
|
18
|
+
Enforces security boundaries and structured extension registration.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
plugin_id: str,
|
|
24
|
+
config: KapselConfig,
|
|
25
|
+
hook_registry: Dict[HookType, List[Callable]],
|
|
26
|
+
kps_command_register_fn: Callable[..., Any],
|
|
27
|
+
env_detector: Optional[EnvironmentDetector] = None,
|
|
28
|
+
):
|
|
29
|
+
self.plugin_id = plugin_id
|
|
30
|
+
self._config = config
|
|
31
|
+
self._hook_registry = hook_registry
|
|
32
|
+
self._kps_command_register_fn = kps_command_register_fn
|
|
33
|
+
self.environment = env_detector or detector
|
|
34
|
+
self.logger = logger
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def plugin_data_dir(self) -> Path:
|
|
38
|
+
"""Isolated persistent directory for this plugin to store files."""
|
|
39
|
+
base_dir = self._config.get_data_dir() / "plugins_data" / self.plugin_id
|
|
40
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
return base_dir
|
|
42
|
+
|
|
43
|
+
def register_kps_command(
|
|
44
|
+
self,
|
|
45
|
+
name: str,
|
|
46
|
+
handler: Callable[..., Optional[int]],
|
|
47
|
+
help_text: str,
|
|
48
|
+
subcommands: Optional[Dict[str, str]] = None,
|
|
49
|
+
usage: Optional[str] = None,
|
|
50
|
+
scope: str = "feature",
|
|
51
|
+
) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Registers a new functional 'kps <name>' command (or system command if scope='system').
|
|
54
|
+
Automatically exposes it to autocompletion and CLI dispatching.
|
|
55
|
+
"""
|
|
56
|
+
self._kps_command_register_fn(
|
|
57
|
+
name=name,
|
|
58
|
+
handler=handler,
|
|
59
|
+
help_text=help_text,
|
|
60
|
+
subcommands=subcommands,
|
|
61
|
+
usage=usage,
|
|
62
|
+
plugin_id=self.plugin_id,
|
|
63
|
+
scope=scope,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def register_hook(self, hook_type: HookType, callback: Callable) -> None:
|
|
67
|
+
"""Registers an extension callback hook."""
|
|
68
|
+
if hook_type not in self._hook_registry:
|
|
69
|
+
self._hook_registry[hook_type] = []
|
|
70
|
+
self._hook_registry[hook_type].append(callback)
|
|
71
|
+
self.logger.debug(f"[Plugin:{self.plugin_id}] Hook registered: {hook_type.value}")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kapsel Plugin System - Hooks Specification.
|
|
3
|
+
Defines standard extension hook types and callback signatures.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class HookType(str, Enum):
|
|
11
|
+
"""Available extension hook points in Kapsel Core."""
|
|
12
|
+
# Triggered when core is ready, before user prompt
|
|
13
|
+
ON_READY = "on_ready"
|
|
14
|
+
|
|
15
|
+
# Injects extra completion candidates into prompt completion
|
|
16
|
+
# Signature: (text_before_cursor: str) -> List[dict]
|
|
17
|
+
# dict format: {"text": str, "display": str, "display_meta": str}
|
|
18
|
+
PROVIDE_COMPLETIONS = "provide_completions"
|
|
19
|
+
|
|
20
|
+
# Pre-execution filter/transformer (e.g. command translation/routing)
|
|
21
|
+
# Signature: (raw_command: str) -> Tuple[bool, str]
|
|
22
|
+
# Returns (is_handled, transformed_command)
|
|
23
|
+
FILTER_COMMAND = "filter_command"
|
|
24
|
+
|
|
25
|
+
# Triggered immediately before executing a command
|
|
26
|
+
# Signature: (command: str) -> None
|
|
27
|
+
ON_BEFORE_EXECUTE = "on_before_execute"
|
|
28
|
+
|
|
29
|
+
# Triggered after executing a command (e.g. for cloud sync or metrics)
|
|
30
|
+
# Signature: (command: str, exit_code: int, duration_ms: float) -> None
|
|
31
|
+
ON_AFTER_EXECUTE = "on_after_execute"
|
|
32
|
+
|
|
33
|
+
# Triggered during shell exit / cleanup
|
|
34
|
+
ON_SHUTDOWN = "on_shutdown"
|