diff --git a/CHANGES.md b/CHANGES.md index 4350e04..ac43aaa 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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) diff --git a/README.md b/README.md index 1c77ccc..e1f9a0f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/plonecli/cli.py b/plonecli/cli.py index 2437422..230a745 100644 --- a/plonecli/cli.py +++ b/plonecli/cli.py @@ -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", @@ -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 diff --git a/tests/test_skill.py b/tests/test_skill.py index edad34a..c836db8 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -71,7 +71,7 @@ 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 @@ -79,12 +79,47 @@ def test_cli_skill_install(mock_config, mock_project, runner, tmp_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