fx-tool 1.0.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.
- fx/__init__.py +10 -0
- fx/__main__.py +4 -0
- fx/commands.py +177 -0
- fx/plugin_sync.py +72 -0
- fx/plugins/__init__.py +7 -0
- fx/plugins/core.py +364 -0
- fx/plugins/cron.py +569 -0
- fx/plugins/diagnostics.py +112 -0
- fx/plugins/runtime.py +424 -0
- fx/runtime_ops.py +127 -0
- fx/state.py +177 -0
- fx/structure.py +364 -0
- fx/support.py +42 -0
- fx/templates.py +463 -0
- fx_tool-1.0.0.dist-info/METADATA +85 -0
- fx_tool-1.0.0.dist-info/RECORD +20 -0
- fx_tool-1.0.0.dist-info/WHEEL +5 -0
- fx_tool-1.0.0.dist-info/entry_points.txt +2 -0
- fx_tool-1.0.0.dist-info/licenses/LICENSE +6 -0
- fx_tool-1.0.0.dist-info/top_level.txt +1 -0
fx/__init__.py
ADDED
fx/__main__.py
ADDED
fx/commands.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public FX command surface.
|
|
3
|
+
|
|
4
|
+
This module owns the FX command registry and runtime entrypoints. Command
|
|
5
|
+
implementations are split into plugin modules under ``fx.plugins``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version as resolve_distribution_version
|
|
12
|
+
from threading import Lock
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from registers.cli.plugins import load_plugins
|
|
16
|
+
from registers.cli.registry import CommandRegistry
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from registers.cli.registry import MISSING # type: ignore[attr-defined]
|
|
20
|
+
except ImportError:
|
|
21
|
+
# Older/incompatible runtime builds may not export MISSING.
|
|
22
|
+
MISSING = object()
|
|
23
|
+
|
|
24
|
+
_registry = CommandRegistry()
|
|
25
|
+
_FX_DISTRIBUTION_NAME = "fx-tool"
|
|
26
|
+
_PLUGINS_PACKAGE = "fx.plugins"
|
|
27
|
+
_REQUIRED_COMMANDS = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"init",
|
|
30
|
+
"status",
|
|
31
|
+
"module",
|
|
32
|
+
"plugin",
|
|
33
|
+
"version",
|
|
34
|
+
"cron",
|
|
35
|
+
"run",
|
|
36
|
+
"install",
|
|
37
|
+
"update",
|
|
38
|
+
"pull",
|
|
39
|
+
"health",
|
|
40
|
+
"history",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
_plugins_lock = Lock()
|
|
44
|
+
_plugins_loaded = False
|
|
45
|
+
_plugin_load_error: Exception | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _resolve_fx_version() -> str:
|
|
49
|
+
try:
|
|
50
|
+
return resolve_distribution_version(_FX_DISTRIBUTION_NAME)
|
|
51
|
+
except PackageNotFoundError:
|
|
52
|
+
return "dev"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
FX_VERSION = _resolve_fx_version()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def argument(
|
|
59
|
+
name: str,
|
|
60
|
+
*,
|
|
61
|
+
type: Any = str,
|
|
62
|
+
help: str = "",
|
|
63
|
+
default: Any = MISSING,
|
|
64
|
+
):
|
|
65
|
+
def decorator(fn):
|
|
66
|
+
if not hasattr(_registry, "stage_argument"):
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
"Incompatible 'registers' runtime: CommandRegistry.stage_argument is required."
|
|
69
|
+
)
|
|
70
|
+
_registry.stage_argument(fn, name, arg_type=type, help_text=help, default=default)
|
|
71
|
+
return fn
|
|
72
|
+
|
|
73
|
+
return decorator
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def option(flag: str, *, help: str = ""):
|
|
77
|
+
def decorator(fn):
|
|
78
|
+
if not hasattr(_registry, "stage_option"):
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
"Incompatible 'registers' runtime: CommandRegistry.stage_option is required."
|
|
81
|
+
)
|
|
82
|
+
_registry.stage_option(fn, flag, help_text=help)
|
|
83
|
+
return fn
|
|
84
|
+
|
|
85
|
+
return decorator
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def register(name: str | None = None, *, description: str = "", help: str = ""):
|
|
89
|
+
def decorator(fn):
|
|
90
|
+
if not hasattr(_registry, "finalize_command"):
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
"Incompatible 'registers' runtime: CommandRegistry.finalize_command is required."
|
|
93
|
+
)
|
|
94
|
+
_registry.finalize_command(fn, name=name, description=description, help_text=help)
|
|
95
|
+
return fn
|
|
96
|
+
|
|
97
|
+
return decorator
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def ensure_plugins_loaded() -> None:
|
|
101
|
+
global _plugins_loaded, _plugin_load_error
|
|
102
|
+
|
|
103
|
+
if _plugins_loaded:
|
|
104
|
+
return
|
|
105
|
+
if _plugin_load_error is not None:
|
|
106
|
+
raise RuntimeError("FX command plugins failed to load.") from _plugin_load_error
|
|
107
|
+
|
|
108
|
+
with _plugins_lock:
|
|
109
|
+
if _plugins_loaded:
|
|
110
|
+
return
|
|
111
|
+
if _plugin_load_error is not None:
|
|
112
|
+
raise RuntimeError("FX command plugins failed to load.") from _plugin_load_error
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
load_plugins(_PLUGINS_PACKAGE, _registry)
|
|
116
|
+
missing = sorted(name for name in _REQUIRED_COMMANDS if not _registry.has(name))
|
|
117
|
+
if missing:
|
|
118
|
+
raise RuntimeError(
|
|
119
|
+
"FX command plugins loaded incompletely. Missing commands: "
|
|
120
|
+
+ ", ".join(missing)
|
|
121
|
+
)
|
|
122
|
+
_plugins_loaded = True
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
_plugin_load_error = exc
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def run(
|
|
129
|
+
argv: Sequence[str] | None = None,
|
|
130
|
+
*,
|
|
131
|
+
print_result: bool = True,
|
|
132
|
+
shell_prompt: str = "fx > ",
|
|
133
|
+
shell_input_fn=None,
|
|
134
|
+
shell_banner: bool = True,
|
|
135
|
+
shell_banner_text: str | None = None,
|
|
136
|
+
shell_title: str = "Functionals FX",
|
|
137
|
+
shell_description: str = "Manage Functionals projects, modules, and plugin structures.",
|
|
138
|
+
shell_colors: bool | None = None,
|
|
139
|
+
shell_usage: bool = True,
|
|
140
|
+
) -> Any:
|
|
141
|
+
ensure_plugins_loaded()
|
|
142
|
+
return _registry.run(
|
|
143
|
+
argv,
|
|
144
|
+
print_result=print_result,
|
|
145
|
+
shell_prompt=shell_prompt,
|
|
146
|
+
shell_input_fn=shell_input_fn,
|
|
147
|
+
shell_banner=shell_banner,
|
|
148
|
+
shell_banner_text=shell_banner_text,
|
|
149
|
+
shell_title=shell_title,
|
|
150
|
+
shell_description=shell_description,
|
|
151
|
+
shell_version=f"Version: {FX_VERSION}",
|
|
152
|
+
shell_colors=shell_colors,
|
|
153
|
+
shell_usage=shell_usage,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def get_registry() -> CommandRegistry:
|
|
158
|
+
ensure_plugins_loaded()
|
|
159
|
+
return _registry
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
163
|
+
run(argv)
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
__all__ = [
|
|
168
|
+
"FX_VERSION",
|
|
169
|
+
"argument",
|
|
170
|
+
"option",
|
|
171
|
+
"register",
|
|
172
|
+
"ensure_plugins_loaded",
|
|
173
|
+
"run",
|
|
174
|
+
"get_registry",
|
|
175
|
+
"main",
|
|
176
|
+
]
|
|
177
|
+
|
fx/plugin_sync.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plugin pull/sync helpers for ``fx``.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import shutil
|
|
10
|
+
|
|
11
|
+
from fx.structure import normalize_identifier
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class SyncReport:
|
|
16
|
+
created: tuple[str, ...] = ()
|
|
17
|
+
updated: tuple[str, ...] = ()
|
|
18
|
+
skipped: tuple[str, ...] = ()
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def synced_aliases(self) -> tuple[str, ...]:
|
|
22
|
+
return tuple(dict.fromkeys([*self.created, *self.updated]))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sync_plugins_from_checkout(
|
|
26
|
+
*,
|
|
27
|
+
checkout_root: Path,
|
|
28
|
+
subdir: str,
|
|
29
|
+
target_plugins_dir: Path,
|
|
30
|
+
force: bool = False,
|
|
31
|
+
) -> SyncReport:
|
|
32
|
+
source_dir = (checkout_root / subdir).resolve()
|
|
33
|
+
if not source_dir.exists() or not source_dir.is_dir():
|
|
34
|
+
raise FileNotFoundError(
|
|
35
|
+
f"Plugin source directory '{subdir}' not found in checkout."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
created: list[str] = []
|
|
39
|
+
updated: list[str] = []
|
|
40
|
+
skipped: list[str] = []
|
|
41
|
+
|
|
42
|
+
target_plugins_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
for candidate in sorted(source_dir.iterdir()):
|
|
45
|
+
if not candidate.is_dir():
|
|
46
|
+
continue
|
|
47
|
+
if not (candidate / "__init__.py").exists():
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
alias = normalize_identifier(candidate.name)
|
|
51
|
+
target = target_plugins_dir / alias
|
|
52
|
+
existed = target.exists()
|
|
53
|
+
|
|
54
|
+
if existed and not force:
|
|
55
|
+
skipped.append(alias)
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
if existed and force:
|
|
59
|
+
shutil.rmtree(target)
|
|
60
|
+
updated.append(alias)
|
|
61
|
+
else:
|
|
62
|
+
created.append(alias)
|
|
63
|
+
|
|
64
|
+
shutil.copytree(candidate, target)
|
|
65
|
+
|
|
66
|
+
return SyncReport(
|
|
67
|
+
created=tuple(created),
|
|
68
|
+
updated=tuple(updated),
|
|
69
|
+
skipped=tuple(skipped),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
fx/plugins/__init__.py
ADDED
fx/plugins/core.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from fx.commands import FX_VERSION, argument, option, register
|
|
6
|
+
from fx.state import (
|
|
7
|
+
module_registry,
|
|
8
|
+
plugin_registry,
|
|
9
|
+
project_registry,
|
|
10
|
+
record_operation,
|
|
11
|
+
resolve_root,
|
|
12
|
+
utc_now,
|
|
13
|
+
)
|
|
14
|
+
from fx.structure import (
|
|
15
|
+
create_module_layout,
|
|
16
|
+
create_plugin_link,
|
|
17
|
+
discover_local_plugins,
|
|
18
|
+
discover_project_package,
|
|
19
|
+
init_project_layout,
|
|
20
|
+
normalize_identifier,
|
|
21
|
+
resolve_plugin_import_base,
|
|
22
|
+
resolve_plugin_layout,
|
|
23
|
+
)
|
|
24
|
+
from fx.support import render_structure_result
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@register(name="init", description="Initialize a cli or db project structure and fx control database")
|
|
28
|
+
@option("--init")
|
|
29
|
+
@argument("project_type", type=str, default="cli", help="Project type: cli or db")
|
|
30
|
+
@argument("project_name", type=str, default="", help="Project display name; defaults to root folder name")
|
|
31
|
+
@argument("root", type=str, default="", help="Project root path; defaults to <project_name> when provided")
|
|
32
|
+
@argument("force", type=bool, default=False, help="Overwrite structure files if they already exist")
|
|
33
|
+
def init(
|
|
34
|
+
project_type: str = "cli",
|
|
35
|
+
project_name: str = "",
|
|
36
|
+
root: str = "",
|
|
37
|
+
force: bool = False,
|
|
38
|
+
) -> str:
|
|
39
|
+
normalized_type = project_type.strip().lower() or "cli"
|
|
40
|
+
if normalized_type not in {"cli", "db"}:
|
|
41
|
+
if root.strip():
|
|
42
|
+
raise ValueError("project_type must be either 'cli' or 'db'.")
|
|
43
|
+
# Backward-compatible shapes:
|
|
44
|
+
# fx init <project_name>
|
|
45
|
+
# fx init <project_name> <root>
|
|
46
|
+
legacy_project_name = project_type
|
|
47
|
+
legacy_root = project_name
|
|
48
|
+
project_name = legacy_project_name
|
|
49
|
+
root = legacy_root
|
|
50
|
+
normalized_type = "cli"
|
|
51
|
+
|
|
52
|
+
name = project_name.strip()
|
|
53
|
+
root_input = root.strip()
|
|
54
|
+
if not root_input:
|
|
55
|
+
if name in {".", "./"}:
|
|
56
|
+
root_input = "."
|
|
57
|
+
name = ""
|
|
58
|
+
else:
|
|
59
|
+
root_input = name or "."
|
|
60
|
+
root_path = resolve_root(root_input)
|
|
61
|
+
root_path.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
if not name or name in {".", "./"}:
|
|
63
|
+
name = root_path.name
|
|
64
|
+
|
|
65
|
+
structure = init_project_layout(
|
|
66
|
+
root=root_path,
|
|
67
|
+
project_name=name,
|
|
68
|
+
project_type=normalized_type,
|
|
69
|
+
force=force,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
projects = project_registry(root_path)
|
|
73
|
+
existing = projects.get(root_path=str(root_path))
|
|
74
|
+
created_at = existing.created_at if existing is not None else utc_now()
|
|
75
|
+
projects.upsert(
|
|
76
|
+
name=name,
|
|
77
|
+
root_path=str(root_path),
|
|
78
|
+
project_type=normalized_type,
|
|
79
|
+
created_at=created_at,
|
|
80
|
+
updated_at=utc_now(),
|
|
81
|
+
)
|
|
82
|
+
record_operation(
|
|
83
|
+
root=root_path,
|
|
84
|
+
command="init",
|
|
85
|
+
arguments={
|
|
86
|
+
"project_type": normalized_type,
|
|
87
|
+
"project_name": name,
|
|
88
|
+
"root": str(root_path),
|
|
89
|
+
"force": force,
|
|
90
|
+
},
|
|
91
|
+
status="success",
|
|
92
|
+
message=f"Initialized {normalized_type} project '{name}'.",
|
|
93
|
+
)
|
|
94
|
+
return render_structure_result(
|
|
95
|
+
title=f"Initialized {normalized_type} project '{name}' at {root_path}",
|
|
96
|
+
root=root_path,
|
|
97
|
+
result=structure,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@register(name="status", description="Show current project structure and registry status")
|
|
102
|
+
@option("--status")
|
|
103
|
+
@argument("root", type=str, default=".", help="Project root path")
|
|
104
|
+
def status(root: str = ".") -> str:
|
|
105
|
+
root_path = resolve_root(root)
|
|
106
|
+
project = project_registry(root_path).get(root_path=str(root_path))
|
|
107
|
+
modules = module_registry(root_path).filter(project_root=str(root_path), order_by="module_name")
|
|
108
|
+
plugins = plugin_registry(root_path).filter(project_root=str(root_path), order_by="alias")
|
|
109
|
+
local_plugins = discover_local_plugins(root_path)
|
|
110
|
+
package_name = discover_project_package(root_path)
|
|
111
|
+
plugin_layout = resolve_plugin_layout(root_path)
|
|
112
|
+
src_root = root_path / "src"
|
|
113
|
+
todo_file = src_root / package_name / "todo.py" if package_name else None
|
|
114
|
+
api_file = src_root / package_name / "api.py" if package_name else None
|
|
115
|
+
models_file = src_root / package_name / "models.py" if package_name else None
|
|
116
|
+
|
|
117
|
+
registered_aliases = [plugin.alias for plugin in plugins]
|
|
118
|
+
missing_on_disk = sorted(set(registered_aliases) - set(local_plugins))
|
|
119
|
+
untracked_on_disk = sorted(set(local_plugins) - set(registered_aliases))
|
|
120
|
+
|
|
121
|
+
lines = [
|
|
122
|
+
f"Root: {root_path}",
|
|
123
|
+
f"Project record: {'present' if project else 'missing'}",
|
|
124
|
+
f"Project type: {getattr(project, 'project_type', 'unknown') if project else 'unknown'}",
|
|
125
|
+
f"pyproject.toml: {'present' if (root_path / 'pyproject.toml').exists() else 'missing'}",
|
|
126
|
+
f"src package: {package_name or 'missing'}",
|
|
127
|
+
f"legacy app.py: {'present' if (root_path / 'app.py').exists() else 'missing'}",
|
|
128
|
+
f"todo.py: {'present' if (todo_file and todo_file.exists()) else 'missing'}",
|
|
129
|
+
f"api.py: {'present' if (api_file and api_file.exists()) else 'missing'}",
|
|
130
|
+
f"legacy models.py: {'present' if (root_path / 'models.py').exists() else 'missing'}",
|
|
131
|
+
f"package models.py: {'present' if (models_file and models_file.exists()) else 'missing'}",
|
|
132
|
+
f"plugins package: {'present' if (plugin_layout.directory / '__init__.py').exists() else 'missing'}",
|
|
133
|
+
f"plugins import base: {plugin_layout.import_base}",
|
|
134
|
+
f"Registered modules: {len(modules)}",
|
|
135
|
+
f"Registered plugin links: {len(plugins)}",
|
|
136
|
+
f"Local plugin packages: {len(local_plugins)}",
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
if missing_on_disk:
|
|
140
|
+
lines.append(f"Missing on disk: {', '.join(missing_on_disk)}")
|
|
141
|
+
if untracked_on_disk:
|
|
142
|
+
lines.append(f"Untracked on disk: {', '.join(untracked_on_disk)}")
|
|
143
|
+
if not missing_on_disk and not untracked_on_disk:
|
|
144
|
+
lines.append("Registry and filesystem plugin lists are aligned.")
|
|
145
|
+
|
|
146
|
+
return "\n".join(lines)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _module_add(
|
|
150
|
+
module_type: Literal["cli", "db"],
|
|
151
|
+
module_name: str,
|
|
152
|
+
root: str = ".",
|
|
153
|
+
force: bool = False,
|
|
154
|
+
) -> str:
|
|
155
|
+
root_path = resolve_root(root)
|
|
156
|
+
normalized = normalize_identifier(module_name)
|
|
157
|
+
import_base = resolve_plugin_import_base(root_path)
|
|
158
|
+
|
|
159
|
+
structure = create_module_layout(
|
|
160
|
+
root=root_path,
|
|
161
|
+
module_type=module_type,
|
|
162
|
+
module_name=normalized,
|
|
163
|
+
force=force,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
modules = module_registry(root_path)
|
|
167
|
+
package_path = f"{import_base}.{normalized}"
|
|
168
|
+
existing = modules.get(package_path=package_path)
|
|
169
|
+
created_at = existing.created_at if existing is not None else utc_now()
|
|
170
|
+
modules.upsert(
|
|
171
|
+
project_root=str(root_path),
|
|
172
|
+
module_type=module_type,
|
|
173
|
+
module_name=normalized,
|
|
174
|
+
package_path=package_path,
|
|
175
|
+
entry_file=str(structure.entry_file or ""),
|
|
176
|
+
created_at=created_at,
|
|
177
|
+
updated_at=utc_now(),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
plugins = plugin_registry(root_path)
|
|
181
|
+
existing_plugin = plugins.get(alias=normalized)
|
|
182
|
+
plugin_created_at = existing_plugin.created_at if existing_plugin is not None else utc_now()
|
|
183
|
+
link_file = str(structure.entry_file.parent / "__init__.py") if structure.entry_file is not None else ""
|
|
184
|
+
plugins.upsert(
|
|
185
|
+
project_root=str(root_path),
|
|
186
|
+
alias=normalized,
|
|
187
|
+
package_path=package_path,
|
|
188
|
+
enabled=True,
|
|
189
|
+
link_file=link_file,
|
|
190
|
+
created_at=plugin_created_at,
|
|
191
|
+
updated_at=utc_now(),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
record_operation(
|
|
195
|
+
root=root_path,
|
|
196
|
+
command="module-add",
|
|
197
|
+
arguments={
|
|
198
|
+
"module_type": module_type,
|
|
199
|
+
"module_name": normalized,
|
|
200
|
+
"root": str(root_path),
|
|
201
|
+
"force": force,
|
|
202
|
+
},
|
|
203
|
+
status="success",
|
|
204
|
+
message=f"Structured {module_type} module '{normalized}'.",
|
|
205
|
+
)
|
|
206
|
+
return render_structure_result(
|
|
207
|
+
title=f"Structured {module_type} module '{normalized}'",
|
|
208
|
+
root=root_path,
|
|
209
|
+
result=structure,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _module_list(root: str = ".") -> str:
|
|
214
|
+
root_path = resolve_root(root)
|
|
215
|
+
modules = module_registry(root_path).filter(project_root=str(root_path), order_by="module_name")
|
|
216
|
+
if not modules:
|
|
217
|
+
return "No modules registered for this project."
|
|
218
|
+
|
|
219
|
+
lines = ["Registered modules:"]
|
|
220
|
+
for entry in modules:
|
|
221
|
+
lines.append(f" {entry.module_name} ({entry.module_type}) {entry.package_path}")
|
|
222
|
+
return "\n".join(lines)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _plugin_make(
|
|
226
|
+
package_path: str,
|
|
227
|
+
alias: str = "",
|
|
228
|
+
root: str = ".",
|
|
229
|
+
force: bool = False,
|
|
230
|
+
) -> str:
|
|
231
|
+
root_path = resolve_root(root)
|
|
232
|
+
resolved_alias = normalize_identifier(alias or package_path.split(".")[-1])
|
|
233
|
+
|
|
234
|
+
structure = create_plugin_link(
|
|
235
|
+
root=root_path,
|
|
236
|
+
package_path=package_path,
|
|
237
|
+
alias=resolved_alias,
|
|
238
|
+
force=force,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
plugins = plugin_registry(root_path)
|
|
242
|
+
existing = plugins.get(alias=resolved_alias)
|
|
243
|
+
created_at = existing.created_at if existing is not None else utc_now()
|
|
244
|
+
plugins.upsert(
|
|
245
|
+
project_root=str(root_path),
|
|
246
|
+
alias=resolved_alias,
|
|
247
|
+
package_path=package_path,
|
|
248
|
+
enabled=True,
|
|
249
|
+
link_file=str(structure.entry_file or ""),
|
|
250
|
+
created_at=created_at,
|
|
251
|
+
updated_at=utc_now(),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
record_operation(
|
|
255
|
+
root=root_path,
|
|
256
|
+
command="plugin-link",
|
|
257
|
+
arguments={
|
|
258
|
+
"package_path": package_path,
|
|
259
|
+
"alias": resolved_alias,
|
|
260
|
+
"root": str(root_path),
|
|
261
|
+
"force": force,
|
|
262
|
+
},
|
|
263
|
+
status="success",
|
|
264
|
+
message=f"Linked plugin '{resolved_alias}' to {package_path}.",
|
|
265
|
+
)
|
|
266
|
+
return render_structure_result(
|
|
267
|
+
title=f"Linked plugin '{resolved_alias}' -> {package_path}",
|
|
268
|
+
root=root_path,
|
|
269
|
+
result=structure,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _plugin_list(root: str = ".") -> str:
|
|
274
|
+
root_path = resolve_root(root)
|
|
275
|
+
plugins = plugin_registry(root_path).filter(project_root=str(root_path), order_by="alias")
|
|
276
|
+
if not plugins:
|
|
277
|
+
return "No plugins linked for this project."
|
|
278
|
+
|
|
279
|
+
lines = ["Linked plugins:"]
|
|
280
|
+
for entry in plugins:
|
|
281
|
+
marker = "enabled" if entry.enabled else "disabled"
|
|
282
|
+
lines.append(f" {entry.alias} -> {entry.package_path} ({marker})")
|
|
283
|
+
return "\n".join(lines)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@register(name="module", description="Manage project modules (add, list)")
|
|
287
|
+
@option("--module")
|
|
288
|
+
@argument("action", type=str, help="Action: add or list")
|
|
289
|
+
@argument("module_type", type=str, default="", help="For add: module type (cli or db); for list: optional root path")
|
|
290
|
+
@argument("module_name", type=str, default="", help="For add: module identifier")
|
|
291
|
+
@argument("root", type=str, default=".", help="Project root path")
|
|
292
|
+
@argument("force", type=bool, default=False, help="For add: overwrite files if they already exist")
|
|
293
|
+
def module_manage(
|
|
294
|
+
action: str,
|
|
295
|
+
module_type: str = "",
|
|
296
|
+
module_name: str = "",
|
|
297
|
+
root: str = ".",
|
|
298
|
+
force: bool = False,
|
|
299
|
+
) -> str:
|
|
300
|
+
normalized_action = action.strip().lower()
|
|
301
|
+
if normalized_action == "add":
|
|
302
|
+
normalized_type = module_type.strip().lower()
|
|
303
|
+
if normalized_type not in {"cli", "db"}:
|
|
304
|
+
raise ValueError("module add requires module_type to be 'cli' or 'db'.")
|
|
305
|
+
if not module_name.strip():
|
|
306
|
+
raise ValueError("module add requires module_name.")
|
|
307
|
+
module_type_value: Literal["cli", "db"] = "cli" if normalized_type == "cli" else "db"
|
|
308
|
+
return _module_add(
|
|
309
|
+
module_type=module_type_value,
|
|
310
|
+
module_name=module_name,
|
|
311
|
+
root=root,
|
|
312
|
+
force=force,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
if normalized_action == "list":
|
|
316
|
+
root_arg = root
|
|
317
|
+
module_type_arg = module_type.strip()
|
|
318
|
+
if root == "." and not module_name.strip() and module_type_arg and module_type_arg not in {"cli", "db"}:
|
|
319
|
+
root_arg = module_type_arg
|
|
320
|
+
return _module_list(root=root_arg)
|
|
321
|
+
|
|
322
|
+
raise ValueError("module action must be one of: add, list.")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@register(name="plugin", description="Manage plugin links (make, list)")
|
|
326
|
+
@option("--plugin")
|
|
327
|
+
@argument("action", type=str, help="Action: make or list")
|
|
328
|
+
@argument("package_path", type=str, default="", help="For make: importable package path; for list: optional root path")
|
|
329
|
+
@argument("alias", type=str, default="", help="For make: local alias under plugins/")
|
|
330
|
+
@argument("root", type=str, default=".", help="Project root path")
|
|
331
|
+
@argument("force", type=bool, default=False, help="For make: overwrite alias shim files if they already exist")
|
|
332
|
+
def plugin_manage(
|
|
333
|
+
action: str,
|
|
334
|
+
package_path: str = "",
|
|
335
|
+
alias: str = "",
|
|
336
|
+
root: str = ".",
|
|
337
|
+
force: bool = False,
|
|
338
|
+
) -> str:
|
|
339
|
+
normalized_action = action.strip().lower()
|
|
340
|
+
if normalized_action in {"make", "link"}:
|
|
341
|
+
if not package_path.strip():
|
|
342
|
+
raise ValueError("plugin make requires package_path.")
|
|
343
|
+
return _plugin_make(
|
|
344
|
+
package_path=package_path,
|
|
345
|
+
alias=alias,
|
|
346
|
+
root=root,
|
|
347
|
+
force=force,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if normalized_action == "list":
|
|
351
|
+
root_arg = root
|
|
352
|
+
package_arg = package_path.strip()
|
|
353
|
+
if root == "." and not alias.strip() and package_arg:
|
|
354
|
+
root_arg = package_arg
|
|
355
|
+
return _plugin_list(root=root_arg)
|
|
356
|
+
|
|
357
|
+
raise ValueError("plugin action must be one of: make, list.")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@register(name="version", description="Show fx version")
|
|
361
|
+
@option("--version")
|
|
362
|
+
@option("-V")
|
|
363
|
+
def show_version() -> str:
|
|
364
|
+
return f"fx {FX_VERSION}"
|