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.
Files changed (114) hide show
  1. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/PKG-INFO +1 -1
  2. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/__init__.py +1 -1
  3. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/__init__.py +6 -0
  4. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/plugin_switch.py +109 -7
  5. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/status.py +16 -1
  6. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/plugin/catalog.py +32 -1
  7. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/plugin/fetcher.py +47 -0
  8. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel_cli.egg-info/PKG-INFO +1 -1
  9. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel_cli.egg-info/SOURCES.txt +1 -0
  10. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/pyproject.toml +1 -1
  11. kapsel_cli-0.5.3/tests/test_plugin_backup.py +161 -0
  12. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/README.md +0 -0
  13. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/__main__.py +0 -0
  14. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/cli.py +0 -0
  15. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/__init__.py +0 -0
  16. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/carapace_engine.py +0 -0
  17. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/carapace_installer.py +0 -0
  18. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/completer.py +0 -0
  19. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/__init__.py +0 -0
  20. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/add.py +0 -0
  21. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/completion.py +0 -0
  22. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/config.py +0 -0
  23. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/datadir.py +0 -0
  24. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/dir_nav.py +0 -0
  25. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/help.py +0 -0
  26. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/language.py +0 -0
  27. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/search.py +0 -0
  28. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/toggle.py +0 -0
  29. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/builtins/upgrade.py +0 -0
  30. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/dispatcher.py +0 -0
  31. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/kps/registry.py +0 -0
  32. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/completion/spec_manager.py +0 -0
  33. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/__init__.py +0 -0
  34. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/block/__init__.py +0 -0
  35. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/block/model.py +0 -0
  36. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/block/registry.py +0 -0
  37. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/block/runner.py +0 -0
  38. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/detector.py +0 -0
  39. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/engine.py +0 -0
  40. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/executor.py +0 -0
  41. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/i18n.py +0 -0
  42. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/plugin/__init__.py +0 -0
  43. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/plugin/base.py +0 -0
  44. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/plugin/context.py +0 -0
  45. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/plugin/hooks.py +0 -0
  46. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/core/plugin/manager.py +0 -0
  47. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/i18n.py +0 -0
  48. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/de/LC_MESSAGES/kapsel.mo +0 -0
  49. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/de/LC_MESSAGES/kapsel.po +0 -0
  50. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/de/help.yaml +0 -0
  51. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/de/tips.yaml +0 -0
  52. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/en/help.yaml +0 -0
  53. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/en/tips.yaml +0 -0
  54. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/es/LC_MESSAGES/kapsel.mo +0 -0
  55. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/es/LC_MESSAGES/kapsel.po +0 -0
  56. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/es/help.yaml +0 -0
  57. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/es/tips.yaml +0 -0
  58. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/fr/LC_MESSAGES/kapsel.mo +0 -0
  59. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/fr/LC_MESSAGES/kapsel.po +0 -0
  60. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/fr/help.yaml +0 -0
  61. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/fr/tips.yaml +0 -0
  62. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ja/LC_MESSAGES/kapsel.mo +0 -0
  63. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ja/LC_MESSAGES/kapsel.po +0 -0
  64. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ja/help.yaml +0 -0
  65. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ja/tips.yaml +0 -0
  66. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/kapsel.pot +0 -0
  67. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ru/LC_MESSAGES/kapsel.mo +0 -0
  68. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ru/LC_MESSAGES/kapsel.po +0 -0
  69. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ru/help.yaml +0 -0
  70. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/ru/tips.yaml +0 -0
  71. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/zh_CN/LC_MESSAGES/kapsel.mo +0 -0
  72. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/zh_CN/LC_MESSAGES/kapsel.po +0 -0
  73. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/zh_CN/help.yaml +0 -0
  74. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/locales/zh_CN/tips.yaml +0 -0
  75. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/storage/__init__.py +0 -0
  76. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/storage/config.py +0 -0
  77. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/storage/history.py +0 -0
  78. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/storage/logger.py +0 -0
  79. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/storage/migrate.py +0 -0
  80. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/ui/__init__.py +0 -0
  81. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/ui/banner.py +0 -0
  82. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/ui/card.py +0 -0
  83. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/ui/prompt.py +0 -0
  84. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/ui/theme.py +0 -0
  85. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/ui/tips.py +0 -0
  86. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel/ui/viewport.py +0 -0
  87. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel_cli.egg-info/dependency_links.txt +0 -0
  88. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel_cli.egg-info/entry_points.txt +0 -0
  89. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel_cli.egg-info/requires.txt +0 -0
  90. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/kapsel_cli.egg-info/top_level.txt +0 -0
  91. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/setup.cfg +0 -0
  92. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_block_system.py +0 -0
  93. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_carapace_installer.py +0 -0
  94. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_config.py +0 -0
  95. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_dir_nav.py +0 -0
  96. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_history.py +0 -0
  97. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_i18n.py +0 -0
  98. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_ai.py +0 -0
  99. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_alias.py +0 -0
  100. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_autopilot.py +0 -0
  101. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_commit.py +0 -0
  102. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_fetcher.py +0 -0
  103. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_init.py +0 -0
  104. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_install.py +0 -0
  105. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_lang.py +0 -0
  106. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_portal.py +0 -0
  107. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_preview.py +0 -0
  108. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_queue.py +0 -0
  109. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_template.py +0 -0
  110. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_plugin_trans.py +0 -0
  111. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_search_and_completions.py +0 -0
  112. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_spec_manager.py +0 -0
  113. {kapsel_cli-0.5.2 → kapsel_cli-0.5.3}/tests/test_toggle.py +0 -0
  114. {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.2
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
@@ -3,4 +3,4 @@ Kapsel: Cross-platform adaptive smart terminal capsule.
3
3
  Wrap complexity, expose simplicity.
4
4
  """
5
5
 
6
- __version__ = "0.5.2"
6
+ __version__ = "0.5.3"
@@ -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.print("[bold #f43f5e]Error:[/] Please specify a plugin name to enable.")
48
- con.print("[dim]Usage: kapsel enable <plugin_name> (e.g. kapsel enable shore)[/]\n")
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
- rich_catalog = load_plugin_catalog_rich()
92
- ver = rich_catalog.get(target, {}).get("version", "0.1.0")
93
- con.print(f"[bold #10b981]✔ Successfully enabled plugin '[bold #00f0ff]{target}[/]' (v{ver}).[/]")
94
- con.print(f"[dim]You can now run 'kps {target}' or explore its commands.[/]\n")
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]@{rich_catalog.get(p, {}).get('version', '0.1.0')}[/]"
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, return default catalog converted to rich format
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.2
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
@@ -95,6 +95,7 @@ tests/test_i18n.py
95
95
  tests/test_plugin_ai.py
96
96
  tests/test_plugin_alias.py
97
97
  tests/test_plugin_autopilot.py
98
+ tests/test_plugin_backup.py
98
99
  tests/test_plugin_commit.py
99
100
  tests/test_plugin_fetcher.py
100
101
  tests/test_plugin_init.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kapsel-cli"
7
- version = "0.5.2"
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