kapsel-cli 0.5.2__tar.gz → 0.5.3__tar.gz
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_cli-0.5.2 → kapsel_cli-0.5.3}/PKG-INFO +1 -1
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/__init__.py +1 -1
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/__init__.py +6 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/plugin_switch.py +109 -7
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/status.py +16 -1
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/plugin/catalog.py +32 -1
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/plugin/fetcher.py +47 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel_cli.egg-info/PKG-INFO +1 -1
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel_cli.egg-info/SOURCES.txt +1 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/pyproject.toml +1 -1
- kapsel_cli-0.5.3/tests/test_plugin_backup.py +161 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/README.md +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/__main__.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/cli.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/__init__.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/carapace_engine.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/carapace_installer.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/completer.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/__init__.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/add.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/completion.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/config.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/datadir.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/dir_nav.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/help.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/language.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/search.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/toggle.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/upgrade.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/dispatcher.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/registry.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/spec_manager.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/__init__.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/block/__init__.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/block/model.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/block/registry.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/block/runner.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/detector.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/engine.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/executor.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/i18n.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/plugin/__init__.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/plugin/base.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/plugin/context.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/plugin/hooks.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/plugin/manager.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/i18n.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/de/LC_MESSAGES/kapsel.mo +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/de/LC_MESSAGES/kapsel.po +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/de/help.yaml +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/de/tips.yaml +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/en/help.yaml +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/en/tips.yaml +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/es/LC_MESSAGES/kapsel.mo +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/es/LC_MESSAGES/kapsel.po +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/es/help.yaml +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/es/tips.yaml +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/fr/LC_MESSAGES/kapsel.mo +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/fr/LC_MESSAGES/kapsel.po +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/fr/help.yaml +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/fr/tips.yaml +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ja/LC_MESSAGES/kapsel.mo +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ja/LC_MESSAGES/kapsel.po +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ja/help.yaml +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ja/tips.yaml +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/kapsel.pot +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ru/LC_MESSAGES/kapsel.mo +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ru/LC_MESSAGES/kapsel.po +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ru/help.yaml +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ru/tips.yaml +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/zh_CN/LC_MESSAGES/kapsel.mo +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/zh_CN/LC_MESSAGES/kapsel.po +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/zh_CN/help.yaml +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/zh_CN/tips.yaml +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/storage/__init__.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/storage/config.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/storage/history.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/storage/logger.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/storage/migrate.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/ui/__init__.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/ui/banner.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/ui/card.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/ui/prompt.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/ui/theme.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/ui/tips.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/ui/viewport.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel_cli.egg-info/dependency_links.txt +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel_cli.egg-info/entry_points.txt +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel_cli.egg-info/requires.txt +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel_cli.egg-info/top_level.txt +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/setup.cfg +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_block_system.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_carapace_installer.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_config.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_dir_nav.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_history.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_i18n.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_ai.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_alias.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_autopilot.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_commit.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_fetcher.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_init.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_install.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_lang.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_portal.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_preview.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_queue.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_template.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_trans.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_search_and_completions.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_spec_manager.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_toggle.py +0 -0
- {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_upgrade.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kapsel-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.3
|
|
4
4
|
Summary: Next-generation cross-platform terminal capsule & ergonomic shell multiplexer with context-aware command translation and 1,000+ CLI completions.
|
|
5
5
|
Author-email: MrEiu <k648888@vip.qq.com>
|
|
6
6
|
License: MIT
|
|
@@ -153,7 +153,13 @@ def register_builtins(registry: "KpsCommandRegistry") -> None:
|
|
|
153
153
|
# 9. upgrade & update (Kapsel Core & official plugins)
|
|
154
154
|
from kapsel.completion.kps.builtins.upgrade import handle_upgrade
|
|
155
155
|
|
|
156
|
+
# Merge catalog entries with locally installed plugins so enable/disable/upgrade
|
|
157
|
+
# completions reflect the actual filesystem state (e.g. workspace dev plugins).
|
|
158
|
+
from kapsel.completion.kps.builtins.plugin_switch import get_all_installed_plugins
|
|
156
159
|
plugin_subcommands = {k: v for k, v in add_subcommands.items() if k != "update"}
|
|
160
|
+
for p in get_all_installed_plugins():
|
|
161
|
+
if p not in plugin_subcommands and p not in ("update", "all"):
|
|
162
|
+
plugin_subcommands[p] = f"Plugin: {p}"
|
|
157
163
|
|
|
158
164
|
registry.register(
|
|
159
165
|
name="upgrade",
|
|
@@ -36,17 +36,69 @@ def get_all_installed_plugins() -> List[str]:
|
|
|
36
36
|
return sorted(installed)
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def _render_plugin_status_table(con: Console) -> None:
|
|
40
|
+
"""Renders a full overview table of all installed plugins with enabled/disabled status."""
|
|
41
|
+
from rich.table import Table
|
|
42
|
+
from kapsel.core.plugin.catalog import _extract_plugin_version, _extract_plugin_description
|
|
43
|
+
from kapsel.storage.config import get_kapsel_dir
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
cfg = load_config()
|
|
47
|
+
enabled_set = set(p.lower() for p in (cfg.enabled_plugins or []))
|
|
48
|
+
all_installed = get_all_installed_plugins()
|
|
49
|
+
|
|
50
|
+
table = Table(
|
|
51
|
+
title="[bold #00f0ff]🧩 Kapsel Plugin Status Overview[/]",
|
|
52
|
+
border_style="#0891b2",
|
|
53
|
+
header_style="bold #00f0ff",
|
|
54
|
+
show_header=True,
|
|
55
|
+
expand=False,
|
|
56
|
+
)
|
|
57
|
+
table.add_column("Plugin", style="bold #a855f7", width=16)
|
|
58
|
+
table.add_column("Version", style="dim #9ca3af", width=10)
|
|
59
|
+
table.add_column("Status", width=14)
|
|
60
|
+
table.add_column("Description", style="#e4e4e7")
|
|
61
|
+
|
|
62
|
+
for name in all_installed:
|
|
63
|
+
# Resolve version & description from actual plugin dir
|
|
64
|
+
ver, desc = "0.1.0", f"Plugin: {name}"
|
|
65
|
+
for base in (Path.cwd() / "plugins", get_kapsel_dir() / "plugins"):
|
|
66
|
+
p_dir = base / name
|
|
67
|
+
if p_dir.is_dir():
|
|
68
|
+
v = _extract_plugin_version(p_dir)
|
|
69
|
+
d = _extract_plugin_description(p_dir)
|
|
70
|
+
if v:
|
|
71
|
+
ver = v
|
|
72
|
+
if d:
|
|
73
|
+
desc = d
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
if not enabled_set or name in enabled_set:
|
|
77
|
+
status = "[bold #10b981]● Enabled[/]"
|
|
78
|
+
else:
|
|
79
|
+
status = "[dim #6b7280]○ Disabled[/]"
|
|
80
|
+
|
|
81
|
+
table.add_row(name, f"v{ver}", status, desc)
|
|
82
|
+
|
|
83
|
+
con.print()
|
|
84
|
+
con.print(table)
|
|
85
|
+
con.print(
|
|
86
|
+
f"[dim]Run [bold #00f0ff]kapsel enable <plugin>[/] or "
|
|
87
|
+
f"[bold #f43f5e]kapsel disable <plugin>[/] to toggle.[/]\n"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
39
91
|
def handle_enable_plugin(args: List[str], console: Optional[Console] = None) -> int:
|
|
40
92
|
"""
|
|
41
93
|
Enables one or more installed Kapsel plugins.
|
|
42
94
|
Usage:
|
|
43
95
|
kapsel enable <plugin_name>
|
|
96
|
+
kapsel enable (shows full plugin status table)
|
|
44
97
|
"""
|
|
45
98
|
con = console or Console(legacy_windows=False)
|
|
46
99
|
if not args:
|
|
47
|
-
con
|
|
48
|
-
|
|
49
|
-
return 1
|
|
100
|
+
_render_plugin_status_table(con)
|
|
101
|
+
return 0
|
|
50
102
|
|
|
51
103
|
target = args[0].strip().lower()
|
|
52
104
|
cfg = load_config()
|
|
@@ -88,13 +140,63 @@ def handle_enable_plugin(args: List[str], console: Optional[Console] = None) ->
|
|
|
88
140
|
except Exception:
|
|
89
141
|
pass
|
|
90
142
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
143
|
+
# Build status card
|
|
144
|
+
from rich.table import Table as _Table
|
|
145
|
+
from rich.panel import Panel as _Panel
|
|
146
|
+
from kapsel.core.plugin.catalog import _extract_plugin_version, _extract_plugin_description
|
|
147
|
+
from pathlib import Path as _Path
|
|
148
|
+
from kapsel.storage.config import get_kapsel_dir as _get_dir
|
|
149
|
+
|
|
150
|
+
# Resolve version & description from actual plugin files first
|
|
151
|
+
ver, desc = None, None
|
|
152
|
+
for base in (_Path.cwd() / "plugins", _get_dir() / "plugins"):
|
|
153
|
+
p_dir = base / target
|
|
154
|
+
if p_dir.is_dir():
|
|
155
|
+
ver = _extract_plugin_version(p_dir)
|
|
156
|
+
desc = _extract_plugin_description(p_dir)
|
|
157
|
+
break
|
|
158
|
+
if not ver:
|
|
159
|
+
ver = load_plugin_catalog_rich().get(target, {}).get("version", "0.1.0")
|
|
160
|
+
if not desc:
|
|
161
|
+
desc = load_plugin_catalog_rich().get(target, {}).get("description", f"Plugin: {target}")
|
|
162
|
+
|
|
163
|
+
# Collect subcommands from live registry if available
|
|
164
|
+
subcommands: dict = {}
|
|
165
|
+
try:
|
|
166
|
+
from kapsel.completion.kps.registry import get_kps_registry
|
|
167
|
+
cmd_entry = get_kps_registry().get(target)
|
|
168
|
+
if cmd_entry:
|
|
169
|
+
subcommands = cmd_entry.subcommands or {}
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
grid = _Table.grid(padding=(0, 2))
|
|
174
|
+
grid.add_column(style="bold #a855f7", width=14)
|
|
175
|
+
grid.add_column(style="#e4e4e7")
|
|
176
|
+
|
|
177
|
+
grid.add_row("Plugin:", f"[bold #00f0ff]{target}[/] [dim]v{ver}[/]")
|
|
178
|
+
grid.add_row("Description:", desc)
|
|
179
|
+
grid.add_row("Status:", "[bold #10b981]✔ Enabled[/]")
|
|
180
|
+
grid.add_row("Run:", f"[bold #38bdf8]kps {target} [dim][command][/][/]")
|
|
181
|
+
|
|
182
|
+
if subcommands:
|
|
183
|
+
grid.add_row("Commands:", "")
|
|
184
|
+
for sub, sub_desc in list(subcommands.items())[:8]:
|
|
185
|
+
grid.add_row("", f" [bold #38bdf8]{sub:<16}[/] [dim]{sub_desc}[/]")
|
|
186
|
+
if len(subcommands) > 8:
|
|
187
|
+
grid.add_row("", f" [dim]... and {len(subcommands) - 8} more[/]")
|
|
188
|
+
|
|
189
|
+
con.print(_Panel(
|
|
190
|
+
grid,
|
|
191
|
+
title=f"[bold #00f0ff]🔌 Plugin Enabled: {target}[/]",
|
|
192
|
+
border_style="#0891b2",
|
|
193
|
+
padding=(1, 2),
|
|
194
|
+
expand=False,
|
|
195
|
+
))
|
|
95
196
|
return 0
|
|
96
197
|
|
|
97
198
|
|
|
199
|
+
|
|
98
200
|
def handle_disable_plugin(args: List[str], console: Optional[Console] = None) -> int:
|
|
99
201
|
"""
|
|
100
202
|
Disables one or more active Kapsel plugins.
|
|
@@ -115,11 +115,26 @@ def handle_status(args: Optional[List[str]] = None, console: Optional[Console] =
|
|
|
115
115
|
active_plugins = [p.lower() for p in enabled_plugins]
|
|
116
116
|
|
|
117
117
|
if active_plugins:
|
|
118
|
+
from kapsel.core.plugin.catalog import _extract_plugin_version
|
|
119
|
+
from kapsel.storage.config import get_kapsel_dir as _get_dir
|
|
120
|
+
|
|
121
|
+
def _get_plugin_version(name: str) -> str:
|
|
122
|
+
# 1. Try actual plugin directory (workspace dev or global)
|
|
123
|
+
for base in (Path.cwd() / "plugins", _get_dir() / "plugins"):
|
|
124
|
+
p = base / name
|
|
125
|
+
if p.is_dir():
|
|
126
|
+
v = _extract_plugin_version(p)
|
|
127
|
+
if v and v != "0.1.0":
|
|
128
|
+
return v
|
|
129
|
+
# 2. Fallback to catalog metadata
|
|
130
|
+
return rich_catalog.get(name, {}).get("version", "0.1.0")
|
|
131
|
+
|
|
118
132
|
plugin_badges = [
|
|
119
|
-
f"[bold #00f0ff]{p}[/][dim]@{
|
|
133
|
+
f"[bold #00f0ff]{p}[/][dim]@{_get_plugin_version(p)}[/]"
|
|
120
134
|
for p in active_plugins
|
|
121
135
|
]
|
|
122
136
|
plugins_str = ", ".join(plugin_badges)
|
|
137
|
+
|
|
123
138
|
else:
|
|
124
139
|
plugins_str = f"[dim]{none_str}[/]"
|
|
125
140
|
|
|
@@ -108,7 +108,37 @@ def load_plugin_catalog_rich() -> Dict[str, Dict[str, Any]]:
|
|
|
108
108
|
except Exception:
|
|
109
109
|
pass
|
|
110
110
|
|
|
111
|
-
# If file not present yet,
|
|
111
|
+
# If file not present yet, try to fetch from remote repository before falling back to defaults
|
|
112
|
+
try:
|
|
113
|
+
from kapsel.core.plugin.fetcher import fetch_remote_catalog
|
|
114
|
+
remote_data = fetch_remote_catalog()
|
|
115
|
+
if remote_data and isinstance(remote_data, dict):
|
|
116
|
+
result = {}
|
|
117
|
+
for k, v in remote_data.items():
|
|
118
|
+
if k.startswith(("_", "$")):
|
|
119
|
+
continue
|
|
120
|
+
if isinstance(v, dict):
|
|
121
|
+
result[k] = {
|
|
122
|
+
"version": v.get("version", "0.1.0"),
|
|
123
|
+
"description": v.get("description", f"Plugin {k}"),
|
|
124
|
+
"changelog": v.get("changelog", ""),
|
|
125
|
+
}
|
|
126
|
+
else:
|
|
127
|
+
result[k] = {
|
|
128
|
+
"version": "0.1.0",
|
|
129
|
+
"description": str(v),
|
|
130
|
+
"changelog": "",
|
|
131
|
+
}
|
|
132
|
+
result.setdefault("update", {
|
|
133
|
+
"version": "0.1.0",
|
|
134
|
+
"description": "Scan and update available plugin catalog and completion dictionary",
|
|
135
|
+
"changelog": "Catalog dictionary update scanner.",
|
|
136
|
+
})
|
|
137
|
+
return result
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
# Final fallback: built-in DEFAULT_CATALOG (offline / import error)
|
|
112
142
|
return {
|
|
113
143
|
k: {
|
|
114
144
|
"version": "0.2.0" if k in ("alias", "install") else "0.1.0",
|
|
@@ -119,6 +149,7 @@ def load_plugin_catalog_rich() -> Dict[str, Dict[str, Any]]:
|
|
|
119
149
|
}
|
|
120
150
|
|
|
121
151
|
|
|
152
|
+
|
|
122
153
|
def _extract_plugin_version(plugin_dir: Path) -> str:
|
|
123
154
|
"""Extracts version string from plugin manifest, pyproject.toml, or __init__.py."""
|
|
124
155
|
# 1. Try reading manifest in plugin.py
|
|
@@ -154,3 +154,50 @@ def fetch_plugin_from_remote(
|
|
|
154
154
|
f"from official repository (https://github.com/MrEiu/plugins)."
|
|
155
155
|
)
|
|
156
156
|
return False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
OFFICIAL_CATALOG_URL = "https://raw.githubusercontent.com/MrEiu/plugins/master/catalog.json"
|
|
160
|
+
OFFICIAL_CATALOG_MIRROR_URL = "https://ghproxy.net/https://raw.githubusercontent.com/MrEiu/plugins/master/catalog.json"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def fetch_remote_catalog(console: Optional[Console] = None) -> Optional[dict]:
|
|
164
|
+
"""
|
|
165
|
+
Fetches the latest catalog.json from the official remote repository.
|
|
166
|
+
Tries direct GitHub raw URL first, then mirror fallback.
|
|
167
|
+
On success, writes to the local global cache and returns the parsed dict.
|
|
168
|
+
Returns None if all attempts fail (offline / unreachable).
|
|
169
|
+
"""
|
|
170
|
+
import json as _json
|
|
171
|
+
from kapsel.storage.config import get_kapsel_dir
|
|
172
|
+
|
|
173
|
+
con = console or Console(legacy_windows=False)
|
|
174
|
+
headers = {"User-Agent": "Kapsel-Plugin-Fetcher/1.0"}
|
|
175
|
+
|
|
176
|
+
for url, label in [
|
|
177
|
+
(OFFICIAL_CATALOG_URL, "GitHub"),
|
|
178
|
+
(OFFICIAL_CATALOG_MIRROR_URL, "Mirror"),
|
|
179
|
+
]:
|
|
180
|
+
try:
|
|
181
|
+
req = urllib.request.Request(url, headers=headers)
|
|
182
|
+
with urllib.request.urlopen(req, timeout=8.0) as resp:
|
|
183
|
+
if resp.status == 200:
|
|
184
|
+
raw = resp.read().decode("utf-8")
|
|
185
|
+
data = _json.loads(raw)
|
|
186
|
+
if isinstance(data, dict):
|
|
187
|
+
# Write to local cache so subsequent calls skip network
|
|
188
|
+
global_cache = get_kapsel_dir() / "plugins_catalog.json"
|
|
189
|
+
try:
|
|
190
|
+
global_cache.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
global_cache.write_text(
|
|
192
|
+
_json.dumps(data, indent=2, ensure_ascii=False) + "\n",
|
|
193
|
+
encoding="utf-8",
|
|
194
|
+
)
|
|
195
|
+
except Exception as cache_err:
|
|
196
|
+
logger.debug(f"Failed to write remote catalog cache: {cache_err}")
|
|
197
|
+
logger.debug(f"Remote catalog fetched from {label} ({len(data)} entries).")
|
|
198
|
+
return data
|
|
199
|
+
except Exception as ex:
|
|
200
|
+
logger.debug(f"Remote catalog fetch failed from {label} ({url}): {ex}")
|
|
201
|
+
|
|
202
|
+
con.print("[dim]Could not reach remote catalog; using built-in defaults.[/]")
|
|
203
|
+
return None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kapsel-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.3
|
|
4
4
|
Summary: Next-generation cross-platform terminal capsule & ergonomic shell multiplexer with context-aware command translation and 1,000+ CLI completions.
|
|
5
5
|
Author-email: MrEiu <k648888@vip.qq.com>
|
|
6
6
|
License: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kapsel-cli"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.3"
|
|
8
8
|
description = "Next-generation cross-platform terminal capsule & ergonomic shell multiplexer with context-aware command translation and 1,000+ CLI completions."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for Kapsel Backup Plugin.
|
|
3
|
+
All comments and descriptions are in English.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from io import StringIO
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from unittest.mock import MagicMock, patch
|
|
11
|
+
import pytest
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
# Ensure root directory is in sys.path
|
|
15
|
+
root_dir = Path(__file__).resolve().parent.parent
|
|
16
|
+
if str(root_dir) not in sys.path:
|
|
17
|
+
sys.path.insert(0, str(root_dir))
|
|
18
|
+
|
|
19
|
+
from plugins.backup.plugin import BackupPlugin
|
|
20
|
+
from plugins.backup.engine import (
|
|
21
|
+
resolve_bk_command,
|
|
22
|
+
format_relative_time,
|
|
23
|
+
get_backup_history,
|
|
24
|
+
select_backup_version_interactive,
|
|
25
|
+
run_bk_passthrough,
|
|
26
|
+
)
|
|
27
|
+
from kapsel.core.plugin.context import PluginContext
|
|
28
|
+
from kapsel.core.plugin.hooks import HookType
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.fixture
|
|
32
|
+
def backup_plugin():
|
|
33
|
+
plugin = BackupPlugin()
|
|
34
|
+
context = MagicMock(spec=PluginContext)
|
|
35
|
+
plugin.on_load(context)
|
|
36
|
+
return plugin
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_backup_manifest(backup_plugin):
|
|
40
|
+
manifest = backup_plugin.manifest
|
|
41
|
+
assert manifest.id == "backup"
|
|
42
|
+
assert manifest.name == "Backup"
|
|
43
|
+
assert manifest.version == "0.1.0"
|
|
44
|
+
assert "backup" in manifest.tags
|
|
45
|
+
assert "gitkeep" in manifest.tags
|
|
46
|
+
assert "restore" in manifest.tags
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_backup_on_load(backup_plugin):
|
|
50
|
+
ctx = backup_plugin.context
|
|
51
|
+
assert ctx.register_hook.call_count >= 2
|
|
52
|
+
hook_types = [call[0][0] for call in ctx.register_hook.call_args_list]
|
|
53
|
+
assert HookType.FILTER_COMMAND in hook_types
|
|
54
|
+
assert HookType.PROVIDE_COMPLETIONS in hook_types
|
|
55
|
+
|
|
56
|
+
assert ctx.register_kps_command.call_count >= 1
|
|
57
|
+
registered_names = [call[1]["name"] for call in ctx.register_kps_command.call_args_list]
|
|
58
|
+
assert "backup" in registered_names
|
|
59
|
+
assert "bk" in registered_names
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_filter_command(backup_plugin):
|
|
63
|
+
# Non-backup command
|
|
64
|
+
handled, cmd = backup_plugin.filter_command("git status")
|
|
65
|
+
assert not handled
|
|
66
|
+
assert cmd == "git status"
|
|
67
|
+
|
|
68
|
+
# Intercept 'bk'
|
|
69
|
+
with patch.object(backup_plugin, "handle_backup", return_value=0) as mock_handle:
|
|
70
|
+
handled, cmd = backup_plugin.filter_command("bk 'test commit'")
|
|
71
|
+
assert handled is True
|
|
72
|
+
assert cmd == ""
|
|
73
|
+
mock_handle.assert_called_once()
|
|
74
|
+
assert mock_handle.call_args[0][0] == ["'test", "commit'"]
|
|
75
|
+
|
|
76
|
+
# Intercept 'backup'
|
|
77
|
+
with patch.object(backup_plugin, "handle_backup", return_value=0) as mock_handle:
|
|
78
|
+
handled, cmd = backup_plugin.filter_command("backup")
|
|
79
|
+
assert handled is True
|
|
80
|
+
assert cmd == ""
|
|
81
|
+
mock_handle.assert_called_once()
|
|
82
|
+
|
|
83
|
+
# Intercept 'kps bk'
|
|
84
|
+
with patch.object(backup_plugin, "handle_backup", return_value=0) as mock_handle:
|
|
85
|
+
handled, cmd = backup_plugin.filter_command("kps bk log")
|
|
86
|
+
assert handled is True
|
|
87
|
+
assert cmd == ""
|
|
88
|
+
mock_handle.assert_called_once_with(["log"], pytest.approx(mock_handle.call_args[0][1]))
|
|
89
|
+
|
|
90
|
+
# Intercept 'kps backup'
|
|
91
|
+
with patch.object(backup_plugin, "handle_backup", return_value=0) as mock_handle:
|
|
92
|
+
handled, cmd = backup_plugin.filter_command("kps backup status")
|
|
93
|
+
assert handled is True
|
|
94
|
+
assert cmd == ""
|
|
95
|
+
mock_handle.assert_called_once()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_provide_completions(backup_plugin):
|
|
99
|
+
ctx = MagicMock()
|
|
100
|
+
ctx.text_before_cursor = "bk res"
|
|
101
|
+
|
|
102
|
+
completions = backup_plugin.provide_completions(ctx)
|
|
103
|
+
assert any(c.text == "restore" for c in completions)
|
|
104
|
+
|
|
105
|
+
ctx.text_before_cursor = "kps backup u"
|
|
106
|
+
completions = backup_plugin.provide_completions(ctx)
|
|
107
|
+
assert any(c.text == "undo" for c in completions)
|
|
108
|
+
|
|
109
|
+
# Irrelevant command returns empty
|
|
110
|
+
ctx.text_before_cursor = "cat file"
|
|
111
|
+
assert backup_plugin.provide_completions(ctx) == []
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_resolve_bk_command():
|
|
115
|
+
cmd = resolve_bk_command()
|
|
116
|
+
# It should resolve either to bk.exe or node cli.js or bk in path
|
|
117
|
+
assert cmd is not None
|
|
118
|
+
assert len(cmd) >= 1
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_format_relative_time():
|
|
122
|
+
assert format_relative_time("invalid date") == "invalid date"
|
|
123
|
+
assert format_relative_time("2026-09-19 15:46:36 +0800") != ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_get_backup_history_mock():
|
|
127
|
+
fake_json = '{"commits": [{"hash": "1234567890", "shortHash": "1234567", "date": "2026-09-19 12:00:00 +0800", "message": "initial"}]}'
|
|
128
|
+
with patch("subprocess.run") as mock_run:
|
|
129
|
+
mock_run.return_value = MagicMock(returncode=0, stdout=fake_json, stderr="")
|
|
130
|
+
commits, err = get_backup_history()
|
|
131
|
+
assert err is None
|
|
132
|
+
assert len(commits) == 1
|
|
133
|
+
assert commits[0]["shortHash"] == "1234567"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_handle_backup_interactive_restore(backup_plugin):
|
|
137
|
+
con = Console(file=StringIO())
|
|
138
|
+
fake_commits = [
|
|
139
|
+
{"hash": "1234567890", "shortHash": "1234567", "date": "2026-09-19 12:00:00 +0800", "message": "save"}
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
with patch("plugins.backup.plugin.get_backup_history", return_value=(fake_commits, None)), \
|
|
143
|
+
patch("plugins.backup.plugin.select_backup_version_interactive", return_value="1234567"), \
|
|
144
|
+
patch("plugins.backup.plugin.run_bk_passthrough", return_value=0) as mock_run:
|
|
145
|
+
ret = backup_plugin.handle_backup(["restore"], con=con)
|
|
146
|
+
assert ret == 0
|
|
147
|
+
mock_run.assert_called_once_with(["restore", "1234567"], con=con)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_handle_backup_interactive_cancelled(backup_plugin):
|
|
151
|
+
con = Console(file=StringIO())
|
|
152
|
+
fake_commits = [
|
|
153
|
+
{"hash": "1234567890", "shortHash": "1234567", "date": "2026-09-19 12:00:00 +0800", "message": "save"}
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
with patch("plugins.backup.plugin.get_backup_history", return_value=(fake_commits, None)), \
|
|
157
|
+
patch("plugins.backup.plugin.select_backup_version_interactive", return_value=None), \
|
|
158
|
+
patch("plugins.backup.plugin.run_bk_passthrough") as mock_run:
|
|
159
|
+
ret = backup_plugin.handle_backup(["restore"], con=con)
|
|
160
|
+
assert ret == 0
|
|
161
|
+
mock_run.assert_not_called()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|