Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 134 additions & 9 deletions plonecli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,8 @@ class ClickFilteredAliasedGroup(ClickAliasedGroup):
def list_commands(self, ctx):
existing_cmds = super().list_commands(ctx)
project = find_project_root()
global_cmds = ["completion", "create", "config", "update", "skill"]
global_only_cmds = ["create"]
global_cmds = ["completion", "create", "apply", "config", "update", "skill"]
global_only_cmds = ["create", "apply"]
if not project:
cmds = [cmd for cmd in existing_cmds if cmd in global_cmds]
else:
Expand Down Expand Up @@ -368,6 +368,87 @@ def add(context, template, data, data_file, defaults, no_git):
echo(f" Committed: {committed}", fg="green")


@cli.command("apply")
@click.argument("spec_file", type=click.Path(exists=True, dir_okay=False))
@click.option(
"--check",
is_flag=True,
help="Validate the spec and print the plan without generating anything.",
)
@click.option(
"--no-git",
"no_git",
is_flag=True,
help="Do not initialise git or auto-commit generated output.",
)
@click.pass_context
def apply(context, spec_file, check, no_git):
"""Scaffold a complete addon from a declarative spec file"""
from pathlib import Path

from plonecli.spec import SpecError, describe_plan, load_spec, validate_spec

config = context.obj["config"]
ensure_templates(config)

try:
spec = load_spec(spec_file)
except SpecError as exc:
raise click.ClickException(
"Invalid spec:\n " + "\n ".join(exc.errors)
) from exc

errors = validate_spec(spec, config)
if errors:
raise click.ClickException(
"Spec validation failed:\n " + "\n ".join(errors)
)

echo(describe_plan(spec, config), fg="green")
if check:
echo("\nSpec is valid (dry-run, nothing generated).", fg="green")
return

reg = TemplateRegistry(config)
resolved = reg.resolve_template_name(spec.template)

if not confirm_clean_git(spec.name, defaults=True):
echo("Aborted.", fg="yellow")
return

git_commit = config.auto_commit and not no_git
echo(f"\nCreating {resolved} project: {spec.name}", fg="green", reverse=True)
steps = reg.get_composite_steps(resolved)
if steps:
for index, step in enumerate(steps):
echo(f"\n Applying template: {step}", fg="green")
run_create(
step, spec.name, config, data=spec.data, defaults=True,
git_commit=git_commit, overwrite=index > 0,
)
else:
run_create(
resolved, spec.name, config, data=spec.data, defaults=True,
git_commit=git_commit,
)

project = find_project_root(Path(spec.name).resolve())
if project is None:
raise click.ClickException(
f"Generated project not detected at {spec.name!r}"
)

for feat in spec.features:
sub = reg.resolve_template_name(feat.template)
echo(f"\n Adding {sub}", fg="green")
run_add(
sub, project, config, data=feat.data, defaults=True,
git_commit=git_commit,
)

echo("\nDone.", fg="green")


@cli.command()
@click.pass_context
def setup(context):
Expand All @@ -389,14 +470,32 @@ def setup(context):
run_create("zope-setup", str(project.root_folder), config, overwrite=True)


def _find_zope_ini(root_folder):
"""Locate the instance zope.ini, preferring var/instance/etc/zope.ini."""
from pathlib import Path

root = Path(root_folder)
default = root / "var" / "instance" / "etc" / "zope.ini"
if default.exists():
return default
matches = sorted(root.glob("*/*/etc/zope.ini"))
return matches[0] if matches else None


@cli.command("serve")
@click.pass_context
def run_serve(context):
"""Start the Plone instance (delegates to invoke start)"""
"""Start the Plone instance"""
project = context.obj.get("project")
if project is None:
raise NotInPackageError(context.command.name)
params = ["uv", "run", "invoke", "start"]
zope_ini = _find_zope_ini(project.root_folder)
if zope_ini is None:
raise click.UsageError(
"No instance found. Run 'plonecli setup' (zope-setup) first."
)
rel_ini = zope_ini.relative_to(project.root_folder)
params = ["uv", "run", "runwsgi", str(rel_ini)]
echo(f"\nRUN: {' '.join(params)}", fg="green", reverse=True)
echo("\nINFO: Open this in a Web Browser: http://localhost:8080")
echo("INFO: You can stop it by pressing CTRL + c\n")
Expand All @@ -407,25 +506,51 @@ def run_serve(context):
@click.option("-v", "--verbose", is_flag=True, help="Verbose test output")
@click.pass_context
def run_test(context, verbose):
"""Run the tests in your package (delegates to invoke test)"""
"""Run the tests in your package"""
project = context.obj.get("project")
if project is None:
raise NotInPackageError(context.command.name)
params = ["uv", "run", "invoke", "test"]
params = ["uv", "run", "--extra", "test", "pytest"]
if verbose:
params.append("--verbose")
params.append("-v")
echo(f"\nRUN: {' '.join(params)}", fg="green", reverse=True)
subprocess.call(params, cwd=str(project.root_folder))


