From 42fff8190de745999f93485c6f84cbf5fcf4af22 Mon Sep 17 00:00:00 2001 From: Vlad Shulman Date: Thu, 6 Aug 2026 13:12:36 -0700 Subject: [PATCH 1/2] Link installed skills into Claude Code and fix the command hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parallel-cli skills install` writes to `~/.agents/skills`, the cross-agent location read by Gemini CLI, Copilot, Codex and Amp. Claude Code only scans `~/.claude/skills` (and `/.claude/skills`), so skills installed by the CLI were invisible to it — the install reported success and nothing showed up. Keep `.agents/skills` as the single canonical copy and link each installed skill into Claude Code's tree when a `.claude` directory is present. Symlinks mean there is nothing to keep in sync; the folder is copied where symlinks need privileges (Windows). The step is best-effort: it never clobbers an existing skill of the same name, and any failure only warns rather than failing the primary install. Uninstall drops the links it created, and install prunes links for skills that a narrower `--skill` set removed, so no dead slash commands are left behind. Post-install output now names the actual commands. The CLI ships loose skills, so they are invoked as `/parallel-web-search`, not `/parallel:parallel-web-search` — that namespaced form belongs to the marketplace plugin, a separate install channel. README gains an Agent Skills section documenting both, and the CLI overview tree gains the `skills` group it was missing. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 44 +++++- parallel_web_tools/cli/skills.py | 71 ++++++++- parallel_web_tools/core/skills.py | 200 +++++++++++++++++++++++++ tests/test_cli.py | 85 +++++++++++ tests/test_skills.py | 233 ++++++++++++++++++++++++++++++ 5 files changed, 623 insertions(+), 10 deletions(-) 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", From 0cf5bae5e0f1bbf25b85b39ee463757d8d506871 Mon Sep 17 00:00:00 2001 From: Vlad Shulman Date: Thu, 6 Aug 2026 16:28:39 -0700 Subject: [PATCH 2/2] Link installed skills into Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `skills install` writes to `~/.agents/skills`, which Gemini CLI, Copilot, Codex and Amp read. Claude Code only scans `~/.claude/skills`, so installed skills never showed up there. Symlink each installed skill into Claude Code's tree when a `.claude` directory is present, so one canonical copy serves every agent. A path that already exists is left alone, and a symlink failure warns instead of failing the install. Also print the slash commands after installing. These are loose skills, so they are `/parallel-web-search`, not `/parallel:parallel-web-search` — that form belongs to the marketplace plugin. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 51 +++---- parallel_web_tools/cli/skills.py | 90 ++++-------- parallel_web_tools/core/skills.py | 200 ------------------------- tests/test_cli.py | 68 ++++----- tests/test_skills.py | 233 ------------------------------ 5 files changed, 81 insertions(+), 561 deletions(-) diff --git a/README.md b/README.md index 12e9c10..dc01ab9 100644 --- a/README.md +++ b/README.md @@ -97,19 +97,14 @@ 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 -└── 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 +└── 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 ``` ## Quick Start @@ -338,26 +333,22 @@ 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 +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` 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. +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 diff --git a/parallel_web_tools/cli/skills.py b/parallel_web_tools/cli/skills.py index 84a315a..8c92dac 100644 --- a/parallel_web_tools/cli/skills.py +++ b/parallel_web_tools/cli/skills.py @@ -3,8 +3,9 @@ from __future__ import annotations import json +import os from pathlib import Path -from typing import Any, NoReturn, Protocol +from typing import NoReturn, Protocol import click from rich.console import Console @@ -20,37 +21,37 @@ 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. +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. - Project installs land in ``/.agents/skills``; global installs have no - project root, so Claude Code's global tree is used instead. + 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. """ - if not project: - return None - return Path(install_dir).parent.parent + 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]") -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]") + 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]") -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]") + if linked: + console.print(f"Linked into Claude Code ([cyan]{claude_skills}[/cyan]); restart it to pick them up.") def create_skills_group( @@ -120,7 +121,6 @@ def skills_install(project: bool, skill_names: tuple[str, ...], output_json: boo SkillsInputError, SkillsInstallLocationError, install_skills, - link_into_claude_code, resolve_install_dir, ) @@ -139,13 +139,6 @@ 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 @@ -154,8 +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]") - _print_skill_commands(console, list(result["installed_skills"])) - _print_claude_code_result(console, result["claude_code"]) + _report_installed(console, str(result["install_dir"]), list(result["installed_skills"]), project) @skills.command(name="uninstall") @click.option( @@ -166,12 +158,7 @@ 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, - unlink_from_claude_code, - ) + from parallel_web_tools.core.skills import SkillsInstallLocationError, resolve_install_dir, uninstall_skills try: install_dir = resolve_install_dir(project=project) @@ -181,13 +168,6 @@ 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 @@ -200,8 +180,6 @@ 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( @@ -226,7 +204,6 @@ def skills_reinstall(project: bool, skill_names: tuple[str, ...], output_json: b SkillsError, SkillsInputError, SkillsInstallLocationError, - link_into_claude_code, reinstall_skills, resolve_install_dir, ) @@ -246,12 +223,6 @@ 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 @@ -261,7 +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]") - _print_skill_commands(console, list(result["installed_skills"])) - _print_claude_code_result(console, result["claude_code"]) + _report_installed(console, str(result["install_dir"]), list(result["installed_skills"]), project) return skills diff --git a/parallel_web_tools/core/skills.py b/parallel_web_tools/core/skills.py index 7b9e171..74d58aa 100644 --- a/parallel_web_tools/core/skills.py +++ b/parallel_web_tools/core/skills.py @@ -22,9 +22,6 @@ 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.""" @@ -94,203 +91,6 @@ 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 bb33bff..b54fb64 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2447,14 +2447,6 @@ 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"]) @@ -2529,15 +2521,16 @@ 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" + 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") - (isolated_home / ".claude").mkdir() + (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={ @@ -2550,61 +2543,60 @@ def test_skills_install_links_into_claude_code_and_prints_commands(self, runner, ): result = runner.invoke(main, ["skills", "install"]) - link_path = isolated_home / ".claude" / "skills" / "parallel-web-search" + link = tmp_path / ".claude" / "skills" / "parallel-web-search" assert result.exit_code == 0 - assert link_path.is_symlink() - assert link_path.resolve() == skill_dir.resolve() + assert link.is_symlink() + assert (link / "SKILL.md").read_text() == "# parallel-web-search\n" assert "/parallel-web-search" in result.output - assert "restart Claude Code" in result.output + assert "restart it" in result.output - def test_skills_install_json_reports_claude_code_step(self, runner, isolated_home): - install_dir = isolated_home / ".agents" / "skills" + 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": [], - "count": 0, + "installed_skills": ["parallel-web-search"], + "count": 1, }, ), ): - result = runner.invoke(main, ["skills", "install", "--json"]) + result = runner.invoke(main, ["skills", "install"]) 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 - ) + 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.uninstall_skills", + "parallel_web_tools.core.skills.install_skills", return_value={ "install_dir": str(install_dir), - "removed_skills": ["parallel-web-search"], + "ref": "main", + "installed_skills": ["parallel-web-search"], "count": 1, }, ), ): - result = runner.invoke(main, ["skills", "uninstall", "--json"]) + result = runner.invoke(main, ["skills", "install"]) 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() + assert not (tmp_path / ".claude").exists() 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 06eff0f..9f25383 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -1,7 +1,6 @@ """Tests for skills helper module.""" import json -import shutil from contextlib import contextmanager import pytest @@ -60,238 +59,6 @@ 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",