personal memory agent
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