forkbit-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ from forkbit_sdk.base_plugin import BasePlugin
2
+ from forkbit_sdk.git_client import GitClient, GitError
3
+
4
+ __all__ = ["BasePlugin", "GitClient", "GitError"]
5
+ __version__ = "0.1.0"
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import Callable
8
+
9
+ from PySide6.QtWidgets import QWidget
10
+
11
+ from forkbit_sdk.git_client import GitClient
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ class BasePlugin:
17
+ """Base class for all ForkBit plugins.
18
+
19
+ Subclass this and override :meth:`on_ready` to build your plugin UI.
20
+ The app creates an instance, injects context (project, settings, git),
21
+ and calls ``on_ready()`` when everything is wired up.
22
+
23
+ Example::
24
+
25
+ from forkbit_sdk import BasePlugin
26
+ from PySide6.QtWidgets import QLabel, QVBoxLayout
27
+
28
+ class MyPlugin(BasePlugin):
29
+ def on_ready(self):
30
+ layout = QVBoxLayout(self.container)
31
+ layout.addWidget(QLabel("Hello!"))
32
+ """
33
+
34
+ def __init__(self):
35
+ self.container = QWidget()
36
+ """The root widget for your plugin UI. Build your layout inside this."""
37
+ self.container.setObjectName("pluginContainer")
38
+ self.git = GitClient()
39
+ """Git operations for the current project. See :class:`GitClient`."""
40
+ self._settings: dict = {}
41
+ self._project_id: str = ""
42
+ self._project_path: str = ""
43
+ self._plugin_id: str = ""
44
+ self._version: str = ""
45
+ self._plugin_name: str = ""
46
+ self._plugin_subtitle: str = ""
47
+ self._plugin_icon: str = ""
48
+ self._plugin_icon_colors: tuple[str, str] = ("", "")
49
+ self._plugin_icon_path: str = ""
50
+ self._data_dir: Path | None = None
51
+ self._notify_fn: Callable[[str, str], None] | None = None
52
+ self._subtitle_changed_fn: Callable[[str], None] | None = None
53
+ self._translate_fn: Callable[
54
+ [str, str, str, Callable[[str], None] | None, Callable[[str], None] | None],
55
+ str,
56
+ ] | None = None
57
+ self._status_chip: QWidget | None = None
58
+
59
+ @property
60
+ def plugin_id(self) -> str:
61
+ """The unique plugin identifier (e.g. ``com.mycompany.myplugin``)."""
62
+ return self._plugin_id
63
+
64
+ @property
65
+ def version(self) -> str:
66
+ """The plugin version from ``plugin.json``."""
67
+ return self._version
68
+
69
+ @property
70
+ def plugin_name(self) -> str:
71
+ """The display name shown in the sidebar."""
72
+ return self._plugin_name
73
+
74
+ @property
75
+ def plugin_subtitle(self) -> str:
76
+ """The subtitle shown below the plugin name in the header."""
77
+ return self._plugin_subtitle
78
+
79
+ @property
80
+ def plugin_icon(self) -> str:
81
+ """The icon name (Lucide icon ID) for this plugin."""
82
+ return self._plugin_icon
83
+
84
+ @property
85
+ def plugin_icon_colors(self) -> tuple[str, str]:
86
+ """Gradient colors ``(start, end)`` for the plugin icon background."""
87
+ return self._plugin_icon_colors
88
+
89
+ @property
90
+ def plugin_icon_path(self) -> str:
91
+ """Path to a custom icon file, if set."""
92
+ return self._plugin_icon_path
93
+
94
+ @property
95
+ def data_dir(self) -> Path:
96
+ """Per-instance directory for plugin file storage.
97
+
98
+ Created automatically by the app before :meth:`on_ready` is called.
99
+ Use :meth:`read_json` and :meth:`write_json` for convenient access.
100
+
101
+ :raises RuntimeError: If accessed before ``on_ready()``.
102
+ """
103
+ if self._data_dir is None:
104
+ raise RuntimeError("data_dir is not available before on_ready()")
105
+ return self._data_dir
106
+
107
+ def read_json(self, filename: str) -> dict:
108
+ """Read a JSON file from :attr:`data_dir`.
109
+
110
+ :param filename: Name of the JSON file (e.g. ``"state.json"``).
111
+ :returns: Parsed dict, or empty dict if the file doesn't exist.
112
+ """
113
+ path = self.data_dir / filename
114
+ if not path.exists():
115
+ return {}
116
+ return json.loads(path.read_text(encoding="utf-8"))
117
+
118
+ def write_json(self, filename: str, data: dict) -> None:
119
+ """Atomic-write a JSON file to :attr:`data_dir`.
120
+
121
+ Writes to a temporary file first, then renames — safe against crashes.
122
+
123
+ :param filename: Name of the JSON file (e.g. ``"state.json"``).
124
+ :param data: The dict to serialize.
125
+ """
126
+ path = self.data_dir / filename
127
+ tmp = path.with_suffix(".tmp")
128
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
129
+ tmp.replace(path)
130
+
131
+ @property
132
+ def settings(self) -> dict:
133
+ """Current plugin settings values as defined in ``settings.json``."""
134
+ return self._settings
135
+
136
+ def secret_file(self, field_id: str) -> bytes:
137
+ """Return the raw bytes for a ``secret_file`` settings field.
138
+
139
+ :param field_id: The field ID from ``settings.json``.
140
+ :returns: Raw file content as bytes.
141
+ :raises KeyError: If no secret file is loaded for the given field.
142
+ """
143
+ value = self._settings.get(field_id)
144
+ if not isinstance(value, bytes):
145
+ raise KeyError(f"No secret file loaded for '{field_id}'")
146
+ return value
147
+
148
+ def secret_file_stream(self, field_id: str) -> io.BytesIO:
149
+ """Return a file-like ``BytesIO`` for a ``secret_file`` settings field.
150
+
151
+ Useful when a library expects a file object instead of raw bytes.
152
+
153
+ :param field_id: The field ID from ``settings.json``.
154
+ :returns: A seekable ``BytesIO`` stream.
155
+ """
156
+ return io.BytesIO(self.secret_file(field_id))
157
+
158
+ @property
159
+ def project_id(self) -> str:
160
+ """The unique ID of the currently open project."""
161
+ return self._project_id
162
+
163
+ @property
164
+ def project_path(self) -> str:
165
+ """Filesystem path to the project directory."""
166
+ return self._project_path
167
+
168
+ def on_ready(self) -> None:
169
+ """Called when the plugin is fully initialized.
170
+
171
+ Override this to build your UI inside :attr:`container` and set up
172
+ initial state. All context properties (:attr:`settings`, :attr:`data_dir`,
173
+ :attr:`git`, etc.) are available at this point.
174
+ """
175
+ pass
176
+
177
+ def on_settings_changed(self, settings: dict) -> None:
178
+ """Called when the user saves plugin settings.
179
+
180
+ Override this to react to settings changes at runtime (e.g. refresh
181
+ data with a new API key). The base implementation updates
182
+ :attr:`settings` — call ``super()`` if you override.
183
+
184
+ :param settings: The full updated settings dict.
185
+ """
186
+ self._settings = settings
187
+
188
+ @property
189
+ def status_chip(self) -> QWidget | None:
190
+ """Optional status chip widget in the plugin header.
191
+
192
+ Injected by the app — ``None`` until :meth:`on_ready` runs.
193
+ """
194
+ return self._status_chip
195
+
196
+ def set_subtitle(self, text: str) -> None:
197
+ """Update the subtitle shown in the plugin header.
198
+
199
+ :param text: New subtitle text (e.g. ``"v1.2.0 — 3 locales"``).
200
+ """
201
+ self._plugin_subtitle = text
202
+ if self._subtitle_changed_fn:
203
+ self._subtitle_changed_fn(text)
204
+
205
+ def notify(self, message: str, level: str = "info") -> None:
206
+ """Show a toast notification to the user.
207
+
208
+ :param message: The notification text.
209
+ :param level: One of ``"info"``, ``"success"``, ``"warning"``, ``"error"``.
210
+ """
211
+ if self._notify_fn:
212
+ self._notify_fn(message, level)
213
+ else:
214
+ log.info("[%s] %s: %s", self._plugin_id, level, message)
215
+
216
+ def translate(
217
+ self,
218
+ text: str,
219
+ source_lang: str,
220
+ target_lang: str,
221
+ on_done: Callable[[str], None] | None = None,
222
+ on_error: Callable[[str], None] | None = None,
223
+ ) -> str:
224
+ """Request an asynchronous translation via the app's translation service.
225
+
226
+ The translation runs in a background thread. Results arrive via callbacks.
227
+
228
+ :param text: The text to translate.
229
+ :param source_lang: Source language code (e.g. ``"en"``).
230
+ :param target_lang: Target language code (e.g. ``"de"``).
231
+ :param on_done: Callback receiving the translated text.
232
+ :param on_error: Callback receiving an error message.
233
+ :returns: A request ID for tracking.
234
+ :raises RuntimeError: If no translation service is configured.
235
+ """
236
+ if self._translate_fn:
237
+ return self._translate_fn(text, source_lang, target_lang, on_done, on_error)
238
+ raise RuntimeError("Translation service is not available")
forkbit_sdk/cli.py ADDED
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from forkbit_sdk import __version__
7
+ from forkbit_sdk.commands import build, init_plugin, release, validate
8
+
9
+ _COMMANDS = {
10
+ "init": init_plugin,
11
+ "validate": validate,
12
+ "build": build,
13
+ "release": release,
14
+ }
15
+
16
+
17
+ def main() -> None:
18
+ parser = argparse.ArgumentParser(prog="forkbit", description="ForkBit Plugin SDK")
19
+ parser.add_argument("--version", action="version", version=f"forkbit-sdk {__version__}")
20
+
21
+ sub = parser.add_subparsers(dest="command")
22
+ plugin_parser = sub.add_parser("plugin", help="Plugin authoring tools")
23
+ plugin_sub = plugin_parser.add_subparsers(dest="action")
24
+
25
+ for cmd in _COMMANDS.values():
26
+ cmd.register(plugin_sub)
27
+
28
+ args = parser.parse_args()
29
+
30
+ if args.command == "plugin":
31
+ if args.action is None:
32
+ plugin_parser.print_help()
33
+ sys.exit(1)
34
+ cmd = _COMMANDS[args.action]
35
+ sys.exit(cmd.run(args))
36
+
37
+ parser.print_help()
38
+
39
+
40
+ if __name__ == "__main__":
41
+ main()
File without changes
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import zipfile
8
+ from pathlib import Path
9
+
10
+ PLATFORM_TAGS = {
11
+ ("darwin", "arm64"): "macos_arm64",
12
+ ("darwin", "x86_64"): "macos_x86_64",
13
+ ("win32", "AMD64"): "windows_x86_64",
14
+ ("linux", "x86_64"): "linux_x86_64",
15
+ }
16
+
17
+ ALL_PLATFORMS = ["macos_arm64", "macos_x86_64", "windows_x86_64", "linux_x86_64"]
18
+
19
+ PIP_PLATFORM_MAP = {
20
+ "macos_arm64": "macosx_11_0_arm64",
21
+ "macos_x86_64": "macosx_11_0_x86_64",
22
+ "windows_x86_64": "win_amd64",
23
+ "linux_x86_64": "manylinux2014_x86_64",
24
+ }
25
+
26
+
27
+ def current_platform_tag() -> str:
28
+ return PLATFORM_TAGS.get((sys.platform, platform.machine()), "")
29
+
30
+
31
+ def python_version_tag() -> str:
32
+ return f"{sys.version_info.major}{sys.version_info.minor}"
33
+
34
+
35
+ def is_pure_wheel(whl_path: Path) -> bool:
36
+ return "-none-any.whl" in whl_path.name
37
+
38
+
39
+ def unpack_wheel(whl_path: Path, dest: Path) -> None:
40
+ with zipfile.ZipFile(whl_path, "r") as zf:
41
+ for info in zf.infolist():
42
+ if info.filename.endswith(".dist-info/") or ".dist-info/" in info.filename:
43
+ continue
44
+ zf.extract(info, dest)
45
+
46
+
47
+ def download_for_current_platform(
48
+ requirements: list[str],
49
+ vendor_dir: Path,
50
+ platform_tag: str,
51
+ ) -> bool:
52
+ tmp = vendor_dir / "_download"
53
+ tmp.mkdir(parents=True, exist_ok=True)
54
+
55
+ print(f" Resolving {', '.join(requirements)} ...")
56
+ result = subprocess.run(
57
+ [
58
+ sys.executable, "-m", "pip", "download",
59
+ *requirements,
60
+ "--dest", str(tmp),
61
+ "--only-binary=:all:",
62
+ ],
63
+ capture_output=True,
64
+ text=True,
65
+ )
66
+ if result.returncode != 0:
67
+ stderr = result.stderr.strip()
68
+ last_line = stderr.splitlines()[-1] if stderr else "unknown error"
69
+ print(f" ✗ Failed to download dependencies for {platform_tag}:")
70
+ print(f" {last_line}")
71
+ shutil.rmtree(tmp, ignore_errors=True)
72
+ return False
73
+
74
+ _sort_wheels(tmp, vendor_dir, platform_tag)
75
+ shutil.rmtree(tmp, ignore_errors=True)
76
+ return True
77
+
78
+
79
+ def download_for_platform(
80
+ requirements: list[str],
81
+ vendor_dir: Path,
82
+ platform_tag: str,
83
+ ) -> bool:
84
+ pip_platform = PIP_PLATFORM_MAP.get(platform_tag)
85
+ if not pip_platform:
86
+ print(f" ✗ Unknown platform: {platform_tag}")
87
+ return False
88
+
89
+ tmp = vendor_dir / f"_download_{platform_tag}"
90
+ tmp.mkdir(parents=True, exist_ok=True)
91
+
92
+ py_ver = python_version_tag()
93
+
94
+ result = subprocess.run(
95
+ [
96
+ sys.executable, "-m", "pip", "download",
97
+ *requirements,
98
+ "--dest", str(tmp),
99
+ "--only-binary=:all:",
100
+ "--platform", pip_platform,
101
+ "--python-version", py_ver,
102
+ ],
103
+ capture_output=True,
104
+ text=True,
105
+ )
106
+ if result.returncode != 0:
107
+ stderr = result.stderr.strip()
108
+ last_line = stderr.splitlines()[-1] if stderr else "unknown error"
109
+ print(f" ✗ Failed to download dependencies for {platform_tag}:")
110
+ print(f" {last_line}")
111
+ shutil.rmtree(tmp, ignore_errors=True)
112
+ return False
113
+
114
+ _sort_wheels(tmp, vendor_dir, platform_tag)
115
+ shutil.rmtree(tmp, ignore_errors=True)
116
+ return True
117
+
118
+
119
+ def _sort_wheels(download_dir: Path, vendor_dir: Path, platform_tag: str) -> None:
120
+ common_dir = vendor_dir / "common"
121
+ plat_dir = vendor_dir / platform_tag
122
+
123
+ for whl in download_dir.glob("*.whl"):
124
+ if is_pure_wheel(whl):
125
+ dest = common_dir
126
+ else:
127
+ dest = plat_dir
128
+ dest.mkdir(parents=True, exist_ok=True)
129
+ unpack_wheel(whl, dest)
130
+
131
+
132
+ def copy_plugin_source(plugin_dir: Path, build_dir: Path) -> None:
133
+ for item in plugin_dir.iterdir():
134
+ if item.name.startswith("_") or item.name.startswith("."):
135
+ continue
136
+ if item.suffix == ".forkbit":
137
+ continue
138
+ if item.is_file():
139
+ shutil.copy2(item, build_dir / item.name)
140
+ elif item.is_dir() and item.name != "vendor":
141
+ shutil.copytree(item, build_dir / item.name)
142
+
143
+
144
+ def compile_pyc(build_dir: Path) -> None:
145
+ import compileall
146
+ import py_compile
147
+
148
+ compileall.compile_dir(
149
+ str(build_dir),
150
+ quiet=1,
151
+ legacy=True,
152
+ )
153
+
154
+ for py_file in list(build_dir.rglob("*.py")):
155
+ pyc_file = py_file.with_suffix(".pyc")
156
+ if not pyc_file.exists():
157
+ try:
158
+ py_compile.compile(str(py_file), cfile=str(pyc_file), doraise=True)
159
+ except py_compile.PyCompileError:
160
+ continue
161
+ py_file.unlink()
162
+
163
+
164
+ def create_zip(plugin_dir: Path, build_dir: Path, manifest: dict) -> Path:
165
+ plugin_id = manifest["id"]
166
+ version = manifest["version"]
167
+ output_name = f"{plugin_id}-{version}.forkbit"
168
+ output_path = plugin_dir / output_name
169
+
170
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
171
+ for file_path in sorted(build_dir.rglob("*")):
172
+ if file_path.is_file():
173
+ arcname = file_path.relative_to(build_dir)
174
+ zf.write(file_path, arcname)
175
+
176
+ return output_path
177
+
178
+
179
+ def format_size(size_bytes: int) -> str:
180
+ size_kb = size_bytes / 1024
181
+ if size_kb > 1024:
182
+ return f"{size_kb / 1024:.1f} MB"
183
+ return f"{size_kb:.0f} KB"
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from forkbit_sdk.commands import validate
9
+ from forkbit_sdk.commands._shared import (
10
+ copy_plugin_source,
11
+ create_zip,
12
+ current_platform_tag,
13
+ download_for_current_platform,
14
+ format_size,
15
+ )
16
+
17
+
18
+ def run(args) -> int:
19
+ plugin_dir = Path(args.dir).resolve()
20
+
21
+ print(f"Building plugin in {plugin_dir}/\n")
22
+ print("Running validation...")
23
+
24
+ result = validate._Result()
25
+ manifest = validate._check_manifest(plugin_dir, result)
26
+ if manifest:
27
+ validate._check_entry_point(plugin_dir, manifest, result)
28
+ validate._check_imports(plugin_dir, manifest, result)
29
+
30
+ if not result.success:
31
+ print()
32
+ result.print()
33
+ print(f"\nBuild aborted: {len(result.failed)} validation error(s).")
34
+ return 1
35
+
36
+ print(f" ✓ All {len(result.passed)} checks passed\n")
37
+
38
+ platform_tag = current_platform_tag()
39
+ if not platform_tag:
40
+ print(f" ✗ Unsupported platform: {sys.platform}/{platform.machine()}")
41
+ return 1
42
+
43
+ for old in plugin_dir.glob("*.forkbit"):
44
+ old.unlink()
45
+
46
+ build_dir = plugin_dir / "_build"
47
+ if build_dir.exists():
48
+ shutil.rmtree(build_dir)
49
+ build_dir.mkdir()
50
+
51
+ copy_plugin_source(plugin_dir, build_dir)
52
+
53
+ requirements = manifest.get("requirements", [])
54
+ if requirements:
55
+ print(f"Vendoring dependencies for {platform_tag}...")
56
+ vendor_dir = build_dir / "vendor"
57
+ if not download_for_current_platform(requirements, vendor_dir, platform_tag):
58
+ shutil.rmtree(build_dir, ignore_errors=True)
59
+ return 1
60
+ print(f" ✓ Dependencies vendored\n")
61
+
62
+ output_path = create_zip(plugin_dir, build_dir, manifest)
63
+ shutil.rmtree(build_dir, ignore_errors=True)
64
+
65
+ print(f"Built: {output_path.name} ({format_size(output_path.stat().st_size)})")
66
+ print(f"Platform: {platform_tag}")
67
+ return 0
68
+
69
+
70
+ def register(subparsers) -> None:
71
+ p = subparsers.add_parser("build", help="Build plugin for current platform (dev)")
72
+ p.add_argument("dir", nargs="?", default=".", help="Plugin directory to build")
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ _ID_PATTERN = re.compile(r"^com\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$")
9
+
10
+
11
+ def _prompt(label: str, default: str = "") -> str:
12
+ suffix = f" [{default}]" if default else ""
13
+ value = input(f"{label}{suffix}: ").strip()
14
+ return value or default
15
+
16
+
17
+ def _to_class_name(name: str) -> str:
18
+ return re.sub(r"[^a-zA-Z0-9]", "", name.title()) + "Plugin"
19
+
20
+
21
+ def _generate_manifest(plugin_id: str, name: str, description: str) -> str:
22
+ data = {
23
+ "id": plugin_id,
24
+ "name": name,
25
+ "version": "0.1.0",
26
+ "description": description,
27
+ "entry": "main.py",
28
+ "min_app_version": "0.2.0",
29
+ "requirements": [],
30
+ }
31
+ return json.dumps(data, indent=2, ensure_ascii=False)
32
+
33
+
34
+ def _generate_main(name: str) -> str:
35
+ cls = _to_class_name(name)
36
+ return f'''\
37
+ from PySide6.QtCore import Qt
38
+ from PySide6.QtWidgets import QLabel, QVBoxLayout
39
+
40
+ from forkbit_sdk import BasePlugin
41
+
42
+
43
+ class {cls}(BasePlugin):
44
+
45
+ def on_ready(self) -> None:
46
+ layout = QVBoxLayout(self.container)
47
+ layout.setAlignment(Qt.AlignmentFlag.AlignTop)
48
+
49
+ status = QLabel("Plugin loaded successfully.")
50
+ layout.addWidget(status)
51
+ '''
52
+
53
+
54
+ def _generate_settings() -> str:
55
+ return json.dumps({"fields": []}, indent=2, ensure_ascii=False)
56
+
57
+
58
+ def run(args) -> int:
59
+ target = Path(args.dir)
60
+
61
+ if not args.non_interactive:
62
+ print("ForkBit Plugin Scaffold\n")
63
+ plugin_id = _prompt("Plugin ID (com.<vendor>.<name>)", "")
64
+ name = _prompt("Display name", "")
65
+ description = _prompt("Description", "")
66
+ else:
67
+ plugin_id = args.id or ""
68
+ name = args.name or ""
69
+ description = args.description or ""
70
+
71
+ if not plugin_id:
72
+ print("Error: Plugin ID is required.", file=sys.stderr)
73
+ return 1
74
+ if not _ID_PATTERN.match(plugin_id):
75
+ print(
76
+ f"Error: Plugin ID '{plugin_id}' does not match required format "
77
+ "com.<vendor>.<name> (lowercase, alphanumeric + underscore).",
78
+ file=sys.stderr,
79
+ )
80
+ return 1
81
+ if not name:
82
+ print("Error: Display name is required.", file=sys.stderr)
83
+ return 1
84
+
85
+ if target.name == ".":
86
+ target = Path.cwd() / plugin_id.split(".")[-1]
87
+
88
+ if target.exists() and any(target.iterdir()):
89
+ print(f"Error: Directory '{target}' already exists and is not empty.", file=sys.stderr)
90
+ return 1
91
+
92
+ target.mkdir(parents=True, exist_ok=True)
93
+ (target / "plugin.json").write_text(_generate_manifest(plugin_id, name, description) + "\n")
94
+ (target / "main.py").write_text(_generate_main(name))
95
+ (target / "settings.json").write_text(_generate_settings() + "\n")
96
+
97
+ print(f"\nCreated plugin scaffold in {target}/")
98
+ print(" plugin.json — manifest")
99
+ print(f" main.py — {_to_class_name(name)} (BasePlugin subclass)")
100
+ print(" settings.json — settings schema (empty)")
101
+ print("\nNext steps:")
102
+ print(" 1. Build your UI in on_ready()")
103
+ print(f" 2. Run: forkbit plugin validate {target}")
104
+ print(f" 3. Run: forkbit plugin build {target}")
105
+ return 0
106
+
107
+
108
+ def register(subparsers) -> None:
109
+ p = subparsers.add_parser("init", help="Scaffold a new plugin project")
110
+ p.add_argument("dir", nargs="?", default=".", help="Directory to create plugin in")
111
+ p.add_argument("--id", help="Plugin ID (non-interactive mode)")
112
+ p.add_argument("--name", help="Display name (non-interactive mode)")
113
+ p.add_argument("--description", default="", help="Description (non-interactive mode)")
114
+ p.add_argument(
115
+ "--non-interactive", action="store_true",
116
+ help="Skip prompts, use --id/--name/--description flags",
117
+ )