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 ADDED
@@ -0,0 +1,9 @@
1
+ """cliyard — YAML-driven CLI framework for any REST API.
2
+
3
+ Quick start::
4
+
5
+ pip install cliyard
6
+ from cliyard.runtime import create_cli
7
+ cli = create_cli("./my-api-spec/")
8
+ cli()
9
+ """
@@ -0,0 +1 @@
1
+ """CLI entry point and command registration."""
@@ -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()
@@ -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")
@@ -0,0 +1,4 @@
1
+ from cliyard.client.http import ApiError
2
+ from cliyard.client.http import request
3
+
4
+ __all__ = ["ApiError", "request"]