personal memory agent
0
fork

Configure Feed

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

feat(cli): sol skills install/uninstall/list replaces npx and make skills loop

- New think/skills_cli.py with user-mode (copy bundles into ~/.{claude,codex,gemini}/skills) and project-mode (symlink talent/ + apps/*/talent into <DIR>/.{claude,agents}/skills) flows.
- Registered skills in think/sol_cli.py (Specialized tools group).
- Makefile: make skills is now a one-line wrapper around sol skills install --project; dropped skills from direct prereqs of install and install-service (.installed recursion handles it); replaced npx skills add/remove with sol skills install/uninstall; deleted unused SKILL_DIRS.
- Project-mode preserves stale-symlink cleanup, dedupe-by-name error, and relative-symlink target shape from the old shell loop.
- Tests: tests/test_skills_cli.py covers user/project modes, conflict matrix, agent skip semantics, gemini conditional, dedupe, stale cleanup, CLI surface, and source-resolution.
- tests/test_journal_skill.py:test_make_skills_idempotent rewritten to drive install_project directly (no temp .venv).
- Docs: docs/SOLCLI.md and docs/project-structure.md updated to point at sol skills install --project with make skills as wrapper.

+1032 -89
+6 -52
Makefile
··· 75 75 $(UV) lock 76 76 77 77 # Install package in editable mode with isolated venv 78 - install: doctor skills .installed 78 + install: doctor .installed 79 79 @(cd /tmp && $(CURDIR)/$(VENV_BIN)/python -c "from think.sol_cli import main") 2>/dev/null || { \ 80 80 echo ">>> re-registering editable install"; \ 81 81 $(UV) pip install -e . --no-deps; \ ··· 108 108 @touch .installed 109 109 @$(VENV_BIN)/sol install-models || { echo "sol install-models failed" >&2; exit 1; } 110 110 111 - # Directories where AI coding agents look for skills 112 - SKILL_DIRS := journal/.agents/skills journal/.claude/skills 113 - 114 - # Discover SKILL.md files in talent/ and apps/*/talent/, symlink into agent skill dirs 111 + # Setup skill symlinks 115 112 skills: 116 - @rm -rf .agents/skills .claude/skills 117 - @# Collect all skill directories (containing SKILL.md) 118 - @SKILLS=""; \ 119 - for skill_md in talent/*/SKILL.md apps/*/talent/*/SKILL.md; do \ 120 - [ -f "$$skill_md" ] || continue; \ 121 - skill_dir=$$(dirname "$$skill_md"); \ 122 - skill_name=$$(basename "$$skill_dir"); \ 123 - if echo "$$SKILLS" | grep -qw "$$skill_name"; then \ 124 - echo "Error: duplicate skill name '$$skill_name' found in $$skill_dir" >&2; \ 125 - echo "Each skill directory name must be unique across talent/ and apps/*/talent/." >&2; \ 126 - exit 1; \ 127 - fi; \ 128 - SKILLS="$$SKILLS $$skill_name"; \ 129 - done; \ 130 - for dir in $(SKILL_DIRS); do \ 131 - mkdir -p "$$dir"; \ 132 - for link in "$$dir"/*; do \ 133 - ([ -e "$$link" ] || [ -L "$$link" ]) || continue; \ 134 - skill_name=$$(basename "$$link"); \ 135 - if ! echo "$$SKILLS" | grep -qw "$$skill_name"; then \ 136 - rm -rf "$$link"; \ 137 - fi; \ 138 - done; \ 139 - done; \ 140 - count=0; \ 141 - for skill_md in talent/*/SKILL.md apps/*/talent/*/SKILL.md; do \ 142 - [ -f "$$skill_md" ] || continue; \ 143 - skill_dir=$$(dirname "$$skill_md"); \ 144 - skill_name=$$(basename "$$skill_dir"); \ 145 - for dir in $(SKILL_DIRS); do \ 146 - target="../../../$$skill_dir"; \ 147 - link="$$dir/$$skill_name"; \ 148 - if [ -L "$$link" ] && [ "$$(readlink "$$link")" = "$$target" ]; then \ 149 - :; \ 150 - else \ 151 - rm -rf "$$link"; \ 152 - ln -s "$$target" "$$link"; \ 153 - fi; \ 154 - done; \ 155 - count=$$((count + 1)); \ 156 - done; \ 157 - if [ "$$count" -gt 0 ]; then \ 158 - echo "Linked $$count skill(s) into $(SKILL_DIRS)"; \ 159 - fi 113 + @$(VENV_BIN)/sol skills install --project 160 114 161 115 # Start local dev stack against fixture journal (no observers, no daily processing) 162 116 dev: .installed ··· 464 418 @python3 scripts/doctor.py $(if $(VERBOSE),--verbose) $(if $(JSON),--json) $(if $(PORT),--port $(PORT)) 465 419 466 420 # Service management (override port: make install-service PORT=8000) 467 - install-service: doctor skills .installed 421 + install-service: doctor .installed 468 422 @MODE=$$($(PYTHON) -m think.install_guard check); \ 469 423 RC=$$?; \ 470 424 case "$$MODE" in \ ··· 499 453 ;; \ 500 454 esac; \ 501 455 $(PYTHON) -m think.install_guard install; \ 502 - CI=true npx --yes skills add ./skills/solstone -g -a claude-code -y; \ 456 + $(VENV_BIN)/sol skills install; \ 503 457 $(VENV_BIN)/sol service install --port $(or $(PORT),5015); \ 504 458 $(VENV_BIN)/sol service restart; \ 505 459 echo "Waiting for supervisor to report healthy..."; \ ··· 549 503 fi; \ 550 504 $(VENV_BIN)/sol service stop > /dev/null 2>&1 || true; \ 551 505 $(VENV_BIN)/sol service uninstall; \ 552 - CI=true npx --yes skills remove -g -a claude-code -y solstone; \ 506 + $(VENV_BIN)/sol skills uninstall; \ 553 507 $(PYTHON) -m think.install_guard uninstall 554 508 555 509 uninstall:
+7 -5
docs/SOLCLI.md
··· 161 161 - `-f, --facet`: facet name (default: `SOL_FACET` env). 162 162 ``` 163 163 164 - 4. **Run `make skills`** to create the symlink in `journal/.agents/skills/`. 164 + 4. **Run `sol skills install --project`** to create the symlink in `journal/.agents/skills/` (`make skills` wraps this). 165 165 166 166 5. **Update AGENTS.md** — add the skill to the Skills table. 167 167 ··· 183 183 |------|-----------|-----------| 184 184 | `apps/<name>/call.py` | Typer app with commands | Yes | 185 185 | `apps/<name>/talent/<name>/SKILL.md` | Skill doc for agents | If agents should use it | 186 - | `journal/.agents/skills/<name>` | Symlink (via `make skills`) | Auto-generated | 186 + | `journal/.agents/skills/<name>` | Symlink (via `sol skills install --project`; `make skills` wrapper) | Auto-generated | 187 187 | `AGENTS.md` Skills table | Add trigger description | If skill exists | 188 188 | `tests/test_<name>_call.py` | CLI tests | Yes | 189 189 ··· 279 279 │ ├── journal/SKILL.md # Skills not tied to an app 280 280 │ ├── coding/SKILL.md 281 281 │ └── *.md # Agent prompt files 282 - ├── journal/.agents/skills/ # Symlinks (generated by make skills) 282 + ├── journal/.agents/skills/ # Symlinks (generated by sol skills install --project; make skills wrapper) 283 283 └── AGENTS.md # Sol identity + skill table 284 284 ``` 285 285 ··· 298 298 | Observe (capture) | `transcribe`, `describe`, `sense`, `transfer`, `observer` | 299 299 | Talent (AI agents) | `agents`, `cortex`, `talent`, `call`, `engage` | 300 300 | Convey (web UI) | `convey`, `restart-convey`, `maint` | 301 - | Specialized | `config`, `streams`, `journal-stats`, `formatter`, `detect-created` | 301 + | Specialized | `config`, `skills`, `streams`, `journal-stats`, `formatter`, `detect-created` | 302 302 | Installation | `doctor` | 303 303 | Help | `help`, `chat` | 304 304 ··· 320 320 | `identity` | `think/tools/sol.py` | self, partner, agency, pulse, awareness, briefing | 321 321 | `navigate` | `think/tools/navigate.py` | *(single command)* | 322 322 323 + `sol skills` manages coding-agent skill installation; `sol call skills` manages owner-wide journal skill patterns. 324 + 323 325 ## Skill System 324 326 325 - Skills are documented in `SKILL.md` files and symlinked into `journal/.agents/skills/` by `make skills`. 327 + Skills are documented in `SKILL.md` files and symlinked into `journal/.agents/skills/` by `sol skills install --project`; `make skills` wraps this. 326 328 327 329 **Skill locations:** 328 330 - App skills: `apps/<name>/talent/<name>/SKILL.md`
+2 -2
docs/project-structure.md
··· 34 34 35 35 ## Agent & Skill Organization 36 36 37 - `talent/*.md` stores agent personas and generator templates. Apps can add their own in `apps/*/talent/*.md`. Skills live at `talent/*/SKILL.md` and are symlinked into `journal/.agents/skills/` and `journal/.claude/skills/` via `make skills`. 37 + `talent/*.md` stores agent personas and generator templates. Apps can add their own in `apps/*/talent/*.md`. Skills live at `talent/*/SKILL.md` and are symlinked into `journal/.agents/skills/` and `journal/.claude/skills/` via `sol skills install --project`, wrapped by `make skills`. 38 38 39 39 ## File Locations 40 40 ··· 43 43 - **Live Logs**: `journal/health/<service>.log` 44 44 - **Agent Personas**: `talent/*.md` (apps can add their own in `talent/`, see [docs/APPS.md](docs/APPS.md)) 45 45 - **Generator Templates**: `talent/*.md` (apps can add their own in `talent/`, see [docs/APPS.md](docs/APPS.md)) 46 - - **Agent Skills**: `talent/*/SKILL.md` - symlinked into `journal/.agents/skills/` and `journal/.claude/skills/` via `make skills`, read https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices to create the best skills 46 + - **Agent Skills**: `talent/*/SKILL.md` - symlinked into `journal/.agents/skills/` and `journal/.claude/skills/` via `sol skills install --project`, wrapped by `make skills`; read https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices to create the best skills 47 47 - **Scratch Space**: `scratch/` - git-ignored local workspace
+18 -30
tests/test_journal_skill.py
··· 9 9 10 10 import pytest 11 11 12 + from think.skills_cli import install_project 13 + 12 14 13 15 def _repo_root() -> Path: 14 16 return Path(__file__).resolve().parent.parent ··· 63 65 64 66 @pytest.mark.timeout(30) 65 67 def test_make_skills_idempotent(tmp_path): 68 + """The make skills wrapper delegates to the idempotent project installer.""" 66 69 repo_root = _repo_root() 67 70 temp_root = tmp_path / "repo" 68 71 temp_root.mkdir() ··· 70 73 shutil.copy2(repo_root / "Makefile", temp_root / "Makefile") 71 74 shutil.copytree(repo_root / "talent", temp_root / "talent", symlinks=True) 72 75 shutil.copytree(repo_root / "apps", temp_root / "apps", symlinks=True) 73 - # `make skills` only reads talent/ and apps/*/talent/, and writes symlinks 74 - # under journal/.agents/skills and journal/.claude/skills. Copying the live 75 - # journal/ pulls in whatever real capture data the dev box has accumulated 76 - # (chronicle/ alone can be 100+ GB), and copytree blows past the 30s budget 77 - # on os.sendfile before make skills ever runs. An empty journal/ is all the 78 - # target needs. 79 - (temp_root / "journal").mkdir() 80 - 81 - subprocess.run( 82 - ["make", "skills"], 83 - cwd=temp_root, 84 - check=True, 85 - capture_output=True, 86 - text=True, 87 - ) 88 76 89 77 def link_state(root: Path) -> dict[str, tuple[str, int]]: 90 78 return { ··· 96 84 if path.is_symlink() 97 85 } 98 86 99 - first = link_state(temp_root / "journal") 87 + first_report = install_project(temp_root, temp_root, ["all"]) 88 + assert first_report.error_count == 0 100 89 101 - subprocess.run( 102 - ["make", "skills"], 103 - cwd=temp_root, 104 - check=True, 105 - capture_output=True, 106 - text=True, 107 - ) 90 + first = link_state(temp_root) 108 91 109 - second = link_state(temp_root / "journal") 92 + second_report = install_project(temp_root, temp_root, ["all"]) 93 + assert second_report.error_count == 0 94 + 95 + second = link_state(temp_root) 110 96 assert first == second 97 + assert ( 98 + temp_root / ".claude" / "skills" / "journal" 99 + ).readlink().as_posix() == "../../talent/journal" 111 100 112 101 # Skill-discovery contract: claude code looks at <cwd>/.claude/skills/, so 113 - # after `make skills` the journal-cwd path must resolve to a real SKILL.md 114 - # whose content starts with frontmatter. Verifying it here against the tmp 115 - # tree (rather than the gitignored repo_root/journal/.claude/...) means 116 - # the test is hermetic — it doesn't depend on the dev box having 117 - # previously run `make install` or `make skills` to produce the artifact. 118 - discovered = temp_root / "journal" / ".claude" / "skills" / "journal" / "SKILL.md" 102 + # after project skill installation the cwd path must resolve to a real 103 + # SKILL.md whose content starts with frontmatter. Verifying it here against 104 + # the tmp tree means the test is hermetic — it doesn't depend on the dev box 105 + # having previously run `make install` or `make skills`. 106 + discovered = temp_root / ".claude" / "skills" / "journal" / "SKILL.md" 119 107 assert discovered.is_file() 120 108 assert discovered.read_text(encoding="utf-8").startswith("---")
+384
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 shutil 8 + import sys 9 + from pathlib import Path 10 + 11 + import pytest 12 + 13 + from think import skills_cli 14 + from think.skills_cli import ( 15 + GLOBAL_SKIP_MESSAGE, 16 + discover_user_bundles, 17 + get_project_root, 18 + install_project, 19 + install_user, 20 + list_project_status, 21 + list_user_status, 22 + uninstall_user, 23 + ) 24 + 25 + 26 + def _write_skill(path: Path, content: bytes | None = None) -> None: 27 + path.mkdir(parents=True, exist_ok=True) 28 + (path / "SKILL.md").write_bytes(content or b"---\nname: test\n---\n") 29 + 30 + 31 + def _mini_user_repo(tmp_path: Path, content: bytes | None = None) -> Path: 32 + repo = tmp_path / "repo" 33 + _write_skill(repo / "skills" / "solstone", content) 34 + return repo 35 + 36 + 37 + def _mini_project_repo(tmp_path: Path) -> Path: 38 + repo = tmp_path / "repo" 39 + _write_skill(repo / "talent" / "journal") 40 + _write_skill(repo / "talent" / "routines") 41 + _write_skill(repo / "apps" / "foo" / "talent" / "bar") 42 + return repo 43 + 44 + 45 + def _home(tmp_path: Path, *parents: str) -> Path: 46 + home = tmp_path / "home" 47 + home.mkdir() 48 + for parent in parents: 49 + (home / parent).mkdir() 50 + return home 51 + 52 + 53 + def test_install_user_creates_targets_for_present_agents(tmp_path): 54 + repo = _mini_user_repo(tmp_path, b"solstone bytes") 55 + home = _home(tmp_path, ".claude", ".codex") 56 + 57 + report = install_user(repo, home, ["all"]) 58 + 59 + assert report.error_count == 0 60 + source = repo / "skills" / "solstone" / "SKILL.md" 61 + assert ( 62 + home / ".claude" / "skills" / "solstone" / "SKILL.md" 63 + ).read_bytes() == source.read_bytes() 64 + assert ( 65 + home / ".codex" / "skills" / "solstone" / "SKILL.md" 66 + ).read_bytes() == source.read_bytes() 67 + 68 + 69 + def test_install_user_skips_codex_when_parent_absent(tmp_path): 70 + repo = _mini_user_repo(tmp_path) 71 + home = _home(tmp_path, ".claude") 72 + 73 + report = install_user(repo, home, ["all"]) 74 + 75 + assert report.error_count == 0 76 + assert any( 77 + row.agent == "claude" and row.action == "installed" for row in report.rows 78 + ) 79 + assert any(row.agent == "codex" and row.action == "skipped" for row in report.rows) 80 + 81 + 82 + def test_install_user_skips_gemini_silently_in_default_all(tmp_path): 83 + repo = _mini_user_repo(tmp_path) 84 + home = _home(tmp_path, ".claude") 85 + 86 + default_report = install_user(repo, home, ["all"]) 87 + explicit_report = install_user(repo, home, ["gemini"]) 88 + 89 + assert all(row.agent != "gemini" for row in default_report.rows) 90 + assert explicit_report.rows == [ 91 + skills_cli.ActionRow( 92 + "gemini", 93 + "", 94 + "skipped", 95 + home / ".gemini", 96 + reason=f"config dir absent at {home / '.gemini'}", 97 + ) 98 + ] 99 + 100 + 101 + def test_install_user_all_skipped_prints_global_message(tmp_path, capsys): 102 + repo = _mini_user_repo(tmp_path) 103 + home = _home(tmp_path) 104 + 105 + report = install_user(repo, home, ["all"]) 106 + skills_cli._print_report(report, "install") 107 + 108 + captured = capsys.readouterr() 109 + assert report.error_count == 0 110 + assert GLOBAL_SKIP_MESSAGE in captured.out 111 + 112 + 113 + def test_install_user_replaces_modified_source(tmp_path): 114 + repo = _mini_user_repo(tmp_path, b"first") 115 + home = _home(tmp_path, ".claude") 116 + install_user(repo, home, ["claude"]) 117 + (repo / "skills" / "solstone" / "SKILL.md").write_bytes(b"second") 118 + 119 + report = install_user(repo, home, ["claude"]) 120 + 121 + assert report.error_count == 0 122 + assert ( 123 + home / ".claude" / "skills" / "solstone" / "SKILL.md" 124 + ).read_bytes() == b"second" 125 + 126 + 127 + def test_install_user_replaces_existing_regular_file_target(tmp_path): 128 + repo = _mini_user_repo(tmp_path, b"fresh") 129 + home = _home(tmp_path, ".claude") 130 + target = home / ".claude" / "skills" / "solstone" 131 + target.mkdir(parents=True) 132 + (target / "SKILL.md").write_bytes(b"stale") 133 + 134 + report = install_user(repo, home, ["claude"]) 135 + 136 + assert report.error_count == 0 137 + assert (target / "SKILL.md").read_bytes() == b"fresh" 138 + 139 + 140 + def test_install_user_refuses_symlink_target(tmp_path): 141 + repo = _mini_user_repo(tmp_path) 142 + home = _home(tmp_path, ".claude") 143 + target = home / ".claude" / "skills" / "solstone" 144 + target.parent.mkdir(parents=True) 145 + target.symlink_to(tmp_path) 146 + 147 + report = install_user(repo, home, ["claude"]) 148 + 149 + assert report.error_count == 1 150 + assert target.is_symlink() 151 + assert report.rows[0].reason == "refusing to overwrite symlink" 152 + 153 + 154 + def test_install_user_refuses_regular_file_target(tmp_path): 155 + repo = _mini_user_repo(tmp_path) 156 + home = _home(tmp_path, ".claude") 157 + target = home / ".claude" / "skills" / "solstone" 158 + target.parent.mkdir(parents=True) 159 + target.write_text("not a dir", encoding="utf-8") 160 + 161 + report = install_user(repo, home, ["claude"]) 162 + 163 + assert report.error_count == 1 164 + assert target.is_file() 165 + assert report.rows[0].reason == "refusing to overwrite non-directory" 166 + 167 + 168 + def test_install_user_permission_error_prints_clean_message(tmp_path, capsys): 169 + repo = _mini_user_repo(tmp_path) 170 + home = _home(tmp_path, ".claude") 171 + target = home / ".claude" / "skills" / "solstone" 172 + target.mkdir(parents=True) 173 + target.chmod(0o500) 174 + try: 175 + report = install_user(repo, home, ["claude"]) 176 + skills_cli._print_report(report, "install") 177 + finally: 178 + target.chmod(0o700) 179 + 180 + captured = capsys.readouterr() 181 + assert report.error_count == 1 182 + assert "error:" in captured.err 183 + assert "Traceback" not in captured.err 184 + 185 + 186 + def test_uninstall_user_removes_only_bundle_dirs(tmp_path): 187 + repo = _mini_user_repo(tmp_path) 188 + home = _home(tmp_path, ".claude") 189 + solstone = home / ".claude" / "skills" / "solstone" 190 + hop = home / ".claude" / "skills" / "hop" 191 + _write_skill(solstone) 192 + _write_skill(hop) 193 + 194 + report = uninstall_user(repo, home, ["claude"]) 195 + 196 + assert report.error_count == 0 197 + assert not solstone.exists() 198 + assert hop.exists() 199 + 200 + 201 + def test_uninstall_user_absent_target_is_no_op(tmp_path): 202 + repo = _mini_user_repo(tmp_path) 203 + home = _home(tmp_path, ".claude") 204 + 205 + report = uninstall_user(repo, home, ["claude"]) 206 + 207 + assert report.error_count == 0 208 + assert report.rows[0].action == "skipped" 209 + assert report.rows[0].reason == "nothing to remove" 210 + 211 + 212 + def test_install_user_agent_filter(tmp_path): 213 + repo = _mini_user_repo(tmp_path) 214 + home = _home(tmp_path, ".claude", ".codex") 215 + 216 + report = install_user(repo, home, ["claude"]) 217 + 218 + assert report.error_count == 0 219 + assert (home / ".claude" / "skills" / "solstone").exists() 220 + assert not (home / ".codex" / "skills" / "solstone").exists() 221 + 222 + 223 + def test_install_project_creates_symlinks(tmp_path): 224 + repo = _mini_project_repo(tmp_path) 225 + target = tmp_path / "work" 226 + 227 + report = install_project(repo, target, ["all"]) 228 + 229 + assert report.error_count == 0 230 + for agent_dir in [".claude", ".agents"]: 231 + link_parent = target / agent_dir / "skills" 232 + for name in ["journal", "routines", "bar"]: 233 + link = link_parent / name 234 + assert link.is_symlink() 235 + assert os.readlink(link) == os.path.relpath( 236 + repo / ("talent" if name != "bar" else "apps/foo/talent") / name, 237 + link_parent, 238 + ) 239 + 240 + 241 + def test_install_project_idempotent(tmp_path): 242 + repo = _mini_project_repo(tmp_path) 243 + target = tmp_path / "work" 244 + install_project(repo, target, ["all"]) 245 + before = { 246 + path: (os.readlink(path), path.lstat().st_mtime_ns) 247 + for path in sorted((target / ".claude" / "skills").iterdir()) 248 + } 249 + 250 + report = install_project(repo, target, ["all"]) 251 + 252 + after = { 253 + path: (os.readlink(path), path.lstat().st_mtime_ns) 254 + for path in sorted((target / ".claude" / "skills").iterdir()) 255 + } 256 + assert report.error_count == 0 257 + assert all(row.action == "noop" for row in report.rows) 258 + assert before == after 259 + 260 + 261 + def test_install_project_cleans_stale_symlinks(tmp_path): 262 + repo = _mini_project_repo(tmp_path) 263 + target = tmp_path / "work" 264 + install_project(repo, target, ["all"]) 265 + shutil.rmtree(repo / "talent" / "routines") 266 + 267 + report = install_project(repo, target, ["all"]) 268 + 269 + assert report.error_count == 0 270 + assert not (target / ".claude" / "skills" / "routines").exists() 271 + assert any(row.action == "removed" and row.reason == "stale" for row in report.rows) 272 + 273 + 274 + def test_install_project_dedupe_error(tmp_path): 275 + repo = tmp_path / "repo" 276 + _write_skill(repo / "talent" / "foo") 277 + _write_skill(repo / "apps" / "x" / "talent" / "foo") 278 + 279 + with pytest.raises(ValueError) as exc_info: 280 + install_project(repo, tmp_path / "work", ["all"]) 281 + 282 + message = str(exc_info.value) 283 + assert str(repo / "talent" / "foo") in message 284 + assert str(repo / "apps" / "x" / "talent" / "foo") in message 285 + 286 + 287 + def test_install_project_agent_claude_only(tmp_path): 288 + repo = _mini_project_repo(tmp_path) 289 + target = tmp_path / "work" 290 + 291 + report = install_project(repo, target, ["claude"]) 292 + 293 + assert report.error_count == 0 294 + assert (target / ".claude" / "skills" / "journal").is_symlink() 295 + assert not (target / ".agents").exists() 296 + 297 + 298 + def test_install_project_rejects_codex_or_gemini(tmp_path): 299 + repo = _mini_project_repo(tmp_path) 300 + 301 + with pytest.raises(ValueError, match="--agent codex is not supported"): 302 + install_project(repo, tmp_path / "work", ["codex"]) 303 + with pytest.raises(ValueError, match="--agent gemini is not supported"): 304 + install_project(repo, tmp_path / "work", ["gemini"]) 305 + 306 + 307 + def test_install_project_relative_target_outside_repo(tmp_path): 308 + repo = _mini_project_repo(tmp_path) 309 + target = tmp_path / "outside" / "work" 310 + 311 + install_project(repo, target, ["all"]) 312 + 313 + link_parent = target / ".claude" / "skills" 314 + link = link_parent / "journal" 315 + assert os.readlink(link) == os.path.relpath( 316 + repo / "talent" / "journal", link_parent 317 + ) 318 + 319 + 320 + def test_list_status_reports_installed_and_not_installed(tmp_path): 321 + user_repo = _mini_user_repo(tmp_path) 322 + home = _home(tmp_path, ".claude", ".codex") 323 + install_user(user_repo, home, ["claude"]) 324 + 325 + rows = list_user_status(user_repo, home, ["all"]) 326 + 327 + assert ("claude", "solstone", "installed") in { 328 + (row.agent, row.skill, row.state) for row in rows 329 + } 330 + assert ("codex", "solstone", "not installed") in { 331 + (row.agent, row.skill, row.state) for row in rows 332 + } 333 + 334 + 335 + def test_list_project_status_reports_correct_symlink_only(tmp_path): 336 + repo = _mini_project_repo(tmp_path) 337 + target = tmp_path / "work" 338 + install_project(repo, target, ["claude"]) 339 + 340 + rows = list_project_status(repo, target, ["all"]) 341 + 342 + assert ("claude", "journal", "installed") in { 343 + (row.agent, row.skill, row.state) for row in rows 344 + } 345 + assert ("agents", "journal", "not installed") in { 346 + (row.agent, row.skill, row.state) for row in rows 347 + } 348 + 349 + 350 + def test_main_install_user_default(monkeypatch, tmp_path, capsys): 351 + repo = _mini_user_repo(tmp_path) 352 + home = _home(tmp_path, ".claude") 353 + monkeypatch.setenv("HOME", str(home)) 354 + monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(repo)) 355 + monkeypatch.setattr(sys, "argv", ["sol skills", "install"]) 356 + 357 + exit_code = skills_cli.main() 358 + 359 + captured = capsys.readouterr() 360 + assert exit_code == 0 361 + assert "installed claude solstone" in captured.out 362 + assert (home / ".claude" / "skills" / "solstone" / "SKILL.md").exists() 363 + 364 + 365 + def test_main_install_project_no_dir_uses_cwd(monkeypatch, tmp_path): 366 + repo = _mini_project_repo(tmp_path) 367 + target = tmp_path / "work" 368 + target.mkdir() 369 + monkeypatch.chdir(target) 370 + monkeypatch.setattr(skills_cli, "get_project_root", lambda: str(repo)) 371 + monkeypatch.setattr(sys, "argv", ["sol skills", "install", "--project"]) 372 + 373 + exit_code = skills_cli.main() 374 + 375 + assert exit_code == 0 376 + assert (target / ".claude" / "skills" / "journal").is_symlink() 377 + 378 + 379 + def test_repo_root_resolution_works_from_arbitrary_cwd(monkeypatch, tmp_path): 380 + monkeypatch.chdir(tmp_path) 381 + 382 + bundles = discover_user_bundles(Path(get_project_root())) 383 + 384 + assert [bundle.name for bundle in bundles] == ["solstone"]
+613
think/skills_cli.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """sol skills — install, uninstall, and inspect coding-agent skill bundles. 5 + 6 + Two install modes: 7 + 8 + - User mode (default): copies <repo>/skills/<name>/ into per-agent user 9 + config directories (~/.claude/skills/, ~/.codex/skills/, optionally 10 + ~/.gemini/skills/). 11 + - Project mode (--project [DIR]): symlinks talent/ and apps/*/talent/ 12 + SKILL.md sources into <DIR>/.claude/skills/ and <DIR>/.agents/skills/. 13 + 14 + Subcommands: install, uninstall, list. 15 + 16 + Note: this is a different namespace from `sol call skills`, which manages 17 + owner-wide journal skill patterns under apps/skills/. 18 + """ 19 + 20 + from __future__ import annotations 21 + 22 + import argparse 23 + import os 24 + import shutil 25 + import sys 26 + import tempfile 27 + from dataclasses import dataclass 28 + from pathlib import Path 29 + from typing import Callable 30 + 31 + from think.utils import get_project_root 32 + 33 + ALL_AGENTS = "all" 34 + PROJECT_MULTI_AGENT = "agents" 35 + PROJECT_CLAUDE_SKILLS_REL = ".claude/skills" 36 + PROJECT_AGENTS_SKILLS_REL = ".agents/skills" 37 + GLOBAL_SKIP_MESSAGE = ( 38 + "no AI coding agent config directories found — skipping skill registration" 39 + ) 40 + SUBCOMMAND_DESCRIPTION = """User mode: copies/removes <repo>/skills/* in per-agent user config dirs. 41 + Project mode: symlinks/removes talent and apps/*/talent skills under DIR. 42 + User-mode install refuses symlink bundle targets; regular files inside bundle 43 + dirs are replaced atomically. 44 + Gemini is skipped silently in default --agent all when ~/.gemini/skills/ is 45 + absent; explicit --agent gemini prints a skip line. 46 + This is separate from `sol call skills`, which manages owner-wide journal 47 + skill patterns.""" 48 + 49 + 50 + @dataclass(frozen=True) 51 + class AgentSpec: 52 + name: str 53 + display_name: str 54 + parent_dir: str 55 + skills_dir: str 56 + silent_when_default_all: bool 57 + 58 + 59 + @dataclass(frozen=True) 60 + class ActionRow: 61 + agent: str 62 + skill: str 63 + action: str 64 + path: Path 65 + reason: str | None = None 66 + 67 + 68 + @dataclass 69 + class InstallReport: 70 + rows: list[ActionRow] 71 + 72 + @property 73 + def error_count(self) -> int: 74 + return sum(1 for row in self.rows if row.action == "error") 75 + 76 + @property 77 + def all_skipped(self) -> bool: 78 + return bool(self.rows) and all( 79 + row.action == "skipped" 80 + and row.reason is not None 81 + and row.reason.startswith("config dir absent at ") 82 + for row in self.rows 83 + ) 84 + 85 + 86 + @dataclass(frozen=True) 87 + class StatusRow: 88 + agent: str 89 + skill: str 90 + state: str 91 + path: Path 92 + 93 + 94 + AGENTS: dict[str, AgentSpec] = { 95 + "claude": AgentSpec( 96 + name="claude", 97 + display_name="Claude Code", 98 + parent_dir=".claude", 99 + skills_dir=PROJECT_CLAUDE_SKILLS_REL, 100 + silent_when_default_all=False, 101 + ), 102 + "codex": AgentSpec( 103 + name="codex", 104 + display_name="Codex", 105 + parent_dir=".codex", 106 + skills_dir=".codex/skills", 107 + silent_when_default_all=False, 108 + ), 109 + "gemini": AgentSpec( 110 + name="gemini", 111 + display_name="Gemini", 112 + parent_dir=".gemini", 113 + skills_dir=".gemini/skills", 114 + silent_when_default_all=True, 115 + ), 116 + } 117 + 118 + 119 + def discover_user_bundles(repo_root: Path) -> list[Path]: 120 + """Return public user skill bundle directories.""" 121 + root = repo_root / "skills" 122 + if not root.is_dir(): 123 + return [] 124 + return sorted( 125 + path 126 + for path in root.iterdir() 127 + if path.is_dir() 128 + and not path.name.startswith(".") 129 + and (path / "SKILL.md").is_file() 130 + ) 131 + 132 + 133 + def discover_project_sources(repo_root: Path) -> list[Path]: 134 + """Return project skill source directories, rejecting duplicate names.""" 135 + sources = sorted( 136 + [path.parent for path in (repo_root / "talent").glob("*/SKILL.md")] 137 + + [path.parent for path in (repo_root / "apps").glob("*/talent/*/SKILL.md")] 138 + ) 139 + seen: dict[str, Path] = {} 140 + for source in sources: 141 + previous = seen.get(source.name) 142 + if previous is not None: 143 + raise ValueError( 144 + f"duplicate skill name {source.name!r}: {previous} and {source}" 145 + ) 146 + seen[source.name] = source 147 + return sources 148 + 149 + 150 + def _atomic_copy_file(src: Path, dst: Path) -> None: 151 + dst.parent.mkdir(parents=True, exist_ok=True) 152 + fd, temp_path = tempfile.mkstemp(dir=dst.parent, prefix=".tmp_", suffix=".tmp") 153 + try: 154 + with os.fdopen(fd, "wb") as out_file: 155 + with src.open("rb") as in_file: 156 + shutil.copyfileobj(in_file, out_file) 157 + os.replace(temp_path, dst) 158 + except Exception: 159 + try: 160 + os.unlink(temp_path) 161 + except Exception: 162 + pass 163 + raise 164 + 165 + 166 + def _copy_tree_atomically(src_dir: Path, dst_dir: Path) -> str: 167 + existed = dst_dir.exists() 168 + dst_dir.mkdir(parents=True, exist_ok=True) 169 + for src in sorted(path for path in src_dir.rglob("*") if path.is_file()): 170 + _atomic_copy_file(src, dst_dir / src.relative_to(src_dir)) 171 + return "replaced" if existed else "installed" 172 + 173 + 174 + def _expand_user_agents(agents: list[str]) -> tuple[list[AgentSpec], bool]: 175 + default_all = ALL_AGENTS in agents 176 + names = list(AGENTS) if default_all else agents 177 + return [AGENTS[name] for name in names], default_all 178 + 179 + 180 + def _project_targets(target: Path, agents: list[str]) -> list[tuple[str, Path]]: 181 + if ALL_AGENTS in agents: 182 + return [ 183 + ("claude", target / PROJECT_CLAUDE_SKILLS_REL), 184 + (PROJECT_MULTI_AGENT, target / PROJECT_AGENTS_SKILLS_REL), 185 + ] 186 + if agents == ["claude"]: 187 + return [("claude", target / PROJECT_CLAUDE_SKILLS_REL)] 188 + agent = agents[0] if agents else "" 189 + raise ValueError( 190 + f"--agent {agent} is not supported with --project; use --agent all or --agent claude" 191 + ) 192 + 193 + 194 + def _missing_config_row( 195 + spec: AgentSpec, home: Path, default_all: bool 196 + ) -> ActionRow | bool | None: 197 + parent = home / spec.parent_dir 198 + if parent.exists(): 199 + return None 200 + if default_all and spec.silent_when_default_all: 201 + return True 202 + return ActionRow( 203 + spec.name, 204 + "", 205 + "skipped", 206 + parent, 207 + reason=f"config dir absent at {parent}", 208 + ) 209 + 210 + 211 + def _append_write_error( 212 + rows: list[ActionRow], 213 + agent: str, 214 + skill: str, 215 + path: Path, 216 + exc: OSError, 217 + ) -> None: 218 + rows.append(ActionRow(agent, skill, "error", path, reason=str(exc))) 219 + 220 + 221 + def install_user(repo_root: Path, home: Path, agents: list[str]) -> InstallReport: 222 + bundles = discover_user_bundles(repo_root) 223 + selected, default_all = _expand_user_agents(agents) 224 + rows: list[ActionRow] = [] 225 + 226 + for spec in selected: 227 + skip = _missing_config_row(spec, home, default_all) 228 + if skip is True: 229 + continue 230 + if isinstance(skip, ActionRow): 231 + rows.append(skip) 232 + continue 233 + 234 + skills_root = home / spec.skills_dir 235 + try: 236 + skills_root.mkdir(parents=True, exist_ok=True) 237 + except OSError as exc: 238 + _append_write_error(rows, spec.name, "", skills_root, exc) 239 + continue 240 + 241 + for bundle in bundles: 242 + target = skills_root / bundle.name 243 + if target.is_symlink(): 244 + rows.append( 245 + ActionRow( 246 + spec.name, 247 + bundle.name, 248 + "error", 249 + target, 250 + reason="refusing to overwrite symlink", 251 + ) 252 + ) 253 + continue 254 + if target.exists() and not target.is_dir(): 255 + rows.append( 256 + ActionRow( 257 + spec.name, 258 + bundle.name, 259 + "error", 260 + target, 261 + reason="refusing to overwrite non-directory", 262 + ) 263 + ) 264 + continue 265 + try: 266 + action = _copy_tree_atomically(bundle, target) 267 + except OSError as exc: 268 + _append_write_error(rows, spec.name, bundle.name, target, exc) 269 + continue 270 + rows.append(ActionRow(spec.name, bundle.name, action, target)) 271 + 272 + return InstallReport(rows) 273 + 274 + 275 + def uninstall_user(repo_root: Path, home: Path, agents: list[str]) -> InstallReport: 276 + bundles = discover_user_bundles(repo_root) 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 + for bundle in bundles: 290 + target = skills_root / bundle.name 291 + if not target.exists() and not target.is_symlink(): 292 + rows.append( 293 + ActionRow( 294 + spec.name, 295 + bundle.name, 296 + "skipped", 297 + target, 298 + reason="nothing to remove", 299 + ) 300 + ) 301 + continue 302 + if target.is_symlink() or not target.is_dir(): 303 + rows.append( 304 + ActionRow( 305 + spec.name, 306 + bundle.name, 307 + "error", 308 + target, 309 + reason="refusing to remove non-directory", 310 + ) 311 + ) 312 + continue 313 + try: 314 + shutil.rmtree(target) 315 + except OSError as exc: 316 + _append_write_error(rows, spec.name, bundle.name, target, exc) 317 + continue 318 + rows.append(ActionRow(spec.name, bundle.name, "removed", target)) 319 + 320 + return InstallReport(rows) 321 + 322 + 323 + def _install_project_source( 324 + agent: str, 325 + source: Path, 326 + link_parent: Path, 327 + rows: list[ActionRow], 328 + ) -> None: 329 + link = link_parent / source.name 330 + target = os.path.relpath(source, link_parent) 331 + 332 + if link.is_symlink(): 333 + if os.readlink(link) == target: 334 + rows.append(ActionRow(agent, source.name, "noop", link)) 335 + return 336 + try: 337 + link.unlink() 338 + link.symlink_to(target) 339 + except OSError as exc: 340 + _append_write_error(rows, agent, source.name, link, exc) 341 + return 342 + rows.append(ActionRow(agent, source.name, "replaced", link)) 343 + return 344 + 345 + if link.exists(): 346 + rows.append( 347 + ActionRow( 348 + agent, 349 + source.name, 350 + "error", 351 + link, 352 + reason="refusing to overwrite non-symlink", 353 + ) 354 + ) 355 + return 356 + 357 + try: 358 + link.symlink_to(target) 359 + except OSError as exc: 360 + _append_write_error(rows, agent, source.name, link, exc) 361 + return 362 + rows.append(ActionRow(agent, source.name, "installed", link)) 363 + 364 + 365 + def _remove_stale_project_links( 366 + agent: str, 367 + link_parent: Path, 368 + source_names: set[str], 369 + rows: list[ActionRow], 370 + ) -> None: 371 + if not link_parent.is_dir(): 372 + return 373 + for link in sorted(link_parent.iterdir()): 374 + if not link.is_symlink() or link.name in source_names: 375 + continue 376 + try: 377 + link.unlink() 378 + except OSError as exc: 379 + _append_write_error(rows, agent, link.name, link, exc) 380 + continue 381 + rows.append(ActionRow(agent, link.name, "removed", link, reason="stale")) 382 + 383 + 384 + def install_project(repo_root: Path, target: Path, agents: list[str]) -> InstallReport: 385 + sources = discover_project_sources(repo_root) 386 + source_names = {source.name for source in sources} 387 + rows: list[ActionRow] = [] 388 + 389 + for agent, link_parent in _project_targets(target, agents): 390 + try: 391 + link_parent.mkdir(parents=True, exist_ok=True) 392 + except OSError as exc: 393 + _append_write_error(rows, agent, "", link_parent, exc) 394 + continue 395 + for source in sources: 396 + _install_project_source(agent, source, link_parent, rows) 397 + _remove_stale_project_links(agent, link_parent, source_names, rows) 398 + 399 + return InstallReport(rows) 400 + 401 + 402 + def uninstall_project( 403 + repo_root: Path, target: Path, agents: list[str] 404 + ) -> InstallReport: 405 + sources = discover_project_sources(repo_root) 406 + rows: list[ActionRow] = [] 407 + 408 + for agent, link_parent in _project_targets(target, agents): 409 + for source in sources: 410 + link = link_parent / source.name 411 + if not link.exists() and not link.is_symlink(): 412 + rows.append( 413 + ActionRow( 414 + agent, 415 + source.name, 416 + "skipped", 417 + link, 418 + reason="nothing to remove", 419 + ) 420 + ) 421 + continue 422 + if not link.is_symlink(): 423 + rows.append( 424 + ActionRow( 425 + agent, 426 + source.name, 427 + "error", 428 + link, 429 + reason="refusing to remove non-symlink", 430 + ) 431 + ) 432 + continue 433 + try: 434 + link.unlink() 435 + except OSError as exc: 436 + _append_write_error(rows, agent, source.name, link, exc) 437 + continue 438 + rows.append(ActionRow(agent, source.name, "removed", link)) 439 + 440 + return InstallReport(rows) 441 + 442 + 443 + def list_user_status(repo_root: Path, home: Path, agents: list[str]) -> list[StatusRow]: 444 + bundles = discover_user_bundles(repo_root) 445 + selected, _default_all = _expand_user_agents(agents) 446 + rows: list[StatusRow] = [] 447 + 448 + for spec in selected: 449 + for bundle in bundles: 450 + target = home / spec.skills_dir / bundle.name 451 + state = "installed" if (target / "SKILL.md").is_file() else "not installed" 452 + rows.append(StatusRow(spec.name, bundle.name, state, target)) 453 + 454 + return rows 455 + 456 + 457 + def list_project_status( 458 + repo_root: Path, target: Path, agents: list[str] 459 + ) -> list[StatusRow]: 460 + sources = discover_project_sources(repo_root) 461 + rows: list[StatusRow] = [] 462 + 463 + for agent, link_parent in _project_targets(target, agents): 464 + for source in sources: 465 + link = link_parent / source.name 466 + expected = os.path.relpath(source, link_parent) 467 + state = ( 468 + "installed" 469 + if link.is_symlink() and os.readlink(link) == expected 470 + else "not installed" 471 + ) 472 + rows.append(StatusRow(agent, source.name, state, link)) 473 + 474 + return rows 475 + 476 + 477 + def _print_report(report: InstallReport, operation: str) -> None: 478 + for row in report.rows: 479 + if row.action == "noop": 480 + continue 481 + if row.action == "error": 482 + print(f"error: {operation} {row.path}: {row.reason}", file=sys.stderr) 483 + elif row.action == "skipped": 484 + skill = f" {row.skill}" if row.skill else "" 485 + print(f"skipped {row.agent}{skill} ({row.reason})") 486 + elif row.action == "removed" and row.reason: 487 + print(f"removed {row.agent} {row.skill} ({row.reason}) -> {row.path}") 488 + else: 489 + print(f"{row.action} {row.agent} {row.skill} -> {row.path}") 490 + 491 + if report.all_skipped: 492 + print(GLOBAL_SKIP_MESSAGE) 493 + 494 + 495 + def _print_status(rows: list[StatusRow]) -> None: 496 + print(f"{'agent':<10} {'skill':<20} state") 497 + for row in rows: 498 + print(f"{row.agent:<10} {row.skill:<20} {row.state}") 499 + 500 + 501 + def _add_agent_option(parser: argparse.ArgumentParser) -> None: 502 + parser.add_argument( 503 + "--agent", 504 + choices=["claude", "codex", "gemini", ALL_AGENTS], 505 + default=ALL_AGENTS, 506 + help="agent registry to update", 507 + ) 508 + 509 + 510 + def _add_project_option(parser: argparse.ArgumentParser) -> None: 511 + parser.add_argument( 512 + "--project", 513 + nargs="?", 514 + const=os.getcwd(), 515 + default=None, 516 + help="install project symlinks into DIR, or cwd when DIR is omitted", 517 + ) 518 + 519 + 520 + def _build_parser() -> argparse.ArgumentParser: 521 + parser = argparse.ArgumentParser( 522 + prog="sol skills", 523 + description=( 524 + "Install, uninstall, and inspect coding-agent skill bundles. " 525 + "This is separate from `sol call skills`, which manages owner-wide " 526 + "journal skill patterns. User mode refuses symlink bundle targets; " 527 + "gemini is skipped silently in --agent all when ~/.gemini is absent." 528 + ), 529 + ) 530 + subparsers = parser.add_subparsers(dest="cmd", required=True) 531 + 532 + install_parser = subparsers.add_parser( 533 + "install", 534 + help="install skill bundles", 535 + description=SUBCOMMAND_DESCRIPTION, 536 + formatter_class=argparse.RawDescriptionHelpFormatter, 537 + ) 538 + _add_agent_option(install_parser) 539 + _add_project_option(install_parser) 540 + 541 + uninstall_parser = subparsers.add_parser( 542 + "uninstall", 543 + help="uninstall skill bundles", 544 + description=SUBCOMMAND_DESCRIPTION, 545 + formatter_class=argparse.RawDescriptionHelpFormatter, 546 + ) 547 + _add_agent_option(uninstall_parser) 548 + _add_project_option(uninstall_parser) 549 + 550 + list_parser = subparsers.add_parser("list", help="list skill install status") 551 + _add_project_option(list_parser) 552 + 553 + return parser 554 + 555 + 556 + def _resolve_project_target(project_value: str | None) -> Path | None: 557 + if project_value is None: 558 + return None 559 + return Path(project_value).expanduser().resolve() 560 + 561 + 562 + def _run_report( 563 + operation: str, 564 + action: Callable[[Path, Path, list[str]], InstallReport], 565 + repo_root: Path, 566 + location: Path, 567 + agents: list[str], 568 + ) -> int: 569 + report = action(repo_root, location, agents) 570 + _print_report(report, operation) 571 + return 1 if report.error_count else 0 572 + 573 + 574 + def main() -> int: 575 + parser = _build_parser() 576 + args = parser.parse_args() 577 + repo_root = Path(get_project_root()) 578 + target = _resolve_project_target(args.project) 579 + 580 + try: 581 + if args.cmd == "install": 582 + if target is None: 583 + return _run_report( 584 + "install", install_user, repo_root, Path.home(), [args.agent] 585 + ) 586 + return _run_report( 587 + "install", install_project, repo_root, target, [args.agent] 588 + ) 589 + 590 + if args.cmd == "uninstall": 591 + if target is None: 592 + return _run_report( 593 + "uninstall", uninstall_user, repo_root, Path.home(), [args.agent] 594 + ) 595 + return _run_report( 596 + "uninstall", uninstall_project, repo_root, target, [args.agent] 597 + ) 598 + 599 + if args.cmd == "list": 600 + if target is None: 601 + _print_status(list_user_status(repo_root, Path.home(), [ALL_AGENTS])) 602 + else: 603 + _print_status(list_project_status(repo_root, target, [ALL_AGENTS])) 604 + return 0 605 + except (OSError, PermissionError, ValueError) as exc: 606 + print(f"error: {exc}", file=sys.stderr) 607 + return 1 608 + 609 + return 1 610 + 611 + 612 + if __name__ == "__main__": 613 + sys.exit(main())
+2
think/sol_cli.py
··· 49 49 "doctor": "think.doctor", 50 50 "config": "think.config_cli", 51 51 "install-models": "think.install_models", 52 + "skills": "think.skills_cli", 52 53 "password": "think.password_cli", 53 54 "streams": "think.streams", 54 55 "segment": "think.segment", ··· 131 132 "Specialized tools": [ 132 133 "password", 133 134 "config", 135 + "skills", 134 136 "streams", 135 137 "segment", 136 138 "journal-stats",