kapsel-cli 0.3.6__tar.gz → 0.5.2__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 (116) hide show
  1. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/PKG-INFO +1 -1
  2. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/__init__.py +1 -1
  3. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/cli.py +8 -14
  4. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/kps/builtins/__init__.py +23 -2
  5. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/kps/builtins/add.py +132 -51
  6. kapsel_cli-0.5.2/kapsel/completion/kps/builtins/dir_nav.py +318 -0
  7. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/kps/builtins/search.py +11 -4
  8. kapsel_cli-0.5.2/kapsel/completion/kps/builtins/toggle.py +281 -0
  9. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/kps/builtins/upgrade.py +11 -9
  10. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/spec_manager.py +15 -5
  11. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/executor.py +43 -39
  12. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/plugin/catalog.py +18 -3
  13. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/plugin/manager.py +9 -1
  14. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/storage/config.py +13 -0
  15. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/storage/history.py +29 -10
  16. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/ui/banner.py +8 -2
  17. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/ui/prompt.py +80 -4
  18. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/ui/theme.py +15 -0
  19. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel_cli.egg-info/PKG-INFO +1 -1
  20. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel_cli.egg-info/SOURCES.txt +9 -0
  21. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/pyproject.toml +1 -1
  22. kapsel_cli-0.5.2/tests/test_dir_nav.py +241 -0
  23. kapsel_cli-0.5.2/tests/test_history.py +78 -0
  24. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/tests/test_plugin_ai.py +2 -3
  25. kapsel_cli-0.5.2/tests/test_plugin_autopilot.py +175 -0
  26. kapsel_cli-0.5.2/tests/test_plugin_commit.py +165 -0
  27. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/tests/test_plugin_fetcher.py +10 -0
  28. kapsel_cli-0.5.2/tests/test_plugin_lang.py +115 -0
  29. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/tests/test_plugin_portal.py +85 -1
  30. kapsel_cli-0.5.2/tests/test_plugin_preview.py +508 -0
  31. kapsel_cli-0.5.2/tests/test_plugin_queue.py +78 -0
  32. kapsel_cli-0.5.2/tests/test_plugin_template.py +67 -0
  33. kapsel_cli-0.5.2/tests/test_plugin_trans.py +151 -0
  34. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/tests/test_search_and_completions.py +1 -0
  35. kapsel_cli-0.5.2/tests/test_toggle.py +115 -0
  36. kapsel_cli-0.3.6/kapsel/completion/kps/builtins/toggle.py +0 -34
  37. kapsel_cli-0.3.6/tests/test_history.py +0 -223
  38. kapsel_cli-0.3.6/tests/test_plugin_autopilot.py +0 -65
  39. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/README.md +0 -0
  40. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/__main__.py +0 -0
  41. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/__init__.py +0 -0
  42. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/carapace_engine.py +0 -0
  43. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/carapace_installer.py +0 -0
  44. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/completer.py +0 -0
  45. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/kps/__init__.py +0 -0
  46. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/kps/builtins/completion.py +0 -0
  47. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/kps/builtins/config.py +0 -0
  48. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/kps/builtins/datadir.py +0 -0
  49. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/kps/builtins/help.py +0 -0
  50. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/kps/builtins/language.py +0 -0
  51. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/kps/builtins/plugin_switch.py +0 -0
  52. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/kps/builtins/status.py +0 -0
  53. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/kps/dispatcher.py +0 -0
  54. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/completion/kps/registry.py +0 -0
  55. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/__init__.py +0 -0
  56. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/block/__init__.py +0 -0
  57. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/block/model.py +0 -0
  58. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/block/registry.py +0 -0
  59. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/block/runner.py +0 -0
  60. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/detector.py +0 -0
  61. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/engine.py +0 -0
  62. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/i18n.py +0 -0
  63. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/plugin/__init__.py +0 -0
  64. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/plugin/base.py +0 -0
  65. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/plugin/context.py +0 -0
  66. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/plugin/fetcher.py +0 -0
  67. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/core/plugin/hooks.py +0 -0
  68. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/i18n.py +0 -0
  69. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/de/LC_MESSAGES/kapsel.mo +0 -0
  70. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/de/LC_MESSAGES/kapsel.po +0 -0
  71. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/de/help.yaml +0 -0
  72. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/de/tips.yaml +0 -0
  73. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/en/help.yaml +0 -0
  74. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/en/tips.yaml +0 -0
  75. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/es/LC_MESSAGES/kapsel.mo +0 -0
  76. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/es/LC_MESSAGES/kapsel.po +0 -0
  77. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/es/help.yaml +0 -0
  78. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/es/tips.yaml +0 -0
  79. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/fr/LC_MESSAGES/kapsel.mo +0 -0
  80. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/fr/LC_MESSAGES/kapsel.po +0 -0
  81. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/fr/help.yaml +0 -0
  82. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/fr/tips.yaml +0 -0
  83. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/ja/LC_MESSAGES/kapsel.mo +0 -0
  84. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/ja/LC_MESSAGES/kapsel.po +0 -0
  85. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/ja/help.yaml +0 -0
  86. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/ja/tips.yaml +0 -0
  87. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/kapsel.pot +0 -0
  88. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/ru/LC_MESSAGES/kapsel.mo +0 -0
  89. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/ru/LC_MESSAGES/kapsel.po +0 -0
  90. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/ru/help.yaml +0 -0
  91. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/ru/tips.yaml +0 -0
  92. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/zh_CN/LC_MESSAGES/kapsel.mo +0 -0
  93. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/zh_CN/LC_MESSAGES/kapsel.po +0 -0
  94. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/zh_CN/help.yaml +0 -0
  95. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/locales/zh_CN/tips.yaml +0 -0
  96. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/storage/__init__.py +0 -0
  97. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/storage/logger.py +0 -0
  98. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/storage/migrate.py +0 -0
  99. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/ui/__init__.py +0 -0
  100. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/ui/card.py +0 -0
  101. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/ui/tips.py +0 -0
  102. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel/ui/viewport.py +0 -0
  103. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel_cli.egg-info/dependency_links.txt +0 -0
  104. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel_cli.egg-info/entry_points.txt +0 -0
  105. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel_cli.egg-info/requires.txt +0 -0
  106. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/kapsel_cli.egg-info/top_level.txt +0 -0
  107. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/setup.cfg +0 -0
  108. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/tests/test_block_system.py +0 -0
  109. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/tests/test_carapace_installer.py +0 -0
  110. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/tests/test_config.py +0 -0
  111. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/tests/test_i18n.py +0 -0
  112. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/tests/test_plugin_alias.py +0 -0
  113. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/tests/test_plugin_init.py +0 -0
  114. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/tests/test_plugin_install.py +0 -0
  115. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/tests/test_spec_manager.py +0 -0
  116. {kapsel_cli-0.3.6 → kapsel_cli-0.5.2}/tests/test_upgrade.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kapsel-cli