@cli.command("check")
@click.pass_context
def run_check(context):
"""Run ruff and the test suite"""
project = context.obj.get("project")
if project is None:
raise NotInPackageError(context.command.name)
cwd = str(project.root_folder)
ruff = ["uv", "run", "ruff", "check", "."]
echo(f"\nRUN: {' '.join(ruff)}", fg="green", reverse=True)
rc = subprocess.call(ruff, cwd=cwd)
if rc != 0:
raise SystemExit(rc)
pytest = ["uv", "run", "--extra", "test", "pytest"]
echo(f"\nRUN: {' '.join(pytest)}", fg="green", reverse=True)
rc = subprocess.call(pytest, cwd=cwd)
if rc != 0:
raise SystemExit(rc)


@cli.command("debug")
@click.pass_context
def run_debug(context):
"""Start the Plone instance in debug mode (delegates to invoke debug)"""
"""Start the Plone instance in debug mode"""
project = context.obj.get("project")
if project is None:
raise NotInPackageError(context.command.name)
params = ["uv", "run", "invoke", "debug"]
zope_ini = _find_zope_ini(project.root_folder)
if zope_ini is None:
raise click.UsageError(
"No instance found. Run 'plonecli setup' (zope-setup) first."
)
rel_ini = zope_ini.relative_to(project.root_folder)
params = ["uv", "run", "runwsgi", "-d", str(rel_ini)]
echo(f"\nRUN: {' '.join(params)}", fg="green", reverse=True)
echo("INFO: You can stop it by pressing CTRL + c\n")
subprocess.call(params, cwd=str(project.root_folder))
Expand Down
25 changes: 16 additions & 9 deletions plonecli/skills/plonecli/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: plonecli
description: Scaffold and develop Plone packages with plonecli (copier-template based). Use this BEFORE hand-writing any Plone add-on code — including when a plan or task step decides to create/implement a Plone feature such as a behavior, content type, view, viewlet, portlet, vocabulary, indexer, subscriber, control panel, form, REST API service, theme, or upgrade step. Scaffold these with plonecli subtemplates instead of writing files by hand. Also for any plonecli command (create, add, setup, serve, test, debug, update, config); creating a backend add-on or Zope project; scaffolding a GenericSetup upgrade step after profile XML changes so they reach installed sites; adapting an old/legacy package (mr.bob, buildout, setup.py) to plonecli's structure. Triggers on "plonecli ...", "create a Plone addon", "add/create/implement a behavior (or content type / restapi service / ...)", "add a field / add fields to a content type or behavior", "add upgrade_step", "migrate installed Plone sites", "zope-setup". The -d KEY=VALUE answers each template accepts are in reference/templates.md; the per-field question flow and the Plone field/widget catalogue are in reference/fields.md.
description: Scaffold and develop Plone packages with plonecli (copier-template based). Use this BEFORE hand-writing any Plone add-on code — including when a plan or task step decides to create/implement a Plone feature such as a behavior, content type, view, viewlet, portlet, vocabulary, indexer, subscriber, control panel, form, REST API service, theme, language/translation, or upgrade step. Scaffold these with plonecli subtemplates instead of writing files by hand. To scaffold a whole addon plus several features from one declarative YAML file, use `plonecli apply spec.yaml`. Also for any plonecli command (apply, create, add, setup, serve, test, check, debug, update, config); creating a backend add-on or Zope project; scaffolding a GenericSetup upgrade step after profile XML changes so they reach installed sites; adapting an old/legacy package (mr.bob, buildout, setup.py) to plonecli's structure. Triggers on "plonecli ...", "create a Plone addon", "add/create/implement a behavior (or content type / restapi service / language / ...)", "add a field / add fields to a content type or behavior", "add upgrade_step", "migrate installed Plone sites", "zope-setup". The declarative spec format is in reference/spec.md; the -d KEY=VALUE answers each template accepts are in reference/templates.md; the per-field question flow and the Plone field/widget catalogue are in reference/fields.md.
---

