cliyard 0.3.2__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.
- cliyard/__init__.py +9 -0
- cliyard/cli/__init__.py +1 -0
- cliyard/cli/__main__.py +96 -0
- cliyard/cli/auth_cmd.py +167 -0
- cliyard/cli/gen.py +412 -0
- cliyard/client/__init__.py +4 -0
- cliyard/client/auth.py +523 -0
- cliyard/client/credentials.py +154 -0
- cliyard/client/http.py +113 -0
- cliyard/engine/__init__.py +22 -0
- cliyard/engine/assembler.py +281 -0
- cliyard/engine/binder.py +112 -0
- cliyard/engine/builder.py +595 -0
- cliyard/engine/error_handler.py +24 -0
- cliyard/engine/errors.py +46 -0
- cliyard/engine/hooks.py +28 -0
- cliyard/engine/loader.py +218 -0
- cliyard/engine/template.py +212 -0
- cliyard/output/__init__.py +6 -0
- cliyard/output/formatter.py +187 -0
- cliyard/output/handler.py +96 -0
- cliyard/plugin/__init__.py +195 -0
- cliyard/plugin/discovery.py +107 -0
- cliyard/runtime/__init__.py +25 -0
- cliyard/runtime/auth_commands.py +195 -0
- cliyard/runtime/runner.py +241 -0
- cliyard/schema/__init__.py +34 -0
- cliyard/schema/types.py +237 -0
- cliyard/schema/validator.py +205 -0
- cliyard/validate/__init__.py +17 -0
- cliyard/validate/dependency.py +63 -0
- cliyard/validate/types.py +291 -0
- cliyard-0.3.2.dist-info/METADATA +120 -0
- cliyard-0.3.2.dist-info/RECORD +38 -0
- cliyard-0.3.2.dist-info/WHEEL +5 -0
- cliyard-0.3.2.dist-info/entry_points.txt +2 -0
- cliyard-0.3.2.dist-info/licenses/LICENSE +21 -0
- cliyard-0.3.2.dist-info/top_level.txt +1 -0
cliyard/__init__.py
ADDED
cliyard/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI entry point and command registration."""
|
cliyard/cli/__main__.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""cliyard CLI entry point."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from click.exceptions import NoArgsIsHelpError, MissingParameter, UsageError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _intercept_spec_dir() -> None:
|
|
10
|
+
"""If ``--spec-dir`` is in sys.argv, extract it and run the spec-based CLI.
|
|
11
|
+
|
|
12
|
+
This must happen *before* Click's command resolution because the
|
|
13
|
+
spec-driven commands (e.g. ``repos list``) don't exist as registered
|
|
14
|
+
Click subcommands — they are built dynamically from YAML specs.
|
|
15
|
+
|
|
16
|
+
Does NOT intercept if the command is ``auth``, ``gen``, ``init``, or ``run``
|
|
17
|
+
— those are native cliyard commands, not spec-driven commands.
|
|
18
|
+
"""
|
|
19
|
+
if "--spec-dir" not in sys.argv:
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
# Skip interception for native cliyard commands
|
|
23
|
+
for arg in sys.argv[1:]:
|
|
24
|
+
if not arg.startswith("-") and arg in ("auth", "gen", "init", "run"):
|
|
25
|
+
return
|
|
26
|
+
if arg.startswith("-"):
|
|
27
|
+
continue
|
|
28
|
+
break
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
idx = sys.argv.index("--spec-dir")
|
|
32
|
+
except ValueError:
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
if idx + 1 >= len(sys.argv):
|
|
36
|
+
click.echo("Error: --spec-dir requires a directory path", err=True)
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
spec_dir = sys.argv[idx + 1]
|
|
40
|
+
|
|
41
|
+
sys.argv.pop(idx)
|
|
42
|
+
sys.argv.pop(idx)
|
|
43
|
+
|
|
44
|
+
from cliyard.runtime.runner import run_with_spec
|
|
45
|
+
|
|
46
|
+
run_with_spec(spec_dir)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@click.group()
|
|
50
|
+
@click.version_option()
|
|
51
|
+
def cli():
|
|
52
|
+
"""cliyard — YAML-driven CLI framework for any REST API.
|
|
53
|
+
|
|
54
|
+
Turn any REST API into CLI commands by writing simple YAML specs.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@cli.command()
|
|
59
|
+
def init():
|
|
60
|
+
"""Initialize a new cliyard spec directory."""
|
|
61
|
+
click.echo("Not yet implemented. Stay tuned.")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
from cliyard.cli.gen import gen
|
|
65
|
+
|
|
66
|
+
cli.add_command(gen)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@cli.command()
|
|
70
|
+
@click.argument("spec_file")
|
|
71
|
+
@click.argument("resource")
|
|
72
|
+
@click.argument("operation", required=False)
|
|
73
|
+
@click.option("-e", "--extra", type=str, help="Extra args, key=val,key2=val2")
|
|
74
|
+
def run(spec_file, resource, operation, extra):
|
|
75
|
+
"""Run a command from a YAML spec file."""
|
|
76
|
+
click.echo(f"Not yet implemented. Would run: {spec_file} {resource} {operation}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main():
|
|
80
|
+
"""Entry point — intercept --spec-dir before Click, then run CLI."""
|
|
81
|
+
_intercept_spec_dir()
|
|
82
|
+
try:
|
|
83
|
+
cli(standalone_mode=False)
|
|
84
|
+
except MissingParameter as e:
|
|
85
|
+
click.echo(f"Error: {e}", err=True)
|
|
86
|
+
click.echo(f" Usage: cliyard gen --name <name> [--defs-path <path>]")
|
|
87
|
+
except UsageError as e:
|
|
88
|
+
click.echo(f"Error: {e}", err=True)
|
|
89
|
+
except NoArgsIsHelpError as e:
|
|
90
|
+
click.echo(e.format_message())
|
|
91
|
+
except SystemExit:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
main()
|
cliyard/cli/auth_cmd.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""cliyard.auth — Manage authentication credentials.
|
|
2
|
+
|
|
3
|
+
Provides ``cliyard auth login``, ``cliyard auth status``, and
|
|
4
|
+
``cliyard auth logout`` subcommands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.group(name="auth")
|
|
19
|
+
def auth_group() -> None:
|
|
20
|
+
"""Manage authentication credentials.
|
|
21
|
+
|
|
22
|
+
Login to a service, check saved credential status, or clear
|
|
23
|
+
saved credentials.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@auth_group.command(name="login")
|
|
28
|
+
@click.option("--spec-dir", required=True, help="Path to YAML spec directory")
|
|
29
|
+
def auth_login(spec_dir: str) -> None:
|
|
30
|
+
"""Initialize auth credentials by running the auth chain.
|
|
31
|
+
|
|
32
|
+
Reads the auth spec from ``_auth.yaml``, executes all auth steps,
|
|
33
|
+
and persists the resulting tokens according to the ``auth.persist``
|
|
34
|
+
configuration.
|
|
35
|
+
"""
|
|
36
|
+
from cliyard.client.auth import run_auth_chain
|
|
37
|
+
from cliyard.client.credentials import save_service_credentials
|
|
38
|
+
from cliyard.engine.loader import load_service
|
|
39
|
+
|
|
40
|
+
console = Console()
|
|
41
|
+
spec_path = Path(spec_dir).resolve()
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
service = load_service(spec_path)
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
console.print(f"[red]Error loading spec: {exc}[/red]")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
auth_spec = service.get("auth")
|
|
50
|
+
if not auth_spec:
|
|
51
|
+
console.print("[red]No auth config found in _auth.yaml[/red]")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
# Run the auth chain (no pre-filled — full re-login)
|
|
55
|
+
try:
|
|
56
|
+
from cliyard.client.http import HttpClient
|
|
57
|
+
|
|
58
|
+
server = service.get("server", {})
|
|
59
|
+
base_url = server.get("base_url", "http://localhost:8080")
|
|
60
|
+
client = HttpClient(base_url)
|
|
61
|
+
auth_state = run_auth_chain(auth_spec, http_client=client)
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
console.print(f"[red]Auth failed: {exc}[/red]")
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# Save based on persist config
|
|
67
|
+
persist = auth_spec.get("persist", {})
|
|
68
|
+
if persist.get("to") == "cliyard-config" or not persist.get("to"):
|
|
69
|
+
service_id = auth_spec.get("id", service.get("name", "default"))
|
|
70
|
+
fields: dict[str, str | int] = {}
|
|
71
|
+
|
|
72
|
+
persist_fields = persist.get("fields", {})
|
|
73
|
+
for field_name, field_config in persist_fields.items():
|
|
74
|
+
ref: str = field_config.get("from", "")
|
|
75
|
+
default = field_config.get("default")
|
|
76
|
+
|
|
77
|
+
if "." in ref:
|
|
78
|
+
step, key = ref.split(".", 1)
|
|
79
|
+
step_value = auth_state.get(step)
|
|
80
|
+
if isinstance(step_value, dict):
|
|
81
|
+
value = step_value.get(key)
|
|
82
|
+
else:
|
|
83
|
+
value = None
|
|
84
|
+
if value is not None:
|
|
85
|
+
fields[field_name] = value
|
|
86
|
+
elif default is not None:
|
|
87
|
+
fields[field_name] = default
|
|
88
|
+
else:
|
|
89
|
+
# Direct reference: field_name = step_name
|
|
90
|
+
value = auth_state.get(ref)
|
|
91
|
+
if value is not None:
|
|
92
|
+
fields[field_name] = value
|
|
93
|
+
elif default is not None:
|
|
94
|
+
fields[field_name] = default
|
|
95
|
+
|
|
96
|
+
# Auto-compute expires_at from ttl if present
|
|
97
|
+
# Look for a "ttl" key in the extracted auth state
|
|
98
|
+
ttl_value: int | None = None
|
|
99
|
+
for step_name, step_value in auth_state.items():
|
|
100
|
+
if isinstance(step_value, dict) and "ttl" in step_value:
|
|
101
|
+
ttl_value = step_value["ttl"]
|
|
102
|
+
break
|
|
103
|
+
if ttl_value:
|
|
104
|
+
fields["expires_at"] = int(time.time()) + int(ttl_value)
|
|
105
|
+
|
|
106
|
+
if fields:
|
|
107
|
+
save_service_credentials(service_id, fields)
|
|
108
|
+
console.print(f"[green]Credentials saved for '{service_id}'[/green]")
|
|
109
|
+
else:
|
|
110
|
+
console.print("[yellow]No fields configured for persistence.[/yellow]")
|
|
111
|
+
console.print(f"[dim]auth_state: {auth_state}[/dim]")
|
|
112
|
+
else:
|
|
113
|
+
console.print(f"[yellow]Persistence target {persist.get('to')!r} not supported yet.[/yellow]")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@auth_group.command(name="status")
|
|
117
|
+
def auth_status() -> None:
|
|
118
|
+
"""Show saved authentication status."""
|
|
119
|
+
from cliyard.client.credentials import load_credentials
|
|
120
|
+
|
|
121
|
+
console = Console()
|
|
122
|
+
creds = load_credentials()
|
|
123
|
+
services = creds.get("services", {})
|
|
124
|
+
if not services:
|
|
125
|
+
console.print("[yellow]No credentials saved.[/yellow]")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
table = Table()
|
|
129
|
+
table.add_column("Service")
|
|
130
|
+
table.add_column("Fields")
|
|
131
|
+
table.add_column("Expires")
|
|
132
|
+
|
|
133
|
+
for svc_id, fields in services.items():
|
|
134
|
+
field_names = [k for k in fields.keys() if k != "expires_at"]
|
|
135
|
+
expires_at = fields.get("expires_at")
|
|
136
|
+
if expires_at:
|
|
137
|
+
remaining = int(expires_at) - int(time.time())
|
|
138
|
+
if remaining > 0:
|
|
139
|
+
hours = remaining // 3600
|
|
140
|
+
minutes = (remaining % 3600) // 60
|
|
141
|
+
expiry = f"{hours}h {minutes}m remaining"
|
|
142
|
+
else:
|
|
143
|
+
expiry = "[red]EXPIRED[/red]"
|
|
144
|
+
else:
|
|
145
|
+
expiry = "never"
|
|
146
|
+
table.add_row(svc_id, ", ".join(field_names), expiry)
|
|
147
|
+
|
|
148
|
+
console.print(table)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@auth_group.command(name="logout")
|
|
152
|
+
@click.option("--service", "-s", help="Service ID to logout (default: all)")
|
|
153
|
+
def auth_logout(service: str | None) -> None:
|
|
154
|
+
"""Clear saved credentials."""
|
|
155
|
+
console = Console()
|
|
156
|
+
if service:
|
|
157
|
+
from cliyard.client.credentials import clear_service_credentials
|
|
158
|
+
|
|
159
|
+
clear_service_credentials(service)
|
|
160
|
+
console.print(f"[green]Credentials cleared for '{service}'[/green]")
|
|
161
|
+
else:
|
|
162
|
+
creds_path = os.path.expanduser("~/.cliyard/credentials.yaml")
|
|
163
|
+
if os.path.exists(creds_path):
|
|
164
|
+
os.remove(creds_path)
|
|
165
|
+
console.print("[green]All credentials cleared.[/green]")
|
|
166
|
+
else:
|
|
167
|
+
console.print("[yellow]No credentials to clear.[/yellow]")
|
cliyard/cli/gen.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""``cliyard gen`` — Generate a standalone CLI tool from YAML specs.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
cliyard gen --name ketacliv2 --defs-path tests/fixtures/spec-dir/
|
|
6
|
+
cd dist/ketacliv2 && pip install -e .
|
|
7
|
+
ketacliv2 repos list --help
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import yaml # noqa: F401 — used in gen() for parsing resource YAMLs
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
_PYPROJECT_TOML_TEMPLATE = """\
|
|
21
|
+
[project]
|
|
22
|
+
name = "{CLI_NAME}"
|
|
23
|
+
version = "0.1.0"
|
|
24
|
+
requires-python = ">=3.10"
|
|
25
|
+
dependencies = ["cliyard>=0.1.0"]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
{CLI_NAME} = "{pkg_name}.main:main"
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["setuptools>=65.0.0", "wheel"]
|
|
32
|
+
build-backend = "setuptools.build_meta"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
"{pkg_name}" = ["specs/*.yaml", "specs/plugins/*.py", "specs/plugins/spl_parser/*.py"]
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
_MAIN_PY_TEMPLATE = """\
|
|
42
|
+
\"\"\"CLI entry point. Edit this file to customize commands.\"\"\"
|
|
43
|
+
import sys
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
try:
|
|
46
|
+
from importlib.metadata import version, PackageNotFoundError
|
|
47
|
+
_VER = version("{pkg_name}")
|
|
48
|
+
except PackageNotFoundError:
|
|
49
|
+
_VER = "0.0.0"
|
|
50
|
+
from click.exceptions import UsageError
|
|
51
|
+
from cliyard.runtime import create_cli
|
|
52
|
+
|
|
53
|
+
_SPEC_DIR = Path(__file__).parent / "specs"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main():
|
|
57
|
+
try:
|
|
58
|
+
cli = create_cli(str(_SPEC_DIR), version=_VER)
|
|
59
|
+
sys.exit(cli(standalone_mode=False))
|
|
60
|
+
except UsageError as e:
|
|
61
|
+
sys.exit(e.format_message())
|
|
62
|
+
"""
|
|
63
|
+
_INIT_PY = ""
|
|
64
|
+
|
|
65
|
+
_README_TEMPLATE = """\
|
|
66
|
+
# {CLI_NAME}
|
|
67
|
+
|
|
68
|
+
Generated by [cliyard](https://github.com/example/cliyard).
|
|
69
|
+
|
|
70
|
+
## Install
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install -e .
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Usage
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Show available commands
|
|
80
|
+
{CLI_NAME} --help
|
|
81
|
+
|
|
82
|
+
# Authenticate (first time)
|
|
83
|
+
{CLI_NAME} auth add -n myenv -e <endpoint> -u <user> -p <pass>
|
|
84
|
+
|
|
85
|
+
# Or use an existing token
|
|
86
|
+
{CLI_NAME} auth add -n myenv -e <endpoint> -t <token>
|
|
87
|
+
|
|
88
|
+
# Run commands
|
|
89
|
+
{CLI_NAME} <resource> list --page-size 10
|
|
90
|
+
{CLI_NAME} <resource> list --format json
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Adding new resources
|
|
94
|
+
|
|
95
|
+
1. Create a YAML file in `src/{pkg_name}/specs/` (copy an existing one as reference):
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
description: My resource
|
|
99
|
+
path: myresource
|
|
100
|
+
methods:
|
|
101
|
+
list:
|
|
102
|
+
http:
|
|
103
|
+
method: GET
|
|
104
|
+
output:
|
|
105
|
+
items_path: $.items
|
|
106
|
+
fields:
|
|
107
|
+
- name: id
|
|
108
|
+
alias: ID
|
|
109
|
+
create:
|
|
110
|
+
http:
|
|
111
|
+
method: POST
|
|
112
|
+
params:
|
|
113
|
+
body:
|
|
114
|
+
- name: name
|
|
115
|
+
type: string
|
|
116
|
+
required: true
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
2. Re-run `cliyard gen --name {CLI_NAME}` in the project directory to regenerate the CLI.
|
|
120
|
+
|
|
121
|
+
## Customizing commands
|
|
122
|
+
|
|
123
|
+
To add custom logic or new commands, use the plugin system instead of editing ``main.py``:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
# src/{pkg_name}/specs/plugins/my_custom.py
|
|
127
|
+
from cliyard.plugin import register_command
|
|
128
|
+
|
|
129
|
+
@register_command("mycmd")
|
|
130
|
+
def register_mycmd(cli, ctx):
|
|
131
|
+
@click.command("mycmd")
|
|
132
|
+
@click.argument("input")
|
|
133
|
+
def mycmd(input):
|
|
134
|
+
click.echo(f"Input: {input}")
|
|
135
|
+
cli.add_command(mycmd)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
See the **Top-level command plugins** section below for details.
|
|
139
|
+
|
|
140
|
+
## Adding plugins
|
|
141
|
+
|
|
142
|
+
Create a `.py` file in `src/{pkg_name}/specs/plugins/`:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from cliyard.plugin import register_auth_step, register_hook
|
|
146
|
+
|
|
147
|
+
@register_auth_step("custom_login")
|
|
148
|
+
class CustomLogin:
|
|
149
|
+
def execute(self, auth_state, config, http_client):
|
|
150
|
+
# Custom auth logic
|
|
151
|
+
token = "my-token"
|
|
152
|
+
http_client.default_headers["Authorization"] = f"Bearer {token}"
|
|
153
|
+
auth_state["token"] = token
|
|
154
|
+
return token
|
|
155
|
+
|
|
156
|
+
@register_hook("custom_hook")
|
|
157
|
+
def custom_hook(req):
|
|
158
|
+
req.headers["X-Custom"] = "value"
|
|
159
|
+
return req
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Reference plugins in `_auth.yaml`:
|
|
163
|
+
|
|
164
|
+
```yaml
|
|
165
|
+
auth:
|
|
166
|
+
steps:
|
|
167
|
+
- name: login
|
|
168
|
+
type: plugin:custom_login
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Custom method plugins
|
|
172
|
+
|
|
173
|
+
For business logic that involves multiple API calls, write a method plugin:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
# src/{pkg_name}/specs/plugins/my_biz.py
|
|
177
|
+
from cliyard.plugin import register_method
|
|
178
|
+
|
|
179
|
+
@register_method("multi_step_import")
|
|
180
|
+
def multi_step_import(params, http_client, config):
|
|
181
|
+
# Step 1: prepare
|
|
182
|
+
r1 = http_client.request("POST", "/api/prepare", data=params)
|
|
183
|
+
# Step 2: transform and commit
|
|
184
|
+
r2 = http_client.request("POST", "/api/commit", json=r1.json())
|
|
185
|
+
return {"status": "done", "id": r2.json().get("id")}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Reference it in a resource YAML:
|
|
189
|
+
|
|
190
|
+
```yaml
|
|
191
|
+
methods:
|
|
192
|
+
import:
|
|
193
|
+
type: plugin:multi_step_import
|
|
194
|
+
config:
|
|
195
|
+
batch_size: 100
|
|
196
|
+
params:
|
|
197
|
+
body:
|
|
198
|
+
- name: source
|
|
199
|
+
type: string
|
|
200
|
+
required: true
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The plugin function receives ``params`` (validated CLI args), ``http_client``
|
|
204
|
+
(authenticated), and ``config`` (from YAML). The return value is JSON output.
|
|
205
|
+
|
|
206
|
+
## Top-level command plugins
|
|
207
|
+
|
|
208
|
+
For standalone commands that don't belong to any resource group (e.g. ``search``,
|
|
209
|
+
``mock``, ``insert``), use ``@register_command``:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
# src/{pkg_name}/specs/plugins/mycmd.py
|
|
213
|
+
import click
|
|
214
|
+
from cliyard.plugin import register_command
|
|
215
|
+
|
|
216
|
+
@register_command("mycmd")
|
|
217
|
+
def register_mycmd(cli, ctx):
|
|
218
|
+
@click.command("mycmd")
|
|
219
|
+
@click.argument("input")
|
|
220
|
+
@click.option("--verbose", is_flag=True)
|
|
221
|
+
def mycmd(input, verbose):
|
|
222
|
+
\"\"\"My custom top-level command.\"\"\"
|
|
223
|
+
click.echo(f"Input: {input}")
|
|
224
|
+
|
|
225
|
+
cli.add_command(mycmd)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The function receives the top-level ``cli`` (``click.Group``) and ``ctx``
|
|
229
|
+
(``ServiceContext`` containing base_url, auth config, etc.). Call
|
|
230
|
+
``cli.add_command(...)`` with your built Click command.
|
|
231
|
+
|
|
232
|
+
### Available plugin decorators
|
|
233
|
+
|
|
234
|
+
| Decorator | Purpose |
|
|
235
|
+
|-----------|---------|
|
|
236
|
+
| ``@register_auth_step("name")`` | Custom authentication step (referenced in ``_auth.yaml``) |
|
|
237
|
+
| ``@register_field_type("name")`` | Custom field type validator |
|
|
238
|
+
| ``@register_hook("name")`` | Pre/post request hook |
|
|
239
|
+
| ``@register_method("name")`` | Multi-step business method (referenced in YAML via ``type: plugin:xxx``) |
|
|
240
|
+
| ``@register_command("name")`` | **Top-level Click command** (not tied to any resource) |
|
|
241
|
+
| ``@register_field_resolver("name")`` | Dynamic field value resolver (e.g. auto-fill version) |
|
|
242
|
+
|
|
243
|
+
## Multiple environments
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
# Add environments
|
|
247
|
+
{CLI_NAME} auth add -n dev -e https://dev.example.com -t <token>
|
|
248
|
+
{CLI_NAME} auth add -n prod -e https://prod.example.com -t <token> --default
|
|
249
|
+
|
|
250
|
+
# Switch between them
|
|
251
|
+
{CLI_NAME} auth switch prod
|
|
252
|
+
{CLI_NAME} --env prod <resource> list
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Multi-server configuration
|
|
256
|
+
|
|
257
|
+
When an API is composed of multiple services at different endpoints,
|
|
258
|
+
declare them in ``_auth.yaml`` without hardcoding ``base_url``:
|
|
259
|
+
|
|
260
|
+
```yaml
|
|
261
|
+
server:
|
|
262
|
+
- name: serve4go
|
|
263
|
+
prefix: /api/v1
|
|
264
|
+
- name: serve4java
|
|
265
|
+
prefix: /api/v2
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
The ``name`` fields dynamically generate ``--server-<name>`` options on
|
|
269
|
+
``auth add``. Endpoints are provided at authentication time:
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
# Multi-server: one URL per server
|
|
273
|
+
{CLI_NAME} auth add -n prod \\
|
|
274
|
+
--server-serve4go https://go-api.example.com \\
|
|
275
|
+
--server-serve4java https://java-api.example.com
|
|
276
|
+
|
|
277
|
+
# Single server: use -e as fallback
|
|
278
|
+
{CLI_NAME} auth add -n prod -e https://api.example.com
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Resources select their server via the ``server:`` field:
|
|
282
|
+
|
|
283
|
+
```yaml
|
|
284
|
+
# repos.yaml — uses the default (first) server
|
|
285
|
+
description: 仓库管理
|
|
286
|
+
path: repos
|
|
287
|
+
|
|
288
|
+
# alerts.yaml — uses the "serve4java" server
|
|
289
|
+
server: serve4java
|
|
290
|
+
description: 告警规则
|
|
291
|
+
path: alerts
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
If a resource does not specify ``server:``, it uses the first server
|
|
295
|
+
in the list. Auth steps can also specify a ``server:`` field to
|
|
296
|
+
choose which endpoint to authenticate against.
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@click.command()
|
|
301
|
+
@click.option("--name", required=True, help="Name of the generated CLI tool")
|
|
302
|
+
@click.option(
|
|
303
|
+
"--defs-path",
|
|
304
|
+
default=None,
|
|
305
|
+
type=click.Path(exists=True, file_okay=False, resolve_path=True),
|
|
306
|
+
help="Path to YAML spec directory (omit for empty scaffold)",
|
|
307
|
+
)
|
|
308
|
+
@click.option(
|
|
309
|
+
"--output",
|
|
310
|
+
default=None,
|
|
311
|
+
type=click.Path(file_okay=False, resolve_path=True),
|
|
312
|
+
help="Output directory (default: ./<name>/)",
|
|
313
|
+
)
|
|
314
|
+
def gen(name: str, defs_path: str | None, output: str | None) -> None:
|
|
315
|
+
"""Generate a standalone CLI tool from YAML specs.
|
|
316
|
+
|
|
317
|
+
Reads a cliyard service spec directory (containing ``_auth.yaml``
|
|
318
|
+
and resource YAML files) and produces a pip-installable Python package
|
|
319
|
+
in the output directory. If ``--defs-path`` is omitted, generates an
|
|
320
|
+
empty scaffold that you can fill in later.
|
|
321
|
+
"""
|
|
322
|
+
pkg_name = name.replace("-", "_")
|
|
323
|
+
output_dir = Path(output) if output else Path.cwd() / name
|
|
324
|
+
output_dir = output_dir.resolve()
|
|
325
|
+
|
|
326
|
+
# Create output structure
|
|
327
|
+
src_dir = output_dir / "src" / pkg_name
|
|
328
|
+
specs_dir = src_dir / "specs"
|
|
329
|
+
specs_dir.mkdir(parents=True, exist_ok=True)
|
|
330
|
+
|
|
331
|
+
click.echo(f"✔ Created output directory: {output_dir}")
|
|
332
|
+
|
|
333
|
+
# 1. Validate or create defs-path
|
|
334
|
+
if defs_path:
|
|
335
|
+
spec_dir = Path(defs_path).resolve()
|
|
336
|
+
service_yaml = spec_dir / "_auth.yaml"
|
|
337
|
+
if not service_yaml.exists():
|
|
338
|
+
click.echo(f"Error: {spec_dir} does not contain _auth.yaml", err=True)
|
|
339
|
+
sys.exit(1)
|
|
340
|
+
click.echo(f"✔ Found _auth.yaml in {spec_dir}")
|
|
341
|
+
else:
|
|
342
|
+
# No --defs-path: check if output specs/ already has YAMLs
|
|
343
|
+
existing_svc = specs_dir / "_auth.yaml"
|
|
344
|
+
if existing_svc.exists():
|
|
345
|
+
spec_dir = specs_dir # Regenerate from existing specs
|
|
346
|
+
click.echo(f"✔ Reusing specs from {specs_dir}")
|
|
347
|
+
else:
|
|
348
|
+
spec_dir = None
|
|
349
|
+
# Generate a minimal _auth.yaml for the scaffold
|
|
350
|
+
scaffold_svc = specs_dir / "_auth.yaml"
|
|
351
|
+
scaffold_svc.write_text(f"""\
|
|
352
|
+
name: {name}
|
|
353
|
+
version: "0.1.0"
|
|
354
|
+
description: CLI for {name}
|
|
355
|
+
server:
|
|
356
|
+
base_url: http://localhost:8080
|
|
357
|
+
""")
|
|
358
|
+
click.echo("✔ Generated empty scaffold (add YAMLs to src/{pkg_name}/specs/)")
|
|
359
|
+
|
|
360
|
+
# 2. Generate pyproject.toml
|
|
361
|
+
|
|
362
|
+
# 3. Generate pyproject.toml
|
|
363
|
+
pyproject_path = output_dir / "pyproject.toml"
|
|
364
|
+
pyproject_path.write_text(
|
|
365
|
+
_PYPROJECT_TOML_TEMPLATE
|
|
366
|
+
.replace("{CLI_NAME}", name)
|
|
367
|
+
.replace("{pkg_name}", pkg_name)
|
|
368
|
+
)
|
|
369
|
+
click.echo(f"✔ Generated pyproject.toml")
|
|
370
|
+
|
|
371
|
+
# 4. Generate src/<pkg_name>/__init__.py
|
|
372
|
+
init_path = src_dir / "__init__.py"
|
|
373
|
+
init_path.write_text(_INIT_PY)
|
|
374
|
+
click.echo(f"✔ Generated src/{pkg_name}/__init__.py")
|
|
375
|
+
|
|
376
|
+
# 5. Generate src/<pkg_name>/main.py (thin wrapper around create_cli)
|
|
377
|
+
main_path = src_dir / "main.py"
|
|
378
|
+
main_path.write_text(_MAIN_PY_TEMPLATE.format(pkg_name=pkg_name))
|
|
379
|
+
click.echo(f"✔ Generated src/{pkg_name}/main.py")
|
|
380
|
+
|
|
381
|
+
# 6. Copy spec files (skip if already in specs_dir)
|
|
382
|
+
copied = 0
|
|
383
|
+
if spec_dir and spec_dir.resolve() != specs_dir.resolve():
|
|
384
|
+
for yaml_file in sorted(spec_dir.glob("*.yaml")):
|
|
385
|
+
dest = specs_dir / yaml_file.name
|
|
386
|
+
shutil.copy2(yaml_file, dest)
|
|
387
|
+
copied += 1
|
|
388
|
+
elif spec_dir and spec_dir.resolve() == specs_dir.resolve():
|
|
389
|
+
copied = len(list(spec_dir.glob("*.yaml"))) # Already in place
|
|
390
|
+
click.echo(f"✔ Copied {copied} spec file(s) to src/{pkg_name}/specs/")
|
|
391
|
+
|
|
392
|
+
# 6b. Copy plugins directory if it exists
|
|
393
|
+
if spec_dir:
|
|
394
|
+
plugins_src = spec_dir / "plugins"
|
|
395
|
+
if plugins_src.is_dir():
|
|
396
|
+
plugins_dest = specs_dir / "plugins"
|
|
397
|
+
shutil.copytree(plugins_src, plugins_dest, dirs_exist_ok=True)
|
|
398
|
+
click.echo(f"✔ Copied plugins/ to src/{pkg_name}/specs/plugins/")
|
|
399
|
+
|
|
400
|
+
# 7. Generate README.md
|
|
401
|
+
readme_path = output_dir / "README.md"
|
|
402
|
+
readme_path.write_text(
|
|
403
|
+
_README_TEMPLATE
|
|
404
|
+
.replace("{CLI_NAME}", name)
|
|
405
|
+
.replace("{pkg_name}", pkg_name)
|
|
406
|
+
)
|
|
407
|
+
click.echo(f"✔ Generated README.md")
|
|
408
|
+
|
|
409
|
+
click.echo()
|
|
410
|
+
click.echo(f"Package generated at {output_dir}")
|
|
411
|
+
click.echo(f"To install: cd {output_dir} && pip install -e .")
|
|
412
|
+
click.echo(f"To use: {name} --help")
|