3
- Version: 0.3.6
3
+ Version: 0.5.2
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.3.6"
6
+ __version__ = "0.5.2"
@@ -48,13 +48,8 @@ def main(args: Optional[List[str]] = None) -> int:
48
48
  )
49
49
  return result.execution.exit_code
50
50
 
51
- is_toggle_start = False
52
- if clean_args and clean_args[0] == "toggle":
53
- is_toggle_start = True
54
- clean_args = clean_args[1:]
55
-
56
- # If other positional subcommands passed (e.g. kapsel status, kapsel help, kapsel add, kapsel config, kapsel datadir)
57
- if clean_args and not is_toggle_start:
51
+ # If positional subcommands passed (e.g. kapsel toggle, kapsel status, kapsel help, kapsel add, kapsel config, kapsel datadir)
52
+ if clean_args:
58
53
  engine = DualStateEngine()
59
54
  full_cmd = "kapsel " + " ".join(clean_args)
60
55
  result = engine.dispatch(full_cmd)
@@ -66,6 +61,10 @@ def main(args: Optional[List[str]] = None) -> int:
66
61
  )
67
62
  return result.execution.exit_code
68
63
 
64
+ # Do not start interactive shell in non-interactive / piped environments
65
+ if not sys.stdin.isatty() and not os.environ.get("KAPSEL_FORCE_TTY"):
66
+ return 0
67
+
69
68
  # Launch interactive shell (Kapsel Default Mode)
70
69
  engine = DualStateEngine()
71
70
  console = Console(legacy_windows=False)
@@ -76,9 +75,7 @@ def main(args: Optional[List[str]] = None) -> int:
76
75
 
77
76
  os.environ["KAPSEL_ACTIVE"] = "1"
78
77
 
79
- if is_toggle_start:
80
- console.print("[bold #10b981]✔ Kapsel active.[/] [dim]Type 'toggle' or 'exit' to quit.[/]\n")
81
- elif engine.config.enable_banner and not no_banner:
78
+ if engine.config.enable_banner and not no_banner:
82
79
  render_banner()
83
80
 
84
81
  prompt_session = KapselPrompt(engine)
@@ -101,7 +98,7 @@ def main(args: Optional[List[str]] = None) -> int:
101
98
  continue
102
99
 
103
100
  normalized = " ".join(stripped.lower().split())
104
- if normalized in ("exit", "quit", "toggle", "kapsel toggle", "kps toggle"):
101
+ if normalized in ("exit", "quit", "bye"):
105
102
  os.environ.pop("KAPSEL_ACTIVE", None)
106
103
  console.print("[dim]Exited Kapsel.[/]")
107
104
  break
@@ -143,9 +140,6 @@ def kps_cli() -> int:
143
140
  print(f"Kapsel v{__version__}")
144
141
  return 0
