Skip to content
Merged
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
5 changes: 4 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
## 7.0.0b6 (unreleased)


- Nothing changed yet.
- Add `--defaults`, `-d/--data KEY=VALUE` and `--data-file` options to
`plonecli create` and `plonecli add` for non-interactive use (Claude Code /
CI), driving copier without prompts. Update the bundled skill to use them.
[MrTango]


## 7.0.0b5 (2026-05-22)
Expand Down
129 changes: 106 additions & 23 deletions plonecli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,63 @@ def echo(msg, fg="green", reverse=False):
click.echo(click.style(msg, fg=fg, reverse=reverse))


def _parse_data(pairs):
"""Parse ``KEY=VALUE`` strings into a dict of copier answers.

Raises ``click.BadParameter`` if a pair is missing the ``=`` separator.
"""
data = {}
for pair in pairs:
key, sep, value = pair.partition("=")
if not sep or not key:
raise click.BadParameter(
f"expected KEY=VALUE, got {pair!r}", param_hint="-d/--data"
)
data[key] = value
return data


def _load_data_file(path):
"""Load copier answers from a YAML or JSON file into a dict.

YAML is a superset of JSON, so a single ``yaml.safe_load`` covers both.
Raises ``click.BadParameter`` if the file is not a mapping.
"""
import yaml

with open(path, encoding="utf-8") as fh:
loaded = yaml.safe_load(fh) or {}
if not isinstance(loaded, dict):
raise click.BadParameter(
f"{path}: expected a mapping of answers, got {type(loaded).__name__}",
param_hint="--data-file",
)
return loaded


def _collect_data(data_file, data):
"""Merge ``--data-file`` answers with inline ``-d`` ones (``-d`` wins)."""
answers = _load_data_file(data_file) if data_file else {}
answers.update(_parse_data(data))
return answers


class InterspersedCommand(click.Command):
"""A command that accepts options after its positional arguments.

The top-level ``cli`` group is chained, which makes Click disable
interspersed args for every subcommand, so e.g. ``plonecli add upgrade_step
--defaults`` would otherwise leak ``--defaults`` back to the parent parser.
Re-enabling it on this command's own parser keeps chaining intact while
letting flags follow the action argument.
"""

def make_parser(self, ctx):
parser = super().make_parser(ctx)
parser.allow_interspersed_args = True
return parser


def _get_registry():
"""Create a TemplateRegistry with current context."""
config = load_config()
Expand Down Expand Up @@ -101,7 +158,7 @@ def cli(context, list_templates, versions):
pass


class CreateCommand(click.Command):
class CreateCommand(InterspersedCommand):
"""A Click command that lists available templates in help output."""

def format_help(self, ctx, formatter):
Expand Down Expand Up @@ -133,8 +190,28 @@ def format_help(self, ctx, formatter):
@cli.command(cls=CreateCommand)
@click.argument("template", type=click.STRING, shell_complete=get_templates)
@click.argument("name")
@click.option(
"-d",
"--data",
"data",
multiple=True,
metavar="KEY=VALUE",
help="Pre-fill a template answer (repeatable). Skips its prompt.",
)
@click.option(
"--data-file",
"data_file",
type=click.Path(exists=True, dir_okay=False),
help="Load answers from a YAML/JSON file. Overridden by -d.",
)
@click.option(
"--defaults",
is_flag=True,
help="Use template defaults for unanswered questions instead of prompting "
"(non-interactive).",
)
@click.pass_context
def create(context, template, name):
def create(context, template, name, data, data_file, defaults):
"""Create a new Plone package"""
config = context.obj["config"]
reg = TemplateRegistry(config)
Expand All @@ -147,22 +224,43 @@ def create(context, template, name):
possibilities=reg.get_main_templates(),
)

