personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

refactor(sol): remove Python sol skills implementation

Relocate _discover_project_sources and _ROUTER_SKILL_NAMES into solstone/think/doctor.py rather than sharing them, because doctor is still Python and must not shell out to the native binary. They are knowingly duplicated with core/crates/solstone-core-sol/src/skills.rs, and that duplication ends when doctor itself is ported.

Exactly one assertion is knowingly dropped for a reason other than the build verb removal: the duplicate-router-name ValueError branch from the original skills_cli.discover_project_sources. It is unreachable now that the required payload names are a declared constant of two distinct literals.

The build verb is removed from the CLI surface as a deliberate, operator-approved change. Its sys.path.insert plus from scripts import build_skill_references scaffolding is deleted outright, not ported. make skills invokes scripts/build_skill_references.py directly as of the preceding lode.

Delete scripts/capture_skills_parity_oracle.py in this same commit. The harness drives solstone.think.skills_cli directly: it subprocesses python -m solstone.think.skills_cli and monkeypatches skills_cli.get_project_root and skills_cli.resources.files. Once that module is gone, the script cannot run at all; it would be dead code, not merely stale. Its provenance role is fully preserved: the harness and the 30 vectors it produced are committed together at 5240cfaaf, strictly before this deletion, so git log over core/fixtures/native-sol/skills-parity-v1/ still proves the vectors were captured from the live Python oracle. The frozen vectors themselves remain in the tree and continue to gate the native implementation.

Leave solstone/think/setup.py deliberately untouched. Its skills_user_command() and skills_journal_command() build python -m solstone.think.sol_cli skills install ..., but skills is in JOURNAL_ACCESS_ONLY_COMMANDS, so sol_cli rejects it with exit 2 and journal setup reports both skills_user and skills_journal as failures. That defect predates this lode and is left for a follow-up; tests/test_setup.py and tests/test_setup_jsonl.py pin the current argv shape and pass unmodified.

Co-Authored-By: OpenAI Codex <codex@openai.com>