145
142
 
146
- if argv[0] == "toggle":
147
- return main(["toggle"])
148
-
149
143
  # 1. Fast dispatch to registered built-in or plugin kps commands
150
144
  cmd_line = " ".join(argv)
151
145
  builtin_exit = dispatch_kps(cmd_line)
@@ -114,8 +114,13 @@ def register_builtins(registry: "KpsCommandRegistry") -> None:
114
114
  registry.register(
115
115
  name="toggle",
116
116
  handler=handle_toggle_command,
117
- help_text="Toggle Kapsel as default terminal mode (open on first call, close on second)",
118
- usage="kapsel toggle",
117
+ help_text="Toggle Kapsel as default terminal startup shell (auto-launch on opening terminal)",
118
+ subcommands={
119
+ "on": "Enable Kapsel as default terminal startup shell in shell profile",
120
+ "off": "Disable Kapsel default startup and restore host shell",
121
+ "status": "Check current default terminal startup configuration",
122
+ },
123
+ usage="kapsel toggle [on|off|status]",
119
124
  scope="system",
120
125
  )
121
126
 
@@ -187,4 +192,20 @@ def register_builtins(registry: "KpsCommandRegistry") -> None:
187
192
  scope="system",
188
193
  )
189
194
 
195
+ # 11. dir & rm (Directional navigation & file management)
196
+ from kapsel.completion.kps.builtins.dir_nav import handle_dir_command, handle_rm_command
197
+ registry.register(
198
+ name="dir",
199
+ handler=handle_dir_command,
200
+ help_text="Preview directories and view Ctrl+Arrow navigation shortcuts",
201
+ usage="kapsel dir",
202
+ scope="system",
203
+ )
204
+ registry.register(
205
+ name="rm",
206
+ handler=handle_rm_command,
207
+ help_text="Safely permanently delete a file or directory with explicit confirmation",
208
+ usage="kapsel rm <file_or_directory>",
209
+ scope="system",
210
+ )
190
211
 
@@ -9,93 +9,72 @@ import importlib.util
9
9
  from pathlib import Path
10
10
  import shutil
11
11
  import sys
12
- from typing import List, Optional
12
+ from typing import List, Optional, Tuple
13
13
  from rich.console import Console
14
14
  from rich.panel import Panel
15
+ from rich.table import Table
15
16
 
16
17
  from kapsel.storage.config import get_kapsel_dir, load_config, update_config_value
17
18
  from kapsel.ui.banner import ensure_utf8_io
18
19
 
19
20
 
20
- def handle_add_command(args: List[str], console: Optional[Console] = None) -> int:
21
+ def _install_single_plugin(
22
+ target_raw: str,
23
+ con: Console,
24
+ is_dev: bool = False,
25
+ is_batch: bool = False,
26
+ ) -> Tuple[bool, str]:
21
27
  """
22
- Handles 'kapsel add <plugin_name>' system command.
23
- Enables local plugins, triggers their standalone install.py, and registers them into config.yaml.
28
+ Installs a single plugin by name or path:
29
+ 1. Resolves source directory (dev local, user global, or remote fetch).
30
+ 2. Copies to user global plugins directory.
31
+ 3. Adds to config.yaml enabled plugins list.
32
+ 4. Triggers standalone install.py if present.
33
+ Returns (success: bool, message: str).
24
34
  """
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
- cfg = load_config()
45
- is_dev = getattr(cfg, "is_dev", False)
46
-
47
- source_path = Path(target_raw).expanduser()
48
-
49
35
  global_plugins_dir = get_kapsel_dir() / "plugins"
50
36
  global_plugins_dir.mkdir(parents=True, exist_ok=True)
51
37
 
52
- # Determine plugin ID and source:
53
- # In release mode, bare names like 'install' will never be hijacked by local folders.
54
- # Only treat as a local directory if explicit path syntax is used (./, ../, /, \) or in dev mode.
38
+ source_path = Path(target_raw).expanduser()
55
39
  has_path_qualifier = any(sep in target_raw for sep in ("/", "\\")) or target_raw.startswith(".")
56
40
 
57
41
  if (has_path_qualifier or is_dev) and source_path.exists() and source_path.is_dir():
58
- # User specified an explicit directory path (or in dev mode where local directory takes priority)
59
42
  target_name = source_path.name.lower()
60
43
  dest_plugin_dir = global_plugins_dir / target_name
61
44
 
62
- # Verify source has __init__.py
63
45
  if not (source_path / "__init__.py").exists():
64
- con.print(f"[bold #f43f5e]Error:[/] Source directory '{source_path}' does not contain '__init__.py'.")
65
- return 1
46
+ err = f"Source directory '{source_path}' does not contain '__init__.py'."
47
+ con.print(f"[bold #f43f5e]Error:[/] {err}")
48
+ return False, err
66
49
 
