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
@@ -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"