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.
- source_to_skill/__init__.py +3 -0
- source_to_skill/__main__.py +5 -0
- source_to_skill/cli.py +237 -0
- source_to_skill/companions/skill_eval/SKILL.md +77 -0
- source_to_skill/companions/skill_library/SKILL.md +49 -0
- source_to_skill/companions/skill_library/scripts/library.py +160 -0
- source_to_skill/companions/skill_study/SKILL.md +85 -0
- source_to_skill/skill/SKILL.md +388 -0
- source_to_skill/skill/scripts/extract.py +188 -0
- source_to_skill/skill/scripts/extractor/__init__.py +10 -0
- source_to_skill/skill/scripts/extractor/config.py +205 -0
- source_to_skill/skill/scripts/extractor/dependencies.py +114 -0
- source_to_skill/skill/scripts/extractor/media.py +38 -0
- source_to_skill/skill/scripts/extractor/parsers/__init__.py +0 -0
- source_to_skill/skill/scripts/extractor/parsers/article.py +668 -0
- source_to_skill/skill/scripts/extractor/parsers/audio.py +87 -0
- source_to_skill/skill/scripts/extractor/parsers/book.py +493 -0
- source_to_skill/skill/scripts/extractor/parsers/docs.py +505 -0
- source_to_skill/skill/scripts/extractor/parsers/document.py +712 -0
- source_to_skill/skill/scripts/extractor/parsers/notes.py +365 -0
- source_to_skill/skill/scripts/extractor/parsers/paper.py +623 -0
- source_to_skill/skill/scripts/extractor/parsers/playlist.py +186 -0
- source_to_skill/skill/scripts/extractor/parsers/repo.py +858 -0
- source_to_skill/skill/scripts/extractor/parsers/thread.py +450 -0
- source_to_skill/skill/scripts/extractor/parsers/transcript.py +104 -0
- source_to_skill/skill/scripts/extractor/parsers/youtube.py +226 -0
- source_to_skill/skill/scripts/extractor/transcribe.py +132 -0
- source_to_skill/skill/scripts/extractor/utils.py +245 -0
- source_to_skill/skill/tools/diff_source.py +63 -0
- source_to_skill/skill/tools/validate_skill.py +107 -0
- source_to_skill-0.7.0.dist-info/METADATA +435 -0
- source_to_skill-0.7.0.dist-info/RECORD +36 -0
- source_to_skill-0.7.0.dist-info/WHEEL +5 -0
- source_to_skill-0.7.0.dist-info/entry_points.txt +2 -0
- source_to_skill-0.7.0.dist-info/licenses/LICENSE +21 -0
- source_to_skill-0.7.0.dist-info/top_level.txt +1 -0
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.
|