67
- # Install / sync to user global plugin directory
68
50
  shutil.copytree(source_path, dest_plugin_dir, dirs_exist_ok=True)
69
51
  plugin_dir = dest_plugin_dir
70
-
71
52
  else:
72
- # User specified a plugin name (e.g. kapsel add install)
73
53
  target_name = target_raw.lower()
74
54
  dest_plugin_dir = global_plugins_dir / target_name
75
55
  dev_plugin_dir = Path.cwd() / "plugins" / target_name
76
56
 
77
57
  if is_dev and dev_plugin_dir.exists() and dev_plugin_dir.is_dir() and (dev_plugin_dir / "__init__.py").exists():
78
- # If present in local dev directory (dev mode only), sync to global user directory
79
58
  shutil.copytree(dev_plugin_dir, dest_plugin_dir, dirs_exist_ok=True)
80
59
  plugin_dir = dest_plugin_dir
81
60
  elif dest_plugin_dir.exists() and dest_plugin_dir.is_dir():
82
61
  plugin_dir = dest_plugin_dir
83
62
  else:
84
- # 3. Not found locally: Automatically fetch from official remote repository
85
63
  from kapsel.core.plugin.fetcher import fetch_plugin_from_remote
86
-
87
64
  if fetch_plugin_from_remote(target_name, dest_plugin_dir, con):
88
65
  plugin_dir = dest_plugin_dir
89
66
  else:
90
- con.print(f"[bold #f43f5e]Error:[/] Plugin '[white]{target_name}[/]' could not be installed.")
91
- con.print(f"[dim]Expected location:[/] {dest_plugin_dir}")
92
- con.print("[dim]To install a custom plugin, specify its local path or place it into your global plugins directory.[/]\n")
93
- return 1
67
+ err = f"Plugin '{target_name}' could not be installed."
68
+ con.print(f"[bold #f43f5e]Error:[/] {err}")
69
+ if not is_batch:
70
+ con.print(f"[dim]Expected location:[/] {dest_plugin_dir}")
71
+ con.print("[dim]To install a custom plugin, specify its local path or place it into your global plugins directory.[/]\n")
72
+ return False, err
94
73
 
95
- # Verify that the final plugin directory contains __init__.py
96
74
  if not (plugin_dir / "__init__.py").exists():
97
- con.print(f"[bold #f43f5e]Error:[/] Plugin directory '{plugin_dir}' does not contain a valid '__init__.py'.")
98
- return 1
75
+ err = f"Plugin directory '{plugin_dir}' does not contain a valid '__init__.py'."
76
+ con.print(f"[bold #f43f5e]Error:[/] {err}")
77
+ return False, err
99
78
 
100
79
  # Add to config.yaml enabled plugins list
101
80
  cfg = load_config()
@@ -105,7 +84,6 @@ def handle_add_command(args: List[str], console: Optional[Console] = None) -> in
105
84
  update_config_value("plugins", "enabled", new_enabled)
106
85
 
107
86
  # Decoupled plugin dependency installation:
108
- # Trigger standalone install.py if provided by the plugin
109
87
  install_script = plugin_dir / "install.py"
110
88
  if install_script.exists():
111
89
  try:
@@ -132,6 +110,109 @@ def handle_add_command(args: List[str], console: Optional[Console] = None) -> in
132
110
 
133
111
  msg = f"[bold #10b981]✔ Plugin '[#00f0ff]{target_name}[/]' (v{ver}) successfully added and enabled![/]\n\n"
134
112
  msg += f"[dim]Location: {plugin_dir}[/]"