answers = _collect_data(data_file, data)
steps = reg.get_composite_steps(resolved)
if steps:
echo(f"\nCreating {resolved} project: {name}", fg="green", reverse=True)
for step in steps:
echo(f"\n Applying template: {step}", fg="green")
run_create(step, name, config)
run_create(step, name, config, data=answers, defaults=defaults)
else:
echo(f"\nCreating {resolved} project: {name}", fg="green", reverse=True)
run_create(resolved, name, config)
run_create(resolved, name, config, data=answers, defaults=defaults)
context.obj["target_dir"] = name


@cli.command()
@cli.command(cls=InterspersedCommand)
@click.argument("template", type=click.STRING, shell_complete=get_templates)
@click.option(
"-d",
"--data",
"data",
multiple=True,
metavar="KEY=VALUE",
help="Pre-fill a template answer (repeatable). Skips its prompt.",
)
@click.option(
"--data-file",
"data_file",
type=click.Path(exists=True, dir_okay=False),
help="Load answers from a YAML/JSON file. Overridden by -d.",
)
@click.option(
"--defaults",
is_flag=True,
help="Use template defaults for unanswered questions instead of prompting "
"(non-interactive).",
)
@click.pass_context
def add(context, template):
def add(context, template, data, data_file, defaults):
"""Add features to your existing Plone package"""
project = context.obj.get("project")
if project is None:
Expand All @@ -179,8 +277,9 @@ def add(context, template):
possibilities=reg.get_subtemplates(),
)

answers = _collect_data(data_file, data)
echo(f"\nAdding {resolved} to {project.root_folder.name}", fg="green", reverse=True)
run_add(resolved, project, config)
run_add(resolved, project, config, data=answers, defaults=defaults)


@cli.command()
Expand Down Expand Up @@ -315,22 +414,6 @@ def update(context):
echo(f"\nTemplates: {get_templates_info(config)}", fg="green")


class InterspersedCommand(click.Command):
"""A command that accepts options after its positional arguments.

The top-level ``cli`` group is chained, which makes Click disable
interspersed args for every subcommand, so ``plonecli skill install
--force`` leaks ``--force`` back to the parent parser. Re-enabling it on
this command's own parser keeps chaining intact while letting flags follow
the action argument.
"""

def make_parser(self, ctx):
parser = super().make_parser(ctx)
parser.allow_interspersed_args = True
return parser


