diff --git a/README.md b/README.md index de8e325..6d2446a 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,14 @@ The Library is a single skill whose only job is to manage other skills. It's a c Think of it as a `package.json` for agent capabilities — but instead of packages, you're managing skills, agents, and prompts. Instead of a registry, you're pointing at your own private GitHub repos and local paths. -**This is a pure agent application.** There are no scripts, no CLIs, no dependencies, no build tools. The entire application is encoded in `SKILL.md` and a set of cookbook instructions that teach the agent exactly what to do. The agent IS the runtime. This matters because: +**This is primarily an agent application.** The core behavior is still encoded in `SKILL.md` and the cookbook instructions, but the repo can also ship small helper scripts for repeatable operations such as `use`, `sync`, and `freshness`. The agent remains the orchestration layer. + +This matters because: - Any agent harness that reads skill files can run it (Claude Code, Pi, etc.) -- You can modify behavior by editing markdown, not code +- You can keep high-level behavior in markdown while using scripts for repeatable file operations - The skill can be extended, forked, and adapted instantly -- An orchestrator agent can chain library commands without any tooling overhead +- An orchestrator agent can chain library commands without having to re-implement low-level copy and compare logic each time ## Why It Exists @@ -214,6 +216,7 @@ Pull the latest version of all installed items: | `/library remove ` | Remove from catalog and optionally delete local copy | | `/library list` | Show full catalog with install status | | `/library sync` | Re-pull all installed items from source | +| `/library freshness` | Check if installed items match their sources | | `/library search ` | Find entries by name or description | ### Justfile Shortcuts @@ -226,6 +229,7 @@ just use my-skill # Pull a skill just push my-skill # Push changes back just add "name: foo, description: bar, source: /path/to/SKILL.md" just sync # Re-pull all installed items +just freshness # Check whether installed items are current just search "keyword" ``` diff --git a/SKILL.md b/SKILL.md index c30d89f..fd300c4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -33,6 +33,7 @@ The Library is a catalog of references to your agentics. The `library.yaml` file | `/library remove ` | Remove from catalog and optionally local | | `/library list` | Show full catalog with install status | | `/library sync` | Re-pull all installed items from source | +| `/library freshness` | Check if installed items match source | | `/library search ` | Find entries by keyword | ## Cookbook @@ -48,10 +49,13 @@ Each command has a detailed step-by-step guide. **Read the relevant cookbook fil | remove | [cookbook/remove.md](cookbook/remove.md) | User wants to remove an entry from the catalog | | list | [cookbook/list.md](cookbook/list.md) | User wants to see what's available and what's installed | | sync | [cookbook/sync.md](cookbook/sync.md) | User wants to refresh all installed items at once | +| freshness | [cookbook/freshness.md](cookbook/freshness.md) | User wants to check if installed items are up-to-date | | search | [cookbook/search.md](cookbook/search.md) | User is looking for a skill but doesn't know the exact name | **When a user invokes a `/library` command, read the matching cookbook file first, then execute the steps.** +For `/library use`, `/library sync`, and `/library freshness`, preserve the user's project context. If the catalog uses relative `default_dirs`, run the script with `--project-root "$PWD"` from the target project root, or pass an explicit `--target` or `--global` where supported. Do not `cd` into the library repo and run these scripts there unless you intend to target that repo explicitly. + ## Source Format The `source` field in `library.yaml` supports these formats (auto-detected): diff --git a/bin/freshness.py b/bin/freshness.py new file mode 100755 index 0000000..e5eccfb --- /dev/null +++ b/bin/freshness.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +from typing import Optional, List, Tuple +""" +library freshness - check if installed items match their sources. + +Usage: python3 bin/freshness.py [--json] + +Exit codes: + 0 - all installed items are up-to-date (not-installed and unreachable are not failures) + 1 - one or more installed items are outdated + 2 - fatal error (unreadable catalog, etc.) +""" + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +LIBRARY_ROOT = Path(__file__).resolve().parent.parent + +# --------------------------------------------------------------------------- +# YAML parsing (PyYAML optional, fall back to line-by-line parser) +# --------------------------------------------------------------------------- + +def _parse_yaml_fallback(text: str): # type: () -> dict + """ + Minimal line-by-line YAML parser. + + Supported subset: + - Top-level keys (key: value) + - Section keys (two-space indent, key: value) + - List items (- name: val / - key: val on next indented line) + - Inline lists for requires: [skill:foo, agent:bar] + - Comments (#) + + Unsupported: anchors, flow maps, multi-line strings, custom tags. + Fails open per entry (warn + skip); exit 2 only if totally unparsable. + """ + result = {} + lines = text.splitlines() + i = 0 + + def strip_comment(s): + # Very naive: only strip # outside quoted strings + idx = s.find("#") + if idx == -1: + return s.strip() + return s[:idx].strip() + + def parse_inline_list(s): + s = s.strip() + if s.startswith("[") and s.endswith("]"): + inner = s[1:-1] + return [x.strip() for x in inner.split(",") if x.strip()] + return [s] if s else [] + + # We do a two-pass approach: collect raw structure then build dicts + # Pass 1: identify top-level keys and their line ranges + # Actually, let's do a stateful single pass. + + stack = [] # (indent_level, key, container) + current_top_key = None + current_section = None + current_list = None + current_item = None + + def indent_of(line): + return len(line) - len(line.lstrip()) + + while i < len(lines): + raw = lines[i] + i += 1 + stripped = strip_comment(raw) + if not stripped: + continue + ind = indent_of(raw.rstrip()) + + if ind == 0 and not raw.startswith(" ") and not raw.startswith("-"): + # Top-level key + if ":" in stripped: + key, _, val = stripped.partition(":") + key = key.strip() + val = val.strip() + if val: + result[key] = val + else: + result[key] = {} + current_top_key = key + current_section = None + current_list = None + current_item = None + elif ind == 2 and isinstance(result.get(current_top_key), dict): + # Second-level key inside a top-level dict + if stripped.startswith("- "): + # list item at indent 2 inside top-level + item_text = stripped[2:].strip() + if current_section is None: + current_section = "__list__" + result[current_top_key] = [] + if not isinstance(result[current_top_key], list): + result[current_top_key] = [] + if ":" in item_text: + k2, _, v2 = item_text.partition(":") + current_item = {k2.strip(): v2.strip()} + else: + current_item = {} + result[current_top_key].append(current_item) + current_list = result[current_top_key] + elif ":" in stripped: + key, _, val = stripped.partition(":") + key = key.strip() + val = val.strip() + if val: + result[current_top_key][key] = val + else: + result[current_top_key][key] = {} + current_section = key + current_item = None + current_list = None + elif ind == 4: + top = result.get(current_top_key) + if isinstance(top, dict) and current_section and isinstance(top.get(current_section), dict): + # e.g. default_dirs.skills list items + section_val = top[current_section] + if stripped.startswith("- "): + item_text = stripped[2:].strip() + if not isinstance(section_val, list): + top[current_section] = [] + section_val = top[current_section] + if ":" in item_text: + k2, _, v2 = item_text.partition(":") + d = {k2.strip(): v2.strip()} + else: + d = {} + section_val.append(d) + elif ":" in stripped: + k2, _, v2 = stripped.partition(":") + section_val[k2.strip()] = v2.strip() + elif isinstance(top, dict) and current_section: + # might be a list of objects under library.skills etc. + sec = top.get(current_section) + if isinstance(sec, list) and sec: + obj = sec[-1] + if stripped.startswith("- "): + item_text = stripped[2:].strip() + if ":" in item_text: + k2, _, v2 = item_text.partition(":") + new_obj = {k2.strip(): v2.strip()} + else: + new_obj = {} + sec.append(new_obj) + current_item = new_obj + elif ":" in stripped: + k2, _, v2 = stripped.partition(":") + k2 = k2.strip() + v2 = v2.strip() + if v2.startswith("["): + obj[k2] = parse_inline_list(v2) + else: + obj[k2] = v2 + elif ind == 6: + # Deep nested - library.skills items' properties + top = result.get(current_top_key) + if isinstance(top, dict) and current_section: + sec = top.get(current_section) + if isinstance(sec, list) and sec: + obj = sec[-1] + if stripped.startswith("- "): + item_text = stripped[2:].strip() + if ":" in item_text: + k2, _, v2 = item_text.partition(":") + new_obj = {k2.strip(): v2.strip()} + else: + new_obj = {} + sec.append(new_obj) + elif ":" in stripped: + k2, _, v2 = stripped.partition(":") + k2 = k2.strip() + v2 = v2.strip() + if v2.startswith("["): + obj[k2] = parse_inline_list(v2) + else: + obj[k2] = v2 + + return result + + +def load_yaml(path: Path): # type: () -> dict + text = path.read_text(encoding="utf-8") + try: + import yaml + return yaml.safe_load(text) or {} + except ImportError: + pass + try: + return _parse_yaml_fallback(text) + except Exception as e: + print(f"FATAL: could not parse {path}: {e}", file=sys.stderr) + sys.exit(2) + + +# --------------------------------------------------------------------------- +# Hashing +# --------------------------------------------------------------------------- + +def hash_directory(dirpath: Path) -> Optional[str]: + """Return a stable SHA-256 digest of all files under dirpath.""" + if not dirpath.exists(): + return None + h = hashlib.sha256() + try: + for fpath in sorted(dirpath.rglob("*")): + if fpath.is_file(): + rel = str(fpath.relative_to(dirpath)) + h.update(rel.encode()) + h.update(fpath.read_bytes()) + except OSError: + return None + return h.hexdigest() + + +# --------------------------------------------------------------------------- +# Source resolution +# --------------------------------------------------------------------------- + +def resolve_home(p: str) -> Path: + return Path(p).expanduser() + + +def source_is_local(source: str) -> bool: + return source.startswith("/") or source.startswith("~") + + +def source_is_directory(source: str) -> bool: + """A source that ends with / or has no file extension is directory-based.""" + return source.endswith("/") or "." not in Path(source.rstrip("/")).name + + +def local_source_dir(source: str) -> Path: + p = resolve_home(source) + if source_is_directory(source): + return p + return p.parent + + +def parse_github_url(source: str): + """ + Returns (clone_url, branch, subdir_in_repo) or None. + subdir_in_repo is the parent directory of the referenced file. + """ + import re + browser = re.match( + r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.+)", source + ) + raw = re.match( + r"https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)", source + ) + m = browser or raw + if not m: + return None + org, repo, branch, file_path = m.groups() + clone_url = f"https://github.com/{org}/{repo}.git" + if source_is_directory(source): + subdir = file_path.rstrip("/") + else: + subdir = str(Path(file_path).parent) + return clone_url, branch, subdir + + +def clone_repo(clone_url: str, branch: str, tmpdir: Path) -> bool: + """Shallow-clone into tmpdir/repo. Returns True on success.""" + dest = tmpdir / "repo" + r = subprocess.run( + ["git", "clone", "--depth", "1", "--branch", branch, clone_url, str(dest)], + capture_output=True, + ) + if r.returncode != 0: + # Try SSH fallback for github.com + ssh_url = clone_url.replace("https://github.com/", "git@github.com:") + r2 = subprocess.run( + ["git", "clone", "--depth", "1", "--branch", branch, ssh_url, str(dest)], + capture_output=True, + ) + return r2.returncode == 0 + return True + + +# --------------------------------------------------------------------------- +# Target directory resolution +# --------------------------------------------------------------------------- + +def resolve_target_path(path_str: str, project_root: Path) -> Path: + path = Path(path_str).expanduser() + if path.is_absolute(): + return path + return (project_root / path).resolve() + + + +def default_target_dir(catalog: dict, type_key: str, project_root: Path) -> Optional[Path]: + dirs = catalog.get("default_dirs", {}).get(type_key, []) + for entry in dirs: + if isinstance(entry, dict) and "default" in entry: + return resolve_target_path(entry["default"], project_root) + return None + + + +def global_target_dir(catalog: dict, type_key: str, project_root: Path) -> Optional[Path]: + dirs = catalog.get("default_dirs", {}).get(type_key, []) + for entry in dirs: + if isinstance(entry, dict) and "global" in entry: + return resolve_target_path(entry["global"], project_root) + return None + + + +def installed_paths(catalog: dict, name: str, type_key: str, project_root: Path) -> List[Path]: + found = [] + seen = set() + for base in [default_target_dir(catalog, type_key, project_root), global_target_dir(catalog, type_key, project_root)]: + if base is None: + continue + candidate = base / name + resolved = candidate.resolve() + if candidate.exists() and resolved not in seen: + found.append(candidate) + seen.add(resolved) + return found + + +# --------------------------------------------------------------------------- +# Main logic +# --------------------------------------------------------------------------- + +def check_entry(catalog: dict, entry: dict, type_key: str, project_root: Path, tmpdir: Path): + """ + Returns dict with: name, type, status + status in: up-to-date, outdated, not-installed, unreachable + """ + name = entry.get("name", "?") + source = entry.get("source", "") + result = {"name": name, "type": type_key.rstrip("s"), "status": ""} + + inst_paths = installed_paths(catalog, name, type_key, project_root) + if not inst_paths: + result["status"] = "not-installed" + return result + + print(f" checking {name} ...", file=sys.stderr) + + if source_is_local(source): + src_dir = local_source_dir(source) + if not src_dir.exists(): + result["status"] = "unreachable" + return result + src_hash = hash_directory(src_dir) + inst_hashes = [hash_directory(inst) for inst in inst_paths] + if src_hash is None or any(h is None for h in inst_hashes): + result["status"] = "unreachable" + elif all(src_hash == h for h in inst_hashes): + result["status"] = "up-to-date" + else: + result["status"] = "outdated" + else: + parsed = parse_github_url(source) + if parsed is None: + result["status"] = "unreachable" + return result + clone_url, branch, subdir = parsed + repo_tmp = tmpdir / name.replace("/", "_") + repo_tmp.mkdir(parents=True, exist_ok=True) + if not clone_repo(clone_url, branch, repo_tmp): + result["status"] = "unreachable" + return result + src_dir = repo_tmp / "repo" / subdir + if not src_dir.exists(): + result["status"] = "unreachable" + return result + src_hash = hash_directory(src_dir) + inst_hashes = [hash_directory(inst) for inst in inst_paths] + if src_hash is None or any(h is None for h in inst_hashes): + result["status"] = "unreachable" + elif all(src_hash == h for h in inst_hashes): + result["status"] = "up-to-date" + else: + result["status"] = "outdated" + + return result + + +def print_table(rows): # type: (List[dict]) -> None + col_name = max(len(r["name"]) for r in rows) + col_name = max(col_name, 4) + col_type = max(len(r["type"]) for r in rows) + col_type = max(col_type, 4) + col_status = max(len(r["status"]) for r in rows) + col_status = max(col_status, 6) + + header = f"{'Name':<{col_name}} {'Type':<{col_type}} {'Status':<{col_status}}" + sep = "-" * len(header) + print(header) + print(sep) + for r in rows: + print(f"{r['name']:<{col_name}} {r['type']:<{col_type}} {r['status']:<{col_status}}") + + +def print_suggestions(rows): # type: (List[dict]) -> None + statuses = {r["status"] for r in rows} + outdated = [r["name"] for r in rows if r["status"] == "outdated"] + not_installed = [r["name"] for r in rows if r["status"] == "not-installed"] + unreachable = [r["name"] for r in rows if r["status"] == "unreachable"] + + print() + if outdated: + names = ", ".join(outdated) + print(f"Outdated: {names}") + print(" Run /library sync (or /library use ) to update outdated items.") + if not_installed: + names = ", ".join(not_installed) + print(f"Not installed: {names}") + print(" Run /library use to install missing items.") + if unreachable: + names = ", ".join(unreachable) + print(f"Unreachable: {names}") + print(" Check the source path or network for unreachable entries.") + if statuses == {"up-to-date"}: + print("All installed items are current.") + + +def main(): + parser = argparse.ArgumentParser(description="Check library freshness") + parser.add_argument("--json", action="store_true", help="Output JSON instead of table") + parser.add_argument( + "--project-root", + metavar="PATH", + help="Base directory for relative default_dirs paths. Defaults to the current working directory.", + ) + args = parser.parse_args() + + yaml_path_env = os.environ.get("LIBRARY_YAML_PATH", "~/.claude/skills/library/library.yaml") + yaml_path = Path(yaml_path_env).expanduser() + if not yaml_path.exists(): + print(f"FATAL: library.yaml not found at {yaml_path}", file=sys.stderr) + sys.exit(2) + + print(f"Loading catalog from {yaml_path}", file=sys.stderr) + catalog = load_yaml(yaml_path) + lib = catalog.get("library", {}) + project_root = resolve_target_path(args.project_root, Path.cwd()) if args.project_root else Path.cwd().resolve() + + if args.project_root is None and project_root == LIBRARY_ROOT: + print( + "ERROR: refusing to check project-local installs from the library repo root. " + "Run from the target project root or pass --project-root.", + file=sys.stderr, + ) + sys.exit(2) + + entries = [] + for type_key in ("skills", "agents", "prompts"): + section = lib.get(type_key) or [] + if not isinstance(section, list): + continue + for entry in section: + if isinstance(entry, dict) and entry.get("name"): + entries.append((type_key, entry)) + + if not entries: + print("No entries found in catalog.", file=sys.stderr) + sys.exit(0) + + rows = [] + with tempfile.TemporaryDirectory() as td: + tmpdir = Path(td) + for type_key, entry in entries: + try: + row = check_entry(catalog, entry, type_key, project_root, tmpdir) + rows.append(row) + except Exception as e: + rows.append({"name": entry.get("name", "?"), "type": type_key.rstrip("s"), "status": "unreachable"}) + print(f" WARN: error checking {entry.get('name')}: {e}", file=sys.stderr) + + if args.json: + print(json.dumps(rows, indent=2)) + else: + print_table(rows) + print_suggestions(rows) + + has_outdated = any(r["status"] == "outdated" for r in rows) + sys.exit(1 if has_outdated else 0) + + +if __name__ == "__main__": + main() diff --git a/bin/sync.py b/bin/sync.py new file mode 100755 index 0000000..1bc97c5 --- /dev/null +++ b/bin/sync.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 +from typing import Optional, List, Tuple +""" +library sync - re-pull all installed items from their sources. + +Usage: python3 bin/sync.py [--dry-run] + +Exit codes: + 0 - all installed items synced successfully + 1 - one or more items failed to sync + 2 - fatal error (unreadable catalog, etc.) +""" + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +LIBRARY_ROOT = Path(__file__).resolve().parent.parent + +# --------------------------------------------------------------------------- +# YAML parsing (PyYAML optional, fall back to line-by-line parser) +# --------------------------------------------------------------------------- + +def _parse_yaml_fallback(text: str) -> dict: + result = {} + lines = text.splitlines() + i = 0 + + def strip_comment(s): + idx = s.find("#") + if idx == -1: + return s.strip() + return s[:idx].strip() + + def parse_inline_list(s): + s = s.strip() + if s.startswith("[") and s.endswith("]"): + inner = s[1:-1] + return [x.strip() for x in inner.split(",") if x.strip()] + return [s] if s else [] + + def indent_of(line): + return len(line) - len(line.lstrip()) + + current_top_key = None + current_section = None + + while i < len(lines): + raw = lines[i] + i += 1 + stripped = strip_comment(raw) + if not stripped: + continue + ind = indent_of(raw.rstrip()) + + if ind == 0 and not raw.startswith(" ") and not raw.startswith("-"): + if ":" in stripped: + key, _, val = stripped.partition(":") + key = key.strip() + val = val.strip() + result[key] = val if val else {} + current_top_key = key + current_section = None + elif ind == 2 and isinstance(result.get(current_top_key), dict): + if stripped.startswith("- "): + item_text = stripped[2:].strip() + if not isinstance(result[current_top_key], list): + result[current_top_key] = [] + if ":" in item_text: + k2, _, v2 = item_text.partition(":") + current_item = {k2.strip(): v2.strip()} + else: + current_item = {} + result[current_top_key].append(current_item) + elif ":" in stripped: + key, _, val = stripped.partition(":") + key = key.strip() + val = val.strip() + result[current_top_key][key] = val if val else {} + current_section = key + elif ind == 4: + top = result.get(current_top_key) + if isinstance(top, dict) and current_section: + sec = top.get(current_section) + if isinstance(sec, list): + if stripped.startswith("- "): + item_text = stripped[2:].strip() + if ":" in item_text: + k2, _, v2 = item_text.partition(":") + obj = {k2.strip(): v2.strip()} + else: + obj = {} + sec.append(obj) + elif sec and isinstance(sec[-1], dict): + if ":" in stripped: + k2, _, v2 = stripped.partition(":") + k2 = k2.strip() + v2 = v2.strip() + if v2.startswith("["): + sec[-1][k2] = parse_inline_list(v2) + else: + sec[-1][k2] = v2 + elif isinstance(sec, dict): + if stripped.startswith("- "): + item_text = stripped[2:].strip() + if not isinstance(top[current_section], list): + top[current_section] = [] + if ":" in item_text: + k2, _, v2 = item_text.partition(":") + top[current_section].append({k2.strip(): v2.strip()}) + else: + top[current_section].append({}) + elif ind == 6: + top = result.get(current_top_key) + if isinstance(top, dict) and current_section: + sec = top.get(current_section) + if isinstance(sec, list) and sec and isinstance(sec[-1], dict): + obj = sec[-1] + if stripped.startswith("- "): + item_text = stripped[2:].strip() + if ":" in item_text: + k2, _, v2 = item_text.partition(":") + new_obj = {k2.strip(): v2.strip()} + else: + new_obj = {} + sec.append(new_obj) + elif ":" in stripped: + k2, _, v2 = stripped.partition(":") + k2 = k2.strip() + v2 = v2.strip() + if v2.startswith("["): + obj[k2] = parse_inline_list(v2) + else: + obj[k2] = v2 + + return result + + +def load_yaml(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + try: + import yaml + return yaml.safe_load(text) or {} + except ImportError: + pass + try: + return _parse_yaml_fallback(text) + except Exception as e: + print(f"FATAL: could not parse {path}: {e}", file=sys.stderr) + sys.exit(2) + + +# --------------------------------------------------------------------------- +# Source helpers +# --------------------------------------------------------------------------- + +def resolve_home(p: str) -> Path: + return Path(p).expanduser() + + +def source_is_local(source: str) -> bool: + return source.startswith("/") or source.startswith("~") + + +def source_is_directory(source: str) -> bool: + return source.endswith("/") or "." not in Path(source.rstrip("/")).name + + +def local_source_dir(source: str) -> Path: + p = resolve_home(source) + if source_is_directory(source): + return p + return p.parent + + +def parse_github_url(source: str): + import re + browser = re.match( + r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.+)", source + ) + raw = re.match( + r"https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)", source + ) + m = browser or raw + if not m: + return None + org, repo, branch, file_path = m.groups() + clone_url = f"https://github.com/{org}/{repo}.git" + if source_is_directory(source): + subdir = file_path.rstrip("/") + else: + subdir = str(Path(file_path).parent) + return clone_url, branch, subdir + + +def clone_repo(clone_url: str, branch: str, dest: Path) -> bool: + r = subprocess.run( + ["git", "clone", "--depth", "1", "--branch", branch, clone_url, str(dest)], + capture_output=True, + ) + if r.returncode != 0: + ssh_url = clone_url.replace("https://github.com/", "git@github.com:") + r2 = subprocess.run( + ["git", "clone", "--depth", "1", "--branch", branch, ssh_url, str(dest)], + capture_output=True, + ) + return r2.returncode == 0 + return True + + +# --------------------------------------------------------------------------- +# Target directory resolution +# --------------------------------------------------------------------------- + +def resolve_target_path(path_str: str, project_root: Path) -> Path: + path = Path(path_str).expanduser() + if path.is_absolute(): + return path + return (project_root / path).resolve() + + + +def default_target_dir(catalog: dict, type_key: str, project_root: Path) -> Optional[Path]: + dirs = catalog.get("default_dirs", {}).get(type_key, []) + for entry in dirs: + if isinstance(entry, dict) and "default" in entry: + return resolve_target_path(entry["default"], project_root) + return None + + + +def global_target_dir(catalog: dict, type_key: str, project_root: Path) -> Optional[Path]: + dirs = catalog.get("default_dirs", {}).get(type_key, []) + for entry in dirs: + if isinstance(entry, dict) and "global" in entry: + return resolve_target_path(entry["global"], project_root) + return None + + + +def find_installed(catalog: dict, name: str, type_key: str, project_root: Path) -> List[Path]: + """Return all installed locations (default and global) for an entry.""" + found = [] + seen = set() + for dir_fn in (default_target_dir, global_target_dir): + base = dir_fn(catalog, type_key, project_root) + if base is None: + continue + candidate = base / name + resolved = candidate.resolve() + if candidate.exists() and resolved not in seen: + found.append(candidate) + seen.add(resolved) + return found + + +# --------------------------------------------------------------------------- +# Copy logic +# --------------------------------------------------------------------------- + +def copy_skill(src_dir: Path, target_dir: Path, name: str, dry_run: bool) -> bool: + dest = target_dir / name + if dry_run: + print(f" [dry-run] would copy {src_dir} -> {dest}") + return True + try: + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src_dir, dest) + return True + except Exception as e: + print(f" ERROR copying {src_dir} -> {dest}: {e}", file=sys.stderr) + return False + + +def sync_entry(catalog: dict, entry: dict, type_key: str, dry_run: bool, project_root: Path, tmpdir: Path) -> str: + """Sync a single entry. Returns 'synced', 'skipped' (not installed), or 'failed:'.""" + name = entry.get("name", "?") + source = entry.get("source", "") + + installed_paths = find_installed(catalog, name, type_key, project_root) + if not installed_paths: + return "skipped" + + print(f" syncing {name} ...", file=sys.stderr) + + if source_is_local(source): + src_dir = local_source_dir(source) + if not src_dir.exists(): + return "failed:source not found" + ok = True + for inst_path in installed_paths: + target_dir = inst_path.parent + if not copy_skill(src_dir, target_dir, name, dry_run): + ok = False + return "synced" if ok else "failed:copy error" + else: + parsed = parse_github_url(source) + if parsed is None: + return "failed:unrecognized source URL" + clone_url, branch, subdir = parsed + repo_tmp = tmpdir / name.replace("/", "_") + repo_tmp.mkdir(parents=True, exist_ok=True) + dest_repo = repo_tmp / "repo" + if not clone_repo(clone_url, branch, dest_repo): + return "failed:clone failed" + src_dir = dest_repo / subdir + if not src_dir.exists(): + return f"failed:subdir {subdir} not found in repo" + ok = True + for inst_path in installed_paths: + target_dir = inst_path.parent + if not copy_skill(src_dir, target_dir, name, dry_run): + ok = False + return "synced" if ok else "failed:copy error" + + +# --------------------------------------------------------------------------- +# Dependency resolution +# --------------------------------------------------------------------------- + +def resolve_deps(catalog: dict, entry: dict) -> List[Tuple[str, dict]]: + """Return list of (type_key, entry) for each dependency, in install order.""" + requires = entry.get("requires", []) + if not requires: + return [] + lib = catalog.get("library", {}) + type_map = {"skill": "skills", "agent": "agents", "prompt": "prompts"} + deps = [] + for ref in (requires if isinstance(requires, list) else [requires]): + if ":" in ref: + rtype, rname = ref.split(":", 1) + else: + rtype, rname = "skill", ref + type_key = type_map.get(rtype, "skills") + section = lib.get(type_key) or [] + for e in section: + if isinstance(e, dict) and e.get("name") == rname: + deps.append((type_key, e)) + break + return deps + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="Sync all installed library items") + parser.add_argument("--dry-run", action="store_true", help="Show what would be synced without copying") + parser.add_argument( + "--project-root", + metavar="PATH", + help="Base directory for relative default_dirs paths. Defaults to the current working directory.", + ) + args = parser.parse_args() + + yaml_path_env = os.environ.get("LIBRARY_YAML_PATH", "~/.claude/skills/library/library.yaml") + yaml_path = Path(yaml_path_env).expanduser() + if not yaml_path.exists(): + print(f"FATAL: library.yaml not found at {yaml_path}", file=sys.stderr) + sys.exit(2) + + print(f"Loading catalog from {yaml_path}", file=sys.stderr) + catalog = load_yaml(yaml_path) + lib = catalog.get("library", {}) + project_root = resolve_target_path(args.project_root, Path.cwd()) if args.project_root else Path.cwd().resolve() + + if args.project_root is None and project_root == LIBRARY_ROOT: + print( + "ERROR: refusing to sync project-local installs from the library repo root. " + "Run from the target project root or pass --project-root.", + file=sys.stderr, + ) + sys.exit(2) + + all_entries = [] + for type_key in ("skills", "agents", "prompts"): + section = lib.get(type_key) or [] + if not isinstance(section, list): + continue + for entry in section: + if isinstance(entry, dict) and entry.get("name"): + all_entries.append((type_key, entry)) + + if not all_entries: + print("No entries found in catalog.") + sys.exit(0) + + # Find which are installed + installed_entries = [] + for type_key, entry in all_entries: + if find_installed(catalog, entry["name"], type_key, project_root): + installed_entries.append((type_key, entry)) + + if not installed_entries: + print("No installed items found. Run /library use to install items first.") + sys.exit(0) + + # Expand dependencies - process deps before items that need them + ordered = [] + seen = set() + + def add_entry(tk, e): + key = (tk, e["name"]) + if key in seen: + return + seen.add(key) + for dep_tk, dep_e in resolve_deps(catalog, e): + add_entry(dep_tk, dep_e) + ordered.append((tk, e)) + + for tk, e in installed_entries: + add_entry(tk, e) + + if args.dry_run: + print("[dry-run] The following items would be synced:") + + results = [] + with tempfile.TemporaryDirectory() as td: + tmpdir = Path(td) + for type_key, entry in ordered: + name = entry["name"] + try: + status = sync_entry(catalog, entry, type_key, args.dry_run, project_root, tmpdir) + except Exception as e: + status = f"failed:{e}" + results.append({"type": type_key.rstrip("s"), "name": name, "status": status}) + + # Print summary table + print() + col_type = max(len(r["type"]) for r in results) + col_name = max(len(r["name"]) for r in results) + col_status = max(len(r["status"]) for r in results) + col_type = max(col_type, 4) + col_name = max(col_name, 4) + col_status = max(col_status, 6) + + print(f"{'Type':<{col_type}} {'Name':<{col_name}} {'Status':<{col_status}}") + print("-" * (col_type + col_name + col_status + 4)) + for r in results: + print(f"{r['type']:<{col_type}} {r['name']:<{col_name}} {r['status']:<{col_status}}") + + synced = sum(1 for r in results if r["status"] in ("synced",)) + skipped = sum(1 for r in results if r["status"] == "skipped") + failed = sum(1 for r in results if r["status"].startswith("failed")) + dry_synced = sum(1 for r in results if r["status"] == "synced" and args.dry_run) + + print() + if args.dry_run: + print(f"Dry-run: {len(ordered)} items would be processed ({skipped} skipped).") + else: + print(f"Synced: {synced} Skipped (not installed): {skipped} Failed: {failed}") + + sys.exit(1 if failed > 0 else 0) + + +if __name__ == "__main__": + main() diff --git a/bin/use.py b/bin/use.py new file mode 100755 index 0000000..428d7f6 --- /dev/null +++ b/bin/use.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +from typing import Optional, List, Tuple +""" +library use - pull a named item from the catalog into the local environment. + +Usage: python3 bin/use.py [--global] [--target ] + +Exit codes: + 0 - success + 1 - not found or copy failed + 2 - fatal error (unreadable catalog, etc.) +""" + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +LIBRARY_ROOT = Path(__file__).resolve().parent.parent + +# --------------------------------------------------------------------------- +# YAML parsing (PyYAML optional, fall back to line-by-line parser) +# --------------------------------------------------------------------------- + +def _parse_yaml_fallback(text: str) -> dict: + result = {} + lines = text.splitlines() + i = 0 + + def strip_comment(s): + idx = s.find("#") + if idx == -1: + return s.strip() + return s[:idx].strip() + + def parse_inline_list(s): + s = s.strip() + if s.startswith("[") and s.endswith("]"): + inner = s[1:-1] + return [x.strip() for x in inner.split(",") if x.strip()] + return [s] if s else [] + + def indent_of(line): + return len(line) - len(line.lstrip()) + + current_top_key = None + current_section = None + + while i < len(lines): + raw = lines[i] + i += 1 + stripped = strip_comment(raw) + if not stripped: + continue + ind = indent_of(raw.rstrip()) + + if ind == 0 and not raw.startswith(" ") and not raw.startswith("-"): + if ":" in stripped: + key, _, val = stripped.partition(":") + key = key.strip() + val = val.strip() + result[key] = val if val else {} + current_top_key = key + current_section = None + elif ind == 2 and isinstance(result.get(current_top_key), dict): + if stripped.startswith("- "): + item_text = stripped[2:].strip() + if not isinstance(result[current_top_key], list): + result[current_top_key] = [] + if ":" in item_text: + k2, _, v2 = item_text.partition(":") + current_item = {k2.strip(): v2.strip()} + else: + current_item = {} + result[current_top_key].append(current_item) + elif ":" in stripped: + key, _, val = stripped.partition(":") + key = key.strip() + val = val.strip() + result[current_top_key][key] = val if val else {} + current_section = key + elif ind == 4: + top = result.get(current_top_key) + if isinstance(top, dict) and current_section: + sec = top.get(current_section) + if isinstance(sec, list): + if stripped.startswith("- "): + item_text = stripped[2:].strip() + if ":" in item_text: + k2, _, v2 = item_text.partition(":") + obj = {k2.strip(): v2.strip()} + else: + obj = {} + sec.append(obj) + elif sec and isinstance(sec[-1], dict): + if ":" in stripped: + k2, _, v2 = stripped.partition(":") + k2 = k2.strip() + v2 = v2.strip() + if v2.startswith("["): + sec[-1][k2] = parse_inline_list(v2) + else: + sec[-1][k2] = v2 + elif isinstance(sec, dict): + if stripped.startswith("- "): + item_text = stripped[2:].strip() + if not isinstance(top[current_section], list): + top[current_section] = [] + if ":" in item_text: + k2, _, v2 = item_text.partition(":") + top[current_section].append({k2.strip(): v2.strip()}) + else: + top[current_section].append({}) + elif ind == 6: + top = result.get(current_top_key) + if isinstance(top, dict) and current_section: + sec = top.get(current_section) + if isinstance(sec, list) and sec and isinstance(sec[-1], dict): + obj = sec[-1] + if stripped.startswith("- "): + item_text = stripped[2:].strip() + if ":" in item_text: + k2, _, v2 = item_text.partition(":") + new_obj = {k2.strip(): v2.strip()} + else: + new_obj = {} + sec.append(new_obj) + elif ":" in stripped: + k2, _, v2 = stripped.partition(":") + k2 = k2.strip() + v2 = v2.strip() + if v2.startswith("["): + obj[k2] = parse_inline_list(v2) + else: + obj[k2] = v2 + + return result + + +def load_yaml(path: Path) -> dict: + text = path.read_text(encoding="utf-8") + try: + import yaml + return yaml.safe_load(text) or {} + except ImportError: + pass + try: + return _parse_yaml_fallback(text) + except Exception as e: + print(f"FATAL: could not parse {path}: {e}", file=sys.stderr) + sys.exit(2) + + +# --------------------------------------------------------------------------- +# Source helpers +# --------------------------------------------------------------------------- + +def resolve_home(p: str) -> Path: + return Path(p).expanduser() + + +def source_is_local(source: str) -> bool: + return source.startswith("/") or source.startswith("~") + + +def source_is_directory(source: str) -> bool: + return source.endswith("/") or "." not in Path(source.rstrip("/")).name + + +def local_source_dir(source: str) -> Path: + p = resolve_home(source) + if source_is_directory(source): + return p + return p.parent + + +def parse_github_url(source: str): + import re + browser = re.match( + r"https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.+)", source + ) + raw = re.match( + r"https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)", source + ) + m = browser or raw + if not m: + return None + org, repo, branch, file_path = m.groups() + clone_url = f"https://github.com/{org}/{repo}.git" + if source_is_directory(source): + subdir = file_path.rstrip("/") + else: + subdir = str(Path(file_path).parent) + return clone_url, branch, subdir + + +def clone_repo(clone_url: str, branch: str, dest: Path) -> bool: + r = subprocess.run( + ["git", "clone", "--depth", "1", "--branch", branch, clone_url, str(dest)], + capture_output=True, + ) + if r.returncode != 0: + ssh_url = clone_url.replace("https://github.com/", "git@github.com:") + r2 = subprocess.run( + ["git", "clone", "--depth", "1", "--branch", branch, ssh_url, str(dest)], + capture_output=True, + ) + return r2.returncode == 0 + return True + + +# --------------------------------------------------------------------------- +# Target directory resolution +# --------------------------------------------------------------------------- + +def resolve_target_path(path_str: str, project_root: Path) -> Path: + path = Path(path_str).expanduser() + if path.is_absolute(): + return path + return (project_root / path).resolve() + + + +def get_target_dir(catalog: dict, type_key: str, use_global: bool, project_root: Path) -> Optional[Path]: + dirs = catalog.get("default_dirs", {}).get(type_key, []) + key = "global" if use_global else "default" + for entry in dirs: + if isinstance(entry, dict) and key in entry: + return resolve_target_path(entry[key], project_root) + return None + + +# --------------------------------------------------------------------------- +# Name resolution +# --------------------------------------------------------------------------- + +def find_entry(catalog: dict, name: str) -> Optional[Tuple[str, dict]]: + """Search skills, then agents, then prompts. First exact match wins.""" + lib = catalog.get("library", {}) + for type_key in ("skills", "agents", "prompts"): + section = lib.get(type_key) or [] + if not isinstance(section, list): + continue + for entry in section: + if isinstance(entry, dict) and entry.get("name") == name: + return type_key, entry + return None + + +# --------------------------------------------------------------------------- +# Dependency resolution +# --------------------------------------------------------------------------- + +def resolve_deps(catalog: dict, entry: dict) -> List[Tuple[str, dict]]: + requires = entry.get("requires", []) + if not requires: + return [] + lib = catalog.get("library", {}) + type_map = {"skill": "skills", "agent": "agents", "prompt": "prompts"} + deps = [] + for ref in (requires if isinstance(requires, list) else [requires]): + if ":" in ref: + rtype, rname = ref.split(":", 1) + else: + rtype, rname = "skill", ref + type_key = type_map.get(rtype, "skills") + section = lib.get(type_key) or [] + for e in section: + if isinstance(e, dict) and e.get("name") == rname: + deps.append((type_key, e)) + break + else: + print(f" WARN: dependency {ref} not found in catalog", file=sys.stderr) + return deps + + +# --------------------------------------------------------------------------- +# Install logic +# --------------------------------------------------------------------------- + +def install_entry( + catalog: dict, + type_key: str, + entry: dict, + target_override, # type: Optional[Path] + use_global, # type: bool + project_root: Path, + tmpdir: Path, + installed_set: set, +) -> bool: + name = entry.get("name", "?") + source = entry.get("source", "") + + if name in installed_set: + return True + installed_set.add(name) + + # Install dependencies first + for dep_tk, dep_entry in resolve_deps(catalog, entry): + print(f"Installing dependency: {dep_entry['name']} ({dep_tk.rstrip('s')})") + if not install_entry(catalog, dep_tk, dep_entry, None, use_global, project_root, tmpdir, installed_set): + print(f" WARN: dependency {dep_entry['name']} failed to install", file=sys.stderr) + + # Determine target + if target_override is not None: + target_dir = target_override + else: + target_dir = get_target_dir(catalog, type_key, use_global, project_root) + if target_dir is None: + print(f"ERROR: could not determine target directory for {type_key}", file=sys.stderr) + return False + + target_dir.mkdir(parents=True, exist_ok=True) + dest = target_dir / name + + print(f"Installing {name} ({type_key.rstrip('s')}) -> {dest}") + + if source_is_local(source): + src_dir = local_source_dir(source) + if not src_dir.exists(): + print(f"ERROR: source not found: {src_dir}", file=sys.stderr) + return False + try: + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src_dir, dest) + print(f" OK: installed from {src_dir}") + return True + except Exception as e: + print(f"ERROR: copy failed: {e}", file=sys.stderr) + return False + else: + parsed = parse_github_url(source) + if parsed is None: + print(f"ERROR: unrecognized source URL: {source}", file=sys.stderr) + return False + clone_url, branch, subdir = parsed + repo_tmp = tmpdir / name.replace("/", "_") + repo_tmp.mkdir(parents=True, exist_ok=True) + dest_repo = repo_tmp / "repo" + print(f" Cloning {clone_url} (branch: {branch}) ...") + if not clone_repo(clone_url, branch, dest_repo): + print(f"ERROR: clone failed for {clone_url}", file=sys.stderr) + return False + src_dir = dest_repo / subdir + if not src_dir.exists(): + print(f"ERROR: subdir {subdir} not found in cloned repo", file=sys.stderr) + return False + try: + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src_dir, dest) + print(f" OK: installed from {clone_url}/{subdir}") + return True + except Exception as e: + print(f"ERROR: copy failed: {e}", file=sys.stderr) + return False + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="Install or refresh a library item") + parser.add_argument("name", help="Entry name from library.yaml") + parser.add_argument("--global", dest="use_global", action="store_true", + help="Install to global directory") + parser.add_argument("--target", metavar="PATH", help="Override target directory") + parser.add_argument( + "--project-root", + metavar="PATH", + help="Base directory for relative default_dirs and relative --target paths. Defaults to the current working directory.", + ) + args = parser.parse_args() + + yaml_path_env = os.environ.get("LIBRARY_YAML_PATH", "~/.claude/skills/library/library.yaml") + yaml_path = Path(yaml_path_env).expanduser() + if not yaml_path.exists(): + print(f"FATAL: library.yaml not found at {yaml_path}", file=sys.stderr) + sys.exit(2) + + catalog = load_yaml(yaml_path) + + result = find_entry(catalog, args.name) + if result is None: + print(f"ERROR: '{args.name}' not found in catalog. Try /library search.", file=sys.stderr) + sys.exit(1) + + type_key, entry = result + project_root = resolve_target_path(args.project_root, Path.cwd()) if args.project_root else Path.cwd().resolve() + target_override = resolve_target_path(args.target, project_root) if args.target else None + + if not args.use_global and args.target is None and args.project_root is None and project_root == LIBRARY_ROOT: + print( + "ERROR: refusing to install into the library repo via relative default_dirs. " + "Run from the target project root or pass --project-root, --target, or --global.", + file=sys.stderr, + ) + sys.exit(2) + + installed_set: set = set() + with tempfile.TemporaryDirectory() as td: + tmpdir = Path(td) + ok = install_entry( + catalog, type_key, entry, target_override, args.use_global, project_root, tmpdir, installed_set + ) + + if ok: + target_dir = target_override or get_target_dir(catalog, type_key, args.use_global, project_root) + dest = (target_dir / args.name) if target_dir else Path("?") + print(f"\nDone: {args.name} installed to {dest}") + sys.exit(0) + else: + print(f"\nFailed to install {args.name}.", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/cookbook/freshness.md b/cookbook/freshness.md new file mode 100644 index 0000000..0cf0d01 --- /dev/null +++ b/cookbook/freshness.md @@ -0,0 +1,43 @@ +# /library freshness + +Check whether installed skills are up-to-date with their sources. No marker files. No state directory. Every run is a live comparison. + +## Context + +Freshness works by hashing the contents of the installed directory and comparing it against the source directory at the time of the check. There is no cached state - if the source changed since the last `use` or `sync`, the hashes will differ. + +Two source types are handled: + +- **Local path** - both directories are on disk; hash both with SHA-256 and compare directly +- **Git URL** - shallow-clone the source repo into a temp directory, hash the relevant subdirectory, compare against installed + +## Run + +```bash +python3 ~/.claude/skills/library/bin/freshness.py --project-root "$PWD" +``` + +Output as JSON (for scripting): + +```bash +python3 ~/.claude/skills/library/bin/freshness.py --project-root "$PWD" --json +``` + +Notes: +- Relative `default_dirs` entries in `library.yaml` are resolved against `--project-root`. +- For project-local installs, run from the target project root and pass `--project-root "$PWD"`. +- If multiple installed copies exist, freshness reports `up-to-date` only when all installed copies match the source. +- The script refuses to run from the library repo root unless the project root is made explicit. + +The script prints a table with `Name`, `Type`, and `Status` columns, followed by actionable suggestions. JSON output is an array of `{name, type, status}` objects. + +Status values: + +| Status | Meaning | +| ------------- | -------------------------------------------------------------- | +| up-to-date | Installed content matches source | +| outdated | Source has changed since last install | +| not-installed | Entry exists in catalog but has no local copy | +| unreachable | Source path missing or git clone failed | + +Exit 0 if all installed items are up-to-date, 1 if any are outdated, 2 on fatal error. Not-installed and unreachable entries do not trigger exit 1. diff --git a/cookbook/list.md b/cookbook/list.md index 9f4e9ec..212d1e2 100644 --- a/cookbook/list.md +++ b/cookbook/list.md @@ -19,6 +19,7 @@ git pull ### 3. Check Install Status For each entry: - Determine the type and corresponding default/global directories from `default_dirs` +- Resolve relative `default_dirs` paths against the target project root, not the library repo - Check if a directory matching the entry name exists in the **default** directory - Check if a directory matching the entry name exists in the **global** directory - Search recursively for name matches diff --git a/cookbook/push.md b/cookbook/push.md index 5b4eeb5..83f07a4 100644 --- a/cookbook/push.md +++ b/cookbook/push.md @@ -15,6 +15,7 @@ The user provides a skill name or description. ### 2. Locate the Local Copy - Check the default directory for the type (from `default_dirs`) +- Resolve relative `default_dirs` paths against the target project root, not the library repo - Check the global directory - If found in multiple places, ask which one to push - If not found locally, tell the user there's nothing to push diff --git a/cookbook/remove.md b/cookbook/remove.md index e46ad53..f615215 100644 --- a/cookbook/remove.md +++ b/cookbook/remove.md @@ -33,6 +33,7 @@ Show the entry details and ask: ### 5. Delete Local Copy (if requested) If the user confirmed local deletion: - Check the default directory for the type (from `default_dirs`) +- Resolve relative `default_dirs` paths against the target project root, not the library repo - Check the global directory - Remove the directory or file: ```bash diff --git a/cookbook/sync.md b/cookbook/sync.md index 2691fe4..7af5f4f 100644 --- a/cookbook/sync.md +++ b/cookbook/sync.md @@ -1,94 +1,23 @@ # Sync All Installed Items ## Context -Refresh every locally installed skill, agent, and prompt by re-pulling from its source. A fast, lazy "make sure everything is up to date" command. +Refresh every locally installed skill, agent, and prompt by re-pulling from its source. Only items that are already installed (found in default or global directories) are synced - items that have never been installed are skipped. Dependencies are re-pulled before the items that require them. -## Steps +## Run -### 1. Sync the Library Repo -Pull the latest catalog before reading: ```bash -cd -git pull +python3 ~/.claude/skills/library/bin/sync.py --project-root "$PWD" ``` -### 2. Read the Catalog -- Read `library.yaml` -- Parse all entries from `library.skills`, `library.agents`, and `library.prompts` - -### 3. Find All Installed Items -For each entry in the catalog: -- Determine the type (skill, agent, prompt) and corresponding directories from `default_dirs` -- Check if a directory or file matching the entry name exists in the **default** directory -- Check if a directory or file matching the entry name exists in the **global** directory -- Search recursively for name matches -- Collect every entry that is installed locally (either default or global) -- If nothing is installed, tell the user and exit - -### 4. Re-pull Each Installed Item -For each installed entry, fetch the latest from its source: - -**If source is a local path** (starts with `/` or `~`): -- Resolve `~` to the home directory -- Get the parent directory of the referenced file -- For skills: copy the entire parent directory to the target: - ```bash - cp -R / // - ``` -- For agents: copy just the agent file to the target: - ```bash - cp /.md - ``` -- For prompts: copy just the prompt file to the target: - ```bash - cp /.md - ``` - -**If source is a GitHub URL**: -- Parse the URL to extract: `org`, `repo`, `branch`, `file_path` - - Browser URL pattern: `https://github.com///blob//` - - Raw URL pattern: `https://raw.githubusercontent.com////` -- Determine the clone URL: `https://github.com//.git` -- Determine the parent directory path within the repo (everything before the filename) -- Clone into a temporary directory: - ```bash - tmp_dir=$(mktemp -d) - git clone --depth 1 --branch https://github.com//.git "$tmp_dir" - ``` -- Copy the parent directory of the file to the target: - ```bash - cp -R "$tmp_dir//" // - ``` -- Clean up: - ```bash - rm -rf "$tmp_dir" - ``` - -**If clone fails (private repo)**, try SSH: - ```bash - git clone --depth 1 --branch git@github.com:/.git "$tmp_dir" - ``` - -### 5. Resolve Dependencies -For each installed entry that has a `requires` field: -- Check if each dependency is also installed -- If a dependency is not installed, pull it as well -- Process dependencies before the items that require them - -### 6. Report Results -Display a summary table: +Preview what would be synced without copying anything: +```bash +python3 ~/.claude/skills/library/bin/sync.py --project-root "$PWD" --dry-run ``` -## Sync Complete -| Type | Name | Status | -|------|------|--------| -| skill | skill-name | refreshed | -| agent | agent-name | refreshed | -| skill | other-skill | failed: | - -Synced: X items -Failed: Y items -``` +Notes: +- Relative `default_dirs` entries in `library.yaml` are resolved against `--project-root`. +- For project-local installs, run from the target project root and pass `--project-root "$PWD"`. +- The script refuses to run from the library repo root unless the project root is made explicit. -If any items failed (e.g., network error, missing source), list them with the reason so the user can fix individually. +The script scans default and global directories for installed items, re-pulls each from its source (local path or GitHub clone), and prints a summary table with per-item status (`synced`, `skipped`, or `failed:`). Exit 0 if all synced, 1 if any failed, 2 on fatal error. diff --git a/cookbook/use.md b/cookbook/use.md index abf46e8..569de22 100644 --- a/cookbook/use.md +++ b/cookbook/use.md @@ -1,93 +1,32 @@ # Use a Skill from the Library ## Context -Pull a skill, agent, or prompt from the catalog into the local environment. If already installed locally, overwrite with the latest from the source (refresh). +Pull a skill, agent, or prompt from the catalog into the local environment. If already installed locally, overwrite with the latest from the source (refresh). Dependencies declared in `requires` are installed first. ## Input -The user provides a skill name or description. +The user provides an entry name (exact match against `library.yaml`). Optionally: `--global` to install to the global directory, or `--target ` to override the destination. -## Steps +## Run -### 1. Sync the Library Repo -Pull the latest catalog before reading: ```bash -cd -git pull +python3 ~/.claude/skills/library/bin/use.py --project-root "$PWD" ``` -### 2. Find the Entry -- Read `library.yaml` -- Search across `library.skills`, `library.agents`, and `library.prompts` -- Match by name (exact) or description (fuzzy/keyword match) -- If multiple matches, show them and ask the user to pick one -- If no match, tell the user and suggest `/library search` +Install to the global directory: -### 3. Resolve Dependencies -If the entry has a `requires` field: -- For each typed reference (`skill:name`, `agent:name`, `prompt:name`): - - Look it up in `library.yaml` - - If found, recursively run the `use` workflow for that dependency first - - If not found, warn the user: "Dependency not found in library catalog" -- Process all dependencies before the requested item - -### 4. Determine Target Directory -- Read `default_dirs` from `library.yaml` -- If user said "global" or "globally" → use the `global` path -- If user specified a custom path → use that path -- Otherwise → use the `default` path -- Select the correct section based on type (skills/agents/prompts) - -### 5. Fetch from Source - -**If source is a local path** (starts with `/` or `~`): -- Resolve `~` to the home directory -- Get the parent directory of the referenced file -- For skills: copy the entire parent directory to the target: - ```bash - cp -R / // - ``` -- For agents: copy just the agent file to the target: - ```bash - cp /.md - ``` -- For prompts: copy just the prompt file to the target: - ```bash - cp /.md - ``` -- If the agent or prompt is nested in a subdirectory under the `agents/` or `commands/` directories, copy the subdirectory to the target as well, creating the subdir if it doesn't exist. This is useful because it keeps the agents or commands grouped together. +```bash +python3 ~/.claude/skills/library/bin/use.py --global +``` -**If source is a GitHub URL**: -- Parse the URL to extract: `org`, `repo`, `branch`, `file_path` - - Browser URL pattern: `https://github.com///blob//` - - Raw URL pattern: `https://raw.githubusercontent.com////` -- Determine the clone URL: `https://github.com//.git` -- Determine the parent directory path within the repo (everything before the filename) -- Clone into a temporary directory: - ```bash - tmp_dir=$(mktemp -d) - git clone --depth 1 --branch https://github.com//.git "$tmp_dir" - ``` -- Copy the parent directory of the file to the target: - ```bash - cp -R "$tmp_dir//" // - ``` -- Clean up: - ```bash - rm -rf "$tmp_dir" - ``` +Override the target directory: -**If clone fails (private repo)**, try SSH: - ```bash - git clone --depth 1 --branch git@github.com:/.git "$tmp_dir" - ``` +```bash +python3 ~/.claude/skills/library/bin/use.py --project-root "$PWD" --target /path/to/destination +``` -### 6. Verify Installation -- Confirm the target directory exists -- Confirm the main file (SKILL.md, AGENT.md, or prompt file) exists in it -- Report success with the installed path +Notes: +- Relative `default_dirs` entries in `library.yaml` are resolved against `--project-root`. +- For project-local installs, run from the target project root and pass `--project-root "$PWD"`. +- The script refuses to install into the library repo itself unless the target is made explicit. -### 7. Confirm -Tell the user: -- What was installed and where -- Any dependencies that were also installed -- If this was a refresh (overwrite), mention that +The script handles source resolution (local directory, local file, GitHub URL), dependency installation, directory creation, and overwrites existing copies. Progress lines and the final installed path are printed to stdout. Exit 0 on success, 1 if the name is not found or copy fails, 2 on fatal error. diff --git a/justfile b/justfile index 3c6207a..91deaa0 100644 --- a/justfile +++ b/justfile @@ -32,6 +32,10 @@ sync: list: claude --dangerously-skip-permissions --model opus "/library list" +# Check if installed items are up-to-date with their sources +freshness: + claude --dangerously-skip-permissions --model opus "/library freshness" + # Search the catalog by keyword search keyword: claude --dangerously-skip-permissions --model opus "/library search {{keyword}}"