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):