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
9 changes: 8 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
## 7.0.0b5 (unreleased)


- Nothing changed yet.
- Fix `plonecli skill install --force` (and other flags) being rejected when
passed after the action argument, caused by the chained top-level group.
[MrTango]

- Default `plonecli skill install|update` to `--scope user` so the skill lands
in `~/.agents/skills` / `~/.claude/skills` instead of the current directory.
Use `--scope project` for a project-local install.
[MrTango]


## 7.0.0b4 (2026-05-21)
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,11 +218,11 @@ This pulls the latest copier-templates and checks PyPI for plonecli updates.
plonecli ships an [Agent Skill](https://www.anthropic.com/news/skills) that teaches AI coding agents how to use it. Because the skill follows the Agent Skills open standard, the same `SKILL.md` is loaded by Claude Code, Codex, Gemini CLI, Cursor and other compatible agents.

```shell
# install into the current project (.agents/skills + .claude/skills)
# install globally for your user (~/.agents/skills + ~/.claude/skills)
plonecli skill install

# install globally for your user (~/.agents/skills + ~/.claude/skills)
plonecli skill install --scope user
# install into the current project (.agents/skills + .claude/skills)
plonecli skill install --scope project

# refresh after upgrading plonecli
plonecli skill update
Expand All @@ -231,7 +231,7 @@ plonecli skill update
plonecli skill status
```

The skill is written to `.agents/skills/plonecli` (the open-standard discovery path) and linked from `.claude/skills/plonecli` for Claude Code. Pass `--copy` if your environment cannot create symlinks, and `--force` to overwrite an existing install.
The skill is written to `~/.agents/skills/plonecli` (the open-standard discovery path) and linked from `~/.claude/skills/plonecli` for Claude Code. Use `--scope project` to install into the current project instead. Pass `--copy` if your environment cannot create symlinks, and `--force` to overwrite an existing install.


### Reconfiguring an Existing Project
Expand Down
28 changes: 22 additions & 6 deletions plonecli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,13 +315,29 @@ def update(context):
echo(f"\nTemplates: {get_templates_info(config)}", fg="green")


@cli.command("skill")
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(
"--scope",
type=click.Choice(["project", "user"]),
default="project",
help="Install for this project (default) or globally for the current user.",
default="user",
help="Install globally for the current user (default) or into this project.",
)
@click.option(
"--copy",
Expand All @@ -335,9 +351,9 @@ def skill(context, action, scope, copy_only, force):
"""Install/update the plonecli Agent Skill for AI coding agents.

Drops the bundled SKILL.md (Agent Skills open standard) into
`.agents/skills/plonecli` and links it from `.claude/skills/plonecli`, so
Claude Code, Codex, Gemini CLI, Cursor and other compatible agents pick it
up. Use --scope user to install into your home directory instead.
`~/.agents/skills/plonecli` and links it from `~/.claude/skills/plonecli`,
so Claude Code, Codex, Gemini CLI, Cursor and other compatible agents pick
it up. Use --scope project to install into the current project instead.
"""
from plonecli import skill_installer

Expand Down
41 changes: 38 additions & 3 deletions tests/test_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,20 +71,55 @@ def test_copy_only_makes_claude_a_copy(tmp_path):
def test_cli_skill_install(mock_config, mock_project, runner, tmp_path):
mock_config.return_value = MagicMock(templates_dir="/tmp/nonexistent")
with runner.isolated_filesystem(temp_dir=tmp_path) as fs:
result = runner.invoke(cli, ["skill", "install"])
result = runner.invoke(cli, ["skill", "install", "--scope", "project"])
assert result.exit_code == 0, result.output
assert "Installed plonecli skill" in result.output
from pathlib import Path

assert (Path(fs) / ".agents" / "skills" / "plonecli" / "SKILL.md").is_file()


@patch("plonecli.cli.find_project_root", return_value=None)
@patch("plonecli.cli.load_config")
def test_cli_skill_install_defaults_to_user_scope(
mock_config, mock_project, runner, tmp_path, monkeypatch
):
"""No --scope must install into the user home, not the current directory."""
from pathlib import Path

mock_config.return_value = MagicMock(templates_dir="/tmp/nonexistent")
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path))
with runner.isolated_filesystem(temp_dir=tmp_path) as fs:
result = runner.invoke(cli, ["skill", "install"])
assert result.exit_code == 0, result.output
assert (tmp_path / ".agents" / "skills" / "plonecli" / "SKILL.md").is_file()
assert not (Path(fs) / ".agents").exists()


@patch("plonecli.cli.find_project_root", return_value=None)
@patch("plonecli.cli.load_config")
def test_cli_skill_install_force_after_action(
mock_config, mock_project, runner, tmp_path
):
"""`--force` must work after the action despite the chained parent group."""
mock_config.return_value = MagicMock(templates_dir="/tmp/nonexistent")
with runner.isolated_filesystem(temp_dir=tmp_path):
assert (
runner.invoke(cli, ["skill", "install", "--scope", "project"]).exit_code
== 0
)
result = runner.invoke(cli, ["skill", "install", "--scope", "project", "--force"])
assert result.exit_code == 0, result.output
assert "Installed plonecli skill" in result.output


@patch("plonecli.cli.find_project_root", return_value=None)
@patch("plonecli.cli.load_config")
def test_cli_skill_install_twice_errors(mock_config, mock_project, runner, tmp_path):
mock_config.return_value = MagicMock(templates_dir="/tmp/nonexistent")
with runner.isolated_filesystem(temp_dir=tmp_path):
assert runner.invoke(cli, ["skill", "install"]).exit_code == 0
result = runner.invoke(cli, ["skill", "install"])
first = runner.invoke(cli, ["skill", "install", "--scope", "project"])
assert first.exit_code == 0
result = runner.invoke(cli, ["skill", "install", "--scope", "project"])
assert result.exit_code != 0
assert "already installed" in result.output
Loading