source-to-skill 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. source_to_skill/__init__.py +3 -0
  2. source_to_skill/__main__.py +5 -0
  3. source_to_skill/cli.py +237 -0
  4. source_to_skill/companions/skill_eval/SKILL.md +77 -0
  5. source_to_skill/companions/skill_library/SKILL.md +49 -0
  6. source_to_skill/companions/skill_library/scripts/library.py +160 -0
  7. source_to_skill/companions/skill_study/SKILL.md +85 -0
  8. source_to_skill/skill/SKILL.md +388 -0
  9. source_to_skill/skill/scripts/extract.py +188 -0
  10. source_to_skill/skill/scripts/extractor/__init__.py +10 -0
  11. source_to_skill/skill/scripts/extractor/config.py +205 -0
  12. source_to_skill/skill/scripts/extractor/dependencies.py +114 -0
  13. source_to_skill/skill/scripts/extractor/media.py +38 -0
  14. source_to_skill/skill/scripts/extractor/parsers/__init__.py +0 -0
  15. source_to_skill/skill/scripts/extractor/parsers/article.py +668 -0
  16. source_to_skill/skill/scripts/extractor/parsers/audio.py +87 -0
  17. source_to_skill/skill/scripts/extractor/parsers/book.py +493 -0
  18. source_to_skill/skill/scripts/extractor/parsers/docs.py +505 -0
  19. source_to_skill/skill/scripts/extractor/parsers/document.py +712 -0
  20. source_to_skill/skill/scripts/extractor/parsers/notes.py +365 -0
  21. source_to_skill/skill/scripts/extractor/parsers/paper.py +623 -0
  22. source_to_skill/skill/scripts/extractor/parsers/playlist.py +186 -0
  23. source_to_skill/skill/scripts/extractor/parsers/repo.py +858 -0
  24. source_to_skill/skill/scripts/extractor/parsers/thread.py +450 -0
  25. source_to_skill/skill/scripts/extractor/parsers/transcript.py +104 -0
  26. source_to_skill/skill/scripts/extractor/parsers/youtube.py +226 -0
  27. source_to_skill/skill/scripts/extractor/transcribe.py +132 -0
  28. source_to_skill/skill/scripts/extractor/utils.py +245 -0
  29. source_to_skill/skill/tools/diff_source.py +63 -0
  30. source_to_skill/skill/tools/validate_skill.py +107 -0
  31. source_to_skill-0.7.0.dist-info/METADATA +435 -0
  32. source_to_skill-0.7.0.dist-info/RECORD +36 -0
  33. source_to_skill-0.7.0.dist-info/WHEEL +5 -0
  34. source_to_skill-0.7.0.dist-info/entry_points.txt +2 -0
  35. source_to_skill-0.7.0.dist-info/licenses/LICENSE +21 -0
  36. source_to_skill-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """source-to-skill: turn videos, podcasts, papers, books, docs, notes and threads into agent skills."""
