diff --git a/README.md b/README.md index 4844467..dc01ab9 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,25 @@ result = findall_table( result.result.show() ``` +## Agent Skills + +```bash +parallel-cli skills list # See what's available +parallel-cli skills install # Install all skills globally +parallel-cli skills install --skill parallel-web-search # Install one +parallel-cli skills install --project # Install into the current project +``` + +Skills install into `~/.agents/skills` (or `/.agents/skills`), the cross-agent location +read by Gemini CLI, Copilot, Codex and Amp. Claude Code only scans its own tree, so the installer +also symlinks each skill into `~/.claude/skills` (or `/.claude/skills`) when a `.claude` +directory is present. An existing skill of the same name is left untouched. Restart Claude Code to +pick up new skills. + +These are loose skills, so each is invoked by its folder name — `/parallel-web-search`, not +`/parallel:parallel-web-search`. The namespaced form belongs to the Claude Code plugin, a separate +install channel from this CLI. + ## Programmatic Usage ```python diff --git a/parallel_web_tools/cli/skills.py b/parallel_web_tools/cli/skills.py index 72d4b8a..8c92dac 100644 --- a/parallel_web_tools/cli/skills.py +++ b/parallel_web_tools/cli/skills.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import os +from pathlib import Path from typing import NoReturn, Protocol import click @@ -19,6 +21,39 @@ def __call__( ) -> NoReturn: ... +def _report_installed(console: Console, install_dir: str, skill_names: list[str], project: bool) -> None: + """Print how to invoke the installed skills, linking them into Claude Code first. + + Skills install into ``.agents/skills``, which Gemini CLI, Copilot, Codex and + Amp read but Claude Code does not -- it only scans ``.claude/skills``. Link + rather than copy so one canonical copy serves every agent. + """ + install_path = Path(install_dir) + + # These are loose skills, so each is invoked by its folder name. + console.print(f"Commands: [cyan]{', '.join('/' + name for name in skill_names)}[/cyan]") + + claude_skills = (install_path.parent.parent if project else Path.home()) / ".claude" / "skills" + if not claude_skills.parent.exists(): + return # Claude Code is not installed here. + + linked = False + for name in skill_names: + link = claude_skills / name + if link.exists() or link.is_symlink(): + continue # Never clobber an existing skill, and never relink our own. + try: + claude_skills.mkdir(parents=True, exist_ok=True) + os.symlink(install_path / name, link, target_is_directory=True) + linked = True + except OSError as e: + # Windows needs Developer Mode or admin rights to create symlinks. + console.print(f"[yellow]Could not link '{name}' into Claude Code: {e}[/yellow]") + + if linked: + console.print(f"Linked into Claude Code ([cyan]{claude_skills}[/cyan]); restart it to pick them up.") + + def create_skills_group( console: Console, handle_error: HandleError, @@ -112,6 +147,7 @@ def skills_install(project: bool, skill_names: tuple[str, ...], output_json: boo console.print(f"Location: [cyan]{result['install_dir']}[/cyan]") console.print(f"Ref: [cyan]{result['ref']}[/cyan]") console.print(f"Installed ({result['count']}): [cyan]{', '.join(result['installed_skills'])}[/cyan]") + _report_installed(console, str(result["install_dir"]), list(result["installed_skills"]), project) @skills.command(name="uninstall") @click.option( @@ -196,5 +232,6 @@ def skills_reinstall(project: bool, skill_names: tuple[str, ...], output_json: b console.print(f"Ref: [cyan]{result['ref']}[/cyan]") console.print(f"Removed ({result['removed_count']}): [cyan]{', '.join(result['removed_skills'])}[/cyan]") console.print(f"Installed ({result['installed_count']}): [cyan]{', '.join(result['installed_skills'])}[/cyan]") + _report_installed(console, str(result["install_dir"]), list(result["installed_skills"]), project) return skills diff --git a/tests/test_cli.py b/tests/test_cli.py index 78e45d6..b54fb64 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2521,6 +2521,83 @@ def test_skills_install_project_sets_project_flag(self, runner): assert result.exit_code == 0 mock_dir.assert_called_once_with(project=True) + def test_skills_install_links_into_claude_code(self, runner, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + skill_dir = install_dir / "parallel-web-search" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("# parallel-web-search\n") + (tmp_path / ".claude").mkdir() + + with ( + mock.patch("parallel_web_tools.core.skills.resolve_install_dir", return_value=install_dir), + mock.patch("parallel_web_tools.cli.skills.Path.home", return_value=tmp_path), + mock.patch( + "parallel_web_tools.core.skills.install_skills", + return_value={ + "install_dir": str(install_dir), + "ref": "main", + "installed_skills": ["parallel-web-search"], + "count": 1, + }, + ), + ): + result = runner.invoke(main, ["skills", "install"]) + + link = tmp_path / ".claude" / "skills" / "parallel-web-search" + assert result.exit_code == 0 + assert link.is_symlink() + assert (link / "SKILL.md").read_text() == "# parallel-web-search\n" + assert "/parallel-web-search" in result.output + assert "restart it" in result.output + + def test_skills_install_never_clobbers_an_existing_skill(self, runner, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + (install_dir / "parallel-web-search").mkdir(parents=True) + existing = tmp_path / ".claude" / "skills" / "parallel-web-search" + existing.mkdir(parents=True) + (existing / "SKILL.md").write_text("hand-written") + + with ( + mock.patch("parallel_web_tools.core.skills.resolve_install_dir", return_value=install_dir), + mock.patch("parallel_web_tools.cli.skills.Path.home", return_value=tmp_path), + mock.patch( + "parallel_web_tools.core.skills.install_skills", + return_value={ + "install_dir": str(install_dir), + "ref": "main", + "installed_skills": ["parallel-web-search"], + "count": 1, + }, + ), + ): + result = runner.invoke(main, ["skills", "install"]) + + assert result.exit_code == 0 + assert not existing.is_symlink() + assert (existing / "SKILL.md").read_text() == "hand-written" + + def test_skills_install_skips_when_claude_code_absent(self, runner, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + (install_dir / "parallel-web-search").mkdir(parents=True) + + with ( + mock.patch("parallel_web_tools.core.skills.resolve_install_dir", return_value=install_dir), + mock.patch("parallel_web_tools.cli.skills.Path.home", return_value=tmp_path), + mock.patch( + "parallel_web_tools.core.skills.install_skills", + return_value={ + "install_dir": str(install_dir), + "ref": "main", + "installed_skills": ["parallel-web-search"], + "count": 1, + }, + ), + ): + result = runner.invoke(main, ["skills", "install"]) + + assert result.exit_code == 0 + assert not (tmp_path / ".claude").exists() + def test_skills_install_project_root_not_found(self, runner): from parallel_web_tools.core.skills import SkillsInstallLocationError