@cli.command("skill", cls=InterspersedCommand)
@click.argument("action", type=click.Choice(["install", "update", "status"]))
@click.option(
Expand Down
11 changes: 6 additions & 5 deletions plonecli/skills/plonecli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ On first run, plonecli clones the copier-templates to `~/.copier-templates/plone

## Critical rules

- **`create`/`add` are interactive by default — always run them non-interactively here.** copier opens prompts you cannot answer in Claude Code / CI (they hang or fail). Pass `--defaults` (use template defaults for unasked questions) plus `-d/--data KEY=VALUE` (repeatable) for each answer the user specified — or `--data-file PATH` for a YAML/JSON file of answers (`-d` overrides matching keys). Required answers without a default (`content_type_name`, `behavior_name`, `service_name`, `upgrade_step_title`) **must** be given via `-d`. Example: `plonecli add upgrade_step --defaults -d upgrade_step_title="Reimport viewlets"`. **Never give up on a prompt and hand-write the files the subtemplate would generate** — drive plonecli non-interactively instead. Don't invoke `copier` directly. See [reference/add.md](reference/add.md).
- **Never start the dev server yourself.** Do not run `plonecli serve` / `plonecli debug` / `invoke start`. Assume the instance is already running; if it is not, ask the user to start it. (`plonecli test` is fine to run.)
- **Use native `uv`.** Run things as `uv run <command>`; never `uv pip` or `pip` unless explicitly told.
- **Tests must pass — never skip them.** After scaffolding or adding a feature, run `plonecli test` and report real results.
- **Profile XML changes need an upgrade step — scaffold it automatically.** Whenever you edit GenericSetup profile XML under `profiles/default/` (e.g. `catalog.xml`, `types/*.xml`, `types.xml`, `workflows.xml`, `registry.xml`, `rolemap.xml`) in a way that must propagate to already-installed sites, run `plonecli add upgrade_step` as part of the same change — don't leave it to the user to remember. It bumps `profiles/default/metadata.xml` and registers a GS upgrade handler; then fill that handler so existing sites actually get the change (reapply the relevant import step or migrate data). Never hand-edit `metadata.xml`'s version to "do an upgrade" — that bumps the number without a registered step. Details and what does/doesn't need a step: [reference/add.md](reference/add.md).
- **Profile XML changes need an upgrade step — scaffold it automatically.** Whenever you edit GenericSetup profile XML under `profiles/default/` (e.g. `catalog.xml`, `types/*.xml`, `types.xml`, `workflows.xml`, `registry.xml`, `rolemap.xml`) in a way that must propagate to already-installed sites, run `plonecli add upgrade_step --defaults -d upgrade_step_title="<what changed>"` as part of the same change — don't leave it to the user to remember. It bumps `profiles/default/metadata.xml` and registers a GS upgrade handler; then fill that handler so existing sites actually get the change (reapply the relevant import step or migrate data). Never hand-edit `metadata.xml`'s version to "do an upgrade" — that bumps the number without a registered step. Details and what does/doesn't need a step: [reference/add.md](reference/add.md).
- **Don't recreate to change settings.** Re-running `create` over an existing project is wrong; use the reconfigure flow ([reference/maintain.md](reference/maintain.md)).
- After `create`/`add`/reconfigure, generated files change — review `git status`/diff and preserve intentional local edits.

Expand All @@ -57,10 +58,10 @@ On first run, plonecli clones the copier-templates to `~/.copier-templates/plone
plonecli create backend_addon collective.todo
cd collective.todo

# add features
plonecli add content_type
plonecli add behavior
plonecli add restapi_service
# add features (non-interactive: --defaults + -d for each answer)
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"

# wrap it in a runnable Plone instance (adds the zope-setup / invoke harness)
plonecli setup
Expand Down
26 changes: 23 additions & 3 deletions plonecli/skills/plonecli/reference/add.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ plonecli add behavior
plonecli add restapi_service
```

## Non-interactive use (required in Claude Code / CI)

By default `add` (and `create`) drop into copier's **interactive prompts**, which cannot be driven from a non-tty environment — they will hang or fail. **Never work around this by hand-rolling the files copier would generate.** Instead drive plonecli non-interactively:

- `--defaults` — answer every question from the template's defaults (no prompts).
- `-d/--data KEY=VALUE` — pre-fill a specific answer (repeatable); overrides the default and skips that prompt.
- `--data-file PATH` — load answers from a YAML/JSON file (handy for many answers); inline `-d` overrides matching keys.

```shell
# fully non-interactive: defaults for everything, override the few you care about
plonecli add content_type --defaults -d content_type_name="Talk" -d content_type_description="A conference talk"
plonecli add behavior --defaults -d behavior_name="IFeatured"
plonecli add restapi_service --defaults -d service_name="@todos"
```

Pass `-d` for every answer the user has specified; `--defaults` covers the rest. Required answers that have no default (e.g. `content_type_name`, `behavior_name`, `service_name`, `upgrade_step_title`) **must** be supplied with `-d` or copier still has to prompt. Don't invent values the user hasn't given — ask first, then pass them via `-d`. The per-template question/answer keys are listed below and shown by the prompts themselves.

## Subtemplates are gated by project type

plonecli detects the project type from `pyproject.toml` (e.g. `backend_addon`, `project`). Only subtemplates whose `parent` matches that type are offered. So:
Expand All @@ -26,7 +43,7 @@ Always confirm what is actually available here with `plonecli -l` (run inside th
| `behavior` | A behavior (reusable schema/marker applied to content types). | Wire the behavior onto a content type; add tests. |
| `restapi_service` | A `plone.restapi` service (endpoint, adapter, registration). | Add tests exercising the endpoint. |

copier will prompt interactively for the specifics (names, fields, options)answer per the user's requirements. Do not invent answers; if the user hasn't specified e.g. field names, ask.
copier asks for the specifics (names, fields, options). In Claude Code / CI you cannot answer prompts, so run non-interactively with `--defaults` and pass the user's choices via `-d KEY=VALUE` (see "Non-interactive use" above). Do not invent answers; if the user hasn't specified e.g. field names, ask, then pass them via `-d`.

## After adding

Expand All @@ -40,10 +57,11 @@ When you change GenericSetup profile XML under `profiles/default/` in a way that

```shell
cd collective.todo
plonecli add upgrade_step
# non-interactive: title is required, the rest default from the addon
plonecli add upgrade_step --defaults -d upgrade_step_title="Reimport viewlets"
```

It prompts for (defaults injected from the addon):
Questions (defaults injected from the addon — pass `-d` to override any):

| Question | Default | Meaning |
|---|---|---|
Expand All @@ -52,6 +70,8 @@ It prompts for (defaults injected from the addon):
| `source_version` | current `metadata.xml` version | Version being upgraded **from**. |
| `destination_version` | `source + 1` | Version being upgraded **to**. |

`upgrade_step_title` has no default, so it **must** be passed with `-d` — otherwise copier still prompts. Do not skip the command and hand-write the upgrade step files yourself; run it non-interactively as above.

What it does (so you don't do it by hand):

- Bumps `profiles/default/metadata.xml` to `destination_version`.
Expand Down
2 changes: 2 additions & 0 deletions plonecli/skills/plonecli/reference/create.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ plonecli create addon collective.todo # backend add-on + zope-setup (
plonecli create zope-setup my-project # Zope project setup
```

Like `add`, `create` is interactive by default. In Claude Code / CI run it non-interactively with `--defaults` (use template defaults) plus `-d/--data KEY=VALUE` for any answer you want to set, e.g. `plonecli create backend_addon collective.todo --defaults -d package_description="Todo manager"`. Do not call `copier` directly.

## Project templates

Discover them live with `plonecli -l` (this is authoritative — the registry scans `copier.yml` files in the templates clone, so available templates depend on the configured template repo/branch).
Expand Down
8 changes: 8 additions & 0 deletions plonecli/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ def run_create(
target_name: str,
config: PlonecliConfig,
data: dict | None = None,
defaults: bool = False,
) -> None:
"""Run copier to create a new project from a main template.

Expand All @@ -142,6 +143,8 @@ def run_create(
target_name: Output directory name.
config: Global plonecli configuration.
data: Optional answers to pre-fill (skips interactive prompts for these).
defaults: Use template defaults for unanswered questions instead of
prompting (non-interactive mode).
"""
ensure_templates_cloned(config)
src = str(get_template_path(template_name, config))
Expand All @@ -151,6 +154,7 @@ def run_create(
dst_path=target_name,
data=data or {},
user_defaults=_build_user_defaults(config),
defaults=defaults,
unsafe=True,
)

Expand All @@ -160,6 +164,7 @@ def run_add(
project: ProjectContext,
config: PlonecliConfig,
data: dict | None = None,
defaults: bool = False,
) -> None:
"""Run copier to add a subtemplate to an existing project.

Expand All @@ -168,6 +173,8 @@ def run_add(
project: Detected project context.
config: Global plonecli configuration.
data: Optional extra answers.
defaults: Use template defaults for unanswered questions instead of
prompting (non-interactive mode).
"""
ensure_templates_cloned(config)
src = str(get_template_path(template_name, config))
Expand All @@ -186,5 +193,6 @@ def run_add(
dst_path=str(project.root_folder),
data=template_data,
user_defaults=_build_user_defaults(config),
defaults=defaults,
unsafe=True,
)
Loading
Loading