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,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
|