# plonecli
Expand All @@ -19,21 +19,24 @@ On first run, plonecli clones the copier-templates to `~/.copier-templates/plone

| Command | Scope | What it does |
|---|---|---|
| `apply <spec.yaml>` | anywhere | Scaffold a whole addon + features from one declarative spec, non-interactively. `--check` validates only. See [reference/spec.md](reference/spec.md). |
| `create <template> <name>` | anywhere | Scaffold a new project (`backend_addon`, `addon` = backend_addon+zope-setup, or `zope-setup`). See [reference/create.md](reference/create.md). |
| `add <subtemplate>` | inside a project | Add a feature. Gated by project type. See [reference/add.md](reference/add.md). |
| `setup` | inside a `backend_addon` | Apply `zope-setup` in place (run a Plone instance around the addon). |
| `serve` | inside a project | `uv run invoke start` → http://localhost:8080. **See server rule below.** |
| `test [-v]` | inside a project | `uv run invoke test`. |
| `debug` | inside a project | `uv run invoke debug`. |
| `serve` | inside a project | `uv run runwsgi <instance>/etc/zope.ini` → http://localhost:8080. Needs an instance (run `setup` first). **See server rule below.** |
| `test [-v]` | inside a project | `uv run --extra test pytest`. |
| `check` | inside a project | `uv run ruff check .` then `uv run --extra test pytest`. |
| `debug` | inside a project | `uv run runwsgi -d <instance>/etc/zope.ini`. |
| `update` | anywhere | Pull latest copier-templates + check PyPI for plonecli updates. |
| `config` | anywhere | Interactive global settings → `~/.plonecli/config.toml`. |

`add`, `setup`, `serve`, `test`, `debug` require being inside a plonecli-generated project (detected from `pyproject.toml`); otherwise they fail with `NotInPackageError`. Subtemplates are filtered by the project's type, so `plonecli -l` shows different options depending on where you are.
`add`, `setup`, `serve`, `test`, `check`, `debug` require being inside a plonecli-generated project (detected from `pyproject.toml`); otherwise they fail with `NotInPackageError`. Subtemplates are filtered by the project's type, so `plonecli -l` shows different options depending on where you are.

`serve`, `test`, `debug` additionally need the `invoke` harness (`tasks.py`), which only the **`zope-setup`** layer provides. A project made with `create backend_addon` alone has no `tasks.py` — run `plonecli setup` first (or scaffold with the `addon` composite / `zope-setup`) before these commands work.
`test` and `check` run directly via `uv run` and work in any addon. `serve`/`debug` need a runnable instance (`var/instance/etc/zope.ini`), which the **`zope-setup`** layer creates — run `plonecli setup` first (or scaffold with the `addon` composite / `zope-setup`).

## Decision flow

0. **Whole addon with several features in one shot?** → write a spec file and run `plonecli apply spec.yaml` (validate first with `--check`). One declarative file → addon + all features, non-interactive, ruff-clean, tests passing. Best for an agent scaffolding from a feature list. Format and rules: [reference/spec.md](reference/spec.md). For single steps, use `create`/`add` below.
1. **New project?** → `create`. Pure backend add-on: `plonecli create backend_addon my.addon`. Add-on **with** a runnable instance in one step: `plonecli create addon my.addon` (composite = `backend_addon` + `zope-setup`). Zope project: `plonecli create zope-setup my-project`. `addon` is **not** an alias of `backend_addon` — they are different templates. Details and template list: [reference/create.md](reference/create.md).
2. **Add a feature to an existing addon?** → `cd` into the project, then `plonecli add content_type` / `behavior` / `restapi_service`. Wiring specifics: [reference/add.md](reference/add.md). **Adding fields to a content type or behavior** has no subtemplate — gather name/type/required/default per field and edit the schema by hand following [reference/fields.md](reference/fields.md). **Old/legacy package that doesn't fit the templates?** (no `[tool.plone.backend_addon.settings]`, `setup.py`/`bobtemplate.cfg`/buildout layout, `plonecli -l` lists nothing, or `add` lands files/registrations wrong) → don't hand-write subtemplate files, and don't re-run the `backend_addon` template (it overwrites `__init__.py` and real code). Inspect the structure and make the minimal edits the subtemplate hooks need. See the legacy rule below and [reference/migrate.md](reference/migrate.md).
3. **Changed GenericSetup profile XML (`profiles/default/*`) that must reach already-installed sites?** → `plonecli add upgrade_step` to scaffold the migration. See the upgrade-step rule below and [reference/add.md](reference/add.md).
Expand Down Expand Up @@ -65,14 +68,18 @@ cd collective.todo
plonecli add content_type --defaults -d content_type_name="Talk"
plonecli add behavior --defaults -d behavior_name="IFeatured"
plonecli add restapi_service --defaults -d service_name="@todos"
plonecli add language --defaults -d language_code="de" -d language_name="German"

# wrap it in a runnable Plone instance (adds the zope-setup / invoke harness)
# wrap it in a runnable Plone instance (adds the zope-setup layer)
plonecli setup

# verify (needs the zope-setup layer added above)
plonecli test
# verify — works in any addon, no instance needed
plonecli test # uv run --extra test pytest
plonecli check # ruff check + pytest
```

Shortcut: `plonecli create addon collective.todo` scaffolds `backend_addon` **and** `zope-setup` in one step — use it instead of `create backend_addon` + `setup`. Do not run both; that applies zope-setup twice.

To scaffold an addon **and** all its features from one declarative file, use `plonecli apply spec.yaml` — see [reference/spec.md](reference/spec.md).

For anything beyond this happy path, read the matching file in `reference/`.
71 changes: 71 additions & 0 deletions plonecli/skills/plonecli/reference/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Declarative spec (`plonecli apply`)

`plonecli apply spec.yaml` scaffolds a complete addon — the main project plus an
ordered list of feature subtemplates — from **one** declarative YAML file, fully
non-interactively. Use it when you have a feature list up front (e.g. an agent
turning a requirements doc into an addon). For one-off steps, use `create`/`add`.

```shell
plonecli apply spec.yaml # validate, then generate
plonecli apply --check spec.yaml # validate + print the plan, generate nothing
plonecli apply --no-git spec.yaml # skip git init / auto-commit
```

`apply` validates the **whole** spec up front (fail-fast) before writing any
files, so a typo in feature #5 is reported before feature #1 is generated.

## Format

```yaml
addon:
template: backend_addon # backend_addon | addon (composite) | zope-setup
name: collective.todo # target package/project name (also the directory)
data: # answers for the main template (optional)
plone_version: "6.1"
features: # ordered subtemplates (optional)
- template: content_type
data:
content_type_name: Todos
global_allow: true
- template: content_type
data:
content_type_name: Todo
global_allow: false
parent_content_type: Todos
- template: behavior
data:
behavior_name: IFeatured
- template: restapi_service
data:
service_name: stats
- template: language
data:
language_code: de
language_name: German
```

* `addon.template` / `addon.name` are required.
* `data` keys are the template's `-d` answers — see [templates.md](templates.md)
for every template's keys, defaults and choices.
* `features` run in order, each on top of the generated addon, exactly as
repeated `plonecli add` calls would.
* `package_name`/`package_folder` for features are filled automatically from the
generated addon — do not put them in feature `data`.

## What the spec does NOT cover: fields

Fields are **out of scope**. `content_type` and `behavior` emit an empty schema
(`pass`); the spec only chooses templates and their options. Add fields **after**
generation by editing the generated schema class (see [fields.md](fields.md)) or
with plone-snippets. Do not expect the spec to define fields.

## Validation rules

`apply` (and `--check`) report, before generating:

* unknown `addon.template` (not a project template);
* a feature `template` that is not a valid subtemplate for the project type;
* unknown answer keys for a template;
* missing required answers (e.g. `content_type_name`);
* values outside a template's fixed `choices` (dynamic choices like
`plone_version` are not range-checked here — copier validates them at run time).
17 changes: 15 additions & 2 deletions plonecli/skills/plonecli/reference/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,10 @@ Example (containment): `plonecli add content_type --defaults -d content_type_nam
| `http_post` | `false` | |
| `http_patch` | `false` | |
| `http_delete` | `false` | |
| `service_for` | `plone.dexterity.interfaces.IDexterityContainer` | choices: `…IDexterityContainer`, `…IDexterityContent`, `Products.CMFPlone.interfaces.IPloneSiteRoot`, `zope.interface.Interface`. |
| `service_for` | `plone.dexterity.interfaces.IDexterityContainer` | choices: default Plone content-type interfaces **plus the addon's own content-type interfaces** (scanned) **plus `<enter manually>`**. |
| `service_for_manual` | "" | only asked when `service_for=<enter manually>`; must be non-empty. |

Hidden/computed: `service_module`, `service_class`, `service_endpoint`.
Hidden/computed: `service_module`, `service_class`, `service_endpoint`, `service_for_resolved`.

### `view` — BrowserView (optional page template)

Expand Down Expand Up @@ -244,6 +245,18 @@ See [add.md](add.md) for the full upgrade-step workflow (fill the handler, add a
| `site_name` | `New Plone Site` (name†) | site title in header/tab. |
| `language` | `en` | ISO 639-1 two-letter code. |

### `language` — translation locale (`.po` catalog)

| Key | Default | Notes |
|---|---|---|
| `language_code` | **required** | ISO 639-1 code, e.g. `de`, `fr`, `es`. |
| `language_name` | `<language_code>` | human-readable name, e.g. `German`. |

Creates `src/<pkg>/locales/<code>/LC_MESSAGES/<pkg>.po`. The addon already ships
an empty `<pkg>.pot` and a `_` MessageFactory in `src/<pkg>/i18n.py`; fill the
`.po`, then the dev instance/tests compile `.mo` automatically
(`zope_i18n_compile_mo_files`). Hidden/computed: `current_date`.

### `mockup_pattern` — Mockup JS pattern scaffold

| Key | Default | Notes |
Expand Down
Loading
Loading