personal memory agent
0

Configure Feed

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

solstone / tests / test_skills_cli.py
18 kB 536 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4from __future__ import annotations 5 6import os 7import sys 8from pathlib import Path 9 10import pytest 11 12from solstone.think import skills_build, skills_cli 13from solstone.think.skills_cli import ( 14 install_project, 15 install_user, 16 list_project_status, 17 list_user_status, 18 resolve_user_skill, 19 uninstall_user, 20) 21 22 23def _write_skill(path: Path, content: bytes | None = None) -> None: 24 path.mkdir(parents=True, exist_ok=True) 25 (path / "SKILL.md").write_bytes(content or b"---\nname: test\n---\n") 26 27 28def _mini_user_repo(tmp_path: Path, content: bytes | None = None) -> Path: 29 skill_dir = tmp_path / "sol" 30 _write_skill(skill_dir, content) 31 return skill_dir 32 33 34def _mini_project_repo(tmp_path: Path) -> Path: 35 repo = tmp_path / "repo" 36 _write_skill(repo / "solstone" / "talent" / "sol") 37 _write_skill(repo / "solstone" / "talent" / "journal") 38 _write_skill(repo / "solstone" / "talent" / "routines") 39 _write_skill(repo / "solstone" / "apps" / "foo" / "talent" / "bar") 40 return repo 41 42 43def _home(tmp_path: Path, *parents: str) -> Path: 44 home = tmp_path / "home" 45 home.mkdir() 46 for parent in parents: 47 (home / parent).mkdir() 48 return home 49 50 51def test_install_user_creates_targets_for_present_agents(tmp_path): 52 repo = _mini_user_repo(tmp_path, b"solstone bytes") 53 home = _home(tmp_path, ".claude", ".codex") 54 55 report = install_user(repo, home, ["all"]) 56 57 assert report.error_count == 0 58 source = repo / "SKILL.md" 59 assert ( 60 home / ".claude" / "skills" / "sol" / "SKILL.md" 61 ).read_bytes() == source.read_bytes() 62 assert ( 63 home / ".codex" / "skills" / "sol" / "SKILL.md" 64 ).read_bytes() == source.read_bytes() 65 assert {path.name for path in (home / ".claude" / "skills").iterdir()} == {"sol"} 66 assert {path.name for path in (home / ".codex" / "skills").iterdir()} == {"sol"} 67 68 69def test_install_user_creates_missing_codex_parent_dir(tmp_path): 70 repo = _mini_user_repo(tmp_path) 71 home = _home(tmp_path) 72 73 report = install_user(repo, home, ["codex"]) 74 75 assert report.error_count == 0 76 assert (home / ".codex" / "skills" / "sol" / "SKILL.md").exists() 77 assert report.rows == [ 78 skills_cli.ActionRow( 79 "codex", 80 "sol", 81 "installed", 82 home / ".codex" / "skills" / "sol", 83 ) 84 ] 85 86 87def test_install_user_creates_missing_gemini_parent_dir(tmp_path): 88 repo = _mini_user_repo(tmp_path) 89 home = _home(tmp_path) 90 91 report = install_user(repo, home, ["gemini"]) 92 93 assert report.error_count == 0 94 assert (home / ".gemini" / "skills" / "sol" / "SKILL.md").exists() 95 assert report.rows == [ 96 skills_cli.ActionRow( 97 "gemini", 98 "sol", 99 "installed", 100 home / ".gemini" / "skills" / "sol", 101 ) 102 ] 103 104 105def test_install_user_creates_all_three_when_none_exist(tmp_path): 106 repo = _mini_user_repo(tmp_path) 107 home = _home(tmp_path) 108 109 report = install_user(repo, home, ["all"]) 110 111 assert report.error_count == 0 112 for agent in [".claude", ".codex", ".gemini"]: 113 assert (home / agent / "skills" / "sol" / "SKILL.md").exists() 114 assert [row.action for row in report.rows] == [ 115 "installed", 116 "installed", 117 "installed", 118 ] 119 120 121def test_install_user_replaces_modified_source(tmp_path): 122 repo = _mini_user_repo(tmp_path, b"first") 123 home = _home(tmp_path, ".claude") 124 install_user(repo, home, ["claude"]) 125 (repo / "SKILL.md").write_bytes(b"second") 126 127 report = install_user(repo, home, ["claude"]) 128 129 assert report.error_count == 0 130 assert (home / ".claude" / "skills" / "sol" / "SKILL.md").read_bytes() == b"second" 131 132 133def test_install_user_replaces_existing_regular_file_target(tmp_path): 134 repo = _mini_user_repo(tmp_path, b"fresh") 135 home = _home(tmp_path, ".claude") 136 target = home / ".claude" / "skills" / "sol" 137 target.mkdir(parents=True) 138 (target / "SKILL.md").write_bytes(b"stale") 139 140 report = install_user(repo, home, ["claude"]) 141 142 assert report.error_count == 0 143 assert (target / "SKILL.md").read_bytes() == b"fresh" 144 145 146def test_install_user_replaces_stray_symlink_target(tmp_path): 147 repo = _mini_user_repo(tmp_path) 148 home = _home(tmp_path, ".claude") 149 target = home / ".claude" / "skills" / "sol" 150 target.parent.mkdir(parents=True) 151 target.symlink_to(tmp_path / "whatever") 152 153 report = install_user(repo, home, ["claude"]) 154 155 assert report.error_count == 0 156 assert target.is_dir() 157 assert (target / "SKILL.md").exists() 158 assert report.rows[0].action == "replaced" 159 160 161def test_install_user_replaces_stray_regular_file_target(tmp_path): 162 repo = _mini_user_repo(tmp_path) 163 home = _home(tmp_path, ".claude") 164 target = home / ".claude" / "skills" / "sol" 165 target.parent.mkdir(parents=True) 166 target.write_text("not a dir", encoding="utf-8") 167 168 report = install_user(repo, home, ["claude"]) 169 170 assert report.error_count == 0 171 assert target.is_dir() 172 assert (target / "SKILL.md").exists() 173 assert report.rows[0].action == "replaced" 174 175 176def test_install_user_permission_error_prints_clean_message(tmp_path, capsys): 177 repo = _mini_user_repo(tmp_path) 178 home = _home(tmp_path, ".claude") 179 target = home / ".claude" / "skills" / "sol" 180 target.mkdir(parents=True) 181 target.chmod(0o500) 182 try: 183 report = install_user(repo, home, ["claude"]) 184 skills_cli._print_report(report, "install") 185 finally: 186 target.chmod(0o700) 187 188 captured = capsys.readouterr() 189 assert report.error_count == 1 190 assert "error:" in captured.err 191 assert "Traceback" not in captured.err 192 193 194def test_uninstall_user_removes_only_bundle_dirs(tmp_path): 195 repo = _mini_user_repo(tmp_path) 196 home = _home(tmp_path, ".claude") 197 sol = home / ".claude" / "skills" / "sol" 198 hop = home / ".claude" / "skills" / "hop" 199 _write_skill(sol) 200 _write_skill(hop) 201 202 report = uninstall_user(repo, home, ["claude"]) 203 204 assert report.error_count == 0 205 assert not sol.exists() 206 assert hop.exists() 207 208 209def test_uninstall_user_absent_target_is_no_op(tmp_path): 210 repo = _mini_user_repo(tmp_path) 211 home = _home(tmp_path, ".claude") 212 213 report = uninstall_user(repo, home, ["claude"]) 214 215 assert report.error_count == 0 216 assert report.rows[0].action == "skipped" 217 assert report.rows[0].reason == "nothing to remove" 218 219 220def test_install_user_agent_filter(tmp_path): 221 repo = _mini_user_repo(tmp_path) 222 home = _home(tmp_path, ".claude", ".codex") 223 224 report = install_user(repo, home, ["claude"]) 225 226 assert report.error_count == 0 227 assert (home / ".claude" / "skills" / "sol").exists() 228 assert not (home / ".codex" / "skills" / "sol").exists() 229 230 231def test_install_user_leaves_existing_solstone_bundle_untouched(tmp_path): 232 repo = _mini_user_repo(tmp_path) 233 home = _home(tmp_path, ".claude") 234 old_bundle = home / ".claude" / "skills" / "solstone" 235 old_bundle.mkdir(parents=True) 236 (old_bundle / "SKILL.md").write_bytes(b"old bundle") 237 238 report = install_user(repo, home, ["claude"]) 239 240 assert report.error_count == 0 241 assert (home / ".claude" / "skills" / "sol" / "SKILL.md").exists() 242 assert (old_bundle / "SKILL.md").read_bytes() == b"old bundle" 243 assert all(row.skill != "solstone" for row in report.rows) 244 245 246def test_install_project_creates_symlinks(tmp_path): 247 repo = _mini_project_repo(tmp_path) 248 target = tmp_path / "work" 249 250 report = install_project(repo, target, ["all"]) 251 252 assert report.error_count == 0 253 for agent_dir in [".claude", ".agents"]: 254 link_parent = target / agent_dir / "skills" 255 for name in ["journal", "sol"]: 256 link = link_parent / name 257 assert link.is_symlink() 258 assert os.readlink(link) == os.path.relpath( 259 repo / "solstone" / "talent" / name, 260 link_parent, 261 ) 262 assert {path.name for path in link_parent.iterdir()} == {"journal", "sol"} 263 264 265def test_install_project_idempotent(tmp_path): 266 repo = _mini_project_repo(tmp_path) 267 target = tmp_path / "work" 268 install_project(repo, target, ["all"]) 269 before = { 270 path: (os.readlink(path), path.lstat().st_mtime_ns) 271 for path in sorted((target / ".claude" / "skills").iterdir()) 272 } 273 274 report = install_project(repo, target, ["all"]) 275 276 after = { 277 path: (os.readlink(path), path.lstat().st_mtime_ns) 278 for path in sorted((target / ".claude" / "skills").iterdir()) 279 } 280 assert report.error_count == 0 281 assert all(row.action == "noop" for row in report.rows) 282 assert before == after 283 284 285def test_install_project_cleans_stale_symlinks(tmp_path): 286 repo = _mini_project_repo(tmp_path) 287 target = tmp_path / "work" 288 install_project(repo, target, ["all"]) 289 stale = target / ".claude" / "skills" / "entities" 290 stale.symlink_to( 291 os.path.relpath( 292 repo / "solstone" / "apps" / "foo" / "talent" / "bar", stale.parent 293 ) 294 ) 295 296 report = install_project(repo, target, ["all"]) 297 298 assert report.error_count == 0 299 assert not stale.exists() 300 assert any(row.action == "removed" and row.reason == "stale" for row in report.rows) 301 302 303def test_install_project_preserves_obsolete_user_directory_with_warning(tmp_path): 304 repo = _mini_project_repo(tmp_path) 305 target = tmp_path / "work" 306 obsolete = target / ".claude" / "skills" / "entities" 307 obsolete.mkdir(parents=True) 308 (obsolete / "SKILL.md").write_bytes(b"user content") 309 310 report = install_project(repo, target, ["all"]) 311 312 assert (obsolete / "SKILL.md").read_bytes() == b"user content" 313 assert report.error_count == 0 314 warning = next(row for row in report.rows if row.path == obsolete) 315 assert warning.action == "warning" 316 assert warning.skill == "entities" 317 assert warning.reason == "user content at stale target preserved" 318 319 320def test_install_project_dedupe_error(monkeypatch, tmp_path): 321 repo = _mini_project_repo(tmp_path) 322 monkeypatch.setattr(skills_cli, "ROUTER_SKILL_NAMES", ("sol", "sol")) 323 324 with pytest.raises(ValueError) as exc_info: 325 install_project(repo, tmp_path / "work", ["all"]) 326 327 message = str(exc_info.value) 328 assert "duplicate skill name 'sol'" in message 329 330 331def test_install_project_agent_claude_only(tmp_path): 332 repo = _mini_project_repo(tmp_path) 333 target = tmp_path / "work" 334 335 report = install_project(repo, target, ["claude"]) 336 337 assert report.error_count == 0 338 assert (target / ".claude" / "skills" / "journal").is_symlink() 339 assert not (target / ".agents").exists() 340 341 342def test_install_project_rejects_codex_or_gemini(tmp_path): 343 repo = _mini_project_repo(tmp_path) 344 345 with pytest.raises(ValueError, match="--agent codex is not supported"): 346 install_project(repo, tmp_path / "work", ["codex"]) 347 with pytest.raises(ValueError, match="--agent gemini is not supported"): 348 install_project(repo, tmp_path / "work", ["gemini"]) 349 350 351def test_install_project_relative_target_outside_repo(tmp_path): 352 repo = _mini_project_repo(tmp_path) 353 target = tmp_path / "outside" / "work" 354 355 install_project(repo, target, ["all"]) 356 357 link_parent = target / ".claude" / "skills" 358 link = link_parent / "journal" 359 assert os.readlink(link) == os.path.relpath( 360 repo / "solstone" / "talent" / "journal", link_parent 361 ) 362 363 364def test_install_project_emits_warning_for_user_content_at_target(tmp_path, capsys): 365 repo = _mini_project_repo(tmp_path) 366 target = tmp_path / "work" 367 link = target / ".claude" / "skills" / "journal" 368 link.parent.mkdir(parents=True) 369 link.write_bytes(b"user-content") 370 371 report = install_project(repo, target, ["all"]) 372 373 assert link.read_bytes() == b"user-content" 374 assert report.error_count == 0 375 assert report.warning_count == 1 376 warning = next(row for row in report.rows if row.action == "warning") 377 assert warning.agent == "claude" 378 assert warning.skill == "journal" 379 assert warning.path == link 380 assert warning.reason == "user content at target preserved" 381 assert ( 382 skills_cli._run_report("install", lambda *_args: report, repo, target, ["all"]) 383 == 0 384 ) 385 assert "Warnings:" in capsys.readouterr().out 386 387 388def test_install_project_emits_warning_for_user_directory_at_target(tmp_path): 389 repo = _mini_project_repo(tmp_path) 390 target = tmp_path / "work" 391 link = target / ".claude" / "skills" / "journal" 392 link.mkdir(parents=True) 393 (link / "SKILL.md").write_bytes(b"user-content") 394 395 report = install_project(repo, target, ["all"]) 396 397 assert (link / "SKILL.md").read_bytes() == b"user-content" 398 assert report.error_count == 0 399 assert report.warning_count == 1 400 warning = next(row for row in report.rows if row.action == "warning") 401 assert warning.agent == "claude" 402 assert warning.skill == "journal" 403 assert warning.path == link 404 assert warning.reason == "user content at target preserved" 405 406 407def test_list_status_reports_installed_and_not_installed(tmp_path): 408 user_repo = _mini_user_repo(tmp_path) 409 home = _home(tmp_path, ".claude", ".codex") 410 install_user(user_repo, home, ["claude"]) 411 412 rows = list_user_status(user_repo, home, ["all"]) 413 414 assert ("claude", "sol", "installed") in { 415 (row.agent, row.skill, row.state) for row in rows 416 } 417 assert ("codex", "sol", "not installed") in { 418 (row.agent, row.skill, row.state) for row in rows 419 } 420 421 422def test_list_project_status_reports_correct_symlink_only(tmp_path): 423 repo = _mini_project_repo(tmp_path) 424 target = tmp_path / "work" 425 install_project(repo, target, ["claude"]) 426 427 rows = list_project_status(repo, target, ["all"]) 428 429 assert ("claude", "journal", "installed") in { 430 (row.agent, row.skill, row.state) for row in rows 431 } 432 assert ("agents", "journal", "not installed") in { 433 (row.agent, row.skill, row.state) for row in rows 434 } 435 436 437def test_main_install_user_default(monkeypatch, tmp_path, capsys): 438 repo = _mini_user_repo(tmp_path) 439 home = _home(tmp_path, ".claude") 440 monkeypatch.setenv("HOME", str(home)) 441 monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(repo)) 442 monkeypatch.setattr(skills_cli, "resolve_user_skill", lambda: repo) 443 monkeypatch.setattr(sys, "argv", ["sol skills", "install"]) 444 445 exit_code = skills_cli.main() 446 447 captured = capsys.readouterr() 448 assert exit_code == 0 449 assert "installed claude sol" in captured.out 450 assert (home / ".claude" / "skills" / "sol" / "SKILL.md").exists() 451 452 453def test_main_install_project_no_dir_uses_cwd(monkeypatch, tmp_path): 454 repo = _mini_project_repo(tmp_path) 455 target = tmp_path / "work" 456 target.mkdir() 457 monkeypatch.chdir(target) 458 monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(repo)) 459 monkeypatch.setattr(sys, "argv", ["sol skills", "install", "--project"]) 460 461 exit_code = skills_cli.main() 462 463 assert exit_code == 0 464 assert (target / ".claude" / "skills" / "journal").is_symlink() 465 466 467def test_repo_root_resolution_works_from_arbitrary_cwd(monkeypatch, tmp_path): 468 monkeypatch.chdir(tmp_path) 469 470 result = resolve_user_skill() 471 472 assert result.name == "sol" 473 assert (result / "SKILL.md").is_file() 474 475 476def test_user_skill_missing_file_fails_loudly(monkeypatch, tmp_path, capsys): 477 fake_talent = tmp_path / "talent" 478 fake_talent.mkdir() 479 home = _home(tmp_path, ".claude") 480 monkeypatch.setattr(skills_cli.resources, "files", lambda _package: fake_talent) 481 482 with pytest.raises(FileNotFoundError) as exc_info: 483 resolve_user_skill() 484 485 assert "solstone/talent/sol/SKILL.md" in str(exc_info.value) 486 487 monkeypatch.setenv("HOME", str(home)) 488 monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) 489 monkeypatch.setattr(sys, "argv", ["sol skills", "install"]) 490 491 exit_code = skills_cli.main() 492 493 captured = capsys.readouterr() 494 assert exit_code == 1 495 assert "error:" in captured.err 496 assert "solstone/talent/sol/SKILL.md" in captured.err 497 assert "Traceback" not in captured.err 498 499 500def test_main_build_generates_references(monkeypatch, tmp_path, capsys): 501 output = tmp_path / "commands.md" 502 monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) 503 monkeypatch.setattr(skills_build, "build", lambda: [output]) 504 monkeypatch.setattr(sys, "argv", ["sol skills", "build"]) 505 506 exit_code = skills_cli.main() 507 508 captured = capsys.readouterr() 509 assert exit_code == 0 510 assert f"generated {output}" in captured.out 511 512 513def test_main_build_check_green(monkeypatch, tmp_path, capsys): 514 monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) 515 monkeypatch.setattr(skills_build, "check", lambda: []) 516 monkeypatch.setattr(sys, "argv", ["sol skills", "build", "--check"]) 517 518 exit_code = skills_cli.main() 519 520 captured = capsys.readouterr() 521 assert exit_code == 0 522 assert "generated skill references are current" in captured.out 523 524 525def test_main_build_check_reports_stale_path(monkeypatch, tmp_path, capsys): 526 stale = tmp_path / "commands.md" 527 monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(tmp_path)) 528 monkeypatch.setattr(skills_build, "check", lambda: [stale]) 529 monkeypatch.setattr(sys, "argv", ["sol skills", "build", "--check"]) 530 531 exit_code = skills_cli.main() 532 533 captured = capsys.readouterr() 534 assert exit_code == 1 535 assert str(stale) in captured.err 536 assert "run `sol skills build`" in captured.err