2
+
3
+ __version__ = "0.7.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from source_to_skill.cli import main
4
+
5
+ sys.exit(main())
source_to_skill/cli.py ADDED
@@ -0,0 +1,237 @@
1
+ """Command-line entry point for the source-to-skill package.
2
+
3
+ The agent skill itself lives in a self-contained folder (SKILL.md + scripts/
4
+ + tools/). In a wheel it is bundled as ``source_to_skill/skill/``; in a
5
+ source checkout / editable install it is read from ``skills/source-to-skill/``.
6
+ The companion skills (skill-study, skill-eval, skill-library) ship the same
7
+ way, as ``source_to_skill/companions/<name_with_underscores>/`` in a wheel.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import importlib.util
13
+ import shutil
14
+ import sys
15
+ import tempfile
16
+ from pathlib import Path
17
+
18
+ from source_to_skill import __version__
19
+
20
+ SKILL_NAME = "source-to-skill"
21
+
22
+ # Installed next to source-to-skill unless --only source-to-skill.
23
+ COMPANIONS = ("skill-study", "skill-eval", "skill-library")
24
+
25
+ # agent -> (user-level skills root relative to $HOME, project-level root
26
+ # relative to cwd), from each agent's skills documentation. Codex, Amp,
27
+ # Goose and most others read the shared .agents/skills folders.
28
+ AGENT_DIRS = {
29
+ "claude": (Path(".claude/skills"), Path(".claude/skills")),
30
+ "agents": (Path(".agents/skills"), Path(".agents/skills")),
31
+ "codex": (Path(".agents/skills"), Path(".agents/skills")),
32
+ "copilot": (Path(".copilot/skills"), Path(".github/skills")),
33
+ "gemini": (Path(".gemini/skills"), Path(".gemini/skills")),
34
+ "cursor": (Path(".cursor/skills"), Path(".cursor/skills")),
35
+ "opencode": (Path(".config/opencode/skills"), Path(".opencode/skills")),
36
+ "kiro": (Path(".kiro/skills"), Path(".kiro/skills")),
37
+ "windsurf": (Path(".codeium/windsurf/skills"), Path(".windsurf/skills")),
38
+ "factory": (Path(".factory/skills"), Path(".factory/skills")),
39
+ }
40
+
41
+ IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo", ".DS_Store")
42
+
43
+
44
+ def skill_dir(name: str = SKILL_NAME) -> Path:
45
+ """Return a bundled skill folder (wheel install or source checkout)."""
46
+ here = Path(__file__).resolve().parent
47
+ wheel = (
48
+ here / "skill" if name == SKILL_NAME
49
+ else here / "companions" / name.replace("-", "_")
50
+ )
51
+ candidates = [
52
+ wheel, # installed wheel
53
+ here.parent.parent / "skills" / name, # src/ checkout, editable install
54
+ ]
55
+ for candidate in candidates:
56
+ if (candidate / "SKILL.md").is_file():
57
+ return candidate
58
+ raise FileNotFoundError(
59
+ "bundled skill not found; looked in: "
60
+ + ", ".join(str(c) for c in candidates)
61
+ )
62
+
63
+
64
+ def _load_module(path: Path, name: str):
65
+ """Import a bundled script by path with its folder on sys.path."""
66
+ folder = str(path.parent)
67
+ if folder not in sys.path:
68
+ sys.path.insert(0, folder)
69
+ spec = importlib.util.spec_from_file_location(name, path)
70
+ module = importlib.util.module_from_spec(spec)
71
+ spec.loader.exec_module(module)
72
+ return module
73
+
74
+
75
+ def run_extract(argv: list[str]) -> int:
76
+ module = _load_module(skill_dir() / "scripts" / "extract.py", "_sts_extract")
77
+ return int(module.main(list(argv)) or 0)
78
+
79
+
80
+ def run_validate(argv: list[str]) -> int:
81
+ module = _load_module(
82
+ skill_dir() / "tools" / "validate_skill.py", "_sts_validate_skill"
83
+ )
84
+ return int(module.main(list(argv)) or 0)
85
+
86
+
87
+ def install_target(agent: str, project: bool, dest: str | None) -> Path:
88
+ if dest:
89
+ return Path(dest).expanduser().absolute()
90
+ user_root, project_root = AGENT_DIRS[agent]
91
+ base = Path.cwd() / project_root if project else Path.home() / user_root
92
+ return base / SKILL_NAME
93
+
94
+
95
+ def _exists(path: Path) -> bool:
96
+ return path.exists() or path.is_symlink()
97
+
98
+
99
+ def _copy_skill(source: Path, target: Path) -> None:
100
+ """Copy one skill folder to target, replacing whatever is there."""
101
+ target.parent.mkdir(parents=True, exist_ok=True)
102
+ # Copy into a sibling temp dir first, then swap: the old copy is moved
103
+ # aside into the staging dir (a symlink moves as a link, its target is
104
+ # never touched) and restored if the new one cannot take its place,
105
+ # so a failure never leaves a half-replaced or missing skill behind.
106
+ staging = Path(tempfile.mkdtemp(prefix=f".{SKILL_NAME}-", dir=target.parent))
107
+ try:
108
+ staged = staging / "new"
109
+ old = staging / "old"
110
+ shutil.copytree(source, staged, ignore=IGNORE)
111
+ if _exists(target):
112
+ target.rename(old)
113
+ try:
114
+ staged.rename(target)
115
+ except OSError:
116
+ if _exists(old):
117
+ old.rename(target)
118
+ raise
119
+ finally:
120
+ # Deletes the old copy; rmtree removes a moved symlink as a link.
121
+ shutil.rmtree(staging, ignore_errors=True)
122
+
123
+
124
+ def run_install(args: argparse.Namespace) -> int:
125
+ target = install_target(args.agent, args.project, args.dest)
126
+ # --dest names exactly one folder: installing companions into its
127
+ # parent could replace (with --force) unrelated folders there.
128
+ only = args.only or bool(args.dest)
129
+ # Companions go next to source-to-skill, under their own names.
130
+ plan = [(skill_dir(), target)]
131
+ if not only:
132
+ plan += [
133
+ (skill_dir(name), target.parent / name) for name in COMPANIONS
134
+ ]
135
+ existing = [dest for _, dest in plan if _exists(dest)]
136
+ if existing and not args.force:
137
+ print(
138
+ "ERROR: already exists: "
139
+ + ", ".join(str(dest) for dest in existing)
140
+ + ". Re-run with --force to replace "
141
+ + ("it." if len(existing) == 1 else "them."),
142
+ file=sys.stderr,
143
+ )
144
+ return 1
145
+ for source, dest in plan:
146
+ verb = "Replaced" if _exists(dest) else "Installed"
147
+ _copy_skill(source, dest)
148
+ print(f"{verb} {dest.name} {__version__} skill at {dest}")
149
+ print("Next: in your agent, run /source-to-skill <url-or-file>")
150
+ if not only:
151
+ print("Then: /skill-study <skill> · /skill-eval <skill> · "
152
+ "/skill-library")
153
+ print("Optional extractors: pip install 'source-to-skill[all]' "
154
+ "(check with: source-to-skill check)")
155
+ return 0
156
+
157
+
158
+ def build_parser() -> argparse.ArgumentParser:
159
+ parser = argparse.ArgumentParser(
160
+ prog="source-to-skill",
161
+ description=(
162
+ "Turn YouTube videos, playlists & channels, podcasts, meetings, "
163
+ "papers, books, articles, GitHub repos, docs sites, notes, Office "
164
+ "files and HN/Reddit threads into AI agent skills."
165
+ ),
166
+ )
167
+ parser.add_argument(
168
+ "--version", action="version", version=f"%(prog)s {__version__}"
169
+ )
170
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
171
+
172
+ install = sub.add_parser(
173
+ "install",
174
+ help="copy source-to-skill and its companion skills into a skills folder"
175
+ )
176
+ install.add_argument(
177
+ "--agent",
178
+ choices=sorted(AGENT_DIRS),
179
+ default="claude",
180
+ help="target agent: claude (~/.claude/skills, default); "
181
+ "agents or codex (~/.agents/skills, also read by Amp, Goose, Cursor, "
182
+ "Gemini CLI, OpenCode); copilot (~/.copilot/skills); gemini; cursor; "
183
+ "opencode; kiro; windsurf; factory",
184
+ )
185
+ install.add_argument(
186
+ "--project",
187
+ action="store_true",
188
+ help="install into the current project (./.claude/skills, "
189
+ "./.agents/skills, ./.github/skills, ...) instead of your home dir",
190
+ )
191
+ install.add_argument(
192
+ "--dest",
193
+ metavar="PATH",
194
+ help="explicit target directory for source-to-skill; installs "
195
+ "only source-to-skill (implies --only - companion skills are not "
196
+ "installed with --dest)",
197
+ )
198
+ install.add_argument(
199
+ "--only",
200
+ action="store_true",
201
+ help="install only source-to-skill, without skill-study, "
202
+ "skill-eval and skill-library",
203
+ )
204
+ install.add_argument(
205
+ "--force", action="store_true", help="replace an existing install"
206
+ )
207
+
208
+ sub.add_parser(
209
+ "extract",
210
+ help="run the extractor: extract <source> [--type TYPE] | --check",
211
+ add_help=False,
212
+ )
213
+ sub.add_parser("check", help="report which optional extractors are available")
214
+ validate = sub.add_parser("validate", help="validate a generated skill folder")
215
+ validate.add_argument("skill_dir", help="path to the skill directory")
216
+ return parser
217
+
218
+
219
+ def main(argv: list[str] | None = None) -> int:
220
+ argv = list(sys.argv[1:] if argv is None else argv)
221
+ # `extract` forwards everything verbatim (incl. --check / --type / -h).
222
+ if argv and argv[0] == "extract":
223
+ return run_extract(argv[1:])
224
+ parser = build_parser()
225
+ args = parser.parse_args(argv)
226
+ if args.command == "install":
227
+ return run_install(args)
228
+ if args.command == "check":
229
+ return run_extract(["--check"])
230
+ if args.command == "validate":
231
+ return run_validate([args.skill_dir])
232
+ parser.print_help()
233
+ return 0
234
+
235
+
236
+ if __name__ == "__main__":
237
+ sys.exit(main())
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: skill-eval
3
+ description: Measure how well a generated knowledge skill answers questions about its source - grounded questions drafted from the original source, answered using only the skill, graded expectation by expectation, with a score, the facts the skill is missing, and an optional fix. Writes evals/evals.json in the skill-creator format. Use when the user runs /skill-eval, or asks "is this skill any good", "test / evaluate / benchmark my skill", "what did the skill miss", or wants to check a skill made by source-to-skill before relying on it.
4
+ ---
5
+
6
+ # skill-eval
7
+
8
+ Check a skill against the source it was made from. The question is not
9
+ "does the skill read well" but "can an agent holding only this skill
10
+ answer what the source answers, and cite it correctly".
11
+
12
+ Usage: `/skill-eval <skill-dir>` (a path, or a skill name found under
13
+ `~/.claude/skills/`, `~/.agents/skills/`, `~/.copilot/skills/` or the
14
+ project's `.claude/skills/`, `.agents/skills/`, `.github/skills/`).
15
+
16
+ ## Step 1 — Get the questions
17
+
18
+ If `<skill-dir>/evals/evals.json` exists (source-to-skill writes one while
19
+ the source is still in context), use it. Otherwise draft one:
20
+
21
+ 1. Read `<skill-dir>/source.json` (topic skills: `sources/*.source.json`).
22
+ Missing → say the skill was not made by source-to-skill and ask for the
23
+ original source.
24
+ 2. Re-extract the source with the source-to-skill extractor into a fresh
25
+ work dir (`python3 scripts/extract.py "<source>" [options from the
26
+ manifest] --work-dir <tmp>/skill_eval`, run from the source-to-skill
27
+ skill folder). Tell the user the `est_tokens` and ask before reading it.
28
+ 3. Draft 8 questions from `full_text.txt`, never from the skill, spread
29
+ across the source's segments: 3 concrete facts or numbers, 2 decision
30
+ rules or "when should I", 2 that need two segments combined, and for
31
+ video or audio 1 "where in the source is X" that needs a timestamp.
32
+ Each gets 1 to 3 expectations: short statements a grader can check
33
+ against an answer, each naming its segment.
34
+
35
+ Write it in the skill-creator format so the same file works there:
36
+
37
+ ```json
38
+ {
39
+ "skill_name": "<slug>",
40
+ "evals": [
41
+ {"id": 1, "prompt": "What learning-rate schedule did the paper use?",
42
+ "expected_output": "Linear warmup for 4000 steps, then inverse square root decay",
43
+ "files": [],
44
+ "expectations": ["States warmup_steps = 4000 (segment 5.3 Optimizer)",
45
+ "Says the rate then decays proportionally to step^-0.5"]}
46
+ ]
47
+ }
48
+ ```
49
+
50
+ ## Step 2 — Answer with only the skill
51
+
52
+ The answers must come from someone who has not read the source. If your
53
+ host can run subagents, give one subagent the skill directory path and the
54
+ eight prompts, and tell it to answer from those files only, citing file
55
+ names and deep links. Without subagents, answer them yourself, reading only
56
+ the skill's files, and say in the report that grading was not blind.
57
+
58
+ ## Step 3 — Grade
59
+
60
+ For each expectation: pass or fail, with one line of evidence quoted from
61
+ the answer. Also fail an answer whose citation points at the wrong file or
62
+ whose timestamp is more than 60 seconds off the moment the source covers
63
+ it. Score = passed expectations / all expectations.
64
+
65
+ Save `<skill-dir>/evals/last-run.json`:
66
+ `{"date": "...", "score": 0.83, "results": [{"id": 1, "passed": [true, false], "evidence": ["...", "..."]}]}`.
67
+
68
+ ## Step 4 — Report and fix
69
+
70
+ Report the score, then each failure as one line: what the source says,
71
+ which skill file should have carried it. Separate two causes: the fact is
72
+ missing from the skill, or it is there but the index in `SKILL.md` did
73
+ not lead to it. Offer to fix both — add the missing facts to the right
74
+ support file, sharpen the index line or the description — then re-run only
75
+ the failed questions. Change nothing without the user's yes, and run
76
+ `python3 tools/validate_skill.py <skill-dir>` (from the source-to-skill
77
+ folder) after any edit.
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: skill-library
3
+ description: Your library of skills made by source-to-skill - lists every video, course, podcast, paper, book, article, docs and repo skill installed for Claude Code, Codex, Copilot, Gemini and other agents, finds which one covers a question, flags skills whose playlist, channel, feed or docs has moved on, and refreshes them. Use when the user runs /skill-library, or asks "what skills do I have", "which of my skills covers X", "what did I learn about X", "are any of my skills out of date", "refresh all my skills", or wants to merge overlapping skills.
4
+ ---
5
+
6
+ # skill-library
7
+
8
+ Run from this skill's directory:
9
+
10
+ python3 scripts/library.py
11
+
12
+ It prints JSON: every skill folder with a `source.json` (or, for topic
13
+ skills, `sources/*.source.json`) under the agents' user and project skills
14
+ folders, with its description, sources, age, and `refresh_due`. It reads
15
+ only; pass `--root <dir>` for another folder and `--stale-days N` to change
16
+ the 30-day refresh threshold.
17
+
18
+ ## List
19
+
20
+ Show a compact table: name, type, title, extracted (date), tokens, and a
21
+ mark on `refresh_due` skills. Group by source type when there are more than
22
+ ten. No skills found → say where you looked and suggest
23
+ `/source-to-skill <url-or-file>`.
24
+
25
+ ## Find
26
+
27
+ For "which skill covers X": match X against each skill's description first,
28
+ then open only the matching skills' `SKILL.md` indexes. Answer with the
29
+ skill name, the support file, and the deep link or section where X is
30
+ covered, then answer the question itself from that file. Name every skill
31
+ that covers X when several do; when they disagree, show both positions
32
+ side by side.
33
+
34
+ ## Refresh
35
+
36
+ `refresh_due` marks playlists, channels, podcast feeds, docs sites, repos,
37
+ threads and notes folders extracted 30 or more days ago. Before refreshing,
38
+ list them with their age and ask which to refresh. For each one the user
39
+ picks, follow the source-to-skill skill's "Refresh an existing skill"
40
+ steps with that skill directory. Run YouTube re-extractions one at a time,
41
+ never in parallel: YouTube rate-limits a machine that asks too often.
42
+
43
+ ## Merge
44
+
45
+ When two or more skills cover the same topic (overlapping descriptions,
46
+ same author or course), offer to build one topic skill from their
47
+ `source.json` origins with `/source-to-skill <source> <source> ... <slug>`.
48
+ The old skills stay until the user deletes them; never delete a skill
49
+ yourself.
@@ -0,0 +1,160 @@
1
+ """Inventory of the skills source-to-skill made on this machine.
2
+
3
+ Usage: python3 scripts/library.py [--root PATH ...] [--stale-days N]
4
+
5
+ Scans the agents' skills folders (user-level and the current project's)
6
+ for skill directories holding a source.json manifest, top-level or under
7
+ sources/ for topic skills, and prints one JSON object with a `skills`
8
+ list: name, path, description, source type, title, origin, extraction
9
+ date, token estimate, and whether the skill is due for a refresh.
10
+ Read-only: it never writes, fetches, or re-extracts anything.
11
+ """
12
+ import json
13
+ import re
14
+ import sys
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ SKILL_ROOTS = (
19
+ "~/.claude/skills", "~/.agents/skills", "~/.copilot/skills",
20
+ "~/.gemini/skills", "~/.cursor/skills", "~/.config/opencode/skills",
21
+ "~/.kiro/skills", "~/.factory/skills", "~/.codeium/windsurf/skills",
22
+ ".claude/skills", ".agents/skills", ".github/skills", ".gemini/skills",
23
+ ".cursor/skills", ".opencode/skills", ".kiro/skills", ".factory/skills",
24
+ ".windsurf/skills",
25
+ )
26
+
27
+ # Sources that keep publishing: a skill made from them goes stale.
28
+ GROWING_TYPES = ("playlist", "docs", "thread", "notes", "repo")
29
+
30
+ DEFAULT_STALE_DAYS = 30
31
+
32
+ FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL)
33
+
34
+
35
+ def _frontmatter(skill_md: Path) -> dict:
36
+ try:
37
+ text = skill_md.read_text(encoding="utf-8")
38
+ except (OSError, UnicodeDecodeError):
39
+ return {}
40
+ match = FRONTMATTER_RE.match(text)
41
+ fields = {}
42
+ block = None # key of a `>` / `|` block scalar being collected
43
+ for line in (match.group(1) if match else "").splitlines():
44
+ if block and (line.startswith((" ", "\t")) or not line.strip()):
45
+ # Folded and literal blocks are joined into one line.
46
+ if line.strip():
47
+ fields[block] = f"{fields[block]} {line.strip()}".strip()
48
+ continue
49
+ block = None
50
+ key, _, value = line.partition(":")
51
+ if not key.strip() or line.startswith(" "):
52
+ continue
53
+ if re.fullmatch(r"[>|][+-]?", value.strip()):
54
+ block = key.strip()
55
+ fields[block] = ""
56
+ elif value.strip():
57
+ fields[key.strip()] = value.strip()
58
+ return fields
59
+
60
+
61
+ def _load(path: Path) -> dict:
62
+ try:
63
+ data = json.loads(path.read_text(encoding="utf-8"))
64
+ except (OSError, ValueError):
65
+ return {}
66
+ return data if isinstance(data, dict) else {}
67
+
68
+
69
+ def _age_days(stamp, now: datetime):
70
+ try:
71
+ then = datetime.strptime(stamp, "%Y-%m-%dT%H:%M:%SZ")
72
+ except (TypeError, ValueError):
73
+ return None
74
+ return (now - then.replace(tzinfo=timezone.utc)).days
75
+
76
+
77
+ def describe(skill_dir: Path, now: datetime, stale_days: int):
78
+ """One inventory entry, or None when the folder is not a made skill."""
79
+ manifests = []
80
+ if (skill_dir / "source.json").is_file():
81
+ manifests.append(_load(skill_dir / "source.json"))
82
+ manifests += [
83
+ _load(path) for path in sorted(skill_dir.glob("sources/*.source.json"))
84
+ ]
85
+ manifests = [m for m in manifests if m.get("source")]
86
+ if not manifests:
87
+ return None
88
+ fields = _frontmatter(skill_dir / "SKILL.md")
89
+ sources = []
90
+ for manifest in manifests:
91
+ age = _age_days(manifest.get("extracted_at"), now)
92
+ sources.append({
93
+ "source_type": manifest.get("source_type"),
94
+ "title": manifest.get("title"),
95
+ "origin": manifest.get("origin") or manifest.get("source"),
96
+ "extracted_at": manifest.get("extracted_at"),
97
+ "age_days": age,
98
+ "est_tokens": manifest.get("est_tokens"),
99
+ "segments": len(manifest.get("segments") or []),
100
+ "refresh_due": (
101
+ manifest.get("source_type") in GROWING_TYPES
102
+ and age is not None and age >= stale_days
103
+ ),
104
+ })
105
+ return {
106
+ "name": fields.get("name") or skill_dir.name,
107
+ "path": str(skill_dir),
108
+ "description": fields.get("description", ""),
109
+ "topic": len(sources) > 1,
110
+ "sources": sources,
111
+ "refresh_due": any(s["refresh_due"] for s in sources),
112
+ }
113
+
114
+
115
+ def scan(roots, now=None, stale_days=DEFAULT_STALE_DAYS) -> list:
116
+ now = now or datetime.now(timezone.utc)
117
+ seen, skills = set(), []
118
+ for root in roots:
119
+ base = Path(root).expanduser()
120
+ if not base.is_dir():
121
+ continue
122
+ for child in sorted(base.iterdir()):
123
+ if not (child / "SKILL.md").is_file():
124
+ continue
125
+ real = child.resolve()
126
+ if real in seen:
127
+ continue
128
+ seen.add(real)
129
+ entry = describe(child, now, stale_days)
130
+ if entry:
131
+ skills.append(entry)
132
+ return skills
133
+
134
+
135
+ def main(argv) -> int:
136
+ roots, stale_days = [], DEFAULT_STALE_DAYS
137
+ args = list(argv)
138
+ while args:
139
+ flag = args.pop(0)
140
+ if flag in ("-h", "--help"):
141
+ print(__doc__)
142
+ return 0
143
+ if flag not in ("--root", "--stale-days") or not args:
144
+ print(__doc__, file=sys.stderr)
145
+ return 2
146
+ value = args.pop(0)
147
+ if flag == "--root":
148
+ roots.append(value)
149
+ elif value.isdigit():
150
+ stale_days = int(value)
151
+ else:
152
+ print("--stale-days needs a whole number.", file=sys.stderr)
153
+ return 2
154
+ skills = scan(roots or SKILL_ROOTS, stale_days=stale_days)
155
+ print(json.dumps({"skills": skills}, indent=2, ensure_ascii=False))
156
+ return 0
157
+
158
+
159
+ if __name__ == "__main__":
160
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: skill-study
3
+ description: Study a skill made by source-to-skill (a video, course, podcast, paper, book, article or docs skill) - quizzes one question at a time with spaced repetition, Feynman-style "explain it back" checks, a study plan over the lessons, and Anki flashcard export. Every answer cites the skill file and, for video and audio, the exact second. Use when the user says /skill-study, "quiz me on", "test me on", "flashcards from", "help me learn / remember", "study plan for", or "make Anki cards" about a source they turned into a skill.
4
+ ---
5
+
6
+ # skill-study
7
+
8
+ Turn a skill made by source-to-skill into active recall. The skill's files
9
+ are the only ground truth: never quiz on, or correct with, facts that are
10
+ not in them.
11
+
12
+ Usage:
13
+
14
+ - `/skill-study <skill>` — quiz (default mode), due cards first.
15
+ - `/skill-study <skill> explain` — the user explains a concept, you check it.
16
+ - `/skill-study <skill> plan` — a study plan over the lessons or chapters.
17
+ - `/skill-study <skill> anki` — export flashcards for Anki.
18
+
19
+ `<skill>` is a path or a skill name. Find a name under `~/.claude/skills/`,
20
+ `~/.agents/skills/`, `~/.copilot/skills/`, then the project's
21
+ `.claude/skills/`, `.agents/skills/`, `.github/skills/`. Several matches or
22
+ none: list what you found and ask.
23
+
24
+ ## Ground rules
25
+
26
+ - Read `SKILL.md` first, then only the support files the current question
27
+ needs (`segments/`, `lessons/`, `chapters/`, `guides/`, `cheatsheet.md`).
28
+ - One question per turn. Wait for the answer before showing anything.
29
+ - Grade honestly: correct, partly correct (say what was missing), or wrong.
30
+ Then give the answer in two or three sentences with its citation:
31
+ `segments/03-training.md` plus the `&t=` deep link or `[hh:mm:ss]` when
32
+ the file has one.
33
+ - Ask for understanding, not trivia: why a rule holds, when a technique
34
+ applies, what a number implies, how two ideas differ. At most one in four
35
+ questions may be a bare number or name recall.
36
+ - If the user disputes a grade and the skill file supports them, concede.
37
+
38
+ ## Progress and spaced repetition
39
+
40
+ Keep progress in `<skill>/.study/progress.json` (create it on first use):
41
+
42
+ ```json
43
+ {
44
+ "cards": [
45
+ {"id": "c1", "q": "...", "a": "...", "source": "lessons/02-gradient-descent.md",
46
+ "link": "https://www.youtube.com/watch?v=IHZwWFHWa-w&t=212s",
47
+ "box": 1, "due": "2026-10-27", "seen": 1, "correct": 0}
48
+ ],
49
+ "sessions": [{"date": "2026-10-26", "asked": 8, "correct": 6}]
50
+ }
51
+ ```
52
+
53
+ Leitner boxes: a correct answer moves the card up one box, a wrong one back
54
+ to box 1. Next due date by box: 1 → tomorrow, 2 → 3 days, 3 → 7 days,
55
+ 4 → 14 days, 5 → 30 days. A quiz asks due cards first, then new cards
56
+ from files not yet covered, spreading across the whole skill rather than
57
+ the first file. Default length: 8 questions, or what the user asks for.
58
+ End with the score, the weakest topic, and when the next cards are due.
59
+
60
+ ## Explain mode
61
+
62
+ Ask the user to explain one core idea from `SKILL.md` in their own words.
63
+ Compare their explanation against the skill: name what was right, what was
64
+ missing, and any misconception, each tied to a file. Then ask one follow-up
65
+ question that probes the gap. Record the concept as a card in box 1 when
66
+ the explanation missed something important.
67
+
68
+ ## Plan mode
69
+
70
+ From the lesson, chapter or segment index, propose sessions of about 25
71
+ minutes: which files to read (or which timestamps to rewatch), in what
72
+ order, with the quiz focus for each. Respect the course order for
73
+ playlists; for books, start from the chapters the core mental models come
74
+ from. Write it to `<skill>/.study/plan.md` only if the user wants it kept.
75
+
76
+ ## Anki export
77
+
78
+ Write `<skill>/.study/flashcards.tsv` — one card per line, tab-separated,
79
+ no header: `front<TAB>back<TAB>tags`. The back ends with the citation and,
80
+ when there is one, the deep link as an HTML link
81
+ (`<a href="...">03-training 14:02</a>`). Tags: the skill name plus the
82
+ lesson or chapter slug. 20 to 40 cards across the whole skill unless the
83
+ user asks otherwise; no duplicates of cards already in `progress.json`.
84
+ Tell the user to import it in Anki with File → Import, fields separated by
85
+ Tab, "Allow HTML in fields" on.