+77 -1761
+4 -3
docs/PORTING.md
··· 269 269 checked by `scripts/check_native_sol_compat.py`. The inventory is the only 270 270 authority for that command set; do not copy the list into docs or gates. The 271 271 removal criterion is zero Python delegation from supported-platform native 272 - `sol`: every remaining compatibility path has a native authority and production 273 - aggregate handler, then the compatibility inventory and module exec bridge are 274 - deleted together. 272 + `sol`: every remaining compatibility path has either a native authority with a 273 + production aggregate handler or an explicit direct native match-arm home for 274 + top-level local behavior, then the compatibility inventory and module exec 275 + bridge are deleted together. 275 276 276 277 ## Version Lockstep 277 278
+6 -1
docs/SOLCLI.md
··· 63 63 64 64 4. **Update parity fixtures and native-sol gates** for the new command. 65 65 66 + Use this authority route for top-level commands that read or write journal data 67 + through the native HTTP boundary. For local commands that touch no journal data 68 + and have no `sol call` oracle path, use a direct match arm in 69 + `solstone_core_sol::run` alongside `root`, `path`, `status`, and `skills`. 70 + 66 71 For host-only commands, use the `journal` dispatcher instead: create a Python 67 72 module with `main()` and register it in `solstone/think/sol_cli.py` with the 68 73 appropriate service or universal surface. ··· 427 432 | `awareness` | `solstone/apps/awareness/native/authority.toml` | status, imports, log, log-read | 428 433 | `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 | 429 434 430 - `sol skills` manages coding-agent skill installation. `make skills` builds generated router references via `scripts/build_skill_references.py` before invoking `sol skills install`. 435 + `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`. 431 436 432 437 ## Skill System 433 438
-532
scripts/capture_skills_parity_oracle.py
··· 1 - #!/usr/bin/env python3 2 - # SPDX-License-Identifier: AGPL-3.0-only 3 - # Copyright (c) 2026 sol pbc 4 - 5 - """Capture the temporary Python `sol skills` parity oracle. 6 - 7 - This intentionally has no ``--check`` mode, unlike 8 - ``scripts/build_journal_resolution_vectors.py``. The oracle captured here is 9 - ``solstone/think/skills_cli.py``, and that Python implementation is deleted in a 10 - later commit of this same lode. Wiring a check gate to it would leave a 11 - permanently red gate after the native Rust implementation becomes the source of 12 - truth. 13 - """ 14 - 15 - from __future__ import annotations 16 - 17 - import argparse 18 - import base64 19 - import json 20 - import os 21 - import shutil 22 - import subprocess 23 - import sys 24 - import tempfile 25 - from collections.abc import Iterable 26 - from dataclasses import dataclass, field 27 - from pathlib import Path 28 - 29 - REPO_ROOT = Path(__file__).resolve().parents[1] 30 - DEFAULT_OUTPUT = ( 31 - REPO_ROOT / "core/fixtures/native-sol/skills-parity-v1/vectors.json" 32 - ) 33 - PYTHON = REPO_ROOT / ".venv/bin/python" 34 - 35 - SCHEMA = "native-sol-skills-parity-v1" 36 - PLACEHOLDERS = { 37 - "project_root": "${PROJECT_ROOT}", 38 - "temp_root": "${TEMP_ROOT}", 39 - "home": "${HOME}", 40 - "cwd": "${CWD}", 41 - "fake_root": "${FAKE_ROOT}", 42 - } 43 - 44 - 45 - @dataclass(frozen=True) 46 - class Operation: 47 - op: str 48 - path: str 49 - content: str | None = None 50 - target: str | None = None 51 - agent: str | None = None 52 - 53 - def as_json(self) -> dict[str, str]: 54 - data = {"op": self.op, "path": self.path} 55 - if self.content is not None: 56 - data["content_b64"] = base64.b64encode(self.content.encode()).decode("ascii") 57 - if self.target is not None: 58 - data["target"] = self.target 59 - if self.agent is not None: 60 - data["agent"] = self.agent 61 - return data 62 - 63 - 64 - @dataclass(frozen=True) 65 - class VectorSpec: 66 - id: str 67 - argv: list[str] 68 - mode: str 69 - home: str 70 - cwd: str = "${PROJECT_ROOT}" 71 - project_root: str = "${PROJECT_ROOT}" 72 - setup: list[Operation] = field(default_factory=list) 73 - compare_stderr: bool = True 74 - python_overrides: bool = False 75 - 76 - 77 - def subst(text: str, values: dict[str, str]) -> str: 78 - for token in PLACEHOLDERS.values(): 79 - if token not in values: 80 - continue 81 - text = text.replace(token, values[token]) 82 - return text 83 - 84 - 85 - def normalize(text: str, values: dict[str, str]) -> str: 86 - ordered = sorted( 87 - ((value, token) for token, value in values.items()), 88 - key=lambda item: len(item[0]), 89 - reverse=True, 90 - ) 91 - for value, token in ordered: 92 - text = text.replace(value, token) 93 - return text 94 - 95 - 96 - def apply_setup(ops: Iterable[Operation], values: dict[str, str]) -> None: 97 - for op in ops: 98 - path = Path(subst(op.path, values)) 99 - if op.op == "mkdir": 100 - path.mkdir(parents=True, exist_ok=True) 101 - elif op.op == "write_file": 102 - path.parent.mkdir(parents=True, exist_ok=True) 103 - assert op.content is not None 104 - path.write_text(op.content, encoding="utf-8") 105 - elif op.op == "symlink": 106 - path.parent.mkdir(parents=True, exist_ok=True) 107 - assert op.target is not None 108 - path.symlink_to(subst(op.target, values)) 109 - elif op.op == "remove": 110 - if path.is_symlink() or path.is_file(): 111 - path.unlink() 112 - elif path.is_dir(): 113 - shutil.rmtree(path) 114 - elif op.op == "mutate_file": 115 - assert op.content is not None 116 - path.write_text(op.content, encoding="utf-8") 117 - elif op.op == "copy_user_skill": 118 - copy_files_only(REPO_ROOT / "solstone/talent/sol", path) 119 - elif op.op == "project_links": 120 - agent = op.agent or "all" 121 - create_project_links(path, agent) 122 - else: 123 - raise ValueError(f"unknown setup op {op.op!r}") 124 - 125 - 126 - def copy_files_only(src: Path, dst: Path) -> None: 127 - for source in sorted(path for path in src.rglob("*") if path.is_file()): 128 - target = dst / source.relative_to(src) 129 - target.parent.mkdir(parents=True, exist_ok=True) 130 - shutil.copyfile(source, target) 131 - 132 - 133 - def create_project_links(project: Path, agent: str) -> None: 134 - targets: list[tuple[str, Path]] 135 - if agent == "claude": 136 - targets = [("claude", project / ".claude/skills")] 137 - elif agent == "all": 138 - targets = [ 139 - ("claude", project / ".claude/skills"), 140 - ("agents", project / ".agents/skills"), 141 - ] 142 - else: 143 - raise ValueError(f"unsupported setup project_links agent {agent!r}") 144 - del targets 145 - for link_parent in ( 146 - [project / ".claude/skills"] 147 - if agent == "claude" 148 - else [project / ".claude/skills", project / ".agents/skills"] 149 - ): 150 - link_parent.mkdir(parents=True, exist_ok=True) 151 - for name in ("journal", "sol"): 152 - source = REPO_ROOT / "solstone/talent" / name 153 - (link_parent / name).symlink_to(os.path.relpath(source, link_parent)) 154 - 155 - 156 - def run_oracle(spec: VectorSpec, values: dict[str, str]) -> subprocess.CompletedProcess[bytes]: 157 - env = os.environ.copy() 158 - env["HOME"] = subst(spec.home, values) 159 - cwd = subst(spec.cwd, values) 160 - argv = [subst(arg, values) for arg in spec.argv[1:]] 161 - if spec.python_overrides: 162 - code = ( 163 - "import sys\n" 164 - "from pathlib import Path\n" 165 - "from solstone.think import skills_cli\n" 166 - f"skills_cli.get_project_root = lambda: {subst(spec.project_root, values)!r}\n" 167 - "skills_cli.resources.files = lambda _package: " 168 - f"Path({subst(spec.project_root, values)!r}) / 'solstone' / 'talent'\n" 169 - f"sys.argv = {['sol skills', *argv]!r}\n" 170 - "raise SystemExit(skills_cli.main())\n" 171 - ) 172 - return subprocess.run( 173 - [str(PYTHON), "-c", code], 174 - cwd=cwd, 175 - env=env, 176 - stdout=subprocess.PIPE, 177 - stderr=subprocess.PIPE, 178 - check=False, 179 - ) 180 - return subprocess.run( 181 - [str(PYTHON), "-m", "solstone.think.skills_cli", *argv], 182 - cwd=cwd, 183 - env=env, 184 - stdout=subprocess.PIPE, 185 - stderr=subprocess.PIPE, 186 - check=False, 187 - ) 188 - 189 - 190 - def vector_specs() -> list[VectorSpec]: 191 - return [ 192 - VectorSpec("fresh_user_install", ["skills", "install"], "user", "${TEMP_ROOT}/u_fresh"), 193 - VectorSpec( 194 - "idempotent_user_reinstall", 195 - ["skills", "install"], 196 - "user", 197 - "${TEMP_ROOT}/u_idempotent", 198 - setup=[ 199 - Operation("copy_user_skill", "${TEMP_ROOT}/u_idempotent/.claude/skills/sol"), 200 - Operation("copy_user_skill", "${TEMP_ROOT}/u_idempotent/.codex/skills/sol"), 201 - Operation("copy_user_skill", "${TEMP_ROOT}/u_idempotent/.gemini/skills/sol"), 202 - ], 203 - ), 204 - VectorSpec( 205 - "replace_on_change", 206 - ["skills", "install", "--agent", "claude"], 207 - "user", 208 - "${TEMP_ROOT}/u_replace_change", 209 - setup=[ 210 - Operation("copy_user_skill", "${TEMP_ROOT}/u_replace_change/.claude/skills/sol"), 211 - Operation( 212 - "mutate_file", 213 - "${TEMP_ROOT}/u_replace_change/.claude/skills/sol/SKILL.md", 214 - "changed\n", 215 - ), 216 - ], 217 - ), 218 - VectorSpec( 219 - "replace_on_change_initial", 220 - ["skills", "install", "--agent", "claude"], 221 - "user", 222 - "${TEMP_ROOT}/u_replace_change_initial", 223 - ), 224 - VectorSpec( 225 - "replace_symlink_target", 226 - ["skills", "install", "--agent", "claude"], 227 - "user", 228 - "${TEMP_ROOT}/u_replace_symlink", 229 - setup=[ 230 - Operation("write_file", "${TEMP_ROOT}/external_populated/keep.txt", "keep\n"), 231 - Operation( 232 - "symlink", 233 - "${TEMP_ROOT}/u_replace_symlink/.claude/skills/sol", 234 - target="${TEMP_ROOT}/external_populated", 235 - ), 236 - ], 237 - ), 238 - VectorSpec( 239 - "replace_regular_file_target", 240 - ["skills", "install", "--agent", "claude"], 241 - "user", 242 - "${TEMP_ROOT}/u_replace_file", 243 - setup=[ 244 - Operation( 245 - "write_file", 246 - "${TEMP_ROOT}/u_replace_file/.claude/skills/sol", 247 - "not a dir\n", 248 - ), 249 - ], 250 - ), 251 - VectorSpec( 252 - "user_uninstall_absent_target", 253 - ["skills", "uninstall", "--agent", "claude"], 254 - "user", 255 - "${TEMP_ROOT}/u_uninstall_absent", 256 - setup=[Operation("mkdir", "${TEMP_ROOT}/u_uninstall_absent/.claude")], 257 - ), 258 - VectorSpec( 259 - "user_uninstall_refuses_regular_file", 260 - ["skills", "uninstall", "--agent", "claude"], 261 - "user", 262 - "${TEMP_ROOT}/u_uninstall_file", 263 - setup=[ 264 - Operation( 265 - "write_file", 266 - "${TEMP_ROOT}/u_uninstall_file/.claude/skills/sol", 267 - "not a dir\n", 268 - ) 269 - ], 270 - ), 271 - VectorSpec( 272 - "user_uninstall_refuses_symlink", 273 - ["skills", "uninstall", "--agent", "claude"], 274 - "user", 275 - "${TEMP_ROOT}/u_uninstall_symlink", 276 - setup=[ 277 - Operation("mkdir", "${TEMP_ROOT}/external_uninstall"), 278 - Operation( 279 - "symlink", 280 - "${TEMP_ROOT}/u_uninstall_symlink/.claude/skills/sol", 281 - target="${TEMP_ROOT}/external_uninstall", 282 - ), 283 - ], 284 - ), 285 - VectorSpec( 286 - "user_install_absent_agent_config", 287 - ["skills", "install", "--agent", "codex"], 288 - "user", 289 - "${TEMP_ROOT}/u_absent_install", 290 - ), 291 - VectorSpec( 292 - "user_uninstall_absent_single_config", 293 - ["skills", "uninstall", "--agent", "claude"], 294 - "user", 295 - "${TEMP_ROOT}/u_absent_uninstall", 296 - ), 297 - VectorSpec( 298 - "user_uninstall_absent_agent_config", 299 - ["skills", "uninstall"], 300 - "user", 301 - "${TEMP_ROOT}/u_global_skip", 302 - ), 303 - VectorSpec( 304 - "explicit_gemini_absent_not_silent", 305 - ["skills", "uninstall", "--agent", "gemini"], 306 - "user", 307 - "${TEMP_ROOT}/u_gemini_explicit", 308 - ), 309 - VectorSpec( 310 - "project_install", 311 - ["skills", "install", "--project", "${TEMP_ROOT}/p_install", "--agent", "all"], 312 - "project", 313 - "${TEMP_ROOT}/p_home", 314 - ), 315 - VectorSpec( 316 - "project_idempotent_reinstall", 317 - ["skills", "install", "--project", "${TEMP_ROOT}/p_idempotent", "--agent", "all"], 318 - "project", 319 - "${TEMP_ROOT}/p_home_idempotent", 320 - setup=[Operation("project_links", "${TEMP_ROOT}/p_idempotent", agent="all")], 321 - ), 322 - VectorSpec( 323 - "project_link_target_changed", 324 - ["skills", "install", "--project", "${TEMP_ROOT}/p_link_changed", "--agent", "all"], 325 - "project", 326 - "${TEMP_ROOT}/p_home_link_changed", 327 - setup=[ 328 - Operation("project_links", "${TEMP_ROOT}/p_link_changed", agent="all"), 329 - Operation("remove", "${TEMP_ROOT}/p_link_changed/.claude/skills/sol"), 330 - Operation( 331 - "symlink", 332 - "${TEMP_ROOT}/p_link_changed/.claude/skills/sol", 333 - target="bogus-target", 334 - ), 335 - ], 336 - ), 337 - VectorSpec( 338 - "project_user_content_preserved", 339 - ["skills", "install", "--project", "${TEMP_ROOT}/p_user_content", "--agent", "all"], 340 - "project", 341 - "${TEMP_ROOT}/p_home_user_content", 342 - setup=[ 343 - Operation( 344 - "write_file", 345 - "${TEMP_ROOT}/p_user_content/.claude/skills/journal", 346 - "user-content\n", 347 - ) 348 - ], 349 - ), 350 - VectorSpec( 351 - "project_stale_link_removal", 352 - ["skills", "install", "--project", "${TEMP_ROOT}/p_stale_link", "--agent", "all"], 353 - "project", 354 - "${TEMP_ROOT}/p_home_stale_link", 355 - setup=[ 356 - Operation("project_links", "${TEMP_ROOT}/p_stale_link", agent="all"), 357 - Operation( 358 - "symlink", 359 - "${TEMP_ROOT}/p_stale_link/.claude/skills/entities", 360 - target="../../../solstone/apps/entities/talent/entities", 361 - ), 362 - ], 363 - ), 364 - VectorSpec( 365 - "project_stale_non_symlink_preserved", 366 - ["skills", "install", "--project", "${TEMP_ROOT}/p_stale_content", "--agent", "all"], 367 - "project", 368 - "${TEMP_ROOT}/p_home_stale_content", 369 - setup=[ 370 - Operation("project_links", "${TEMP_ROOT}/p_stale_content", agent="all"), 371 - Operation( 372 - "write_file", 373 - "${TEMP_ROOT}/p_stale_content/.claude/skills/entities/SKILL.md", 374 - "user stale\n", 375 - ), 376 - ], 377 - ), 378 - VectorSpec( 379 - "project_uninstall", 380 - ["skills", "uninstall", "--project", "${TEMP_ROOT}/p_uninstall", "--agent", "all"], 381 - "project", 382 - "${TEMP_ROOT}/p_home_uninstall", 383 - setup=[Operation("project_links", "${TEMP_ROOT}/p_uninstall", agent="all")], 384 - ), 385 - VectorSpec( 386 - "list_user_mode", 387 - ["skills", "list"], 388 - "user", 389 - "${TEMP_ROOT}/u_list", 390 - setup=[Operation("copy_user_skill", "${TEMP_ROOT}/u_list/.claude/skills/sol")], 391 - ), 392 - VectorSpec( 393 - "list_project_mode", 394 - ["skills", "list", "--project", "${TEMP_ROOT}/p_list"], 395 - "project", 396 - "${TEMP_ROOT}/p_home_list", 397 - setup=[Operation("project_links", "${TEMP_ROOT}/p_list", agent="all")], 398 - ), 399 - VectorSpec( 400 - "list_rejects_agent", 401 - ["skills", "list", "--agent", "claude"], 402 - "usage", 403 - "${TEMP_ROOT}/u_list_agent_error", 404 - compare_stderr=False, 405 - ), 406 - VectorSpec( 407 - "project_rejects_codex", 408 - ["skills", "install", "--project", "${TEMP_ROOT}/p_reject_codex", "--agent", "codex"], 409 - "project", 410 - "${TEMP_ROOT}/p_home_reject_codex", 411 - ), 412 - VectorSpec( 413 - "project_rejects_gemini", 414 - ["skills", "install", "--project", "${TEMP_ROOT}/p_reject_gemini", "--agent", "gemini"], 415 - "project", 416 - "${TEMP_ROOT}/p_home_reject_gemini", 417 - ), 418 - VectorSpec( 419 - "project_no_value_uses_cwd", 420 - ["skills", "install", "--project", "--agent", "claude"], 421 - "project", 422 - "${TEMP_ROOT}/p_home_const_cwd", 423 - cwd="${TEMP_ROOT}/p_const_cwd", 424 - setup=[Operation("mkdir", "${TEMP_ROOT}/p_const_cwd")], 425 - ), 426 - VectorSpec( 427 - "project_tilde_expands", 428 - ["skills", "install", "--project", "~/foo", "--agent", "claude"], 429 - "project", 430 - "${TEMP_ROOT}/p_home_tilde", 431 - ), 432 - VectorSpec( 433 - "project_nonexistent_dir_created", 434 - ["skills", "install", "--project", "${TEMP_ROOT}/p_nonexistent/deep", "--agent", "claude"], 435 - "project", 436 - "${TEMP_ROOT}/p_home_nonexistent", 437 - ), 438 - VectorSpec( 439 - "payload_missing_user_mode", 440 - ["skills", "install", "--agent", "claude"], 441 - "payload-missing", 442 - "${TEMP_ROOT}/missing_user_home", 443 - project_root="${FAKE_ROOT}", 444 - python_overrides=True, 445 - ), 446 - VectorSpec( 447 - "payload_missing_project_mode", 448 - [ 449 - "skills", 450 - "install", 451 - "--project", 452 - "${TEMP_ROOT}/missing_project_target", 453 - "--agent", 454 - "claude", 455 - ], 456 - "payload-missing", 457 - "${TEMP_ROOT}/missing_project_home", 458 - project_root="${FAKE_ROOT}", 459 - python_overrides=True, 460 - ), 461 - ] 462 - 463 - 464 - def fake_root(values: dict[str, str]) -> None: 465 - root = Path(values["${FAKE_ROOT}"]) 466 - (root / "solstone/talent").mkdir(parents=True, exist_ok=True) 467 - 468 - 469 - def capture(output: Path) -> None: 470 - with tempfile.TemporaryDirectory(prefix="solstone-skills-oracle-") as temp: 471 - temp_root = Path(temp) 472 - fake = temp_root / "fake-root" 473 - values = { 474 - "${PROJECT_ROOT}": str(REPO_ROOT), 475 - "${TEMP_ROOT}": str(temp_root), 476 - "${FAKE_ROOT}": str(fake), 477 - } 478 - fake_root(values) 479 - vectors = [] 480 - for spec in vector_specs(): 481 - home = Path(subst(spec.home, values)) 482 - cwd = Path(subst(spec.cwd, values)) 483 - home.mkdir(parents=True, exist_ok=True) 484 - cwd.mkdir(parents=True, exist_ok=True) 485 - local_values = { 486 - **values, 487 - "${HOME}": str(home), 488 - "${CWD}": str(cwd), 489 - } 490 - apply_setup(spec.setup, local_values) 491 - result = run_oracle(spec, local_values) 492 - expected = { 493 - "stdout": normalize(result.stdout.decode("utf-8"), local_values), 494 - "stderr": normalize(result.stderr.decode("utf-8"), local_values), 495 - "exit": result.returncode, 496 - } 497 - if not spec.compare_stderr: 498 - expected["compare_stderr"] = False 499 - expected["stderr"] = "" 500 - vectors.append( 501 - { 502 - "id": spec.id, 503 - "argv": spec.argv, 504 - "mode": spec.mode, 505 - "home": spec.home, 506 - "cwd": spec.cwd, 507 - "project_root": spec.project_root, 508 - "setup": [op.as_json() for op in spec.setup], 509 - "expected": expected, 510 - } 511 - ) 512 - 513 - data = { 514 - "schema": SCHEMA, 515 - "placeholders": PLACEHOLDERS, 516 - "vectors": vectors, 517 - } 518 - output.parent.mkdir(parents=True, exist_ok=True) 519 - output.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") 520 - print(f"wrote {output}") 521 - 522 - 523 - def main(argv: list[str] | None = None) -> int: 524 - parser = argparse.ArgumentParser(description=__doc__) 525 - parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) 526 - args = parser.parse_args(argv) 527 - capture(args.output) 528 - return 0 529 - 530 - 531 - if __name__ == "__main__": 532 - raise SystemExit(main())
-1
scripts/check_journal_io_mechanic.py
··· 119 119 "solstone/apps/home/routes.py", 120 120 "solstone/apps/transcripts/routes.py", 121 121 "solstone/think/data_state.py", 122 - "solstone/think/skills_cli.py", 123 122 } 124 123 ) 125 124
+17 -3
solstone/think/doctor.py
··· 46 46 from typing import IO, Callable, Sequence 47 47 48 48 from solstone.think import features as _features 49 - from solstone.think import maint, parakeet_readiness, skills_cli 49 + from solstone.think import maint, parakeet_readiness 50 50 from solstone.think.health_cli import fetch_supervisor_status 51 51 from solstone.think.media import PDF_EXTENSIONS 52 52 from solstone.think.probe import ( ··· 110 110 "journal_package_version", "blocker", ("linux", "darwin") 111 111 ) 112 112 RETIRED_HOST_SHIM_CHECK = Check("retired_host_shim", "advisory", ("linux", "darwin")) 113 + _ROUTER_SKILL_NAMES = ("sol", "journal") 113 114 _HOST_DEPENDENCY_MODULES = ( 114 115 ("frontmatter", "python-frontmatter"), 115 116 ("flask", "Flask"), ··· 779 780 ) 780 781 781 782 783 + def _discover_project_sources(repo_root: Path) -> list[Path]: 784 + # Knowingly duplicated with core/crates/solstone-core-sol/src/skills.rs 785 + # until doctor is ported out of Python. 786 + sources = [] 787 + for name in _ROUTER_SKILL_NAMES: 788 + source = repo_root / "solstone" / "talent" / name 789 + skill_file = source / "SKILL.md" 790 + if not skill_file.is_file(): 791 + raise FileNotFoundError(f"expected project skill at {skill_file}") 792 + sources.append(source) 793 + return sorted(sources) 794 + 795 + 782 796 def _skill_state_problem_detail( 783 797 skills_dir: Path, expected_sources: dict[str, Path] 784 798 ) -> list[str]: ··· 819 833 return make_result(check, "skip", "no local journal") 820 834 821 835 try: 822 - sources = skills_cli.discover_project_sources(ROOT) 836 + sources = _discover_project_sources(ROOT) 823 837 except Exception as exc: 824 838 return make_result(check, "skip", f"project skill sources unavailable: {exc}") 825 839 ··· 838 852 839 853 if not problems: 840 854 names = ", ".join( 841 - name for name in skills_cli.ROUTER_SKILL_NAMES if name in expected_sources 855 + name for name in _ROUTER_SKILL_NAMES if name in expected_sources 842 856 ) 843 857 return make_result( 844 858 check,
-666
solstone/think/skills_cli.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """sol skills — build, install, uninstall, and inspect coding-agent skills. 5 - 6 - Two install modes: 7 - 8 - - User mode (default): copies the bundled umbrella skill 9 - solstone/talent/sol/ into per-agent user config directories 10 - (~/.claude/skills/, ~/.codex/skills/, ~/.gemini/skills/). 11 - - Project mode (--project [DIR]): symlinks the two router skills (sol, journal) 12 - into <DIR>/.claude/skills/ and <DIR>/.agents/skills/. 13 - 14 - Subcommands: build, install, uninstall, list. 15 - """ 16 - 17 - from __future__ import annotations 18 - 19 - import argparse 20 - import os 21 - import shutil 22 - import sys 23 - import tempfile 24 - from dataclasses import dataclass 25 - from importlib import resources 26 - from pathlib import Path 27 - from typing import Callable 28 - 29 - from solstone.think.utils import get_project_root 30 - 31 - ALL_AGENTS = "all" 32 - PROJECT_MULTI_AGENT = "agents" 33 - PROJECT_CLAUDE_SKILLS_REL = ".claude/skills" 34 - PROJECT_AGENTS_SKILLS_REL = ".agents/skills" 35 - ROUTER_SKILL_NAMES = ("sol", "journal") 36 - GLOBAL_SKIP_MESSAGE = ( 37 - "no AI coding agent config directories found — skipping skill registration" 38 - ) 39 - SUBCOMMAND_DESCRIPTION = """User mode: copies/removes the bundled umbrella skill solstone/talent/sol/ in per-agent user config dirs. 40 - Project mode: symlinks/removes the two router skills (sol, journal) under DIR. 41 - User-mode install creates missing agent config dirs and atomically replaces a 42 - changed skill target.""" 43 - 44 - 45 - @dataclass(frozen=True) 46 - class AgentSpec: 47 - name: str 48 - display_name: str 49 - parent_dir: str 50 - skills_dir: str 51 - silent_when_default_all: bool 52 - 53 - 54 - @dataclass(frozen=True) 55 - class ActionRow: 56 - agent: str 57 - skill: str 58 - action: str 59 - path: Path 60 - reason: str | None = None 61 - 62 - 63 - @dataclass 64 - class InstallReport: 65 - rows: list[ActionRow] 66 - 67 - @property 68 - def error_count(self) -> int: 69 - return sum(1 for row in self.rows if row.action == "error") 70 - 71 - @property 72 - def warning_count(self) -> int: 73 - return sum(1 for row in self.rows if row.action == "warning") 74 - 75 - @property 76 - def all_skipped(self) -> bool: 77 - return bool(self.rows) and all( 78 - row.action == "skipped" 79 - and row.reason is not None 80 - and row.reason.startswith("config dir absent at ") 81 - for row in self.rows 82 - ) 83 - 84 - 85 - @dataclass(frozen=True) 86 - class StatusRow: 87 - agent: str 88 - skill: str 89 - state: str 90 - path: Path 91 - 92 - 93 - AGENTS: dict[str, AgentSpec] = { 94 - "claude": AgentSpec( 95 - name="claude", 96 - display_name="Claude Code", 97 - parent_dir=".claude", 98 - skills_dir=PROJECT_CLAUDE_SKILLS_REL, 99 - silent_when_default_all=False, 100 - ), 101 - "codex": AgentSpec( 102 - name="codex", 103 - display_name="Codex", 104 - parent_dir=".codex", 105 - skills_dir=".codex/skills", 106 - silent_when_default_all=False, 107 - ), 108 - "gemini": AgentSpec( 109 - name="gemini", 110 - display_name="Gemini", 111 - parent_dir=".gemini", 112 - skills_dir=".gemini/skills", 113 - silent_when_default_all=True, 114 - ), 115 - } 116 - 117 - 118 - def resolve_user_skill() -> Path: 119 - """Return the bundled umbrella user skill source directory.""" 120 - skill_dir = Path(str(resources.files("solstone.talent") / "sol")) 121 - skill_file = skill_dir / "SKILL.md" 122 - if not skill_file.is_file(): 123 - raise FileNotFoundError( 124 - "expected bundled umbrella skill at " 125 - f"solstone/talent/sol/SKILL.md ({skill_file})" 126 - ) 127 - return skill_dir 128 - 129 - 130 - def discover_project_sources(repo_root: Path) -> list[Path]: 131 - """Return project skill source directories, rejecting duplicate names.""" 132 - sources: list[Path] = [] 133 - for name in ROUTER_SKILL_NAMES: 134 - source = repo_root / "solstone" / "talent" / name 135 - skill_file = source / "SKILL.md" 136 - if not skill_file.is_file(): 137 - raise FileNotFoundError(f"expected project skill at {skill_file}") 138 - sources.append(source) 139 - sources = sorted(sources) 140 - seen: dict[str, Path] = {} 141 - for source in sources: 142 - previous = seen.get(source.name) 143 - if previous is not None: 144 - raise ValueError( 145 - f"duplicate skill name {source.name!r}: {previous} and {source}" 146 - ) 147 - seen[source.name] = source 148 - return sources 149 - 150 - 151 - def _atomic_copy_file(src: Path, dst: Path) -> None: 152 - dst.parent.mkdir(parents=True, exist_ok=True) 153 - fd, temp_path = tempfile.mkstemp(dir=dst.parent, prefix=".tmp_", suffix=".tmp") 154 - try: 155 - with os.fdopen(fd, "wb") as out_file: 156 - with src.open("rb") as in_file: 157 - shutil.copyfileobj(in_file, out_file) 158 - os.replace(temp_path, dst) 159 - except Exception: 160 - try: 161 - os.unlink(temp_path) 162 - except Exception: 163 - pass 164 - raise 165 - 166 - 167 - def _copy_tree_atomically(src_dir: Path, dst_dir: Path) -> str: 168 - existed = dst_dir.exists() 169 - dst_dir.mkdir(parents=True, exist_ok=True) 170 - for src in sorted(path for path in src_dir.rglob("*") if path.is_file()): 171 - _atomic_copy_file(src, dst_dir / src.relative_to(src_dir)) 172 - return "replaced" if existed else "installed" 173 - 174 - 175 - def _tree_matches(src_dir: Path, dst_dir: Path) -> bool: 176 - src_files = sorted( 177 - path.relative_to(src_dir) for path in src_dir.rglob("*") if path.is_file() 178 - ) 179 - dst_files = sorted( 180 - path.relative_to(dst_dir) for path in dst_dir.rglob("*") if path.is_file() 181 - ) 182 - if src_files != dst_files: 183 - return False 184 - return all( 185 - (src_dir / rel).read_bytes() == (dst_dir / rel).read_bytes() 186 - for rel in src_files 187 - ) 188 - 189 - 190 - def _expand_user_agents(agents: list[str]) -> tuple[list[AgentSpec], bool]: 191 - default_all = ALL_AGENTS in agents 192 - names = list(AGENTS) if default_all else agents 193 - return [AGENTS[name] for name in names], default_all 194 - 195 - 196 - def _project_targets(target: Path, agents: list[str]) -> list[tuple[str, Path]]: 197 - if ALL_AGENTS in agents: 198 - return [ 199 - ("claude", target / PROJECT_CLAUDE_SKILLS_REL), 200 - (PROJECT_MULTI_AGENT, target / PROJECT_AGENTS_SKILLS_REL), 201 - ] 202 - if agents == ["claude"]: 203 - return [("claude", target / PROJECT_CLAUDE_SKILLS_REL)] 204 - agent = agents[0] if agents else "" 205 - raise ValueError( 206 - f"--agent {agent} is not supported with --project; use --agent all or --agent claude" 207 - ) 208 - 209 - 210 - def _missing_config_row( 211 - spec: AgentSpec, home: Path, default_all: bool 212 - ) -> ActionRow | bool | None: 213 - parent = home / spec.parent_dir 214 - if parent.exists(): 215 - return None 216 - if default_all and spec.silent_when_default_all: 217 - return True 218 - return ActionRow( 219 - spec.name, 220 - "", 221 - "skipped", 222 - parent, 223 - reason=f"config dir absent at {parent}", 224 - ) 225 - 226 - 227 - def _append_write_error( 228 - rows: list[ActionRow], 229 - agent: str, 230 - skill: str, 231 - path: Path, 232 - exc: OSError, 233 - ) -> None: 234 - rows.append(ActionRow(agent, skill, "error", path, reason=str(exc))) 235 - 236 - 237 - def install_user(skill_dir: Path, home: Path, agents: list[str]) -> InstallReport: 238 - selected, _default_all = _expand_user_agents(agents) 239 - rows: list[ActionRow] = [] 240 - 241 - for spec in selected: 242 - skills_root = home / spec.skills_dir 243 - try: 244 - skills_root.mkdir(parents=True, exist_ok=True) 245 - except OSError as exc: 246 - _append_write_error(rows, spec.name, "", skills_root, exc) 247 - continue 248 - 249 - target = skills_root / skill_dir.name 250 - try: 251 - if target.is_symlink(): 252 - target.unlink() 253 - action = "replaced" 254 - elif target.exists() and not target.is_dir(): 255 - target.unlink() 256 - action = "replaced" 257 - elif target.is_dir(): 258 - if _tree_matches(skill_dir, target): 259 - rows.append(ActionRow(spec.name, skill_dir.name, "noop", target)) 260 - continue 261 - if not os.access(target, os.W_OK): 262 - raise PermissionError(f"permission denied: {target}") 263 - shutil.rmtree(target) 264 - action = "replaced" 265 - else: 266 - action = "installed" 267 - _copy_tree_atomically(skill_dir, target) 268 - except OSError as exc: 269 - _append_write_error(rows, spec.name, skill_dir.name, target, exc) 270 - continue 271 - rows.append(ActionRow(spec.name, skill_dir.name, action, target)) 272 - 273 - return InstallReport(rows) 274 - 275 - 276 - def uninstall_user(skill_dir: Path, home: Path, agents: list[str]) -> InstallReport: 277 - selected, default_all = _expand_user_agents(agents) 278 - rows: list[ActionRow] = [] 279 - 280 - for spec in selected: 281 - skip = _missing_config_row(spec, home, default_all) 282 - if skip is True: 283 - continue 284 - if isinstance(skip, ActionRow): 285 - rows.append(skip) 286 - continue 287 - 288 - skills_root = home / spec.skills_dir 289 - target = skills_root / skill_dir.name 290 - if not target.exists() and not target.is_symlink(): 291 - rows.append( 292 - ActionRow( 293 - spec.name, 294 - skill_dir.name, 295 - "skipped", 296 - target, 297 - reason="nothing to remove", 298 - ) 299 - ) 300 - continue 301 - if target.is_symlink() or not target.is_dir(): 302 - rows.append( 303 - ActionRow( 304 - spec.name, 305 - skill_dir.name, 306 - "error", 307 - target, 308 - reason="refusing to remove non-directory", 309 - ) 310 - ) 311 - continue 312 - try: 313 - shutil.rmtree(target) 314 - except OSError as exc: 315 - _append_write_error(rows, spec.name, skill_dir.name, target, exc) 316 - continue 317 - rows.append(ActionRow(spec.name, skill_dir.name, "removed", target)) 318 - 319 - return InstallReport(rows) 320 - 321 - 322 - def _install_project_source( 323 - agent: str, 324 - source: Path, 325 - link_parent: Path, 326 - rows: list[ActionRow], 327 - ) -> None: 328 - link = link_parent / source.name 329 - target = os.path.relpath(source, link_parent) 330 - 331 - if link.is_symlink(): 332 - if os.readlink(link) == target: 333 - rows.append(ActionRow(agent, source.name, "noop", link)) 334 - return 335 - try: 336 - link.unlink() 337 - link.symlink_to(target) 338 - except OSError as exc: 339 - _append_write_error(rows, agent, source.name, link, exc) 340 - return 341 - rows.append(ActionRow(agent, source.name, "replaced", link)) 342 - return 343 - 344 - if link.exists(): 345 - rows.append( 346 - ActionRow( 347 - agent, 348 - source.name, 349 - "warning", 350 - link, 351 - reason="user content at target preserved", 352 - ) 353 - ) 354 - return 355 - 356 - try: 357 - link.symlink_to(target) 358 - except OSError as exc: 359 - _append_write_error(rows, agent, source.name, link, exc) 360 - return 361 - rows.append(ActionRow(agent, source.name, "installed", link)) 362 - 363 - 364 - def _remove_stale_project_links( 365 - agent: str, 366 - link_parent: Path, 367 - source_names: set[str], 368 - rows: list[ActionRow], 369 - ) -> None: 370 - if not link_parent.is_dir(): 371 - return 372 - for link in sorted(link_parent.iterdir()): 373 - if link.name in source_names: 374 - continue 375 - if not link.is_symlink(): 376 - rows.append( 377 - ActionRow( 378 - agent, 379 - link.name, 380 - "warning", 381 - link, 382 - reason="user content at stale target preserved", 383 - ) 384 - ) 385 - continue 386 - try: 387 - link.unlink() 388 - except OSError as exc: 389 - _append_write_error(rows, agent, link.name, link, exc) 390 - continue 391 - rows.append(ActionRow(agent, link.name, "removed", link, reason="stale")) 392 - 393 - 394 - def install_project(repo_root: Path, target: Path, agents: list[str]) -> InstallReport: 395 - sources = discover_project_sources(repo_root) 396 - source_names = {source.name for source in sources} 397 - rows: list[ActionRow] = [] 398 - 399 - for agent, link_parent in _project_targets(target, agents): 400 - try: 401 - link_parent.mkdir(parents=True, exist_ok=True) 402 - except OSError as exc: 403 - _append_write_error(rows, agent, "", link_parent, exc) 404 - continue 405 - for source in sources: 406 - _install_project_source(agent, source, link_parent, rows) 407 - _remove_stale_project_links(agent, link_parent, source_names, rows) 408 - 409 - return InstallReport(rows) 410 - 411 - 412 - def uninstall_project( 413 - repo_root: Path, target: Path, agents: list[str] 414 - ) -> InstallReport: 415 - sources = discover_project_sources(repo_root) 416 - rows: list[ActionRow] = [] 417 - 418 - for agent, link_parent in _project_targets(target, agents): 419 - for source in sources: 420 - link = link_parent / source.name 421 - if not link.exists() and not link.is_symlink(): 422 - rows.append( 423 - ActionRow( 424 - agent, 425 - source.name, 426 - "skipped", 427 - link, 428 - reason="nothing to remove", 429 - ) 430 - ) 431 - continue 432 - if not link.is_symlink(): 433 - rows.append( 434 - ActionRow( 435 - agent, 436 - source.name, 437 - "error", 438 - link, 439 - reason="refusing to remove non-symlink", 440 - ) 441 - ) 442 - continue 443 - try: 444 - link.unlink() 445 - except OSError as exc: 446 - _append_write_error(rows, agent, source.name, link, exc) 447 - continue 448 - rows.append(ActionRow(agent, source.name, "removed", link)) 449 - 450 - return InstallReport(rows) 451 - 452 - 453 - def list_user_status(skill_dir: Path, home: Path, agents: list[str]) -> list[StatusRow]: 454 - selected, _default_all = _expand_user_agents(agents) 455 - rows: list[StatusRow] = [] 456 - 457 - for spec in selected: 458 - target = home / spec.skills_dir / skill_dir.name 459 - state = "installed" if (target / "SKILL.md").is_file() else "not installed" 460 - rows.append(StatusRow(spec.name, skill_dir.name, state, target)) 461 - 462 - return rows 463 - 464 - 465 - def list_project_status( 466 - repo_root: Path, target: Path, agents: list[str] 467 - ) -> list[StatusRow]: 468 - sources = discover_project_sources(repo_root) 469 - rows: list[StatusRow] = [] 470 - 471 - for agent, link_parent in _project_targets(target, agents): 472 - for source in sources: 473 - link = link_parent / source.name 474 - expected = os.path.relpath(source, link_parent) 475 - state = ( 476 - "installed" 477 - if link.is_symlink() and os.readlink(link) == expected 478 - else "not installed" 479 - ) 480 - rows.append(StatusRow(agent, source.name, state, link)) 481 - 482 - return rows 483 - 484 - 485 - def _print_report(report: InstallReport, operation: str) -> None: 486 - warnings: list[ActionRow] = [] 487 - for row in report.rows: 488 - if row.action == "noop": 489 - continue 490 - if row.action == "warning": 491 - warnings.append(row) 492 - continue 493 - if row.action == "error": 494 - print(f"error: {operation} {row.path}: {row.reason}", file=sys.stderr) 495 - elif row.action == "skipped": 496 - skill = f" {row.skill}" if row.skill else "" 497 - print(f"skipped {row.agent}{skill} ({row.reason})") 498 - elif row.action == "removed" and row.reason: 499 - print(f"removed {row.agent} {row.skill} ({row.reason}) -> {row.path}") 500 - else: 501 - print(f"{row.action} {row.agent} {row.skill} -> {row.path}") 502 - 503 - if warnings: 504 - print("Warnings:") 505 - for row in warnings: 506 - print(f"warning {row.agent} {row.skill} -> {row.path} ({row.reason})") 507 - 508 - if report.all_skipped: 509 - print(GLOBAL_SKIP_MESSAGE) 510 - 511 - 512 - def _print_status(rows: list[StatusRow]) -> None: 513 - print(f"{'agent':<10} {'skill':<20} state") 514 - for row in rows: 515 - print(f"{row.agent:<10} {row.skill:<20} {row.state}") 516 - 517 - 518 - def _add_agent_option(parser: argparse.ArgumentParser) -> None: 519 - parser.add_argument( 520 - "--agent", 521 - choices=["claude", "codex", "gemini", ALL_AGENTS], 522 - default=ALL_AGENTS, 523 - help="agent registry to update", 524 - ) 525 - 526 - 527 - def _add_project_option(parser: argparse.ArgumentParser) -> None: 528 - parser.add_argument( 529 - "--project", 530 - nargs="?", 531 - const=os.getcwd(), 532 - default=None, 533 - help="install project symlinks into DIR, or cwd when DIR is omitted", 534 - ) 535 - 536 - 537 - def _build_parser() -> argparse.ArgumentParser: 538 - parser = argparse.ArgumentParser( 539 - prog="sol skills", 540 - description=( 541 - "Install, uninstall, and inspect coding-agent skills. " 542 - "User mode copies the bundled umbrella skill solstone/talent/sol/ " 543 - "into per-agent user config dirs. Project mode symlinks the two " 544 - "router skills (sol, journal) into the selected project directory. " 545 - "User-mode install creates missing agent config dirs and atomically " 546 - "replaces a changed skill target." 547 - ), 548 - ) 549 - subparsers = parser.add_subparsers(dest="cmd", required=True) 550 - 551 - install_parser = subparsers.add_parser( 552 - "install", 553 - help="install skills", 554 - description=SUBCOMMAND_DESCRIPTION, 555 - formatter_class=argparse.RawDescriptionHelpFormatter, 556 - ) 557 - _add_agent_option(install_parser) 558 - _add_project_option(install_parser) 559 - 560 - uninstall_parser = subparsers.add_parser( 561 - "uninstall", 562 - help="uninstall skills", 563 - description=SUBCOMMAND_DESCRIPTION, 564 - formatter_class=argparse.RawDescriptionHelpFormatter, 565 - ) 566 - _add_agent_option(uninstall_parser) 567 - _add_project_option(uninstall_parser) 568 - 569 - list_parser = subparsers.add_parser("list", help="list skill install status") 570 - _add_project_option(list_parser) 571 - 572 - build_parser = subparsers.add_parser( 573 - "build", help="build generated router skill references" 574 - ) 575 - build_parser.add_argument( 576 - "--check", 577 - action="store_true", 578 - help="verify generated router skill references are current without writing", 579 - ) 580 - 581 - return parser 582 - 583 - 584 - def _resolve_project_target(project_value: str | None) -> Path | None: 585 - if project_value is None: 586 - return None 587 - return Path(project_value).expanduser().resolve() 588 - 589 - 590 - def _run_report( 591 - operation: str, 592 - action: Callable[[Path, Path, list[str]], InstallReport], 593 - repo_root: Path, 594 - location: Path, 595 - agents: list[str], 596 - ) -> int: 597 - report = action(repo_root, location, agents) 598 - _print_report(report, operation) 599 - return 1 if report.error_count else 0 600 - 601 - 602 - def main() -> int: 603 - parser = _build_parser() 604 - args = parser.parse_args() 605 - repo_root = Path(get_project_root()) 606 - target = _resolve_project_target(getattr(args, "project", None)) 607 - 608 - try: 609 - if args.cmd == "build": 610 - repo_root_text = str(repo_root) 611 - if repo_root_text not in sys.path: 612 - sys.path.insert(0, repo_root_text) 613 - from scripts import build_skill_references as skill_references 614 - 615 - if args.check: 616 - stale = skill_references.check() 617 - for path in stale: 618 - print( 619 - f"stale generated reference {path} (run `sol skills build`)", 620 - file=sys.stderr, 621 - ) 622 - if stale: 623 - return 1 624 - print("generated skill references are current") 625 - return 0 626 - 627 - for path in skill_references.build(): 628 - print(f"generated {path}") 629 - return 0 630 - 631 - if args.cmd == "install": 632 - if target is None: 633 - skill_dir = resolve_user_skill() 634 - return _run_report( 635 - "install", install_user, skill_dir, Path.home(), [args.agent] 636 - ) 637 - return _run_report( 638 - "install", install_project, repo_root, target, [args.agent] 639 - ) 640 - 641 - if args.cmd == "uninstall": 642 - if target is None: 643 - skill_dir = resolve_user_skill() 644 - return _run_report( 645 - "uninstall", uninstall_user, skill_dir, Path.home(), [args.agent] 646 - ) 647 - return _run_report( 648 - "uninstall", uninstall_project, repo_root, target, [args.agent] 649 - ) 650 - 651 - if args.cmd == "list": 652 - if target is None: 653 - skill_dir = resolve_user_skill() 654 - _print_status(list_user_status(skill_dir, Path.home(), [ALL_AGENTS])) 655 - else: 656 - _print_status(list_project_status(repo_root, target, [ALL_AGENTS])) 657 - return 0 658 - except (OSError, PermissionError, ValueError) as exc: 659 - print(f"error: {exc}", file=sys.stderr) 660 - return 1 661 - 662 - return 1 663 - 664 - 665 - if __name__ == "__main__": 666 - sys.exit(main())
+2 -3
tests/test_journal_doctor.py
··· 172 172 173 173 174 174 def install_router_skill_links(doctor, journal: Path) -> None: 175 - sources = doctor.skills_cli.discover_project_sources(doctor.ROOT) 175 + sources = doctor._discover_project_sources(doctor.ROOT) 176 176 for rel_dir in [Path(".claude/skills"), Path(".agents/skills")]: 177 177 skills_dir = journal / rel_dir 178 178 skills_dir.mkdir(parents=True) ··· 1211 1211 skills_dir = journal / ".claude" / "skills" 1212 1212 skills_dir.mkdir(parents=True) 1213 1213 sources = { 1214 - source.name: source 1215 - for source in doctor.skills_cli.discover_project_sources(doctor.ROOT) 1214 + source.name: source for source in doctor._discover_project_sources(doctor.ROOT) 1216 1215 } 1217 1216 (skills_dir / "journal").symlink_to(os.path.relpath(sources["journal"], skills_dir)) 1218 1217 (skills_dir / "entities").symlink_to(
+44 -8
tests/test_journal_skill.py
··· 9 9 10 10 import pytest 11 11 12 - from solstone.think.skills_cli import install_project 13 - 14 12 15 13 def _repo_root() -> Path: 16 14 return Path(__file__).resolve().parent.parent ··· 66 64 67 65 @pytest.mark.timeout(30) 68 66 def test_make_skills_idempotent(tmp_path): 69 - """The make skills wrapper delegates to the idempotent project installer.""" 67 + """The native project installer is idempotent for make skills' target shape.""" 70 68 repo_root = _repo_root() 71 69 temp_root = tmp_path / "repo" 72 70 temp_root.mkdir() 73 71 74 - shutil.copy2(repo_root / "Makefile", temp_root / "Makefile") 72 + (temp_root / ".git").mkdir() 73 + shutil.copy2(repo_root / "pyproject.toml", temp_root / "pyproject.toml") 74 + bin_dir = temp_root / "bin" 75 + bin_dir.mkdir() 76 + native = bin_dir / "solstone-core" 77 + shutil.copy2(repo_root / ".venv" / "bin" / "solstone-core", native) 75 78 (temp_root / "solstone").mkdir() 76 79 shutil.copytree( 77 80 repo_root / "solstone" / "talent", ··· 94 97 if path.is_symlink() 95 98 } 96 99 97 - first_report = install_project(temp_root, temp_root, ["all"]) 98 - assert first_report.error_count == 0 100 + env = {"HOME": str(tmp_path / "home")} 101 + first_run = subprocess.run( 102 + [ 103 + str(native), 104 + "__solstone_identity=sol", 105 + "skills", 106 + "install", 107 + "--project", 108 + str(temp_root), 109 + "--agent", 110 + "all", 111 + ], 112 + cwd=temp_root, 113 + env=env, 114 + check=True, 115 + capture_output=True, 116 + text=True, 117 + ) 118 + assert first_run.stderr == "" 99 119 100 120 first = link_state(temp_root) 101 121 102 - second_report = install_project(temp_root, temp_root, ["all"]) 103 - assert second_report.error_count == 0 122 + second_run = subprocess.run( 123 + [ 124 + str(native), 125 + "__solstone_identity=sol", 126 + "skills", 127 + "install", 128 + "--project", 129 + str(temp_root), 130 + "--agent", 131 + "all", 132 + ], 133 + cwd=temp_root, 134 + env=env, 135 + check=True, 136 + capture_output=True, 137 + text=True, 138 + ) 139 + assert second_run.stderr == "" 104 140 105 141 second = link_state(temp_root) 106 142 assert first == second
-537
tests/test_skills_cli.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - from __future__ import annotations 5 - 6 - import os 7 - import sys 8 - from pathlib import Path 9 - 10 - import pytest 11 - 12 - from scripts import build_skill_references 13 - from solstone.think import skills_cli 14 - from solstone.think.skills_cli import ( 15 - install_project, 16 - install_user, 17 - list_project_status, 18 - list_user_status, 19 - resolve_user_skill, 20 - uninstall_user, 21 - ) 22 - 23 - 24 - def _write_skill(path: Path, content: bytes | None = None) -> None: 25 - path.mkdir(parents=True, exist_ok=True) 26 - (path / "SKILL.md").write_bytes(content or b"---\nname: test\n---\n") 27 - 28 - 29 - def _mini_user_repo(tmp_path: Path, content: bytes | None = None) -> Path: 30 - skill_dir = tmp_path / "sol" 31 - _write_skill(skill_dir, content) 32 - return skill_dir 33 - 34 - 35 - def _mini_project_repo(tmp_path: Path) -> Path: 36 - repo = tmp_path / "repo" 37 - _write_skill(repo / "solstone" / "talent" / "sol") 38 - _write_skill(repo / "solstone" / "talent" / "journal") 39 - _write_skill(repo / "solstone" / "talent" / "routines") 40 - _write_skill(repo / "solstone" / "apps" / "foo" / "talent" / "bar") 41 - return repo 42 - 43 - 44 - def _home(tmp_path: Path, *parents: str) -> Path: 45 - home = tmp_path / "home" 46 - home.mkdir() 47 - for parent in parents: 48 - (home / parent).mkdir() 49 - return home 50 - 51 - 52 - def test_install_user_creates_targets_for_present_agents(tmp_path): 53 - repo = _mini_user_repo(tmp_path, b"solstone bytes") 54 - home = _home(tmp_path, ".claude", ".codex") 55 - 56 - report = install_user(repo, home, ["all"]) 57 - 58 - assert report.error_count == 0 59 - source = repo / "SKILL.md" 60 - assert ( 61 - home / ".claude" / "skills" / "sol" / "SKILL.md" 62 - ).read_bytes() == source.read_bytes() 63 - assert ( 64 - home / ".codex" / "skills" / "sol" / "SKILL.md" 65 - ).read_bytes() == source.read_bytes() 66 - assert {path.name for path in (home / ".claude" / "skills").iterdir()} == {"sol"} 67 - assert {path.name for path in (home / ".codex" / "skills").iterdir()} == {"sol"} 68 - 69 - 70 - def test_install_user_creates_missing_codex_parent_dir(tmp_path): 71 - repo = _mini_user_repo(tmp_path) 72 - home = _home(tmp_path) 73 - 74 - report = install_user(repo, home, ["codex"]) 75 - 76 - assert report.error_count == 0 77 - assert (home / ".codex" / "skills" / "sol" / "SKILL.md").exists() 78 - assert report.rows == [ 79 - skills_cli.ActionRow( 80 - "codex", 81 - "sol", 82 - "installed", 83 - home / ".codex" / "skills" / "sol", 84 - ) 85 - ] 86 - 87 - 88 - def test_install_user_creates_missing_gemini_parent_dir(tmp_path): 89 - repo = _mini_user_repo(tmp_path) 90 - home = _home(tmp_path) 91 - 92 - report = install_user(repo, home, ["gemini"]) 93 - 94 - assert report.error_count == 0 95 - assert (home / ".gemini" / "skills" / "sol" / "SKILL.md").exists() 96 - assert report.rows == [ 97 - skills_cli.ActionRow( 98 - "gemini", 99 - "sol", 100 - "installed", 101 - home / ".gemini" / "skills" / "sol", 102 - ) 103 - ] 104 - 105 - 106 - def test_install_user_creates_all_three_when_none_exist(tmp_path): 107 - repo = _mini_user_repo(tmp_path) 108 - home = _home(tmp_path) 109 - 110 - report = install_user(repo, home, ["all"]) 111 - 112 - assert report.error_count == 0 113 - for agent in [".claude", ".codex", ".gemini"]: 114 - assert (home / agent / "skills" / "sol" / "SKILL.md").exists() 115 - assert [row.action for row in report.rows] == [ 116 - "installed", 117 - "installed", 118 - "installed", 119 - ] 120 - 121 - 122 - def test_install_user_replaces_modified_source(tmp_path): 123 - repo = _mini_user_repo(tmp_path, b"first") 124 - home = _home(tmp_path, ".claude") 125 - install_user(repo, home, ["claude"]) 126 - (repo / "SKILL.md").write_bytes(b"second") 127 - 128 - report = install_user(repo, home, ["claude"]) 129 - 130 - assert report.error_count == 0 131 - assert (home / ".claude" / "skills" / "sol" / "SKILL.md").read_bytes() == b"second" 132 - 133 - 134 - def test_install_user_replaces_existing_regular_file_target(tmp_path): 135 - repo = _mini_user_repo(tmp_path, b"fresh") 136 - home = _home(tmp_path, ".claude") 137 - target = home / ".claude" / "skills" / "sol" 138 - target.mkdir(parents=True) 139 - (target / "SKILL.md").write_bytes(b"stale") 140 - 141 - report = install_user(repo, home, ["claude"]) 142 - 143 - assert report.error_count == 0 144 - assert (target / "SKILL.md").read_bytes() == b"fresh" 145 - 146 - 147 - def test_install_user_replaces_stray_symlink_target(tmp_path): 148 - repo = _mini_user_repo(tmp_path) 149 - home = _home(tmp_path, ".claude") 150 - target = home / ".claude" / "skills" / "sol" 151 - target.parent.mkdir(parents=True) 152 - target.symlink_to(tmp_path / "whatever") 153 - 154 - report = install_user(repo, home, ["claude"]) 155 - 156 - assert report.error_count == 0 157 - assert target.is_dir() 158 - assert (target / "SKILL.md").exists() 159 - assert report.rows[0].action == "replaced" 160 - 161 - 162 - def test_install_user_replaces_stray_regular_file_target(tmp_path): 163 - repo = _mini_user_repo(tmp_path) 164 - home = _home(tmp_path, ".claude") 165 - target = home / ".claude" / "skills" / "sol" 166 - target.parent.mkdir(parents=True) 167 - target.write_text("not a dir", encoding="utf-8") 168 - 169 - report = install_user(repo, home, ["claude"]) 170 - 171 - assert report.error_count == 0 172 - assert target.is_dir() 173 - assert (target / "SKILL.md").exists() 174 - assert report.rows[0].action == "replaced" 175 - 176 - 177 - def test_install_user_permission_error_prints_clean_message(tmp_path, capsys): 178 - repo = _mini_user_repo(tmp_path) 179 - home = _home(tmp_path, ".claude") 180 - target = home / ".claude" / "skills" / "sol" 181 - target.mkdir(parents=True) 182 - target.chmod(0o500) 183 - try: 184 - report = install_user(repo, home, ["claude"]) 185 - skills_cli._print_report(report, "install") 186 - finally: 187 - target.chmod(0o700) 188 - 189 - captured = capsys.readouterr() 190 - assert report.error_count == 1 191 - assert "error:" in captured.err 192 - assert "Traceback" not in captured.err 193 - 194 - 195 - def test_uninstall_user_removes_only_bundle_dirs(tmp_path): 196 - repo = _mini_user_repo(tmp_path) 197 - home = _home(tmp_path, ".claude") 198 - sol = home / ".claude" / "skills" / "sol" 199 - hop = home / ".claude" / "skills" / "hop" 200 - _write_skill(sol) 201 - _write_skill(hop) 202 - 203 - report = uninstall_user(repo, home, ["claude"]) 204 - 205 - assert report.error_count == 0 206 - assert not sol.exists() 207 - assert hop.exists() 208 - 209 - 210 - def test_uninstall_user_absent_target_is_no_op(tmp_path): 211 - repo = _mini_user_repo(tmp_path) 212 - home = _home(tmp_path, ".claude") 213 - 214 - report = uninstall_user(repo, home, ["claude"]) 215 - 216 - assert report.error_count == 0 217 - assert report.rows[0].action == "skipped" 218 - assert report.rows[0].reason == "nothing to remove" 219 - 220 - 221 - def test_install_user_agent_filter(tmp_path): 222 - repo = _mini_user_repo(tmp_path) 223 - home = _home(tmp_path, ".claude", ".codex") 224 - 225 - report = install_user(repo, home, ["claude"]) 226 - 227 - assert report.error_count == 0 228 - assert (home / ".claude" / "skills" / "sol").exists() 229 - assert not (home / ".codex" / "skills" / "sol").exists() 230 - 231 - 232 - def test_install_user_leaves_existing_solstone_bundle_untouched(tmp_path): 233 - repo = _mini_user_repo(tmp_path) 234 - home = _home(tmp_path, ".claude") 235 - old_bundle = home / ".claude" / "skills" / "solstone" 236 - old_bundle.mkdir(parents=True) 237 - (old_bundle / "SKILL.md").write_bytes(b"old bundle") 238 - 239 - report = install_user(repo, home, ["claude"]) 240 - 241 - assert report.error_count == 0 242 - assert (home / ".claude" / "skills" / "sol" / "SKILL.md").exists() 243 - assert (old_bundle / "SKILL.md").read_bytes() == b"old bundle" 244 - assert all(row.skill != "solstone" for row in report.rows) 245 - 246 - 247 - def test_install_project_creates_symlinks(tmp_path): 248 - repo = _mini_project_repo(tmp_path) 249 - target = tmp_path / "work" 250 - 251 - report = install_project(repo, target, ["all"]) 252 - 253 - assert report.error_count == 0 254 - for agent_dir in [".claude", ".agents"]: 255 - link_parent = target / agent_dir / "skills" 256 - for name in ["journal", "sol"]: 257 - link = link_parent / name 258 - assert link.is_symlink() 259 - assert os.readlink(link) == os.path.relpath( 260 - repo / "solstone" / "talent" / name, 261 - link_parent, 262 - ) 263 - assert {path.name for path in link_parent.iterdir()} == {"journal", "sol"} 264 - 265 - 266 - def test_install_project_idempotent(tmp_path): 267 - repo = _mini_project_repo(tmp_path) 268 - target = tmp_path / "work" 269 - install_project(repo, target, ["all"]) 270 - before = { 271 - path: (os.readlink(path), path.lstat().st_mtime_ns) 272 - for path in sorted((target / ".claude" / "skills").iterdir()) 273 - } 274 - 275 - report = install_project(repo, target, ["all"]) 276 - 277 - after = { 278 - path: (os.readlink(path), path.lstat().st_mtime_ns) 279 - for path in sorted((target / ".claude" / "skills").iterdir()) 280 - } 281 - assert report.error_count == 0 282 - assert all(row.action == "noop" for row in report.rows) 283 - assert before == after 284 - 285 - 286 - def test_install_project_cleans_stale_symlinks(tmp_path): 287 - repo = _mini_project_repo(tmp_path) 288 - target = tmp_path / "work" 289 - install_project(repo, target, ["all"]) 290 - stale = target / ".claude" / "skills" / "entities" 291 - stale.symlink_to( 292 - os.path.relpath( 293 - repo / "solstone" / "apps" / "foo" / "talent" / "bar", stale.parent 294 - ) 295 - ) 296 - 297 - report = install_project(repo, target, ["all"]) 298 - 299 - assert report.error_count == 0 300 - assert not stale.exists() 301 - assert any(row.action == "removed" and row.reason == "stale" for row in report.rows) 302 - 303 - 304 - def test_install_project_preserves_obsolete_user_directory_with_warning(tmp_path): 305 - repo = _mini_project_repo(tmp_path) 306 - target = tmp_path / "work" 307 - obsolete = target / ".claude" / "skills" / "entities" 308 - obsolete.mkdir(parents=True) 309 - (obsolete / "SKILL.md").write_bytes(b"user content") 310 - 311 - report = install_project(repo, target, ["all"]) 312 - 313 - assert (obsolete / "SKILL.md").read_bytes() == b"user content" 314 - assert report.error_count == 0 315 - warning = next(row for row in report.rows if row.path == obsolete) 316 - assert warning.action == "warning" 317 - assert warning.skill == "entities" 318 - assert warning.reason == "user content at stale target preserved" 319 - 320 - 321 - def test_install_project_dedupe_error(monkeypatch, tmp_path): 322 - repo = _mini_project_repo(tmp_path) 323 - monkeypatch.setattr(skills_cli, "ROUTER_SKILL_NAMES", ("sol", "sol")) 324 - 325 - with pytest.raises(ValueError) as exc_info: 326 - install_project(repo, tmp_path / "work", ["all"]) 327 - 328 - message = str(exc_info.value) 329 - assert "duplicate skill name 'sol'" in message 330 - 331 - 332 - def test_install_project_agent_claude_only(tmp_path): 333 - repo = _mini_project_repo(tmp_path) 334 - target = tmp_path / "work" 335 - 336 - report = install_project(repo, target, ["claude"]) 337 - 338 - assert report.error_count == 0 339 - assert (target / ".claude" / "skills" / "journal").is_symlink() 340 - assert not (target / ".agents").exists() 341 - 342 - 343 - def test_install_project_rejects_codex_or_gemini(tmp_path): 344 - repo = _mini_project_repo(tmp_path) 345 - 346 - with pytest.raises(ValueError, match="--agent codex is not supported"): 347 - install_project(repo, tmp_path / "work", ["codex"]) 348 - with pytest.raises(ValueError, match="--agent gemini is not supported"): 349 - install_project(repo, tmp_path / "work", ["gemini"]) 350 - 351 - 352 - def test_install_project_relative_target_outside_repo(tmp_path): 353 - repo = _mini_project_repo(tmp_path) 354 - target = tmp_path / "outside" / "work" 355 - 356 - install_project(repo, target, ["all"]) 357 - 358 - link_parent = target / ".claude" / "skills" 359 - link = link_parent / "journal" 360 - assert os.readlink(link) == os.path.relpath( 361 - repo / "solstone" / "talent" / "journal", link_parent 362 - ) 363 - 364 - 365 - def test_install_project_emits_warning_for_user_content_at_target(tmp_path, capsys): 366 - repo = _mini_project_repo(tmp_path) 367 - target = tmp_path / "work" 368 - link = target / ".claude" / "skills" / "journal" 369 - link.parent.mkdir(parents=True) 370 - link.write_bytes(b"user-content") 371 - 372 - report = install_project(repo, target, ["all"]) 373 - 374 - assert link.read_bytes() == b"user-content" 375 - assert report.error_count == 0 376 - assert report.warning_count == 1 377 - warning = next(row for row in report.rows if row.action == "warning") 378 - assert warning.agent == "claude" 379 - assert warning.skill == "journal" 380 - assert warning.path == link 381 - assert warning.reason == "user content at target preserved" 382 - assert ( 383 - skills_cli._run_report("install", lambda *_args: report, repo, target, ["all"]) 384 - == 0 385 - ) 386 - assert "Warnings:" in capsys.readouterr().out 387 - 388 - 389 - def test_install_project_emits_warning_for_user_directory_at_target(tmp_path): 390 - repo = _mini_project_repo(tmp_path) 391 - target = tmp_path / "work" 392 - link = target / ".claude" / "skills" / "journal" 393 - link.mkdir(parents=True) 394 - (link / "SKILL.md").write_bytes(b"user-content") 395 - 396 - report = install_project(repo, target, ["all"]) 397 - 398 - assert (link / "SKILL.md").read_bytes() == b"user-content" 399 - assert report.error_count == 0 400 - assert report.warning_count == 1 401 - warning = next(row for row in report.rows if row.action == "warning") 402 - assert warning.agent == "claude" 403 - assert warning.skill == "journal" 404 - assert warning.path == link 405 - assert warning.reason == "user content at target preserved" 406 - 407 - 408 - def test_list_status_reports_installed_and_not_installed(tmp_path): 409 - user_repo = _mini_user_repo(tmp_path) 410 - home = _home(tmp_path, ".claude", ".codex") 411 - install_user(user_repo, home, ["claude"]) 412 - 413 - rows = list_user_status(user_repo, home, ["all"]) 414 - 415 - assert ("claude", "sol", "installed") in { 416 - (row.agent, row.skill, row.state) for row in rows 417 - } 418 - assert ("codex", "sol", "not installed") in { 419 - (row.agent, row.skill, row.state) for row in rows 420 - } 421 - 422 - 423 - def test_list_project_status_reports_correct_symlink_only(tmp_path): 424 - repo = _mini_project_repo(tmp_path) 425 - target = tmp_path / "work" 426 - install_project(repo, target, ["claude"]) 427 - 428 - rows = list_project_status(repo, target, ["all"]) 429 - 430 - assert ("claude", "journal", "installed") in { 431 - (row.agent, row.skill, row.state) for row in rows 432 - } 433 - assert ("agents", "journal", "not installed") in { 434 - (row.agent, row.skill, row.state) for row in rows 435 - } 436 - 437 - 438 - def test_main_install_user_default(monkeypatch, tmp_path, capsys): 439 - repo = _mini_user_repo(tmp_path) 440 - home = _home(tmp_path, ".claude") 441 - monkeypatch.setenv("HOME", str(home)) 442 - monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(repo)) 443 - monkeypatch.setattr(skills_cli, "resolve_user_skill", lambda: repo) 444 - monkeypatch.setattr(sys, "argv", ["sol skills", "install"]) 445 - 446 - exit_code = skills_cli.main() 447 - 448 - captured = capsys.readouterr() 449 - assert exit_code == 0 450 - assert "installed claude sol" in captured.out 451 - assert (home / ".claude" / "skills" / "sol" / "SKILL.md").exists() 452 - 453 - 454 - def test_main_install_project_no_dir_uses_cwd(monkeypatch, tmp_path): 455 - repo = _mini_project_repo(tmp_path) 456 - target = tmp_path / "work" 457 - target.mkdir() 458 - monkeypatch.chdir(target) 459 - monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(repo)) 460 - monkeypatch.setattr(sys, "argv", ["sol skills", "install", "--project"]) 461 - 462 - exit_code = skills_cli.main() 463 - 464 - assert exit_code == 0 465 - assert (target / ".claude" / "skills" / "journal").is_symlink() 466 - 467 - 468 - def test_repo_root_resolution_works_from_arbitrary_cwd(monkeypatch, tmp_path): 469 - monkeypatch.chdir(tmp_path) 470 - 471 - result = resolve_user_skill() 472 - 473 - assert result.name == "sol" 474 - assert (result / "SKILL.md").is_file() 475 - 476 - 477 - def test_user_skill_missing_file_fails_loudly(monkeypatch, tmp_path, capsys): 478 - fake_talent = tmp_path / "talent" 479 - fake_talent.mkdir() 480 - home = _home(tmp_path, ".claude") 481 - monkeypatch.setattr(skills_cli.resources, "files", lambda _package: fake_talent) 482 - 483 - with pytest.raises(FileNotFoundError) as exc_info: 484 - resolve_user_skill() 485 - 486 - assert "solstone/talent/sol/SKILL.md" in str(exc_info.value) 487 - 488 - monkeypatch.setenv("HOME", str(home)) 489 - monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) 490 - monkeypatch.setattr(sys, "argv", ["sol skills", "install"]) 491 - 492 - exit_code = skills_cli.main() 493 - 494 - captured = capsys.readouterr() 495 - assert exit_code == 1 496 - assert "error:" in captured.err 497 - assert "solstone/talent/sol/SKILL.md" in captured.err 498 - assert "Traceback" not in captured.err 499 - 500 - 501 - def test_main_build_generates_references(monkeypatch, tmp_path, capsys): 502 - output = tmp_path / "commands.md" 503 - monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) 504 - monkeypatch.setattr(build_skill_references, "build", lambda: [output]) 505 - monkeypatch.setattr(sys, "argv", ["sol skills", "build"]) 506 - 507 - exit_code = skills_cli.main() 508 - 509 - captured = capsys.readouterr() 510 - assert exit_code == 0 511 - assert f"generated {output}" in captured.out 512 - 513 - 514 - def test_main_build_check_green(monkeypatch, tmp_path, capsys): 515 - monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) 516 - monkeypatch.setattr(build_skill_references, "check", lambda: []) 517 - monkeypatch.setattr(sys, "argv", ["sol skills", "build", "--check"]) 518 - 519 - exit_code = skills_cli.main() 520 - 521 - captured = capsys.readouterr() 522 - assert exit_code == 0 523 - assert "generated skill references are current" in captured.out 524 - 525 - 526 - def test_main_build_check_reports_stale_path(monkeypatch, tmp_path, capsys): 527 - stale = tmp_path / "commands.md" 528 - monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) 529 - monkeypatch.setattr(build_skill_references, "check", lambda: [stale]) 530 - monkeypatch.setattr(sys, "argv", ["sol skills", "build", "--check"]) 531 - 532 - exit_code = skills_cli.main() 533 - 534 - captured = capsys.readouterr() 535 - assert exit_code == 1 536 - assert str(stale) in captured.err 537 - assert "run `sol skills build`" in captured.err
+3 -3
tests/test_sol_compat_cli.py
··· 103 103 104 104 105 105 def test_rust_and_python_top_level_compat_collections_match() -> None: 106 - source = ( 107 - REPO_ROOT / "core/crates/solstone-core-sol/src/lib.rs" 108 - ).read_text(encoding="utf-8") 106 + source = (REPO_ROOT / "core/crates/solstone-core-sol/src/lib.rs").read_text( 107 + encoding="utf-8" 108 + ) 109 109 match = re.search( 110 110 r"const\s+TOP_LEVEL_COMPAT_COMMANDS\s*:\s*&\[\s*&str\s*\]\s*=\s*&\[(?P<body>.*?)\];", 111 111 source,
+1 -4
tests/test_supervisor.py
··· 524 524 def test_app_supervised_main_uses_watcher_and_compressed_shutdown_knobs( 525 525 tmp_path, monkeypatch 526 526 ): 527 - from solstone.think import install_guard, service, skills_cli 527 + from solstone.think import install_guard, service 528 528 529 529 reconcile = MagicMock() 530 530 install_wrappers = MagicMock() 531 - install_project = MagicMock() 532 531 monkeypatch.setattr(service, "reconcile_installed_unit", reconcile) 533 532 monkeypatch.setattr(install_guard, "install_wrappers", install_wrappers) 534 - monkeypatch.setattr(skills_cli, "install_project", install_project) 535 533 536 534 mod, captures, events, exit_now = _run_supervisor_main_for_shutdown_knobs( 537 535 tmp_path, ··· 555 553 exit_now.assert_not_called() 556 554 reconcile.assert_not_called() 557 555 install_wrappers.assert_not_called() 558 - install_project.assert_not_called() 559 556 560 557 561 558 def test_default_main_uses_default_shutdown_knobs(tmp_path, monkeypatch):