diff --git a/README.md b/README.md index 4844467..12e9c10 100644 --- a/README.md +++ b/README.md @@ -97,14 +97,19 @@ parallel-cli │ ├── extend # Request additional candidates for a run │ ├── schema # Get the schema for a FindAll run │ └── cancel # Cancel a running FindAll -└── monitor # Continuous web change tracking - ├── create # Create a new web monitor (event_stream or snapshot) - ├── list # List monitors (cursor paginated) - ├── get # Get monitor details - ├── update # Update frequency, webhook, metadata - ├── cancel # Cancel a monitor (irreversible) - ├── events # List events for a monitor - └── trigger # Trigger an immediate one-off run +├── monitor # Continuous web change tracking +│ ├── create # Create a new web monitor (event_stream or snapshot) +│ ├── list # List monitors (cursor paginated) +│ ├── get # Get monitor details +│ ├── update # Update frequency, webhook, metadata +│ ├── cancel # Cancel a monitor (irreversible) +│ ├── events # List events for a monitor +│ └── trigger # Trigger an immediate one-off run +└── skills # Install and manage Parallel agent skills + ├── list # List available skills from skills.parallel.ai + ├── install # Install skills for your coding agents + ├── reinstall # Reinstall the managed skill set + └── uninstall # Remove skills installed by parallel-cli ``` ## Quick Start @@ -331,6 +336,29 @@ result = findall_table( result.result.show() ``` +## Agent Skills + +Install Parallel's agent skills into your coding agent: + +```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 +parallel-cli skills install --project # Install into the current project instead +``` + +Skills install into `~/.agents/skills` (or `/.agents/skills` with `--project`), the +cross-agent location read by Gemini CLI, Copilot, Codex and Amp. Claude Code only scans its own +tree, so the installer also links each skill into `~/.claude/skills` (or `/.claude/skills`) +when a `.claude` directory is present. Links are symlinks to the single canonical copy, so there is +nothing to keep in sync; on systems where symlinks need privileges the skill folder is copied +instead. An existing skill of the same name is never overwritten — the CLI reports it and moves on. +Restart Claude Code after installing to pick up new skills. + +The CLI installs loose skills, so each one is invoked by its folder name — `/parallel-web-search`, +not `/parallel:parallel-web-search`. The namespaced form belongs to the Claude Code plugin, which is +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..84a315a 100644 --- a/parallel_web_tools/cli/skills.py +++ b/parallel_web_tools/cli/skills.py @@ -3,7 +3,8 @@ from __future__ import annotations import json -from typing import NoReturn, Protocol +from pathlib import Path +from typing import Any, NoReturn, Protocol import click from rich.console import Console @@ -19,6 +20,39 @@ def __call__( ) -> NoReturn: ... +def _project_root_for(install_dir: str, project: bool) -> Path | None: + """Return the project root a project-local install dir sits in. + + Project installs land in ``/.agents/skills``; global installs have no + project root, so Claude Code's global tree is used instead. + """ + if not project: + return None + return Path(install_dir).parent.parent + + +def _print_skill_commands(console: Console, skill_names: list[str]) -> None: + """Print how to invoke the installed skills. + + Skills installed by the CLI are loose (not bundled in a Claude Code plugin), + so their slash command is the un-namespaced folder name. + """ + if not skill_names: + return + commands = ", ".join(f"/{name}" for name in skill_names) + console.print(f"Skill commands: [cyan]{commands}[/cyan]") + + +def _print_claude_code_result(console: Console, claude_code: dict[str, Any]) -> None: + if claude_code["linked"] or claude_code["copied"]: + console.print( + f"Linked into Claude Code ([cyan]{claude_code['claude_skills_dir']}[/cyan]); " + "restart Claude Code to pick up the new skills." + ) + for warning in claude_code["warnings"]: + console.print(f"[yellow]{warning}[/yellow]") + + def create_skills_group( console: Console, handle_error: HandleError, @@ -86,6 +120,7 @@ def skills_install(project: bool, skill_names: tuple[str, ...], output_json: boo SkillsInputError, SkillsInstallLocationError, install_skills, + link_into_claude_code, resolve_install_dir, ) @@ -104,6 +139,13 @@ def skills_install(project: bool, skill_names: tuple[str, ...], output_json: boo except Exception as e: handle_error(e, output_json=output_json, exit_code=exit_api_error, prefix="Skills install failed") + # Make the freshly installed skills visible to Claude Code (best-effort). + result["claude_code"] = link_into_claude_code( + Path(str(result["install_dir"])), + list(result["installed_skills"]), + project_root=_project_root_for(str(result["install_dir"]), project), + ) + if output_json: print(json.dumps(result, indent=2)) return @@ -112,6 +154,8 @@ 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]") + _print_skill_commands(console, list(result["installed_skills"])) + _print_claude_code_result(console, result["claude_code"]) @skills.command(name="uninstall") @click.option( @@ -122,7 +166,12 @@ def skills_install(project: bool, skill_names: tuple[str, ...], output_json: boo @click.option("--json", "output_json", is_flag=True, help="Output as JSON") def skills_uninstall(project: bool, output_json: bool) -> None: """Uninstall skills previously installed by parallel-cli.""" - from parallel_web_tools.core.skills import SkillsInstallLocationError, resolve_install_dir, uninstall_skills + from parallel_web_tools.core.skills import ( + SkillsInstallLocationError, + resolve_install_dir, + uninstall_skills, + unlink_from_claude_code, + ) try: install_dir = resolve_install_dir(project=project) @@ -132,6 +181,13 @@ def skills_uninstall(project: bool, output_json: bool) -> None: except Exception as e: handle_error(e, output_json=output_json, exit_code=exit_api_error, prefix="Skills uninstall failed") + # Drop the Claude Code links pointing at the skills we just removed. + result["claude_code"] = unlink_from_claude_code( + Path(str(result["install_dir"])), + list(result["removed_skills"]), + project_root=_project_root_for(str(result["install_dir"]), project), + ) + if output_json: print(json.dumps(result, indent=2)) return @@ -144,6 +200,8 @@ def skills_uninstall(project: bool, output_json: bool) -> None: console.print("[bold green]Skills uninstalled[/bold green]") console.print(f"Location: [cyan]{result['install_dir']}[/cyan]") console.print(f"Removed ({result['count']}): [cyan]{', '.join(result['removed_skills'])}[/cyan]") + for warning in result["claude_code"]["warnings"]: + console.print(f"[yellow]{warning}[/yellow]") @skills.command(name="reinstall") @click.option( @@ -168,6 +226,7 @@ def skills_reinstall(project: bool, skill_names: tuple[str, ...], output_json: b SkillsError, SkillsInputError, SkillsInstallLocationError, + link_into_claude_code, reinstall_skills, resolve_install_dir, ) @@ -187,6 +246,12 @@ def skills_reinstall(project: bool, skill_names: tuple[str, ...], output_json: b except Exception as e: handle_error(e, output_json=output_json, exit_code=exit_api_error, prefix="Skills reinstall failed") + result["claude_code"] = link_into_claude_code( + Path(str(result["install_dir"])), + list(result["installed_skills"]), + project_root=_project_root_for(str(result["install_dir"]), project), + ) + if output_json: print(json.dumps(result, indent=2)) return @@ -196,5 +261,7 @@ 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]") + _print_skill_commands(console, list(result["installed_skills"])) + _print_claude_code_result(console, result["claude_code"]) return skills diff --git a/parallel_web_tools/core/skills.py b/parallel_web_tools/core/skills.py index 74d58aa..7b9e171 100644 --- a/parallel_web_tools/core/skills.py +++ b/parallel_web_tools/core/skills.py @@ -22,6 +22,9 @@ PROJECT_ROOT_MARKERS = (".git", "pyproject.toml", "package.json") MANIFEST_FILE_NAME = ".parallel-cli-skills-manifest.json" +# Claude Code only scans its own tree, so installed skills are mirrored into it. +CLAUDE_CODE_DIR_NAME = ".claude" + class SkillsError(Exception): """Base error for skills operations.""" @@ -91,6 +94,203 @@ def resolve_install_dir(project: bool, start: Path | None = None) -> Path: return root / ".agents" / "skills" +def get_claude_code_skills_dir(project_root: Path | None = None) -> Path: + """Return the directory Claude Code scans for skills. + + Global skills live under ``~/.claude/skills``; project skills live under + ``/.claude/skills``. This mirrors ``resolve_install_dir`` but + targets Claude Code's tree instead of the canonical ``.agents/skills`` tree. + """ + base = project_root if project_root is not None else Path.home() + return Path(base) / CLAUDE_CODE_DIR_NAME / "skills" + + +def link_into_claude_code( + install_dir: Path, + skill_names: list[str], + project_root: Path | None = None, +) -> dict[str, Any]: + """Expose already-installed skills to Claude Code. + + Skills are installed into the canonical ``.agents/skills`` tree, which Gemini + CLI, Copilot, Codex and Amp read but Claude Code does not, so freshly + installed skills are invisible to it. When Claude Code is present (a + ``.claude`` directory exists), link each installed skill folder into + ``<...>/.claude/skills`` with an absolute-target symlink, falling back to a + copy where symlinks are unavailable (e.g. Windows without the required + privilege). Links left over from skills that are no longer installed are + pruned, so narrowing the managed set does not leave dead commands behind. + + This is best-effort: any failure is captured in the returned ``warnings`` and + never raised, so it cannot break the primary ``.agents/skills`` install. + """ + result: dict[str, Any] = { + "claude_dir_present": False, + "claude_skills_dir": None, + "linked": [], + "copied": [], + "skipped": [], + "pruned": [], + "warnings": [], + } + + install_dir = Path(install_dir) + present = [path for path in (install_dir / name for name in skill_names) if path.is_dir()] + + try: + claude_root = (project_root if project_root is not None else Path.home()) / CLAUDE_CODE_DIR_NAME + if not claude_root.exists(): + # Claude Code is not installed here; nothing to expose. + return result + + result["claude_dir_present"] = True + claude_skills_dir = get_claude_code_skills_dir(project_root) + result["claude_skills_dir"] = str(claude_skills_dir) + + if _resolves_to_same_dir(claude_skills_dir, install_dir): + # The whole skills dir is already a symlink to the install dir + # (either ours or set up by hand); per-skill links would be redundant. + result["skipped"] = [path.name for path in present] + return result + + for source in present: + name = source.name + target = source.resolve() + claude_skills_dir.mkdir(parents=True, exist_ok=True) + link_path = claude_skills_dir / name + + if link_path.is_symlink(): + if _resolves_to_same_dir(link_path, target): + # Idempotent: correct link already in place. + result["skipped"].append(name) + continue + # A managed symlink pointing at the wrong target: refresh it. + link_path.unlink() + elif link_path.exists(): + # A real file/dir with this name already exists; do not clobber it. + result["skipped"].append(name) + result["warnings"].append(f"Left existing '{link_path}' untouched (not a parallel-cli symlink).") + continue + + try: + os.symlink(target, link_path, target_is_directory=True) + result["linked"].append(name) + except (OSError, NotImplementedError): + # Symlinks unsupported/unprivileged (e.g. Windows): copy instead. + try: + shutil.copytree(target, link_path) + result["copied"].append(name) + except OSError as copy_error: + result["warnings"].append(f"Could not expose '{name}' to Claude Code: {copy_error}") + + result["pruned"] = _prune_dead_links(claude_skills_dir, install_dir, result["warnings"]) + except OSError as error: + result["warnings"].append(f"Skipped Claude Code integration: {error}") + + return result + + +def _prune_dead_links(claude_skills_dir: Path, install_dir: Path, warnings: list[str]) -> list[str]: + """Remove links to skills that no longer exist in ``install_dir``. + + Installing a narrower ``--skill`` set removes the skills dropped from the + managed set, which would otherwise leave dangling links (and dead slash + commands) behind in Claude Code's tree. + """ + pruned: list[str] = [] + if not claude_skills_dir.is_dir(): + return pruned + + install_root = install_dir.resolve() + for entry in sorted(claude_skills_dir.iterdir()): + # Only dangling symlinks that we could have created are candidates. + if not entry.is_symlink() or entry.exists(): + continue + try: + target = Path(os.readlink(entry)) + except OSError: + continue + if target.parent != install_root: + continue + try: + entry.unlink() + pruned.append(entry.name) + except OSError as error: + warnings.append(f"Could not remove stale link '{entry}': {error}") + + return pruned + + +def unlink_from_claude_code( + install_dir: Path, + skill_names: list[str], + project_root: Path | None = None, +) -> dict[str, Any]: + """Remove Claude Code links previously created by ``link_into_claude_code``. + + Only symlinks pointing into ``install_dir`` are removed, so a user's own + skill of the same name is never deleted. Copies made by the Windows fallback + are indistinguishable from hand-authored skills, so they are reported for + manual cleanup instead of being removed. + """ + result: dict[str, Any] = { + "claude_skills_dir": None, + "unlinked": [], + "skipped": [], + "warnings": [], + } + + install_dir = Path(install_dir) + + try: + claude_skills_dir = get_claude_code_skills_dir(project_root) + if not claude_skills_dir.exists(): + return result + + result["claude_skills_dir"] = str(claude_skills_dir) + if _resolves_to_same_dir(claude_skills_dir, install_dir): + # The skills dir itself points at the install dir; nothing per-skill to undo. + return result + + for name in skill_names: + link_path = claude_skills_dir / name + if not link_path.is_symlink(): + if link_path.exists(): + result["skipped"].append(name) + continue + + if not _resolves_to_same_dir(link_path, install_dir / name): + # Points somewhere else entirely; leave it alone. + result["skipped"].append(name) + continue + + try: + link_path.unlink() + result["unlinked"].append(name) + except OSError as error: + result["warnings"].append(f"Could not remove '{link_path}': {error}") + + # Catch links for skills that predate the manifest we just cleared. + result["unlinked"].extend(_prune_dead_links(claude_skills_dir, install_dir, result["warnings"])) + except OSError as error: + result["warnings"].append(f"Skipped Claude Code cleanup: {error}") + + return result + + +def _resolves_to_same_dir(candidate: Path, expected: Path) -> bool: + """Return whether two paths resolve to the same location. + + Both paths may be dangling symlinks or not exist at all (``resolve`` is + non-strict), which is the case when comparing links whose target has just + been uninstalled. + """ + try: + return candidate.resolve() == Path(expected).resolve() + except OSError: + return False + + @contextmanager def _skills_client() -> Iterator[httpx.Client]: with httpx.Client(timeout=30, follow_redirects=True) as client: diff --git a/tests/test_cli.py b/tests/test_cli.py index 78e45d6..bb33bff 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2447,6 +2447,14 @@ def test_enrich_run_wait_output_file(self, runner, tmp_path): class TestSkillsCommands: + @pytest.fixture(autouse=True) + def isolated_home(self, tmp_path): + """Keep the Claude Code linking step away from the real home directory.""" + home = tmp_path / "home" + home.mkdir() + with mock.patch("parallel_web_tools.core.skills.Path.home", return_value=home): + yield home + def test_skills_help_mentions_cdn_and_replacement_behavior(self, runner): result = runner.invoke(main, ["skills", "--help"]) @@ -2521,6 +2529,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_and_prints_commands(self, runner, isolated_home): + install_dir = isolated_home / ".agents" / "skills" + skill_dir = install_dir / "parallel-web-search" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("# parallel-web-search\n") + (isolated_home / ".claude").mkdir() + + with ( + mock.patch("parallel_web_tools.core.skills.resolve_install_dir", return_value=install_dir), + 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_path = isolated_home / ".claude" / "skills" / "parallel-web-search" + assert result.exit_code == 0 + assert link_path.is_symlink() + assert link_path.resolve() == skill_dir.resolve() + assert "/parallel-web-search" in result.output + assert "restart Claude Code" in result.output + + def test_skills_install_json_reports_claude_code_step(self, runner, isolated_home): + install_dir = isolated_home / ".agents" / "skills" + + with ( + mock.patch("parallel_web_tools.core.skills.resolve_install_dir", return_value=install_dir), + mock.patch( + "parallel_web_tools.core.skills.install_skills", + return_value={ + "install_dir": str(install_dir), + "ref": "main", + "installed_skills": [], + "count": 0, + }, + ), + ): + result = runner.invoke(main, ["skills", "install", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["claude_code"]["claude_dir_present"] is False + assert payload["claude_code"]["linked"] == [] + + def test_skills_uninstall_removes_claude_code_links(self, runner, isolated_home): + install_dir = isolated_home / ".agents" / "skills" + install_dir.mkdir(parents=True) + claude_skills = isolated_home / ".claude" / "skills" + claude_skills.mkdir(parents=True) + (claude_skills / "parallel-web-search").symlink_to( + install_dir / "parallel-web-search", target_is_directory=True + ) + + with ( + mock.patch("parallel_web_tools.core.skills.resolve_install_dir", return_value=install_dir), + mock.patch( + "parallel_web_tools.core.skills.uninstall_skills", + return_value={ + "install_dir": str(install_dir), + "removed_skills": ["parallel-web-search"], + "count": 1, + }, + ), + ): + result = runner.invoke(main, ["skills", "uninstall", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["claude_code"]["unlinked"] == ["parallel-web-search"] + assert not (claude_skills / "parallel-web-search").is_symlink() + def test_skills_install_project_root_not_found(self, runner): from parallel_web_tools.core.skills import SkillsInstallLocationError diff --git a/tests/test_skills.py b/tests/test_skills.py index 9f25383..06eff0f 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -1,6 +1,7 @@ """Tests for skills helper module.""" import json +import shutil from contextlib import contextmanager import pytest @@ -59,6 +60,238 @@ def test_project_fails_without_root_markers(self, tmp_path): skills.resolve_install_dir(project=True, start=start) +def _make_skill(install_dir, name: str): + skill_dir = install_dir / name + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text(f"# {name}\n") + return skill_dir + + +class TestLinkIntoClaudeCode: + def test_noop_when_claude_dir_absent(self, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + _make_skill(install_dir, "parallel-web-search") + + result = skills.link_into_claude_code(install_dir, ["parallel-web-search"], project_root=tmp_path) + + assert result["claude_dir_present"] is False + assert result["linked"] == [] + assert not (tmp_path / ".claude").exists() + + def test_symlinks_skills_into_claude_dir(self, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + skill_dir = _make_skill(install_dir, "parallel-web-search") + (tmp_path / ".claude").mkdir() + + result = skills.link_into_claude_code(install_dir, ["parallel-web-search"], project_root=tmp_path) + + link_path = tmp_path / ".claude" / "skills" / "parallel-web-search" + assert result["linked"] == ["parallel-web-search"] + assert result["warnings"] == [] + assert link_path.is_symlink() + assert link_path.resolve() == skill_dir.resolve() + assert (link_path / "SKILL.md").read_text() == "# parallel-web-search\n" + + def test_falls_back_to_home_without_project_root(self, monkeypatch, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + _make_skill(install_dir, "parallel-web-search") + (tmp_path / ".claude").mkdir() + monkeypatch.setattr("parallel_web_tools.core.skills.Path.home", lambda: tmp_path) + + result = skills.link_into_claude_code(install_dir, ["parallel-web-search"]) + + assert result["claude_skills_dir"] == str(tmp_path / ".claude" / "skills") + assert result["linked"] == ["parallel-web-search"] + + def test_is_idempotent(self, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + _make_skill(install_dir, "parallel-web-search") + (tmp_path / ".claude").mkdir() + + skills.link_into_claude_code(install_dir, ["parallel-web-search"], project_root=tmp_path) + result = skills.link_into_claude_code(install_dir, ["parallel-web-search"], project_root=tmp_path) + + assert result["linked"] == [] + assert result["skipped"] == ["parallel-web-search"] + assert result["warnings"] == [] + + def test_refreshes_symlink_pointing_elsewhere(self, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + skill_dir = _make_skill(install_dir, "parallel-web-search") + stale_target = _make_skill(tmp_path / "elsewhere", "parallel-web-search") + claude_skills = tmp_path / ".claude" / "skills" + claude_skills.mkdir(parents=True) + (claude_skills / "parallel-web-search").symlink_to(stale_target, target_is_directory=True) + + result = skills.link_into_claude_code(install_dir, ["parallel-web-search"], project_root=tmp_path) + + assert result["linked"] == ["parallel-web-search"] + assert (claude_skills / "parallel-web-search").resolve() == skill_dir.resolve() + + def test_does_not_clobber_existing_real_directory(self, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + _make_skill(install_dir, "parallel-web-search") + claude_skills = tmp_path / ".claude" / "skills" + existing = claude_skills / "parallel-web-search" + existing.mkdir(parents=True) + (existing / "SKILL.md").write_text("hand-written") + + result = skills.link_into_claude_code(install_dir, ["parallel-web-search"], project_root=tmp_path) + + assert result["linked"] == [] + assert result["skipped"] == ["parallel-web-search"] + assert len(result["warnings"]) == 1 + assert (existing / "SKILL.md").read_text() == "hand-written" + + def test_skips_when_skills_dir_already_points_at_install_dir(self, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + _make_skill(install_dir, "parallel-web-search") + (tmp_path / ".claude").mkdir() + (tmp_path / ".claude" / "skills").symlink_to(install_dir, target_is_directory=True) + + result = skills.link_into_claude_code(install_dir, ["parallel-web-search"], project_root=tmp_path) + + assert result["linked"] == [] + assert result["skipped"] == ["parallel-web-search"] + assert result["warnings"] == [] + + def test_ignores_skill_dirs_that_do_not_exist(self, tmp_path): + (tmp_path / ".claude").mkdir() + + result = skills.link_into_claude_code(tmp_path / ".agents" / "skills", ["ghost"], project_root=tmp_path) + + assert result["linked"] == [] + assert result["skipped"] == [] + assert not (tmp_path / ".claude" / "skills").exists() + + def test_copies_when_symlinks_unavailable(self, monkeypatch, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + _make_skill(install_dir, "parallel-web-search") + (tmp_path / ".claude").mkdir() + + def unsupported(*args, **kwargs): + raise OSError("symlink privilege not held") + + monkeypatch.setattr("parallel_web_tools.core.skills.os.symlink", unsupported) + + result = skills.link_into_claude_code(install_dir, ["parallel-web-search"], project_root=tmp_path) + + link_path = tmp_path / ".claude" / "skills" / "parallel-web-search" + assert result["copied"] == ["parallel-web-search"] + assert result["linked"] == [] + assert not link_path.is_symlink() + assert (link_path / "SKILL.md").read_text() == "# parallel-web-search\n" + + def test_warns_when_copy_fallback_also_fails(self, monkeypatch, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + _make_skill(install_dir, "parallel-web-search") + (tmp_path / ".claude").mkdir() + + def unsupported(*args, **kwargs): + raise OSError("nope") + + monkeypatch.setattr("parallel_web_tools.core.skills.os.symlink", unsupported) + monkeypatch.setattr("parallel_web_tools.core.skills.shutil.copytree", unsupported) + + result = skills.link_into_claude_code(install_dir, ["parallel-web-search"], project_root=tmp_path) + + assert result["linked"] == [] + assert result["copied"] == [] + assert "parallel-web-search" in result["warnings"][0] + + def test_prunes_links_for_skills_no_longer_installed(self, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + _make_skill(install_dir, "parallel-web-search") + dropped = _make_skill(install_dir, "parallel-web-extract") + (tmp_path / ".claude").mkdir() + skills.link_into_claude_code( + install_dir, ["parallel-web-search", "parallel-web-extract"], project_root=tmp_path + ) + + # Mirror a narrowed --skill install: the dropped skill is removed first. + shutil.rmtree(dropped) + result = skills.link_into_claude_code(install_dir, ["parallel-web-search"], project_root=tmp_path) + + claude_skills = tmp_path / ".claude" / "skills" + assert result["pruned"] == ["parallel-web-extract"] + assert not (claude_skills / "parallel-web-extract").is_symlink() + assert (claude_skills / "parallel-web-search").is_symlink() + + def test_prune_leaves_foreign_dangling_links_alone(self, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + _make_skill(install_dir, "parallel-web-search") + claude_skills = tmp_path / ".claude" / "skills" + claude_skills.mkdir(parents=True) + foreign = claude_skills / "someone-elses-skill" + foreign.symlink_to(tmp_path / "elsewhere" / "gone", target_is_directory=True) + + result = skills.link_into_claude_code(install_dir, ["parallel-web-search"], project_root=tmp_path) + + assert result["pruned"] == [] + assert foreign.is_symlink() + + +class TestUnlinkFromClaudeCode: + def test_removes_links_it_created(self, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + skill_dir = _make_skill(install_dir, "parallel-web-search") + (tmp_path / ".claude").mkdir() + skills.link_into_claude_code(install_dir, ["parallel-web-search"], project_root=tmp_path) + + # Mirror uninstall order: the canonical skill is removed first. + shutil.rmtree(skill_dir) + result = skills.unlink_from_claude_code(install_dir, ["parallel-web-search"], project_root=tmp_path) + + assert result["unlinked"] == ["parallel-web-search"] + assert not (tmp_path / ".claude" / "skills" / "parallel-web-search").exists() + assert not (tmp_path / ".claude" / "skills" / "parallel-web-search").is_symlink() + + def test_leaves_unrelated_skills_alone(self, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + claude_skills = tmp_path / ".claude" / "skills" + own_skill = claude_skills / "parallel-web-search" + own_skill.mkdir(parents=True) + (own_skill / "SKILL.md").write_text("hand-written") + foreign_target = _make_skill(tmp_path / "elsewhere", "parallel-web-extract") + (claude_skills / "parallel-web-extract").symlink_to(foreign_target, target_is_directory=True) + + result = skills.unlink_from_claude_code( + install_dir, ["parallel-web-search", "parallel-web-extract"], project_root=tmp_path + ) + + assert result["unlinked"] == [] + assert sorted(result["skipped"]) == ["parallel-web-extract", "parallel-web-search"] + assert (own_skill / "SKILL.md").read_text() == "hand-written" + assert (claude_skills / "parallel-web-extract").is_symlink() + + def test_noop_when_claude_skills_dir_absent(self, tmp_path): + result = skills.unlink_from_claude_code( + tmp_path / ".agents" / "skills", ["parallel-web-search"], project_root=tmp_path + ) + + assert result == {"claude_skills_dir": None, "unlinked": [], "skipped": [], "warnings": []} + + def test_noop_when_skills_dir_points_at_install_dir(self, tmp_path): + install_dir = tmp_path / ".agents" / "skills" + _make_skill(install_dir, "parallel-web-search") + (tmp_path / ".claude").mkdir() + (tmp_path / ".claude" / "skills").symlink_to(install_dir, target_is_directory=True) + + result = skills.unlink_from_claude_code(install_dir, ["parallel-web-search"], project_root=tmp_path) + + assert result["unlinked"] == [] + assert (install_dir / "parallel-web-search").exists() + + +class TestClaudeCodeSkillsDir: + def test_uses_home_by_default(self, monkeypatch, tmp_path): + monkeypatch.setattr("parallel_web_tools.core.skills.Path.home", lambda: tmp_path) + assert skills.get_claude_code_skills_dir() == tmp_path / ".claude" / "skills" + + def test_uses_project_root_when_given(self, tmp_path): + assert skills.get_claude_code_skills_dir(tmp_path / "repo") == tmp_path / "repo" / ".claude" / "skills" + + def _make_index() -> dict: return { "channel": "main",