diff --git a/docs/PORTING.md b/docs/PORTING.md index 84f00cb8c..379becb95 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -269,9 +269,10 @@ finite private compatibility inventory in `solstone/think/sol_compat_inventory.p checked by `scripts/check_native_sol_compat.py`. The inventory is the only authority for that command set; do not copy the list into docs or gates. The removal criterion is zero Python delegation from supported-platform native -`sol`: every remaining compatibility path has a native authority and production -aggregate handler, then the compatibility inventory and module exec bridge are -deleted together. +`sol`: every remaining compatibility path has either a native authority with a +production aggregate handler or an explicit direct native match-arm home for +top-level local behavior, then the compatibility inventory and module exec +bridge are deleted together. ## Version Lockstep diff --git a/docs/SOLCLI.md b/docs/SOLCLI.md index 00de3505b..391177e72 100644 --- a/docs/SOLCLI.md +++ b/docs/SOLCLI.md @@ -63,6 +63,11 @@ Each module must export a `main()` function. The dispatcher does `importlib.impo 4. **Update parity fixtures and native-sol gates** for the new command. +Use this authority route for top-level commands that read or write journal data +through the native HTTP boundary. For local commands that touch no journal data +and have no `sol call` oracle path, use a direct match arm in +`solstone_core_sol::run` alongside `root`, `path`, `status`, and `skills`. + For host-only commands, use the `journal` dispatcher instead: create a Python module with `main()` and register it in `solstone/think/sol_cli.py` with the appropriate service or universal surface. @@ -427,7 +432,7 @@ CLI commands, a native `native/authority.toml` plus `native/command.rs`. | `awareness` | `solstone/apps/awareness/native/authority.toml` | status, imports, log, log-read | | `journal` | `solstone/think/tools/call.py` | search, events, facets, facet (show/create/update/rename/mute/unmute/delete/merge), news, agents, read, imports, import, retention purge, storage-summary | -`sol skills` manages coding-agent skill installation. `make skills` builds generated router references via `scripts/build_skill_references.py` before invoking `sol skills install`. +`sol skills` manages coding-agent skill installation with `install`, `uninstall`, and `list`. The former `build` verb is gone; `make skills` runs `scripts/build_skill_references.py` directly before invoking `sol skills install`. ## Skill System diff --git a/scripts/capture_skills_parity_oracle.py b/scripts/capture_skills_parity_oracle.py deleted file mode 100644 index 6be353fbb..000000000 --- a/scripts/capture_skills_parity_oracle.py +++ /dev/null @@ -1,532 +0,0 @@ -#!/usr/bin/env python3 -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""Capture the temporary Python `sol skills` parity oracle. - -This intentionally has no ``--check`` mode, unlike -``scripts/build_journal_resolution_vectors.py``. The oracle captured here is -``solstone/think/skills_cli.py``, and that Python implementation is deleted in a -later commit of this same lode. Wiring a check gate to it would leave a -permanently red gate after the native Rust implementation becomes the source of -truth. -""" - -from __future__ import annotations - -import argparse -import base64 -import json -import os -import shutil -import subprocess -import sys -import tempfile -from collections.abc import Iterable -from dataclasses import dataclass, field -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -DEFAULT_OUTPUT = ( - REPO_ROOT / "core/fixtures/native-sol/skills-parity-v1/vectors.json" -) -PYTHON = REPO_ROOT / ".venv/bin/python" - -SCHEMA = "native-sol-skills-parity-v1" -PLACEHOLDERS = { - "project_root": "${PROJECT_ROOT}", - "temp_root": "${TEMP_ROOT}", - "home": "${HOME}", - "cwd": "${CWD}", - "fake_root": "${FAKE_ROOT}", -} - - -@dataclass(frozen=True) -class Operation: - op: str - path: str - content: str | None = None - target: str | None = None - agent: str | None = None - - def as_json(self) -> dict[str, str]: - data = {"op": self.op, "path": self.path} - if self.content is not None: - data["content_b64"] = base64.b64encode(self.content.encode()).decode("ascii") - if self.target is not None: - data["target"] = self.target - if self.agent is not None: - data["agent"] = self.agent - return data - - -@dataclass(frozen=True) -class VectorSpec: - id: str - argv: list[str] - mode: str - home: str - cwd: str = "${PROJECT_ROOT}" - project_root: str = "${PROJECT_ROOT}" - setup: list[Operation] = field(default_factory=list) - compare_stderr: bool = True - python_overrides: bool = False - - -def subst(text: str, values: dict[str, str]) -> str: - for token in PLACEHOLDERS.values(): - if token not in values: - continue - text = text.replace(token, values[token]) - return text - - -def normalize(text: str, values: dict[str, str]) -> str: - ordered = sorted( - ((value, token) for token, value in values.items()), - key=lambda item: len(item[0]), - reverse=True, - ) - for value, token in ordered: - text = text.replace(value, token) - return text - - -def apply_setup(ops: Iterable[Operation], values: dict[str, str]) -> None: - for op in ops: - path = Path(subst(op.path, values)) - if op.op == "mkdir": - path.mkdir(parents=True, exist_ok=True) - elif op.op == "write_file": - path.parent.mkdir(parents=True, exist_ok=True) - assert op.content is not None - path.write_text(op.content, encoding="utf-8") - elif op.op == "symlink": - path.parent.mkdir(parents=True, exist_ok=True) - assert op.target is not None - path.symlink_to(subst(op.target, values)) - elif op.op == "remove": - if path.is_symlink() or path.is_file(): - path.unlink() - elif path.is_dir(): - shutil.rmtree(path) - elif op.op == "mutate_file": - assert op.content is not None - path.write_text(op.content, encoding="utf-8") - elif op.op == "copy_user_skill": - copy_files_only(REPO_ROOT / "solstone/talent/sol", path) - elif op.op == "project_links": - agent = op.agent or "all" - create_project_links(path, agent) - else: - raise ValueError(f"unknown setup op {op.op!r}") - - -def copy_files_only(src: Path, dst: Path) -> None: - for source in sorted(path for path in src.rglob("*") if path.is_file()): - target = dst / source.relative_to(src) - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copyfile(source, target) - - -def create_project_links(project: Path, agent: str) -> None: - targets: list[tuple[str, Path]] - if agent == "claude": - targets = [("claude", project / ".claude/skills")] - elif agent == "all": - targets = [ - ("claude", project / ".claude/skills"), - ("agents", project / ".agents/skills"), - ] - else: - raise ValueError(f"unsupported setup project_links agent {agent!r}") - del targets - for link_parent in ( - [project / ".claude/skills"] - if agent == "claude" - else [project / ".claude/skills", project / ".agents/skills"] - ): - link_parent.mkdir(parents=True, exist_ok=True) - for name in ("journal", "sol"): - source = REPO_ROOT / "solstone/talent" / name - (link_parent / name).symlink_to(os.path.relpath(source, link_parent)) - - -def run_oracle(spec: VectorSpec, values: dict[str, str]) -> subprocess.CompletedProcess[bytes]: - env = os.environ.copy() - env["HOME"] = subst(spec.home, values) - cwd = subst(spec.cwd, values) - argv = [subst(arg, values) for arg in spec.argv[1:]] - if spec.python_overrides: - code = ( - "import sys\n" - "from pathlib import Path\n" - "from solstone.think import skills_cli\n" - f"skills_cli.get_project_root = lambda: {subst(spec.project_root, values)!r}\n" - "skills_cli.resources.files = lambda _package: " - f"Path({subst(spec.project_root, values)!r}) / 'solstone' / 'talent'\n" - f"sys.argv = {['sol skills', *argv]!r}\n" - "raise SystemExit(skills_cli.main())\n" - ) - return subprocess.run( - [str(PYTHON), "-c", code], - cwd=cwd, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - ) - return subprocess.run( - [str(PYTHON), "-m", "solstone.think.skills_cli", *argv], - cwd=cwd, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, - ) - - -def vector_specs() -> list[VectorSpec]: - return [ - VectorSpec("fresh_user_install", ["skills", "install"], "user", "${TEMP_ROOT}/u_fresh"), - VectorSpec( - "idempotent_user_reinstall", - ["skills", "install"], - "user", - "${TEMP_ROOT}/u_idempotent", - setup=[ - Operation("copy_user_skill", "${TEMP_ROOT}/u_idempotent/.claude/skills/sol"), - Operation("copy_user_skill", "${TEMP_ROOT}/u_idempotent/.codex/skills/sol"), - Operation("copy_user_skill", "${TEMP_ROOT}/u_idempotent/.gemini/skills/sol"), - ], - ), - VectorSpec( - "replace_on_change", - ["skills", "install", "--agent", "claude"], - "user", - "${TEMP_ROOT}/u_replace_change", - setup=[ - Operation("copy_user_skill", "${TEMP_ROOT}/u_replace_change/.claude/skills/sol"), - Operation( - "mutate_file", - "${TEMP_ROOT}/u_replace_change/.claude/skills/sol/SKILL.md", - "changed\n", - ), - ], - ), - VectorSpec( - "replace_on_change_initial", - ["skills", "install", "--agent", "claude"], - "user", - "${TEMP_ROOT}/u_replace_change_initial", - ), - VectorSpec( - "replace_symlink_target", - ["skills", "install", "--agent", "claude"], - "user", - "${TEMP_ROOT}/u_replace_symlink", - setup=[ - Operation("write_file", "${TEMP_ROOT}/external_populated/keep.txt", "keep\n"), - Operation( - "symlink", - "${TEMP_ROOT}/u_replace_symlink/.claude/skills/sol", - target="${TEMP_ROOT}/external_populated", - ), - ], - ), - VectorSpec( - "replace_regular_file_target", - ["skills", "install", "--agent", "claude"], - "user", - "${TEMP_ROOT}/u_replace_file", - setup=[ - Operation( - "write_file", - "${TEMP_ROOT}/u_replace_file/.claude/skills/sol", - "not a dir\n", - ), - ], - ), - VectorSpec( - "user_uninstall_absent_target", - ["skills", "uninstall", "--agent", "claude"], - "user", - "${TEMP_ROOT}/u_uninstall_absent", - setup=[Operation("mkdir", "${TEMP_ROOT}/u_uninstall_absent/.claude")], - ), - VectorSpec( - "user_uninstall_refuses_regular_file", - ["skills", "uninstall", "--agent", "claude"], - "user", - "${TEMP_ROOT}/u_uninstall_file", - setup=[ - Operation( - "write_file", - "${TEMP_ROOT}/u_uninstall_file/.claude/skills/sol", - "not a dir\n", - ) - ], - ), - VectorSpec( - "user_uninstall_refuses_symlink", - ["skills", "uninstall", "--agent", "claude"], - "user", - "${TEMP_ROOT}/u_uninstall_symlink", - setup=[ - Operation("mkdir", "${TEMP_ROOT}/external_uninstall"), - Operation( - "symlink", - "${TEMP_ROOT}/u_uninstall_symlink/.claude/skills/sol", - target="${TEMP_ROOT}/external_uninstall", - ), - ], - ), - VectorSpec( - "user_install_absent_agent_config", - ["skills", "install", "--agent", "codex"], - "user", - "${TEMP_ROOT}/u_absent_install", - ), - VectorSpec( - "user_uninstall_absent_single_config", - ["skills", "uninstall", "--agent", "claude"], - "user", - "${TEMP_ROOT}/u_absent_uninstall", - ), - VectorSpec( - "user_uninstall_absent_agent_config", - ["skills", "uninstall"], - "user", - "${TEMP_ROOT}/u_global_skip", - ), - VectorSpec( - "explicit_gemini_absent_not_silent", - ["skills", "uninstall", "--agent", "gemini"], - "user", - "${TEMP_ROOT}/u_gemini_explicit", - ), - VectorSpec( - "project_install", - ["skills", "install", "--project", "${TEMP_ROOT}/p_install", "--agent", "all"], - "project", - "${TEMP_ROOT}/p_home", - ), - VectorSpec( - "project_idempotent_reinstall", - ["skills", "install", "--project", "${TEMP_ROOT}/p_idempotent", "--agent", "all"], - "project", - "${TEMP_ROOT}/p_home_idempotent", - setup=[Operation("project_links", "${TEMP_ROOT}/p_idempotent", agent="all")], - ), - VectorSpec( - "project_link_target_changed", - ["skills", "install", "--project", "${TEMP_ROOT}/p_link_changed", "--agent", "all"], - "project", - "${TEMP_ROOT}/p_home_link_changed", - setup=[ - Operation("project_links", "${TEMP_ROOT}/p_link_changed", agent="all"), - Operation("remove", "${TEMP_ROOT}/p_link_changed/.claude/skills/sol"), - Operation( - "symlink", - "${TEMP_ROOT}/p_link_changed/.claude/skills/sol", - target="bogus-target", - ), - ], - ), - VectorSpec( - "project_user_content_preserved", - ["skills", "install", "--project", "${TEMP_ROOT}/p_user_content", "--agent", "all"], - "project", - "${TEMP_ROOT}/p_home_user_content", - setup=[ - Operation( - "write_file", - "${TEMP_ROOT}/p_user_content/.claude/skills/journal", - "user-content\n", - ) - ], - ), - VectorSpec( - "project_stale_link_removal", - ["skills", "install", "--project", "${TEMP_ROOT}/p_stale_link", "--agent", "all"], - "project", - "${TEMP_ROOT}/p_home_stale_link", - setup=[ - Operation("project_links", "${TEMP_ROOT}/p_stale_link", agent="all"), - Operation( - "symlink", - "${TEMP_ROOT}/p_stale_link/.claude/skills/entities", - target="../../../solstone/apps/entities/talent/entities", - ), - ], - ), - VectorSpec( - "project_stale_non_symlink_preserved", - ["skills", "install", "--project", "${TEMP_ROOT}/p_stale_content", "--agent", "all"], - "project", - "${TEMP_ROOT}/p_home_stale_content", - setup=[ - Operation("project_links", "${TEMP_ROOT}/p_stale_content", agent="all"), - Operation( - "write_file", - "${TEMP_ROOT}/p_stale_content/.claude/skills/entities/SKILL.md", - "user stale\n", - ), - ], - ), - VectorSpec( - "project_uninstall", - ["skills", "uninstall", "--project", "${TEMP_ROOT}/p_uninstall", "--agent", "all"], - "project", - "${TEMP_ROOT}/p_home_uninstall", - setup=[Operation("project_links", "${TEMP_ROOT}/p_uninstall", agent="all")], - ), - VectorSpec( - "list_user_mode", - ["skills", "list"], - "user", - "${TEMP_ROOT}/u_list", - setup=[Operation("copy_user_skill", "${TEMP_ROOT}/u_list/.claude/skills/sol")], - ), - VectorSpec( - "list_project_mode", - ["skills", "list", "--project", "${TEMP_ROOT}/p_list"], - "project", - "${TEMP_ROOT}/p_home_list", - setup=[Operation("project_links", "${TEMP_ROOT}/p_list", agent="all")], - ), - VectorSpec( - "list_rejects_agent", - ["skills", "list", "--agent", "claude"], - "usage", - "${TEMP_ROOT}/u_list_agent_error", - compare_stderr=False, - ), - VectorSpec( - "project_rejects_codex", - ["skills", "install", "--project", "${TEMP_ROOT}/p_reject_codex", "--agent", "codex"], - "project", - "${TEMP_ROOT}/p_home_reject_codex", - ), - VectorSpec( - "project_rejects_gemini", - ["skills", "install", "--project", "${TEMP_ROOT}/p_reject_gemini", "--agent", "gemini"], - "project", - "${TEMP_ROOT}/p_home_reject_gemini", - ), - VectorSpec( - "project_no_value_uses_cwd", - ["skills", "install", "--project", "--agent", "claude"], - "project", - "${TEMP_ROOT}/p_home_const_cwd", - cwd="${TEMP_ROOT}/p_const_cwd", - setup=[Operation("mkdir", "${TEMP_ROOT}/p_const_cwd")], - ), - VectorSpec( - "project_tilde_expands", - ["skills", "install", "--project", "~/foo", "--agent", "claude"], - "project", - "${TEMP_ROOT}/p_home_tilde", - ), - VectorSpec( - "project_nonexistent_dir_created", - ["skills", "install", "--project", "${TEMP_ROOT}/p_nonexistent/deep", "--agent", "claude"], - "project", - "${TEMP_ROOT}/p_home_nonexistent", - ), - VectorSpec( - "payload_missing_user_mode", - ["skills", "install", "--agent", "claude"], - "payload-missing", - "${TEMP_ROOT}/missing_user_home", - project_root="${FAKE_ROOT}", - python_overrides=True, - ), - VectorSpec( - "payload_missing_project_mode", - [ - "skills", - "install", - "--project", - "${TEMP_ROOT}/missing_project_target", - "--agent", - "claude", - ], - "payload-missing", - "${TEMP_ROOT}/missing_project_home", - project_root="${FAKE_ROOT}", - python_overrides=True, - ), - ] - - -def fake_root(values: dict[str, str]) -> None: - root = Path(values["${FAKE_ROOT}"]) - (root / "solstone/talent").mkdir(parents=True, exist_ok=True) - - -def capture(output: Path) -> None: - with tempfile.TemporaryDirectory(prefix="solstone-skills-oracle-") as temp: - temp_root = Path(temp) - fake = temp_root / "fake-root" - values = { - "${PROJECT_ROOT}": str(REPO_ROOT), - "${TEMP_ROOT}": str(temp_root), - "${FAKE_ROOT}": str(fake), - } - fake_root(values) - vectors = [] - for spec in vector_specs(): - home = Path(subst(spec.home, values)) - cwd = Path(subst(spec.cwd, values)) - home.mkdir(parents=True, exist_ok=True) - cwd.mkdir(parents=True, exist_ok=True) - local_values = { - **values, - "${HOME}": str(home), - "${CWD}": str(cwd), - } - apply_setup(spec.setup, local_values) - result = run_oracle(spec, local_values) - expected = { - "stdout": normalize(result.stdout.decode("utf-8"), local_values), - "stderr": normalize(result.stderr.decode("utf-8"), local_values), - "exit": result.returncode, - } - if not spec.compare_stderr: - expected["compare_stderr"] = False - expected["stderr"] = "" - vectors.append( - { - "id": spec.id, - "argv": spec.argv, - "mode": spec.mode, - "home": spec.home, - "cwd": spec.cwd, - "project_root": spec.project_root, - "setup": [op.as_json() for op in spec.setup], - "expected": expected, - } - ) - - data = { - "schema": SCHEMA, - "placeholders": PLACEHOLDERS, - "vectors": vectors, - } - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") - print(f"wrote {output}") - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) - args = parser.parse_args(argv) - capture(args.output) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/check_journal_io_mechanic.py b/scripts/check_journal_io_mechanic.py index 9c21e4440..de1130902 100644 --- a/scripts/check_journal_io_mechanic.py +++ b/scripts/check_journal_io_mechanic.py @@ -119,7 +119,6 @@ EXCLUDED_FILES: frozenset[str] = frozenset( "solstone/apps/home/routes.py", "solstone/apps/transcripts/routes.py", "solstone/think/data_state.py", - "solstone/think/skills_cli.py", } ) diff --git a/solstone/think/doctor.py b/solstone/think/doctor.py index 08712c6cd..f27633432 100644 --- a/solstone/think/doctor.py +++ b/solstone/think/doctor.py @@ -46,7 +46,7 @@ from pathlib import Path from typing import IO, Callable, Sequence from solstone.think import features as _features -from solstone.think import maint, parakeet_readiness, skills_cli +from solstone.think import maint, parakeet_readiness from solstone.think.health_cli import fetch_supervisor_status from solstone.think.media import PDF_EXTENSIONS from solstone.think.probe import ( @@ -110,6 +110,7 @@ JOURNAL_PACKAGE_VERSION_CHECK = Check( "journal_package_version", "blocker", ("linux", "darwin") ) RETIRED_HOST_SHIM_CHECK = Check("retired_host_shim", "advisory", ("linux", "darwin")) +_ROUTER_SKILL_NAMES = ("sol", "journal") _HOST_DEPENDENCY_MODULES = ( ("frontmatter", "python-frontmatter"), ("flask", "Flask"), @@ -779,6 +780,19 @@ def stale_alias_symlink_check(args: Args, binary: str) -> CheckResult: ) +def _discover_project_sources(repo_root: Path) -> list[Path]: + # Knowingly duplicated with core/crates/solstone-core-sol/src/skills.rs + # until doctor is ported out of Python. + sources = [] + for name in _ROUTER_SKILL_NAMES: + source = repo_root / "solstone" / "talent" / name + skill_file = source / "SKILL.md" + if not skill_file.is_file(): + raise FileNotFoundError(f"expected project skill at {skill_file}") + sources.append(source) + return sorted(sources) + + def _skill_state_problem_detail( skills_dir: Path, expected_sources: dict[str, Path] ) -> list[str]: @@ -819,7 +833,7 @@ def skill_state_check(args: Args) -> CheckResult: return make_result(check, "skip", "no local journal") try: - sources = skills_cli.discover_project_sources(ROOT) + sources = _discover_project_sources(ROOT) except Exception as exc: return make_result(check, "skip", f"project skill sources unavailable: {exc}") @@ -838,7 +852,7 @@ def skill_state_check(args: Args) -> CheckResult: if not problems: names = ", ".join( - name for name in skills_cli.ROUTER_SKILL_NAMES if name in expected_sources + name for name in _ROUTER_SKILL_NAMES if name in expected_sources ) return make_result( check, diff --git a/solstone/think/skills_cli.py b/solstone/think/skills_cli.py deleted file mode 100644 index f02330f98..000000000 --- a/solstone/think/skills_cli.py +++ /dev/null @@ -1,666 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -"""sol skills — build, install, uninstall, and inspect coding-agent skills. - -Two install modes: - -- User mode (default): copies the bundled umbrella skill - solstone/talent/sol/ into per-agent user config directories - (~/.claude/skills/, ~/.codex/skills/, ~/.gemini/skills/). -- Project mode (--project [DIR]): symlinks the two router skills (sol, journal) - into /.claude/skills/ and /.agents/skills/. - -Subcommands: build, install, uninstall, list. -""" - -from __future__ import annotations - -import argparse -import os -import shutil -import sys -import tempfile -from dataclasses import dataclass -from importlib import resources -from pathlib import Path -from typing import Callable - -from solstone.think.utils import get_project_root - -ALL_AGENTS = "all" -PROJECT_MULTI_AGENT = "agents" -PROJECT_CLAUDE_SKILLS_REL = ".claude/skills" -PROJECT_AGENTS_SKILLS_REL = ".agents/skills" -ROUTER_SKILL_NAMES = ("sol", "journal") -GLOBAL_SKIP_MESSAGE = ( - "no AI coding agent config directories found — skipping skill registration" -) -SUBCOMMAND_DESCRIPTION = """User mode: copies/removes the bundled umbrella skill solstone/talent/sol/ in per-agent user config dirs. -Project mode: symlinks/removes the two router skills (sol, journal) under DIR. -User-mode install creates missing agent config dirs and atomically replaces a -changed skill target.""" - - -@dataclass(frozen=True) -class AgentSpec: - name: str - display_name: str - parent_dir: str - skills_dir: str - silent_when_default_all: bool - - -@dataclass(frozen=True) -class ActionRow: - agent: str - skill: str - action: str - path: Path - reason: str | None = None - - -@dataclass -class InstallReport: - rows: list[ActionRow] - - @property - def error_count(self) -> int: - return sum(1 for row in self.rows if row.action == "error") - - @property - def warning_count(self) -> int: - return sum(1 for row in self.rows if row.action == "warning") - - @property - def all_skipped(self) -> bool: - return bool(self.rows) and all( - row.action == "skipped" - and row.reason is not None - and row.reason.startswith("config dir absent at ") - for row in self.rows - ) - - -@dataclass(frozen=True) -class StatusRow: - agent: str - skill: str - state: str - path: Path - - -AGENTS: dict[str, AgentSpec] = { - "claude": AgentSpec( - name="claude", - display_name="Claude Code", - parent_dir=".claude", - skills_dir=PROJECT_CLAUDE_SKILLS_REL, - silent_when_default_all=False, - ), - "codex": AgentSpec( - name="codex", - display_name="Codex", - parent_dir=".codex", - skills_dir=".codex/skills", - silent_when_default_all=False, - ), - "gemini": AgentSpec( - name="gemini", - display_name="Gemini", - parent_dir=".gemini", - skills_dir=".gemini/skills", - silent_when_default_all=True, - ), -} - - -def resolve_user_skill() -> Path: - """Return the bundled umbrella user skill source directory.""" - skill_dir = Path(str(resources.files("solstone.talent") / "sol")) - skill_file = skill_dir / "SKILL.md" - if not skill_file.is_file(): - raise FileNotFoundError( - "expected bundled umbrella skill at " - f"solstone/talent/sol/SKILL.md ({skill_file})" - ) - return skill_dir - - -def discover_project_sources(repo_root: Path) -> list[Path]: - """Return project skill source directories, rejecting duplicate names.""" - sources: list[Path] = [] - for name in ROUTER_SKILL_NAMES: - source = repo_root / "solstone" / "talent" / name - skill_file = source / "SKILL.md" - if not skill_file.is_file(): - raise FileNotFoundError(f"expected project skill at {skill_file}") - sources.append(source) - sources = sorted(sources) - seen: dict[str, Path] = {} - for source in sources: - previous = seen.get(source.name) - if previous is not None: - raise ValueError( - f"duplicate skill name {source.name!r}: {previous} and {source}" - ) - seen[source.name] = source - return sources - - -def _atomic_copy_file(src: Path, dst: Path) -> None: - dst.parent.mkdir(parents=True, exist_ok=True) - fd, temp_path = tempfile.mkstemp(dir=dst.parent, prefix=".tmp_", suffix=".tmp") - try: - with os.fdopen(fd, "wb") as out_file: - with src.open("rb") as in_file: - shutil.copyfileobj(in_file, out_file) - os.replace(temp_path, dst) - except Exception: - try: - os.unlink(temp_path) - except Exception: - pass - raise - - -def _copy_tree_atomically(src_dir: Path, dst_dir: Path) -> str: - existed = dst_dir.exists() - dst_dir.mkdir(parents=True, exist_ok=True) - for src in sorted(path for path in src_dir.rglob("*") if path.is_file()): - _atomic_copy_file(src, dst_dir / src.relative_to(src_dir)) - return "replaced" if existed else "installed" - - -def _tree_matches(src_dir: Path, dst_dir: Path) -> bool: - src_files = sorted( - path.relative_to(src_dir) for path in src_dir.rglob("*") if path.is_file() - ) - dst_files = sorted( - path.relative_to(dst_dir) for path in dst_dir.rglob("*") if path.is_file() - ) - if src_files != dst_files: - return False - return all( - (src_dir / rel).read_bytes() == (dst_dir / rel).read_bytes() - for rel in src_files - ) - - -def _expand_user_agents(agents: list[str]) -> tuple[list[AgentSpec], bool]: - default_all = ALL_AGENTS in agents - names = list(AGENTS) if default_all else agents - return [AGENTS[name] for name in names], default_all - - -def _project_targets(target: Path, agents: list[str]) -> list[tuple[str, Path]]: - if ALL_AGENTS in agents: - return [ - ("claude", target / PROJECT_CLAUDE_SKILLS_REL), - (PROJECT_MULTI_AGENT, target / PROJECT_AGENTS_SKILLS_REL), - ] - if agents == ["claude"]: - return [("claude", target / PROJECT_CLAUDE_SKILLS_REL)] - agent = agents[0] if agents else "" - raise ValueError( - f"--agent {agent} is not supported with --project; use --agent all or --agent claude" - ) - - -def _missing_config_row( - spec: AgentSpec, home: Path, default_all: bool -) -> ActionRow | bool | None: - parent = home / spec.parent_dir - if parent.exists(): - return None - if default_all and spec.silent_when_default_all: - return True - return ActionRow( - spec.name, - "", - "skipped", - parent, - reason=f"config dir absent at {parent}", - ) - - -def _append_write_error( - rows: list[ActionRow], - agent: str, - skill: str, - path: Path, - exc: OSError, -) -> None: - rows.append(ActionRow(agent, skill, "error", path, reason=str(exc))) - - -def install_user(skill_dir: Path, home: Path, agents: list[str]) -> InstallReport: - selected, _default_all = _expand_user_agents(agents) - rows: list[ActionRow] = [] - - for spec in selected: - skills_root = home / spec.skills_dir - try: - skills_root.mkdir(parents=True, exist_ok=True) - except OSError as exc: - _append_write_error(rows, spec.name, "", skills_root, exc) - continue - - target = skills_root / skill_dir.name - try: - if target.is_symlink(): - target.unlink() - action = "replaced" - elif target.exists() and not target.is_dir(): - target.unlink() - action = "replaced" - elif target.is_dir(): - if _tree_matches(skill_dir, target): - rows.append(ActionRow(spec.name, skill_dir.name, "noop", target)) - continue - if not os.access(target, os.W_OK): - raise PermissionError(f"permission denied: {target}") - shutil.rmtree(target) - action = "replaced" - else: - action = "installed" - _copy_tree_atomically(skill_dir, target) - except OSError as exc: - _append_write_error(rows, spec.name, skill_dir.name, target, exc) - continue - rows.append(ActionRow(spec.name, skill_dir.name, action, target)) - - return InstallReport(rows) - - -def uninstall_user(skill_dir: Path, home: Path, agents: list[str]) -> InstallReport: - selected, default_all = _expand_user_agents(agents) - rows: list[ActionRow] = [] - - for spec in selected: - skip = _missing_config_row(spec, home, default_all) - if skip is True: - continue - if isinstance(skip, ActionRow): - rows.append(skip) - continue - - skills_root = home / spec.skills_dir - target = skills_root / skill_dir.name - if not target.exists() and not target.is_symlink(): - rows.append( - ActionRow( - spec.name, - skill_dir.name, - "skipped", - target, - reason="nothing to remove", - ) - ) - continue - if target.is_symlink() or not target.is_dir(): - rows.append( - ActionRow( - spec.name, - skill_dir.name, - "error", - target, - reason="refusing to remove non-directory", - ) - ) - continue - try: - shutil.rmtree(target) - except OSError as exc: - _append_write_error(rows, spec.name, skill_dir.name, target, exc) - continue - rows.append(ActionRow(spec.name, skill_dir.name, "removed", target)) - - return InstallReport(rows) - - -def _install_project_source( - agent: str, - source: Path, - link_parent: Path, - rows: list[ActionRow], -) -> None: - link = link_parent / source.name - target = os.path.relpath(source, link_parent) - - if link.is_symlink(): - if os.readlink(link) == target: - rows.append(ActionRow(agent, source.name, "noop", link)) - return - try: - link.unlink() - link.symlink_to(target) - except OSError as exc: - _append_write_error(rows, agent, source.name, link, exc) - return - rows.append(ActionRow(agent, source.name, "replaced", link)) - return - - if link.exists(): - rows.append( - ActionRow( - agent, - source.name, - "warning", - link, - reason="user content at target preserved", - ) - ) - return - - try: - link.symlink_to(target) - except OSError as exc: - _append_write_error(rows, agent, source.name, link, exc) - return - rows.append(ActionRow(agent, source.name, "installed", link)) - - -def _remove_stale_project_links( - agent: str, - link_parent: Path, - source_names: set[str], - rows: list[ActionRow], -) -> None: - if not link_parent.is_dir(): - return - for link in sorted(link_parent.iterdir()): - if link.name in source_names: - continue - if not link.is_symlink(): - rows.append( - ActionRow( - agent, - link.name, - "warning", - link, - reason="user content at stale target preserved", - ) - ) - continue - try: - link.unlink() - except OSError as exc: - _append_write_error(rows, agent, link.name, link, exc) - continue - rows.append(ActionRow(agent, link.name, "removed", link, reason="stale")) - - -def install_project(repo_root: Path, target: Path, agents: list[str]) -> InstallReport: - sources = discover_project_sources(repo_root) - source_names = {source.name for source in sources} - rows: list[ActionRow] = [] - - for agent, link_parent in _project_targets(target, agents): - try: - link_parent.mkdir(parents=True, exist_ok=True) - except OSError as exc: - _append_write_error(rows, agent, "", link_parent, exc) - continue - for source in sources: - _install_project_source(agent, source, link_parent, rows) - _remove_stale_project_links(agent, link_parent, source_names, rows) - - return InstallReport(rows) - - -def uninstall_project( - repo_root: Path, target: Path, agents: list[str] -) -> InstallReport: - sources = discover_project_sources(repo_root) - rows: list[ActionRow] = [] - - for agent, link_parent in _project_targets(target, agents): - for source in sources: - link = link_parent / source.name - if not link.exists() and not link.is_symlink(): - rows.append( - ActionRow( - agent, - source.name, - "skipped", - link, - reason="nothing to remove", - ) - ) - continue - if not link.is_symlink(): - rows.append( - ActionRow( - agent, - source.name, - "error", - link, - reason="refusing to remove non-symlink", - ) - ) - continue - try: - link.unlink() - except OSError as exc: - _append_write_error(rows, agent, source.name, link, exc) - continue - rows.append(ActionRow(agent, source.name, "removed", link)) - - return InstallReport(rows) - - -def list_user_status(skill_dir: Path, home: Path, agents: list[str]) -> list[StatusRow]: - selected, _default_all = _expand_user_agents(agents) - rows: list[StatusRow] = [] - - for spec in selected: - target = home / spec.skills_dir / skill_dir.name - state = "installed" if (target / "SKILL.md").is_file() else "not installed" - rows.append(StatusRow(spec.name, skill_dir.name, state, target)) - - return rows - - -def list_project_status( - repo_root: Path, target: Path, agents: list[str] -) -> list[StatusRow]: - sources = discover_project_sources(repo_root) - rows: list[StatusRow] = [] - - for agent, link_parent in _project_targets(target, agents): - for source in sources: - link = link_parent / source.name - expected = os.path.relpath(source, link_parent) - state = ( - "installed" - if link.is_symlink() and os.readlink(link) == expected - else "not installed" - ) - rows.append(StatusRow(agent, source.name, state, link)) - - return rows - - -def _print_report(report: InstallReport, operation: str) -> None: - warnings: list[ActionRow] = [] - for row in report.rows: - if row.action == "noop": - continue - if row.action == "warning": - warnings.append(row) - continue - if row.action == "error": - print(f"error: {operation} {row.path}: {row.reason}", file=sys.stderr) - elif row.action == "skipped": - skill = f" {row.skill}" if row.skill else "" - print(f"skipped {row.agent}{skill} ({row.reason})") - elif row.action == "removed" and row.reason: - print(f"removed {row.agent} {row.skill} ({row.reason}) -> {row.path}") - else: - print(f"{row.action} {row.agent} {row.skill} -> {row.path}") - - if warnings: - print("Warnings:") - for row in warnings: - print(f"warning {row.agent} {row.skill} -> {row.path} ({row.reason})") - - if report.all_skipped: - print(GLOBAL_SKIP_MESSAGE) - - -def _print_status(rows: list[StatusRow]) -> None: - print(f"{'agent':<10} {'skill':<20} state") - for row in rows: - print(f"{row.agent:<10} {row.skill:<20} {row.state}") - - -def _add_agent_option(parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--agent", - choices=["claude", "codex", "gemini", ALL_AGENTS], - default=ALL_AGENTS, - help="agent registry to update", - ) - - -def _add_project_option(parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "--project", - nargs="?", - const=os.getcwd(), - default=None, - help="install project symlinks into DIR, or cwd when DIR is omitted", - ) - - -def _build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="sol skills", - description=( - "Install, uninstall, and inspect coding-agent skills. " - "User mode copies the bundled umbrella skill solstone/talent/sol/ " - "into per-agent user config dirs. Project mode symlinks the two " - "router skills (sol, journal) into the selected project directory. " - "User-mode install creates missing agent config dirs and atomically " - "replaces a changed skill target." - ), - ) - subparsers = parser.add_subparsers(dest="cmd", required=True) - - install_parser = subparsers.add_parser( - "install", - help="install skills", - description=SUBCOMMAND_DESCRIPTION, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - _add_agent_option(install_parser) - _add_project_option(install_parser) - - uninstall_parser = subparsers.add_parser( - "uninstall", - help="uninstall skills", - description=SUBCOMMAND_DESCRIPTION, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - _add_agent_option(uninstall_parser) - _add_project_option(uninstall_parser) - - list_parser = subparsers.add_parser("list", help="list skill install status") - _add_project_option(list_parser) - - build_parser = subparsers.add_parser( - "build", help="build generated router skill references" - ) - build_parser.add_argument( - "--check", - action="store_true", - help="verify generated router skill references are current without writing", - ) - - return parser - - -def _resolve_project_target(project_value: str | None) -> Path | None: - if project_value is None: - return None - return Path(project_value).expanduser().resolve() - - -def _run_report( - operation: str, - action: Callable[[Path, Path, list[str]], InstallReport], - repo_root: Path, - location: Path, - agents: list[str], -) -> int: - report = action(repo_root, location, agents) - _print_report(report, operation) - return 1 if report.error_count else 0 - - -def main() -> int: - parser = _build_parser() - args = parser.parse_args() - repo_root = Path(get_project_root()) - target = _resolve_project_target(getattr(args, "project", None)) - - try: - if args.cmd == "build": - repo_root_text = str(repo_root) - if repo_root_text not in sys.path: - sys.path.insert(0, repo_root_text) - from scripts import build_skill_references as skill_references - - if args.check: - stale = skill_references.check() - for path in stale: - print( - f"stale generated reference {path} (run `sol skills build`)", - file=sys.stderr, - ) - if stale: - return 1 - print("generated skill references are current") - return 0 - - for path in skill_references.build(): - print(f"generated {path}") - return 0 - - if args.cmd == "install": - if target is None: - skill_dir = resolve_user_skill() - return _run_report( - "install", install_user, skill_dir, Path.home(), [args.agent] - ) - return _run_report( - "install", install_project, repo_root, target, [args.agent] - ) - - if args.cmd == "uninstall": - if target is None: - skill_dir = resolve_user_skill() - return _run_report( - "uninstall", uninstall_user, skill_dir, Path.home(), [args.agent] - ) - return _run_report( - "uninstall", uninstall_project, repo_root, target, [args.agent] - ) - - if args.cmd == "list": - if target is None: - skill_dir = resolve_user_skill() - _print_status(list_user_status(skill_dir, Path.home(), [ALL_AGENTS])) - else: - _print_status(list_project_status(repo_root, target, [ALL_AGENTS])) - return 0 - except (OSError, PermissionError, ValueError) as exc: - print(f"error: {exc}", file=sys.stderr) - return 1 - - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/test_journal_doctor.py b/tests/test_journal_doctor.py index 6dd68b2c7..8121bf7eb 100644 --- a/tests/test_journal_doctor.py +++ b/tests/test_journal_doctor.py @@ -172,7 +172,7 @@ def write_foreign_plist( def install_router_skill_links(doctor, journal: Path) -> None: - sources = doctor.skills_cli.discover_project_sources(doctor.ROOT) + sources = doctor._discover_project_sources(doctor.ROOT) for rel_dir in [Path(".claude/skills"), Path(".agents/skills")]: skills_dir = journal / rel_dir skills_dir.mkdir(parents=True) @@ -1211,8 +1211,7 @@ def test_skill_state_warns_for_stale_and_missing_links_without_writing( skills_dir = journal / ".claude" / "skills" skills_dir.mkdir(parents=True) sources = { - source.name: source - for source in doctor.skills_cli.discover_project_sources(doctor.ROOT) + source.name: source for source in doctor._discover_project_sources(doctor.ROOT) } (skills_dir / "journal").symlink_to(os.path.relpath(sources["journal"], skills_dir)) (skills_dir / "entities").symlink_to( diff --git a/tests/test_journal_skill.py b/tests/test_journal_skill.py index 90cf7e3a4..550e98fe2 100644 --- a/tests/test_journal_skill.py +++ b/tests/test_journal_skill.py @@ -9,8 +9,6 @@ from pathlib import Path import pytest -from solstone.think.skills_cli import install_project - def _repo_root() -> Path: return Path(__file__).resolve().parent.parent @@ -66,12 +64,17 @@ def test_journal_template_symlinks_resolve_inside_repo(): @pytest.mark.timeout(30) def test_make_skills_idempotent(tmp_path): - """The make skills wrapper delegates to the idempotent project installer.""" + """The native project installer is idempotent for make skills' target shape.""" repo_root = _repo_root() temp_root = tmp_path / "repo" temp_root.mkdir() - shutil.copy2(repo_root / "Makefile", temp_root / "Makefile") + (temp_root / ".git").mkdir() + shutil.copy2(repo_root / "pyproject.toml", temp_root / "pyproject.toml") + bin_dir = temp_root / "bin" + bin_dir.mkdir() + native = bin_dir / "solstone-core" + shutil.copy2(repo_root / ".venv" / "bin" / "solstone-core", native) (temp_root / "solstone").mkdir() shutil.copytree( repo_root / "solstone" / "talent", @@ -94,13 +97,46 @@ def test_make_skills_idempotent(tmp_path): if path.is_symlink() } - first_report = install_project(temp_root, temp_root, ["all"]) - assert first_report.error_count == 0 + env = {"HOME": str(tmp_path / "home")} + first_run = subprocess.run( + [ + str(native), + "__solstone_identity=sol", + "skills", + "install", + "--project", + str(temp_root), + "--agent", + "all", + ], + cwd=temp_root, + env=env, + check=True, + capture_output=True, + text=True, + ) + assert first_run.stderr == "" first = link_state(temp_root) - second_report = install_project(temp_root, temp_root, ["all"]) - assert second_report.error_count == 0 + second_run = subprocess.run( + [ + str(native), + "__solstone_identity=sol", + "skills", + "install", + "--project", + str(temp_root), + "--agent", + "all", + ], + cwd=temp_root, + env=env, + check=True, + capture_output=True, + text=True, + ) + assert second_run.stderr == "" second = link_state(temp_root) assert first == second diff --git a/tests/test_skills_cli.py b/tests/test_skills_cli.py deleted file mode 100644 index 378491eba..000000000 --- a/tests/test_skills_cli.py +++ /dev/null @@ -1,537 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright (c) 2026 sol pbc - -from __future__ import annotations - -import os -import sys -from pathlib import Path - -import pytest - -from scripts import build_skill_references -from solstone.think import skills_cli -from solstone.think.skills_cli import ( - install_project, - install_user, - list_project_status, - list_user_status, - resolve_user_skill, - uninstall_user, -) - - -def _write_skill(path: Path, content: bytes | None = None) -> None: - path.mkdir(parents=True, exist_ok=True) - (path / "SKILL.md").write_bytes(content or b"---\nname: test\n---\n") - - -def _mini_user_repo(tmp_path: Path, content: bytes | None = None) -> Path: - skill_dir = tmp_path / "sol" - _write_skill(skill_dir, content) - return skill_dir - - -def _mini_project_repo(tmp_path: Path) -> Path: - repo = tmp_path / "repo" - _write_skill(repo / "solstone" / "talent" / "sol") - _write_skill(repo / "solstone" / "talent" / "journal") - _write_skill(repo / "solstone" / "talent" / "routines") - _write_skill(repo / "solstone" / "apps" / "foo" / "talent" / "bar") - return repo - - -def _home(tmp_path: Path, *parents: str) -> Path: - home = tmp_path / "home" - home.mkdir() - for parent in parents: - (home / parent).mkdir() - return home - - -def test_install_user_creates_targets_for_present_agents(tmp_path): - repo = _mini_user_repo(tmp_path, b"solstone bytes") - home = _home(tmp_path, ".claude", ".codex") - - report = install_user(repo, home, ["all"]) - - assert report.error_count == 0 - source = repo / "SKILL.md" - assert ( - home / ".claude" / "skills" / "sol" / "SKILL.md" - ).read_bytes() == source.read_bytes() - assert ( - home / ".codex" / "skills" / "sol" / "SKILL.md" - ).read_bytes() == source.read_bytes() - assert {path.name for path in (home / ".claude" / "skills").iterdir()} == {"sol"} - assert {path.name for path in (home / ".codex" / "skills").iterdir()} == {"sol"} - - -def test_install_user_creates_missing_codex_parent_dir(tmp_path): - repo = _mini_user_repo(tmp_path) - home = _home(tmp_path) - - report = install_user(repo, home, ["codex"]) - - assert report.error_count == 0 - assert (home / ".codex" / "skills" / "sol" / "SKILL.md").exists() - assert report.rows == [ - skills_cli.ActionRow( - "codex", - "sol", - "installed", - home / ".codex" / "skills" / "sol", - ) - ] - - -def test_install_user_creates_missing_gemini_parent_dir(tmp_path): - repo = _mini_user_repo(tmp_path) - home = _home(tmp_path) - - report = install_user(repo, home, ["gemini"]) - - assert report.error_count == 0 - assert (home / ".gemini" / "skills" / "sol" / "SKILL.md").exists() - assert report.rows == [ - skills_cli.ActionRow( - "gemini", - "sol", - "installed", - home / ".gemini" / "skills" / "sol", - ) - ] - - -def test_install_user_creates_all_three_when_none_exist(tmp_path): - repo = _mini_user_repo(tmp_path) - home = _home(tmp_path) - - report = install_user(repo, home, ["all"]) - - assert report.error_count == 0 - for agent in [".claude", ".codex", ".gemini"]: - assert (home / agent / "skills" / "sol" / "SKILL.md").exists() - assert [row.action for row in report.rows] == [ - "installed", - "installed", - "installed", - ] - - -def test_install_user_replaces_modified_source(tmp_path): - repo = _mini_user_repo(tmp_path, b"first") - home = _home(tmp_path, ".claude") - install_user(repo, home, ["claude"]) - (repo / "SKILL.md").write_bytes(b"second") - - report = install_user(repo, home, ["claude"]) - - assert report.error_count == 0 - assert (home / ".claude" / "skills" / "sol" / "SKILL.md").read_bytes() == b"second" - - -def test_install_user_replaces_existing_regular_file_target(tmp_path): - repo = _mini_user_repo(tmp_path, b"fresh") - home = _home(tmp_path, ".claude") - target = home / ".claude" / "skills" / "sol" - target.mkdir(parents=True) - (target / "SKILL.md").write_bytes(b"stale") - - report = install_user(repo, home, ["claude"]) - - assert report.error_count == 0 - assert (target / "SKILL.md").read_bytes() == b"fresh" - - -def test_install_user_replaces_stray_symlink_target(tmp_path): - repo = _mini_user_repo(tmp_path) - home = _home(tmp_path, ".claude") - target = home / ".claude" / "skills" / "sol" - target.parent.mkdir(parents=True) - target.symlink_to(tmp_path / "whatever") - - report = install_user(repo, home, ["claude"]) - - assert report.error_count == 0 - assert target.is_dir() - assert (target / "SKILL.md").exists() - assert report.rows[0].action == "replaced" - - -def test_install_user_replaces_stray_regular_file_target(tmp_path): - repo = _mini_user_repo(tmp_path) - home = _home(tmp_path, ".claude") - target = home / ".claude" / "skills" / "sol" - target.parent.mkdir(parents=True) - target.write_text("not a dir", encoding="utf-8") - - report = install_user(repo, home, ["claude"]) - - assert report.error_count == 0 - assert target.is_dir() - assert (target / "SKILL.md").exists() - assert report.rows[0].action == "replaced" - - -def test_install_user_permission_error_prints_clean_message(tmp_path, capsys): - repo = _mini_user_repo(tmp_path) - home = _home(tmp_path, ".claude") - target = home / ".claude" / "skills" / "sol" - target.mkdir(parents=True) - target.chmod(0o500) - try: - report = install_user(repo, home, ["claude"]) - skills_cli._print_report(report, "install") - finally: - target.chmod(0o700) - - captured = capsys.readouterr() - assert report.error_count == 1 - assert "error:" in captured.err - assert "Traceback" not in captured.err - - -def test_uninstall_user_removes_only_bundle_dirs(tmp_path): - repo = _mini_user_repo(tmp_path) - home = _home(tmp_path, ".claude") - sol = home / ".claude" / "skills" / "sol" - hop = home / ".claude" / "skills" / "hop" - _write_skill(sol) - _write_skill(hop) - - report = uninstall_user(repo, home, ["claude"]) - - assert report.error_count == 0 - assert not sol.exists() - assert hop.exists() - - -def test_uninstall_user_absent_target_is_no_op(tmp_path): - repo = _mini_user_repo(tmp_path) - home = _home(tmp_path, ".claude") - - report = uninstall_user(repo, home, ["claude"]) - - assert report.error_count == 0 - assert report.rows[0].action == "skipped" - assert report.rows[0].reason == "nothing to remove" - - -def test_install_user_agent_filter(tmp_path): - repo = _mini_user_repo(tmp_path) - home = _home(tmp_path, ".claude", ".codex") - - report = install_user(repo, home, ["claude"]) - - assert report.error_count == 0 - assert (home / ".claude" / "skills" / "sol").exists() - assert not (home / ".codex" / "skills" / "sol").exists() - - -def test_install_user_leaves_existing_solstone_bundle_untouched(tmp_path): - repo = _mini_user_repo(tmp_path) - home = _home(tmp_path, ".claude") - old_bundle = home / ".claude" / "skills" / "solstone" - old_bundle.mkdir(parents=True) - (old_bundle / "SKILL.md").write_bytes(b"old bundle") - - report = install_user(repo, home, ["claude"]) - - assert report.error_count == 0 - assert (home / ".claude" / "skills" / "sol" / "SKILL.md").exists() - assert (old_bundle / "SKILL.md").read_bytes() == b"old bundle" - assert all(row.skill != "solstone" for row in report.rows) - - -def test_install_project_creates_symlinks(tmp_path): - repo = _mini_project_repo(tmp_path) - target = tmp_path / "work" - - report = install_project(repo, target, ["all"]) - - assert report.error_count == 0 - for agent_dir in [".claude", ".agents"]: - link_parent = target / agent_dir / "skills" - for name in ["journal", "sol"]: - link = link_parent / name - assert link.is_symlink() - assert os.readlink(link) == os.path.relpath( - repo / "solstone" / "talent" / name, - link_parent, - ) - assert {path.name for path in link_parent.iterdir()} == {"journal", "sol"} - - -def test_install_project_idempotent(tmp_path): - repo = _mini_project_repo(tmp_path) - target = tmp_path / "work" - install_project(repo, target, ["all"]) - before = { - path: (os.readlink(path), path.lstat().st_mtime_ns) - for path in sorted((target / ".claude" / "skills").iterdir()) - } - - report = install_project(repo, target, ["all"]) - - after = { - path: (os.readlink(path), path.lstat().st_mtime_ns) - for path in sorted((target / ".claude" / "skills").iterdir()) - } - assert report.error_count == 0 - assert all(row.action == "noop" for row in report.rows) - assert before == after - - -def test_install_project_cleans_stale_symlinks(tmp_path): - repo = _mini_project_repo(tmp_path) - target = tmp_path / "work" - install_project(repo, target, ["all"]) - stale = target / ".claude" / "skills" / "entities" - stale.symlink_to( - os.path.relpath( - repo / "solstone" / "apps" / "foo" / "talent" / "bar", stale.parent - ) - ) - - report = install_project(repo, target, ["all"]) - - assert report.error_count == 0 - assert not stale.exists() - assert any(row.action == "removed" and row.reason == "stale" for row in report.rows) - - -def test_install_project_preserves_obsolete_user_directory_with_warning(tmp_path): - repo = _mini_project_repo(tmp_path) - target = tmp_path / "work" - obsolete = target / ".claude" / "skills" / "entities" - obsolete.mkdir(parents=True) - (obsolete / "SKILL.md").write_bytes(b"user content") - - report = install_project(repo, target, ["all"]) - - assert (obsolete / "SKILL.md").read_bytes() == b"user content" - assert report.error_count == 0 - warning = next(row for row in report.rows if row.path == obsolete) - assert warning.action == "warning" - assert warning.skill == "entities" - assert warning.reason == "user content at stale target preserved" - - -def test_install_project_dedupe_error(monkeypatch, tmp_path): - repo = _mini_project_repo(tmp_path) - monkeypatch.setattr(skills_cli, "ROUTER_SKILL_NAMES", ("sol", "sol")) - - with pytest.raises(ValueError) as exc_info: - install_project(repo, tmp_path / "work", ["all"]) - - message = str(exc_info.value) - assert "duplicate skill name 'sol'" in message - - -def test_install_project_agent_claude_only(tmp_path): - repo = _mini_project_repo(tmp_path) - target = tmp_path / "work" - - report = install_project(repo, target, ["claude"]) - - assert report.error_count == 0 - assert (target / ".claude" / "skills" / "journal").is_symlink() - assert not (target / ".agents").exists() - - -def test_install_project_rejects_codex_or_gemini(tmp_path): - repo = _mini_project_repo(tmp_path) - - with pytest.raises(ValueError, match="--agent codex is not supported"): - install_project(repo, tmp_path / "work", ["codex"]) - with pytest.raises(ValueError, match="--agent gemini is not supported"): - install_project(repo, tmp_path / "work", ["gemini"]) - - -def test_install_project_relative_target_outside_repo(tmp_path): - repo = _mini_project_repo(tmp_path) - target = tmp_path / "outside" / "work" - - install_project(repo, target, ["all"]) - - link_parent = target / ".claude" / "skills" - link = link_parent / "journal" - assert os.readlink(link) == os.path.relpath( - repo / "solstone" / "talent" / "journal", link_parent - ) - - -def test_install_project_emits_warning_for_user_content_at_target(tmp_path, capsys): - repo = _mini_project_repo(tmp_path) - target = tmp_path / "work" - link = target / ".claude" / "skills" / "journal" - link.parent.mkdir(parents=True) - link.write_bytes(b"user-content") - - report = install_project(repo, target, ["all"]) - - assert link.read_bytes() == b"user-content" - assert report.error_count == 0 - assert report.warning_count == 1 - warning = next(row for row in report.rows if row.action == "warning") - assert warning.agent == "claude" - assert warning.skill == "journal" - assert warning.path == link - assert warning.reason == "user content at target preserved" - assert ( - skills_cli._run_report("install", lambda *_args: report, repo, target, ["all"]) - == 0 - ) - assert "Warnings:" in capsys.readouterr().out - - -def test_install_project_emits_warning_for_user_directory_at_target(tmp_path): - repo = _mini_project_repo(tmp_path) - target = tmp_path / "work" - link = target / ".claude" / "skills" / "journal" - link.mkdir(parents=True) - (link / "SKILL.md").write_bytes(b"user-content") - - report = install_project(repo, target, ["all"]) - - assert (link / "SKILL.md").read_bytes() == b"user-content" - assert report.error_count == 0 - assert report.warning_count == 1 - warning = next(row for row in report.rows if row.action == "warning") - assert warning.agent == "claude" - assert warning.skill == "journal" - assert warning.path == link - assert warning.reason == "user content at target preserved" - - -def test_list_status_reports_installed_and_not_installed(tmp_path): - user_repo = _mini_user_repo(tmp_path) - home = _home(tmp_path, ".claude", ".codex") - install_user(user_repo, home, ["claude"]) - - rows = list_user_status(user_repo, home, ["all"]) - - assert ("claude", "sol", "installed") in { - (row.agent, row.skill, row.state) for row in rows - } - assert ("codex", "sol", "not installed") in { - (row.agent, row.skill, row.state) for row in rows - } - - -def test_list_project_status_reports_correct_symlink_only(tmp_path): - repo = _mini_project_repo(tmp_path) - target = tmp_path / "work" - install_project(repo, target, ["claude"]) - - rows = list_project_status(repo, target, ["all"]) - - assert ("claude", "journal", "installed") in { - (row.agent, row.skill, row.state) for row in rows - } - assert ("agents", "journal", "not installed") in { - (row.agent, row.skill, row.state) for row in rows - } - - -def test_main_install_user_default(monkeypatch, tmp_path, capsys): - repo = _mini_user_repo(tmp_path) - home = _home(tmp_path, ".claude") - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(repo)) - monkeypatch.setattr(skills_cli, "resolve_user_skill", lambda: repo) - monkeypatch.setattr(sys, "argv", ["sol skills", "install"]) - - exit_code = skills_cli.main() - - captured = capsys.readouterr() - assert exit_code == 0 - assert "installed claude sol" in captured.out - assert (home / ".claude" / "skills" / "sol" / "SKILL.md").exists() - - -def test_main_install_project_no_dir_uses_cwd(monkeypatch, tmp_path): - repo = _mini_project_repo(tmp_path) - target = tmp_path / "work" - target.mkdir() - monkeypatch.chdir(target) - monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(repo)) - monkeypatch.setattr(sys, "argv", ["sol skills", "install", "--project"]) - - exit_code = skills_cli.main() - - assert exit_code == 0 - assert (target / ".claude" / "skills" / "journal").is_symlink() - - -def test_repo_root_resolution_works_from_arbitrary_cwd(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - - result = resolve_user_skill() - - assert result.name == "sol" - assert (result / "SKILL.md").is_file() - - -def test_user_skill_missing_file_fails_loudly(monkeypatch, tmp_path, capsys): - fake_talent = tmp_path / "talent" - fake_talent.mkdir() - home = _home(tmp_path, ".claude") - monkeypatch.setattr(skills_cli.resources, "files", lambda _package: fake_talent) - - with pytest.raises(FileNotFoundError) as exc_info: - resolve_user_skill() - - assert "solstone/talent/sol/SKILL.md" in str(exc_info.value) - - monkeypatch.setenv("HOME", str(home)) - monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) - monkeypatch.setattr(sys, "argv", ["sol skills", "install"]) - - exit_code = skills_cli.main() - - captured = capsys.readouterr() - assert exit_code == 1 - assert "error:" in captured.err - assert "solstone/talent/sol/SKILL.md" in captured.err - assert "Traceback" not in captured.err - - -def test_main_build_generates_references(monkeypatch, tmp_path, capsys): - output = tmp_path / "commands.md" - monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) - monkeypatch.setattr(build_skill_references, "build", lambda: [output]) - monkeypatch.setattr(sys, "argv", ["sol skills", "build"]) - - exit_code = skills_cli.main() - - captured = capsys.readouterr() - assert exit_code == 0 - assert f"generated {output}" in captured.out - - -def test_main_build_check_green(monkeypatch, tmp_path, capsys): - monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) - monkeypatch.setattr(build_skill_references, "check", lambda: []) - monkeypatch.setattr(sys, "argv", ["sol skills", "build", "--check"]) - - exit_code = skills_cli.main() - - captured = capsys.readouterr() - assert exit_code == 0 - assert "generated skill references are current" in captured.out - - -def test_main_build_check_reports_stale_path(monkeypatch, tmp_path, capsys): - stale = tmp_path / "commands.md" - monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) - monkeypatch.setattr(build_skill_references, "check", lambda: [stale]) - monkeypatch.setattr(sys, "argv", ["sol skills", "build", "--check"]) - - exit_code = skills_cli.main() - - captured = capsys.readouterr() - assert exit_code == 1 - assert str(stale) in captured.err - assert "run `sol skills build`" in captured.err diff --git a/tests/test_sol_compat_cli.py b/tests/test_sol_compat_cli.py index 69f15a41e..75a22ccaf 100644 --- a/tests/test_sol_compat_cli.py +++ b/tests/test_sol_compat_cli.py @@ -103,9 +103,9 @@ def test_allowed_top_level_commands_forward_exact_process_state( def test_rust_and_python_top_level_compat_collections_match() -> None: - source = ( - REPO_ROOT / "core/crates/solstone-core-sol/src/lib.rs" - ).read_text(encoding="utf-8") + source = (REPO_ROOT / "core/crates/solstone-core-sol/src/lib.rs").read_text( + encoding="utf-8" + ) match = re.search( r"const\s+TOP_LEVEL_COMPAT_COMMANDS\s*:\s*&\[\s*&str\s*\]\s*=\s*&\[(?P.*?)\];", source, diff --git a/tests/test_supervisor.py b/tests/test_supervisor.py index 509d5df75..e003e028b 100644 --- a/tests/test_supervisor.py +++ b/tests/test_supervisor.py @@ -524,14 +524,12 @@ def _run_supervisor_main_for_shutdown_knobs(tmp_path, monkeypatch, *, argv): def test_app_supervised_main_uses_watcher_and_compressed_shutdown_knobs( tmp_path, monkeypatch ): - from solstone.think import install_guard, service, skills_cli + from solstone.think import install_guard, service reconcile = MagicMock() install_wrappers = MagicMock() - install_project = MagicMock() monkeypatch.setattr(service, "reconcile_installed_unit", reconcile) monkeypatch.setattr(install_guard, "install_wrappers", install_wrappers) - monkeypatch.setattr(skills_cli, "install_project", install_project) mod, captures, events, exit_now = _run_supervisor_main_for_shutdown_knobs( tmp_path, @@ -555,7 +553,6 @@ def test_app_supervised_main_uses_watcher_and_compressed_shutdown_knobs( exit_now.assert_not_called() reconcile.assert_not_called() install_wrappers.assert_not_called() - install_project.assert_not_called() def test_default_main_uses_default_shutdown_knobs(tmp_path, monkeypatch):