113
+ return True, msg
114
+
115
+
116
+ def _handle_add_all(con: Console, is_dev: bool = False) -> int:
117
+ """
118
+ Sequentially installs and runs installation scripts for all official Kapsel plugins.
119
+ """
120
+ from kapsel.core.plugin.catalog import load_plugin_catalog_rich, DEFAULT_CATALOG
121
+ catalog = load_plugin_catalog_rich()
122
+ if not catalog:
123
+ catalog = {k: {"description": v} for k, v in DEFAULT_CATALOG.items()}
124
+
125
+ plugin_names = set()
126
+ for k in catalog.keys():
127
+ if k not in ("update", "all") and not k.startswith(("_", "$")):
128
+ plugin_names.add(k)
129
+
130
+ dev_plugins_dir = Path.cwd() / "plugins"
131
+ if is_dev and dev_plugins_dir.is_dir():
132
+ for item in dev_plugins_dir.iterdir():
133
+ if item.is_dir() and (item / "__init__.py").exists():
134
+ if item.name.lower() not in ("update", "all"):
135
+ plugin_names.add(item.name.lower())
136
+
137
+ ordered_plugins = sorted(plugin_names)
138
+ total = len(ordered_plugins)
139
+
140
+ con.print(f"\n[bold #00f0ff]============================================================[/]")
141
+ con.print(f"[bold #00f0ff] 📦 Kapsel Batch Plugin Installer: {total} Plugins Found [/]")
142
+ con.print(f"[bold #00f0ff]============================================================[/]\n")
143
+
144
+ succeeded: List[str] = []
145
+ failed: List[str] = []
146
+
147
+ for idx, name in enumerate(ordered_plugins, 1):
148
+ con.print(f"[bold #00f0ff]━━━ [{idx}/{total}] Adding & Installing Plugin: [white]{name}[/] ━━━[/]")
149
+ try:
150
+ ok, msg = _install_single_plugin(name, con, is_dev=is_dev, is_batch=True)
151
+ if ok:
152
+ succeeded.append(name)
153
+ con.print(f" [bold #10b981]✔ Added & configured successfully[/]\n")
154
+ else:
155
+ failed.append(name)
156
+ con.print(f" [bold #f43f5e]✘ Installation failed[/]\n")
157
+ except Exception as e:
158
+ failed.append(name)
159
+ con.print(f" [bold #f43f5e]✘ Unexpected error during '{name}' install:[/] {e}\n")
160
+
161
+ # Summary table
162
+ table = Table(title="[bold #00f0ff]📊 Batch Installation Summary[/]", border_style="#0891b2")
163
+ table.add_column("Plugin", style="#00f0ff")
164
+ table.add_column("Status", style="white")
165
+
166
+ for name in ordered_plugins:
167
+ if name in succeeded:
168
+ table.add_row(name, "[bold #10b981]✔ Installed & Enabled[/]")
169
+ else:
170
+ table.add_row(name, "[bold #f43f5e]✘ Failed[/]")
171
+
172
+ con.print(table)
173
+ summary_color = "#10b981" if not failed else "#f59e0b"
174
+ con.print(f"\n[{summary_color}]Batch Completed:[/] [white]{len(succeeded)} succeeded[/], "
175
+ f"[{'#f43f5e' if failed else 'white'}]{len(failed)} failed[/].\n")
176
+ return 0 if succeeded else 1
177
+
178
+
179
+ def handle_add_command(args: List[str], console: Optional[Console] = None) -> int:
180
+ """
181
+ Handles 'kapsel add <plugin_name>' system command.
182
+ Enables local plugins, triggers their standalone install.py, and registers them into config.yaml.
183
+ Supports 'kapsel add all' to sequentially install all available plugins.
184
+ """
185
+ ensure_utf8_io()
186
+ con = console or Console(legacy_windows=False)
187
+
188
+ if not args:
189
+ con.print("[bold #f43f5e]Error:[/] Please specify a plugin name to add.")
190
+ con.print("[dim]Usage: kapsel add <plugin_name> | all | update (e.g. kapsel add all)[/]\n")
191
+ return 1
192
+
193
+ target_raw = args[0].strip()
194
+
195
+ # Handle 'kapsel add update' to scan and refresh catalog subcommands dictionary
196
+ if target_raw.lower() == "update":
197
+ from kapsel.core.plugin.catalog import update_plugin_catalog, render_catalog_table
198
+ con.print("[bold #00f0ff]🔄 Scanning repository plugins and updating catalog dictionary...[/]")
199
+ updated_catalog = update_plugin_catalog(con)
200
+ render_catalog_table(updated_catalog, con)
201
+ con.print("[bold #10b981]✔ Plugin catalog and subcommands completion dictionary successfully updated![/]\n")
202
+ return 0
203
+
204
+ cfg = load_config()
205
+ is_dev = getattr(cfg, "is_dev", False)
206
+
207
+ # Handle 'kapsel add all' to sequentially install all available plugins
208
+ if target_raw.lower() == "all":
209
+ return _handle_add_all(con, is_dev=is_dev)
210
+
211
+ # Handle single plugin installation
212
+ success, msg = _install_single_plugin(target_raw, con, is_dev=is_dev, is_batch=False)
213
+ if success:
214
+ con.print(Panel(msg, title="[bold #00f0ff]🔌 Plugin System[/]", border_style="#0891b2", expand=False))
215
+ return 0
216
+ else:
217
+ return 1
135
218
 
