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,215 @@
1
+ """
2
+ Fig Completion Engine.
3
+ AST context walker and token stream parser for Fig.Spec definitions.
4
+ Supports deep multi-level subcommands (e.g. 'docker compose up'),
5
+ flags/options (-f, --flag), and descriptions.
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+ import shlex
11
+ from typing import Any, Dict, List, Optional, Tuple, Union
12
+
13
+ from kapsel.completion.fig_schema import (
14
+ FigOption,
15
+ FigSpec,
16
+ FigSubcommand,
17
+ parse_fig_spec,
18
+ )
19
+ from kapsel.storage.logger import get_kapsel_dir, logger
20
+
21
+ BUILTIN_SPECS_DIR = Path(__file__).parent / "specs"
22
+
23
+
24
+ def get_user_specs_dir() -> Path:
25
+ d = get_kapsel_dir() / "registry" / "specs"
26
+ d.mkdir(parents=True, exist_ok=True)
27
+ return d
28
+
29
+
30
+ class FigCandidate:
31
+ def __init__(self, insert_text: str, display_text: str, description: str, kind: str = "subcommand"):
32
+ self.insert_text = insert_text
33
+ self.display_text = display_text
34
+ self.description = description
35
+ self.kind = kind # "subcommand" | "option" | "arg"
36
+
37
+ def __repr__(self):
38
+ return f"<FigCandidate {self.insert_text} ({self.kind}): {self.description}>"
39
+
40
+
41
+ class FigEngine:
42
+ """
43
+ Main Fig-compatible completion engine.
44
+ Pre-indexes all available Fig.Spec JSON definitions and evaluates
45
+ current command line token streams with sub-millisecond response.
46
+ """
47
+
48
+ def __init__(self):
49
+ self._specs: Dict[str, FigSpec] = {}
50
+ self.reload_specs()
51
+
52
+ def reload_specs(self) -> None:
53
+ """Loads all builtin and user-defined Fig.Spec JSON files."""
54
+ specs = {}
55
+
56
+ # 1. Built-in specs
57
+ if BUILTIN_SPECS_DIR.exists():
58
+ for f in BUILTIN_SPECS_DIR.glob("*.json"):
59
+ try:
60
+ with open(f, "r", encoding="utf-8") as fp:
61
+ data = json.load(fp)
62
+ spec = parse_fig_spec(data)
63
+ specs[spec.name.lower()] = spec
64
+ except Exception as e:
65
+ logger.error(f"Failed to parse builtin Fig spec {f}: {e}")
66
+
67
+ # 2. User/Community custom specs in ~/.kapsel/registry/specs/
68
+ user_dir = get_user_specs_dir()
69
+ for f in user_dir.glob("*.json"):
70
+ try:
71
+ with open(f, "r", encoding="utf-8") as fp:
72
+ data = json.load(fp)
73
+ spec = parse_fig_spec(data)
74
+ specs[spec.name.lower()] = spec
75
+ except Exception as e:
76
+ logger.error(f"Failed to parse user Fig spec {f}: {e}")
77
+
78
+ self._specs = specs
79
+
80
+ def has_spec_for_tool(self, tool: str) -> bool:
81
+ return tool.lower() in self._specs
82
+
83
+ def list_known_tools(self) -> List[str]:
84
+ return sorted(self._specs.keys())
85
+
86
+ def get_spec(self, tool: str) -> Optional[FigSpec]:
87
+ return self._specs.get(tool.lower())
88
+
89
+ def tokenize_line(self, line: str) -> Tuple[List[str], str]:
90
+ """
91
+ Tokenizes command line before cursor.
92
+ Returns (completed_tokens, current_partial_token).
93
+ e.g. "git commit -m" -> (["git", "commit"], "-m")
94
+ e.g. "git commit " -> (["git", "commit"], "")
95
+ """
96
+ if not line:
97
+ return [], ""
98
+
99
+ ends_with_space = line.endswith(" ")
100
+
101
+ # Use split preserving empty trailing if space
102
+ raw_tokens = line.split()
103
+ if ends_with_space:
104
+ return raw_tokens, ""
105
+ else:
106
+ if not raw_tokens:
107
+ return [], ""
108
+ return raw_tokens[:-1], raw_tokens[-1]
109
+
110
+ def get_completions(self, line_before_cursor: str) -> List[FigCandidate]:
111
+ """
112
+ Core evaluation algorithm:
113
+ Walks the Fig.Spec AST based on input tokens and returns context-relevant candidates.
114
+ """
115
+ completed, partial = self.tokenize_line(line_before_cursor)
116
+
117
+ if not completed and not partial:
118
+ return []
119
+
120
+ # If user is at the very first token (the root CLI command, e.g. "gi" -> "git")
121
+ if not completed:
122
+ results = []
123
+ prefix = partial.lower()
124
+ for tool_name, spec in self._specs.items():
125
+ if tool_name.startswith(prefix):
126
+ results.append(
127
+ FigCandidate(
128
+ insert_text=tool_name,
129
+ display_text=tool_name,
130
+ description=spec.description,
131
+ kind="subcommand",
132
+ )
133
+ )
134
+ return results
135
+
136
+ root_cmd = completed[0].lower()
137
+ spec = self.get_spec(root_cmd)
138
+ if not spec:
139
+ return []
140
+
141
+ # AST Context Traversal
142
+ current_subcommands = spec.subcommands
143
+ available_options: List[FigOption] = list(spec.options)
144
+
145
+ # Tokens after the root command
146
+ sub_tokens = completed[1:]
147
+
148
+ for tok in sub_tokens:
149
+ tok_lower = tok.lower()
150
+
151
+ # Check if this token matches a subcommand
152
+ matched_sub: Optional[FigSubcommand] = None
153
+ for sc in current_subcommands:
154
+ if tok_lower in [n.lower() for n in sc.name]:
155
+ matched_sub = sc
156
+ break
157
+
158
+ if matched_sub:
159
+ # Descend down AST
160
+ current_subcommands = matched_sub.subcommands
161
+ # Add options from this subcommand
162
+ available_options.extend(matched_sub.options)
163
+ else:
164
+ # Token was an option or argument, skip descending subcommands
165
+ pass
166
+
167
+ candidates: List[FigCandidate] = []
168
+ prefix = partial.lower()
169
+ seen_insert_texts = set()
170
+
171
+ # 1. If user is not specifically typing a flag ('-'), show Subcommands FIRST
172
+ if not partial.startswith("-"):
173
+ for sc in current_subcommands:
174
+ primary = sc.primary_name
175
+ if primary.lower().startswith(prefix) and primary not in seen_insert_texts:
176
+ seen_insert_texts.add(primary)
177
+ candidates.append(
178
+ FigCandidate(
179
+ insert_text=primary,
180
+ display_text=primary,
181
+ description=sc.description,
182
+ kind="subcommand",
183
+ )
184
+ )
185
+
186
+ # 2. Evaluate Options (Flags)
187
+ # If user typed '-', show only options matching the flag prefix
188
+ # If user didn't type '-', show options below subcommands if prefix is empty
189
+ if partial.startswith("-") or partial == "":
190
+ for opt in available_options:
191
+ matched_names = [n for n in opt.name if n.lower().startswith(prefix)]
192
+ for opt_name in matched_names:
193
+ if opt_name not in seen_insert_texts:
194
+ seen_insert_texts.add(opt_name)
195
+ candidates.append(
196
+ FigCandidate(
197
+ insert_text=opt_name,
198
+ display_text=opt.display_names,
199
+ description=opt.description,
200
+ kind="option",
201
+ )
202
+ )
203
+
204
+ return candidates
205
+
206
+
207
+ # Singleton engine instance
208
+ _FIG_ENGINE: Optional[FigEngine] = None
209
+
210
+
211
+ def get_fig_engine() -> FigEngine:
212
+ global _FIG_ENGINE
213
+ if _FIG_ENGINE is None:
214
+ _FIG_ENGINE = FigEngine()
215
+ return _FIG_ENGINE
@@ -0,0 +1,135 @@
1
+ """
2
+ Fig.Spec declarative schema data models.
3
+ 100% aligned with withfig/autocomplete (and Amazon Q Developer CLI) schema:
4
+ Spec -> Subcommands -> Options (Flags) -> Arguments
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Dict, List, Optional, Union
9
+
10
+
11
+ @dataclass
12
+ class FigArg:
13
+ name: str = ""
14
+ description: str = ""
15
+ is_optional: bool = False
16
+ is_variadic: bool = False
17
+ suggestions: List[str] = field(default_factory=list)
18
+
19
+
20
+ @dataclass
21
+ class FigOption:
22
+ name: List[str] = field(default_factory=list) # e.g. ["-m", "--message"]
23
+ description: str = ""
24
+ args: Optional[FigArg] = None
25
+ is_persistent: bool = False
26
+
27
+ @property
28
+ def primary_name(self) -> str:
29
+ return self.name[0] if self.name else ""
30
+
31
+ @property
32
+ def display_names(self) -> str:
33
+ return ", ".join(self.name)
34
+
35
+
36
+ @dataclass
37
+ class FigSubcommand:
38
+ name: List[str] = field(default_factory=list) # e.g. ["commit"] or ["checkout", "co"]
39
+ description: str = ""
40
+ subcommands: List["FigSubcommand"] = field(default_factory=list)
41
+ options: List[FigOption] = field(default_factory=list)
42
+ args: Optional[List[FigArg]] = None
43
+
44
+ @property
45
+ def primary_name(self) -> str:
46
+ return self.name[0] if self.name else ""
47
+
48
+
49
+ @dataclass
50
+ class FigSpec:
51
+ name: str = ""
52
+ description: str = ""
53
+ subcommands: List[FigSubcommand] = field(default_factory=list)
54
+ options: List[FigOption] = field(default_factory=list)
55
+ args: Optional[List[FigArg]] = None
56
+
57
+
58
+ def parse_fig_arg(raw: Any) -> Optional[FigArg]:
59
+ if not raw:
60
+ return None
61
+ if isinstance(raw, str):
62
+ return FigArg(name=raw)
63
+ if isinstance(raw, dict):
64
+ return FigArg(
65
+ name=raw.get("name", ""),
66
+ description=raw.get("description", ""),
67
+ is_optional=raw.get("isOptional", False),
68
+ is_variadic=raw.get("isVariadic", False),
69
+ suggestions=raw.get("suggestions", []),
70
+ )
71
+ return None
72
+
73
+
74
+ def parse_fig_option(raw: Dict[str, Any]) -> FigOption:
75
+ raw_name = raw.get("name", [])
76
+ if isinstance(raw_name, str):
77
+ names = [raw_name]
78
+ elif isinstance(raw_name, list):
79
+ names = [str(n) for n in raw_name]
80
+ else:
81
+ names = []
82
+
83
+ arg_data = raw.get("args")
84
+ parsed_arg = parse_fig_arg(arg_data)
85
+
86
+ return FigOption(
87
+ name=names,
88
+ description=raw.get("description", ""),
89
+ args=parsed_arg,
90
+ is_persistent=raw.get("isPersistent", False),
91
+ )
92
+
93
+
94
+ def parse_fig_subcommand(raw: Dict[str, Any]) -> FigSubcommand:
95
+ raw_name = raw.get("name", [])
96
+ if isinstance(raw_name, str):
97
+ names = [raw_name]
98
+ elif isinstance(raw_name, list):
99
+ names = [str(n) for n in raw_name]
100
+ else:
101
+ names = []
102
+
103
+ subcmds = [parse_fig_subcommand(s) for s in raw.get("subcommands", []) if isinstance(s, dict)]
104
+ opts = [parse_fig_option(o) for o in raw.get("options", []) if isinstance(o, dict)]
105
+
106
+ raw_args = raw.get("args")
107
+ parsed_args = None
108
+ if isinstance(raw_args, list):
109
+ parsed_args = [parse_fig_arg(a) for a in raw_args if a]
110
+ elif isinstance(raw_args, (dict, str)):
111
+ parsed_args = [parse_fig_arg(raw_args)]
112
+
113
+ return FigSubcommand(
114
+ name=names,
115
+ description=raw.get("description", ""),
116
+ subcommands=subcmds,
117
+ options=opts,
118
+ args=parsed_args,
119
+ )
120
+
121
+
122
+ def parse_fig_spec(raw: Dict[str, Any]) -> FigSpec:
123
+ name = raw.get("name", "")
124
+ if isinstance(name, list):
125
+ name = name[0] if name else ""
126
+
127
+ subcmds = [parse_fig_subcommand(s) for s in raw.get("subcommands", []) if isinstance(s, dict)]
128
+ opts = [parse_fig_option(o) for o in raw.get("options", []) if isinstance(o, dict)]
129
+
130
+ return FigSpec(
131
+ name=str(name),
132
+ description=raw.get("description", ""),
133
+ subcommands=subcmds,
134
+ options=opts,
135
+ )
@@ -0,0 +1,92 @@
1
+ """
2
+ Fig Spec Importer & Converter Service.
3
+ Fetches and converts completion specs from withfig/autocomplete (or local files)
4
+ directly into Kapsel Fig.Spec JSON format.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+ import re
10
+ import sys
11
+ from typing import Optional
12
+ import urllib.request
13
+
14
+ GITHUB_RAW_BASE = "https://raw.githubusercontent.com/withfig/autocomplete/master/src"
15
+
16
+
17
+ def fetch_remote_ts_spec(tool_name: str) -> Optional[str]:
18
+ url = f"{GITHUB_RAW_BASE}/{tool_name}.ts"
19
+ try:
20
+ req = urllib.request.Request(
21
+ url,
22
+ headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Kapsel/0.2.0"},
23
+ )
24
+ with urllib.request.urlopen(req, timeout=10) as resp:
25
+ if resp.status == 200:
26
+ return resp.read().decode("utf-8")
27
+ except Exception:
28
+ pass
29
+ return None
30
+
31
+
32
+ def parse_simple_ts_to_dict(tool_name: str, ts_content: str) -> dict:
33
+ """
34
+ Parses common Fig TypeScript completionSpec patterns into a clean JSON dict.
35
+ Extracts name, description, subcommands, options.
36
+ """
37
+ spec = {
38
+ "name": tool_name,
39
+ "description": f"{tool_name} command line tool",
40
+ "options": [],
41
+ "subcommands": [],
42
+ }
43
+
44
+ # Extract top-level description
45
+ desc_match = re.search(r'description:\s*["\']([^"\']+)["\']', ts_content)
46
+ if desc_match:
47
+ spec["description"] = desc_match.group(1)
48
+
49
+ # Extract subcommands: e.g. { name: "commit", description: "..." }
50
+ subcmd_blocks = re.findall(
51
+ r'\{\s*name:\s*["\']([a-zA-Z0-9_\-]+)["\'],\s*description:\s*["\']([^"\']+)["\']',
52
+ ts_content,
53
+ )
54
+ for name, desc in subcmd_blocks:
55
+ spec["subcommands"].append({
56
+ "name": name,
57
+ "description": desc,
58
+ "options": [],
59
+ })
60
+
61
+ # Extract options: name: ["-f", "--flag"] or name: "--flag"
62
+ opt_blocks = re.findall(
63
+ r'name:\s*(?:\[([^\]]+)\]|["\']([^"\']+)["\'])\s*,\s*description:\s*["\']([^"\']+)["\']',
64
+ ts_content,
65
+ )
66
+ for names_arr, single_name, desc in opt_blocks:
67
+ if names_arr:
68
+ names = [n.strip().strip('"\'') for n in names_arr.split(",") if n.strip()]
69
+ else:
70
+ names = [single_name]
71
+ spec["options"].append({
72
+ "name": names,
73
+ "description": desc,
74
+ })
75
+
76
+ return spec
77
+
78
+
79
+ def import_fig_spec(tool_name: str, output_dir: Path) -> bool:
80
+ output_dir.mkdir(parents=True, exist_ok=True)
81
+ out_file = output_dir / f"{tool_name.lower()}.json"
82
+
83
+ ts_content = fetch_remote_ts_spec(tool_name)
84
+ if not ts_content:
85
+ return False
86
+
87
+ spec_data = parse_simple_ts_to_dict(tool_name, ts_content)
88
+
89
+ with open(out_file, "w", encoding="utf-8") as f:
90
+ json.dump(spec_data, f, ensure_ascii=False, indent=2)
91
+
92
+ return True
@@ -0,0 +1,18 @@
1
+ """
2
+ Kapsel Command Subsystem.
3
+ Integrates command registration and dispatching with autocompletion.
4
+ """
5
+
6
+ from kapsel.completion.kps.dispatcher import dispatch_kps
7
+ from kapsel.completion.kps.registry import (
8
+ KpsCommand,
9
+ KpsCommandRegistry,
10
+ get_kps_registry,
11
+ )
12
+
13
+ __all__ = [
14
+ "KpsCommand",
15
+ "KpsCommandRegistry",
16
+ "get_kps_registry",
17
+ "dispatch_kps",
18
+ ]
@@ -0,0 +1,92 @@
1
+ """
2
+ Kapsel Core Built-in Commands Registration.
3
+ Registers fundamental core commands: help, status, config, datadir, add, toggle.
4
+ These can be invoked interchangeably via 'kapsel <cmd>' or 'kps <cmd>'.
5
+ All comments and descriptions are in English.
6
+ """
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from kapsel.completion.kps.registry import KpsCommandRegistry
12
+
13
+
14
+ def register_builtins(registry: "KpsCommandRegistry") -> None:
15
+ """Registers core commands into the KpsCommandRegistry."""
16
+ from kapsel.completion.kps.builtins.add import handle_add_command
17
+ from kapsel.completion.kps.builtins.config import handle_config_command
18
+ from kapsel.completion.kps.builtins.datadir import handle_datadir_command
19
+ from kapsel.completion.kps.builtins.help import handle_help
20
+ from kapsel.completion.kps.builtins.language import handle_language_command
21
+ from kapsel.completion.kps.builtins.status import handle_status
22
+ from kapsel.completion.kps.builtins.toggle import handle_toggle_command
23
+
24
+ # 1. help
25
+ registry.register(
26
+ name="help",
27
+ handler=handle_help,
28
+ help_text="Display Kapsel manual, interaction mechanisms, and command cheatsheet",
29
+ usage="kapsel help (or kps help)",
30
+ )
31
+
32
+ # 2. status
33
+ registry.register(
34
+ name="status",
35
+ handler=handle_status,
36
+ help_text="Display OS environment, active shell, Git branch, and sandbox status",
37
+ usage="kapsel status (or kps status)",
38
+ )
39
+
40
+ # 3. config
41
+ registry.register(
42
+ name="config",
43
+ handler=handle_config_command,
44
+ help_text="View or modify core configuration in config.yaml",
45
+ subcommands={
46
+ "path": "Print physical configuration file path",
47
+ "edit": "Open configuration in default external editor",
48
+ "get": "Read configuration value for a key",
49
+ "set": "Update configuration value for a key",
50
+ "reload": "Hot-reload configuration from disk",
51
+ },
52
+ usage="kapsel config [path|edit|get|set|reload]",
53
+ )
54
+
55
+ # 4. datadir
56
+ registry.register(
57
+ name="datadir",
58
+ handler=handle_datadir_command,
59
+ help_text="Inspect or relocate data storage sandbox directory",
60
+ subcommands={
61
+ "status": "Show current data directory and stats",
62
+ "path": "Print current data directory path",
63
+ },
64
+ usage="kapsel datadir [path]",
65
+ )
66
+
67
+ # 5. add <plugin_name>
68
+ from kapsel.core.plugin.catalog import load_plugin_catalog
69
+
70
+ registry.register(
71
+ name="add",
72
+ handler=handle_add_command,
73
+ help_text="Enable and register a plugin into Kapsel environment (e.g. kapsel add autopilot)",
74
+ subcommands=load_plugin_catalog(),
75
+ usage="kapsel add <plugin_name> (or: kapsel add update)",
76
+ )
77
+
78
+ # 6. toggle
79
+ registry.register(
80
+ name="toggle",
81
+ handler=handle_toggle_command,
82
+ help_text="Toggle Kapsel as default terminal mode (open on first call, close on second)",
83
+ usage="kapsel toggle (or kps toggle)",
84
+ )
85
+
86
+ # 7. language
87
+ registry.register(
88
+ name="language",
89
+ handler=handle_language_command,
90
+ help_text="View and switch active UI language (en, zh_CN, ja, es, fr, de, ru)",
91
+ usage="kapsel language [en|zh_CN|ja|es|fr|de|ru]",
92
+ )
@@ -0,0 +1,121 @@
1
+ """
2
+ Kapsel System Command: 'kapsel add <plugin_name>'.
3
+ Enables and registers plugins into the Kapsel environment.
4
+ Executes plugin-specific install.py scripts if provided by the plugin.
5
+ All comments and descriptions are in English.
6
+ """
7
+
8
+ import importlib.util
9
+ from pathlib import Path
10
+ import shutil
11
+ import sys
12
+ from typing import List, Optional
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+
16
+ from kapsel.storage.config import get_kapsel_dir, load_config, update_config_value
17
+ from kapsel.ui.banner import ensure_utf8_io
18
+
19
+
20
+ def handle_add_command(args: List[str], console: Optional[Console] = None) -> int:
21
+ """
22
+ Handles 'kapsel add <plugin_name>' system command.
23
+ Enables local plugins, triggers their standalone install.py, and registers them into config.yaml.
24
+ """
25
+ ensure_utf8_io()
26
+ con = console or Console(legacy_windows=False)
27
+
28
+ if not args:
29
+ con.print("[bold #f43f5e]Error:[/] Please specify a plugin name to add.")
30
+ con.print("[dim]Usage: kapsel add <plugin_name> (e.g. kapsel add install)[/]\n")
31
+ return 1
32
+
33
+ target_raw = args[0].strip()
34
+
35
+ # Handle 'kapsel add update' to scan and refresh catalog subcommands dictionary
36
+ if target_raw.lower() == "update":
37
+ from kapsel.core.plugin.catalog import update_plugin_catalog, render_catalog_table
38
+ con.print("[bold #00f0ff]🔄 Scanning repository plugins and updating catalog dictionary...[/]")
39
+ updated_catalog = update_plugin_catalog(con)
40
+ render_catalog_table(updated_catalog, con)
41
+ con.print("[bold #10b981]✔ Plugin catalog and subcommands completion dictionary successfully updated![/]\n")
42
+ return 0
43
+
44
+ source_path = Path(target_raw).expanduser()
45
+
46
+ global_plugins_dir = get_kapsel_dir() / "plugins"
47
+ global_plugins_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ # Determine plugin ID and source
50
+ if source_path.exists() and source_path.is_dir():
51
+ # User specified an explicit directory path (e.g. kapsel add ./plugins/install or /path/to/my-plugin)
52
+ target_name = source_path.name.lower()
53
+ dest_plugin_dir = global_plugins_dir / target_name
54
+
55
+ # Verify source has __init__.py
56
+ if not (source_path / "__init__.py").exists():
57
+ con.print(f"[bold #f43f5e]Error:[/] Source directory '{source_path}' does not contain '__init__.py'.")
58
+ return 1
59
+
60
+ # Install / sync to user global plugin directory
61
+ shutil.copytree(source_path, dest_plugin_dir, dirs_exist_ok=True)
62
+ plugin_dir = dest_plugin_dir
63
+
64
+ else:
65
+ # User specified a plugin name (e.g. kapsel add install)
66
+ target_name = target_raw.lower()
67
+ dest_plugin_dir = global_plugins_dir / target_name
68
+ dev_plugin_dir = Path.cwd() / "plugins" / target_name
69
+
70
+ if dev_plugin_dir.exists() and dev_plugin_dir.is_dir() and (dev_plugin_dir / "__init__.py").exists():
71
+ # If present in local dev directory, automatically sync / update it to global user directory
72
+ shutil.copytree(dev_plugin_dir, dest_plugin_dir, dirs_exist_ok=True)
73
+ plugin_dir = dest_plugin_dir
74
+ elif dest_plugin_dir.exists() and dest_plugin_dir.is_dir():
75
+ plugin_dir = dest_plugin_dir
76
+ else:
77
+ con.print(f"[bold #f43f5e]Error:[/] Plugin '[white]{target_name}[/]' not found in global plugin directory.")
78
+ con.print(f"[dim]Expected location:[/] {dest_plugin_dir}")
79
+ con.print("[dim]To install a plugin, specify its local path or place it into your global plugins directory.[/]\n")
80
+ return 1
81
+
82
+ # Verify that the final plugin directory contains __init__.py
83
+ if not (plugin_dir / "__init__.py").exists():
84
+ con.print(f"[bold #f43f5e]Error:[/] Plugin directory '{plugin_dir}' does not contain a valid '__init__.py'.")
85
+ return 1
86
+
87
+ # Add to config.yaml enabled plugins list
88
+ cfg = load_config()
89
+ current_enabled = cfg.enabled_plugins
90
+ if target_name not in current_enabled:
91
+ new_enabled = current_enabled + [target_name]
92
+ update_config_value("plugins", "enabled", new_enabled)
93
+
94
+ # Decoupled plugin dependency installation:
95
+ # Trigger standalone install.py if provided by the plugin
96
+ install_script = plugin_dir / "install.py"
97
+ if install_script.exists():
98
+ try:
99
+ bin_dir = get_kapsel_dir() / "bin"
100
+ bin_dir.mkdir(parents=True, exist_ok=True)
101
+
102
+ module_name = f"kapsel_installer_{target_name}"
103
+ spec = importlib.util.spec_from_file_location(module_name, install_script)
104
+ if spec and spec.loader:
105
+ installer_mod = importlib.util.module_from_spec(spec)
106
+ sys.modules[module_name] = installer_mod
107
+ spec.loader.exec_module(installer_mod)
108
+ install_fn = getattr(installer_mod, "install", None)
109
+ if callable(install_fn):
110
+ con.print(f"[dim]Running installer for '[#00f0ff]{target_name}[/]'...[/]")
111
+ success = install_fn(con, bin_dir)
112
+ if not success:
113
+ con.print(f"[yellow]Notice: Installation script for '{target_name}' finished with warnings.[/]")
114
+ except Exception as e:
115
+ con.print(f"[yellow]Warning: Failed to execute installer for '{target_name}':[/] {e}")
116
+
117
+ msg = f"[bold #10b981]✔ Plugin '[#00f0ff]{target_name}[/]' successfully added and enabled![/]\n\n"
118
+ msg += f"[dim]Location: {plugin_dir}[/]"
119
+
120
+ con.print(Panel(msg, title="[bold #00f0ff]🔌 Plugin System[/]", border_style="#0891b2", expand=False))
121
+ return 0