136
- con.print(Panel(msg, title="[bold #00f0ff]🔌 Plugin System[/]", border_style="#0891b2", expand=False))
137
- return 0
@@ -0,0 +1,318 @@
1
+ """
2
+ Kapsel Directional Navigation & Native File Management Suite (方向键原生交互与文件管理).
3
+ Completely avoids bottom_toolbar for maximum terminal compatibility.
4
+ Uses native floating completion menus and prompt-line interactions:
5
+ - Ctrl+Right: Opens native floating completion dropdown for subdirectories (cd <dir>/).
6
+ - Ctrl+Left: Double-tap returns to parent directory ('cd ..').
7
+ - Ctrl+Up: Populates 'touch ' on prompt line for cross-platform empty file creation.
8
+ - Ctrl+Down: Double-tap populates 'kapsel rm ' with native completion dropdown + explicit terminal confirmation.
9
+ All comments and docstrings are in English.
10
+ """
11
+
12
+ from pathlib import Path
13
+ import os
14
+ import shutil
15
+ import time
16
+ from typing import List, Optional, Tuple
17
+
18
+ from prompt_toolkit.application.run_in_terminal import run_in_terminal
19
+ from prompt_toolkit.buffer import CompletionState
20
+ from prompt_toolkit.completion import Completion
21
+ from rich.console import Console
22
+ from rich.table import Table
23
+
24
+
25
+ # Double-tap timers
26
+ _LAST_CTRL_LEFT_TIME: float = 0.0
27
+ _LAST_CTRL_DOWN_TIME: float = 0.0
28
+ DOUBLE_TAP_TIMEOUT: float = 2.0
29
+
30
+
31
+ def list_subdirectories(cwd: Optional[Path] = None, show_hidden: bool = False) -> List[Path]:
32
+ """Lists subdirectories within cwd, sorted alphabetically case-insensitively."""
33
+ target = cwd or Path.cwd()
34
+ try:
35
+ dirs = [
36
+ p for p in target.iterdir()
37
+ if p.is_dir() and (show_hidden or not p.name.startswith("."))
38
+ ]
39
+ dirs.sort(key=lambda p: p.name.lower())
40
+ return dirs
41
+ except Exception:
42
+ return []
43
+
44
+
45
+ def list_all_items(cwd: Optional[Path] = None, show_hidden: bool = False) -> List[Path]:
46
+ """Lists all files and subdirectories within cwd for deletion selection."""
47
+ target = cwd or Path.cwd()
48
+ try:
49
+ items = [
50
+ p for p in target.iterdir()
51
+ if (show_hidden or not p.name.startswith("."))
52
+ ]
53
+ items.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
54
+ return items
55
+ except Exception:
56
+ return []
57
+
58
+
59
+ def create_empty_file(cwd: Path, filename: str) -> Tuple[bool, str]:
60
+ """Safely creates a new empty file in the given directory."""
61
+ clean_name = filename.strip().strip("\"'")
62
+ if not clean_name:
63
+ return False, "文件名不能为空 (Filename cannot be empty)"
64
+
65
+ target = cwd / clean_name
66
+ try:
67
+ if target.exists():
68
+ return False, f"目标已存在 (Item already exists): {clean_name}"
69
+ target.parent.mkdir(parents=True, exist_ok=True)
70
+ target.touch()
71
+ return True, f"✔ 空文件已创建 (Created): {clean_name}"
72
+ except Exception as e:
73
+ return False, f"✖ 创建失败 (Creation error): {e}"
74
+
75
+
76
+ def delete_item_permanently(target_path: Path) -> Tuple[bool, str]:
77
+ """Permanently deletes a file or directory without backup."""
78
+ try:
79
+ if not target_path.exists():
80
+ return False, f"目标不存在 (Item not found): {target_path.name}"
81
+ if target_path.is_dir():
82
+ shutil.rmtree(target_path)
83
+ else:
84
+ target_path.unlink()
85
+ return True, f"✔ 已永久删除 (Permanently deleted): {target_path.name}"
86
+ except Exception as e:
87
+ return False, f"✖ 删除失败 (Deletion error): {e}"
88
+
89
+
90
+ # -----------------------------------------------------------------------------
91
+ # 1. Ctrl+Right: Native Floating Completion Dropdown for Subdirectories
92
+ # -----------------------------------------------------------------------------
93
+ def trigger_subfolder_completion(buffer, cwd: Optional[Path] = None) -> bool:
94
+ """
95
+ Populates 'cd ' into buffer and immediately activates prompt_toolkit's
96
+ native floating completion dropdown populated with subdirectories.
97
+ """
98
+ target_cwd = cwd or Path.cwd()
99
+ dirs = list_subdirectories(target_cwd)
100
+
101
+ if not dirs:
102
+ run_in_terminal(lambda: print("\n\x1b[38;5;245m[📁 提示: 当前目录下暂无子文件夹]\x1b[0m"))
103
+ return False
104
+
105
+ # Ensure buffer has 'cd ' prefix
106
+ raw = buffer.text.strip()
107
+ if not raw or raw == "cd":
108
+ buffer.text = "cd "
109
+ buffer.cursor_position = 3
110
+ elif not buffer.text.endswith(" "):
111
+ buffer.text += " "
112
+ buffer.cursor_position = len(buffer.text)
113
+
114
+ completions = []
115
+ for d in dirs:
116
+ name = d.name
117
+ text_insert = f'"{name}"' if " " in name else name
118
+ completions.append(Completion(
119
+ text=text_insert,
120
+ start_position=0,
121
+ display=f"📁 {name}/",
122
+ display_meta="[目录 - Enter选入,再按Enter进入]",
123
+ ))
124
+
125
+ buffer.complete_state = CompletionState(
126
+ original_document=buffer.document,
127
+ completions=completions,
128
+ complete_index=0,
129
+ )
130
+ return True
131
+
132
+
133
+ # -----------------------------------------------------------------------------
134
+ # 2. Ctrl+Left: Double-Tap Parent Directory Return (cd ..)
135
+ # -----------------------------------------------------------------------------
136
+ def handle_ctrl_left_double_tap(buffer, on_cd_parent=None) -> bool:
137
+ """
138
+ Double-tap check for returning to parent directory (cd ..).
139
+ 1st tap: Shows light inline notice.
140
+ 2nd tap (< 2.0s): Executes 'cd ..'.
141
+ """
142
+ global _LAST_CTRL_LEFT_TIME
143
+ now = time.time()
144
+ if now - _LAST_CTRL_LEFT_TIME <= DOUBLE_TAP_TIMEOUT:
145
+ _LAST_CTRL_LEFT_TIME = 0.0
146
+ if on_cd_parent is not None:
147
+ on_cd_parent()
148
+ else:
149
+ buffer.text = "cd .."
150
+ buffer.cursor_position = len(buffer.text)
151
+ buffer.validate_and_handle()
152
+ return True
153
+ else:
154
+ _LAST_CTRL_LEFT_TIME = now
155
+ run_in_terminal(lambda: print("\n\x1b[38;5;245m[📁 提示: 再按一次 Ctrl+Left 返回父目录 (..)]\x1b[0m"))
156
+ return False
157
+
158
+
159
+ # -----------------------------------------------------------------------------
160
+ # 3. Ctrl+Up: Create New Empty File Prompt Line
161
+ # -----------------------------------------------------------------------------
162
+ def trigger_create_file(buffer) -> None:
163
+ """Populates 'touch ' into prompt line for easy cross-platform file creation."""
164
+ buffer.text = "touch "
165
+ buffer.cursor_position = 6
166
+
167
+
168
+ # -----------------------------------------------------------------------------
169
+ # 4. Ctrl+Down: Double-Tap Item Deletion with Native Completion & Confirmation
170
+ # -----------------------------------------------------------------------------
171
+ def handle_ctrl_down_double_tap(buffer, cwd: Optional[Path] = None) -> bool:
172
+ """
173
+ Double-tap check for item deletion.
174
+ 1st tap: Shows caution notice.
175
+ 2nd tap (< 2.0s): Populates 'kapsel rm ' and triggers native floating completions.
176
+ """
177
+ global _LAST_CTRL_DOWN_TIME
178
+ now = time.time()
179
+ if now - _LAST_CTRL_DOWN_TIME <= DOUBLE_TAP_TIMEOUT:
180
+ _LAST_CTRL_DOWN_TIME = 0.0
181
+ target_cwd = cwd or Path.cwd()
182
+ items = list_all_items(target_cwd)
183
+
184
+ if not items:
185
+ run_in_terminal(lambda: print("\n\x1b[38;5;203m[⚠️ 提示: 当前目录下无任何文件或文件夹]\x1b[0m"))
186
+ return False
187
+
188
+ buffer.text = "kapsel rm "
189
+ buffer.cursor_position = len(buffer.text)
190
+
191
+ completions = []
192
+ for it in items:
193
+ name = it.name
194
+ icon = "📁 " if it.is_dir() else "📄 "
195
+ type_label = "[目录]" if it.is_dir() else "[文件]"
196
+ text_insert = f'"{name}"' if " " in name else name
197
+ completions.append(Completion(
198
+ text=text_insert,
199
+ start_position=0,
200
+ display=f"{icon}{name}",
201
+ display_meta=f"{type_label} 永久删除",
202
+ ))
203
+
204
+ buffer.complete_state = CompletionState(
205
+ original_document=buffer.document,
206
+ completions=completions,
207
+ complete_index=0,
208
+ )
209
+ return True
210
+ else:
211
+ _LAST_CTRL_DOWN_TIME = now
212
+ run_in_terminal(lambda: print("\n\x1b[38;5;203m[⚠️ 谨慎操作: 再按一次 Ctrl+Down 进入删除选择]\x1b[0m"))
213
+ return False
214
+
215
+
216
+ # -----------------------------------------------------------------------------
217
+ # CLI Handlers: 'kapsel rm' (strict confirmation) and 'kapsel dir'
218
+ # -----------------------------------------------------------------------------
219
+ def handle_rm_command(args: List[str], console: Optional[Console] = None) -> int:
220
+ """CLI handler for 'kapsel rm' with strict interactive double confirmation."""
221
+ con = console or Console(legacy_windows=False)
222
+ if not args or args[0] in ("-h", "--help", "help"):
223
+ con.print("[dim]Usage: kapsel rm <file_or_directory>[/]")
224
+ return 0
225
+
226
+ raw_path = " ".join(args).strip().strip("\"'")
227
+ target = Path(raw_path)
228
+ if not target.is_absolute():
229
+ target = (Path.cwd() / target).resolve()
230
+
231
+ if not target.exists():
232
+ con.print(f"[bold #f43f5e]✖ 目标不存在: {raw_path}[/]")
233
+ return 1
234
+
235
+ item_type = "目录 (及其中所有内容)" if target.is_dir() else "文件"
236
+ con.print(f"\n[bold #f43f5e]🚨 永久删除警告[/]: 即将永久彻底删除 {item_type} [bold white]{target.name}[/]!")
237
+ con.print("[dim]该操作不可逆,且不保留回收站备份。[/]")
238
+
239
+ try:
240
+ reply = input("👉 请输入 y 确认删除 (输入其他键放弃): ").strip().lower()
241
+ except (KeyboardInterrupt, EOFError):
242
+ con.print("\n[dim]操作已取消。[/]")
243
+ return 0
244
+
245
+ if reply in ("y", "yes"):
246
+ ok, msg = delete_item_permanently(target)
247
+ if ok:
248
+ con.print(f"[bold #10b981]{msg}[/]\n")
249
+ return 0
250
+ else:
251
+ con.print(f"[bold #f43f5e]{msg}[/]\n")
252
+ return 1
253
+ else:
254
+ con.print("[dim]操作已放弃,未做任何更改。[/]\n")
255
+ return 0
256
+
257
+
258
+ def handle_dir_command(args: List[str], console: Optional[Console] = None) -> int:
259
+ """CLI entry point for 'kapsel dir' / 'kps dir'."""
260
+ con = console or Console(legacy_windows=False)
261
+
262
+ if args and args[0] in ("-h", "--help", "help"):
263
+ con.print("\n[bold #00f0ff]📁 Kapsel Directional Navigation & File Management (`kapsel dir`)[/]")
264
+ con.print("[dim]Inspect directories and view keyboard shortcuts for rapid prompt navigation.[/]\n")
265
+ table = Table(border_style="#0891b2", title="[bold #38bdf8]⚡ Directional Hotkeys (Ctrl + Arrows)[/]")
266
+ table.add_column("Shortcut", style="bold #00f0ff", width=16)
267
+ table.add_column("Action", style="white", width=24)
268
+ table.add_column("Details & Safety Mechanism", style="dim", overflow="fold")
269
+
270
+ table.add_row(
271
+ "Ctrl + Right (→)",
272
+ "Browse & Enter Subfolder",
273
+ "Opens native floating dropdown; ↑/↓ select; 1st Enter loads 'cd', 2nd Enter enters folder.",
274
+ )
275
+ table.add_row(
276
+ "Ctrl + Left (←)",
277
+ "Return to Parent (cd ..)",
278
+ "Double-tap safety (按两下): 1st tap warns, 2nd tap within 2s returns to parent.",
279
+ )
280
+ table.add_row(
281
+ "Ctrl + Up (↑)",
282
+ "Create Empty File",
283
+ "Single press populates 'touch '; type filename and Enter to create.",
284
+ )
285
+ table.add_row(
286
+ "Ctrl + Down (↓)",
287
+ "Permanent Deletion",
288
+ "Double-tap opens delete dropdown; terminal prompts explicit (y/N) confirmation.",
289
+ )
290
+ con.print(table)
291
+ con.print()
292
+ return 0
293
+
294
+ cwd = Path.cwd()
295
+ dirs = list_subdirectories(cwd)
296
+ all_items = list_all_items(cwd)
297
+ files = [p for p in all_items if not p.is_dir()]
298
+
299
+ con.print(f"\n[bold #00f0ff]📂 Current Directory:[/] [bold white]{cwd}[/]")
300
+ con.print(f"[dim]Contains {len(dirs)} subdirectories and {len(files)} files.[/]\n")
301
+
302
+ table = Table(border_style="#0891b2")
303
+ table.add_column("Type", justify="center", width=8)
304
+ table.add_column("Name", style="bold white")
305
+ table.add_column("Size / Info", style="dim", justify="right", width=14)
306
+
307
+ for d in dirs:
308
+ table.add_row("[bold #38bdf8]📁 DIR[/]", f"[bold #00f0ff]{d.name}/[/]", "-")
309
+ for f in files[:25]:
310
+ size = f"{f.stat().st_size} B" if f.exists() else "-"
311
+ table.add_row("[dim]📄 FILE[/]", f.name, size)
312
+
313
+ if len(files) > 25:
314
+ table.add_row("[dim]...[/]", f"[dim]({len(files) - 25} more files not shown)[/]", "")
315
+
316
+ con.print(table)
317
+ con.print("[dim]💡 Tip: Press [bold #00f0ff]Ctrl+Right[/] in prompt to open the floating subfolder dropdown![/]\n")
318
+ return 0