skillscout-cli 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {skillscout_cli-0.3.0/skillscout_cli.egg-info → skillscout_cli-0.4.0}/PKG-INFO +32 -5
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/README.md +31 -4
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/pyproject.toml +1 -1
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/cli.py +54 -4
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/core.py +7 -4
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/persona.py +9 -4
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/profile.example.toml +9 -0
- skillscout_cli-0.4.0/skillscout/switch.py +169 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0/skillscout_cli.egg-info}/PKG-INFO +32 -5
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout_cli.egg-info/SOURCES.txt +2 -0
- skillscout_cli-0.4.0/tests/test_include.py +21 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/tests/test_skillscout.py +83 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/LICENSE +0 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/setup.cfg +0 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/__init__.py +0 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/github.py +0 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/history.py +0 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/index.py +0 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/scan.py +0 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/schedule.py +0 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/score.py +0 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/skill.py +0 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout_cli.egg-info/dependency_links.txt +0 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout_cli.egg-info/entry_points.txt +0 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout_cli.egg-info/requires.txt +0 -0
- {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: skillscout-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Keep your agent's skills current for each kind of work you do: weekly index, personas, scans, pinned installs
|
|
5
5
|
Author: Akshit Gupta
|
|
6
6
|
License-Expression: MIT
|
|
@@ -27,7 +27,7 @@ Requires-Dist: pytest; extra == "dev"
|
|
|
27
27
|
Dynamic: license-file
|
|
28
28
|
|
|
29
29
|
<p align="center">
|
|
30
|
-
<img src="https://
|
|
30
|
+
<img src="https://akshit9162.github.io/skillscout/assets/header.svg" width="760" alt="skillscout: keep your agent's skills current, for every kind of work you do" />
|
|
31
31
|
</p>
|
|
32
32
|
|
|
33
33
|
<p align="center">
|
|
@@ -48,7 +48,7 @@ Dynamic: license-file
|
|
|
48
48
|
</p>
|
|
49
49
|
|
|
50
50
|
<p align="center">
|
|
51
|
-
<img src="https://
|
|
51
|
+
<img src="https://akshit9162.github.io/skillscout/assets/demo.svg" width="760" alt="Terminal demo: init drafts keywords from one line; persona add creates a brand persona; discover scores 23,865 skills with a section for the brand persona; persona build writes its agent; claude --agent brand starts it" />
|
|
52
52
|
</p>
|
|
53
53
|
|
|
54
54
|
```bash
|
|
@@ -62,7 +62,7 @@ installed keep changing upstream, and last month's best practice is already stal
|
|
|
62
62
|
watch hundreds of repos.
|
|
63
63
|
|
|
64
64
|
**skillscout keeps your agent current, for each kind of work you do.** Every week a shared index of every
|
|
65
|
-
public skill it can find (
|
|
65
|
+
public skill it can find (32,360 from 1,173 repos at the last build) is rebuilt. skillscout scores it on your
|
|
66
66
|
machine against what *you* work on, tells you what's new and worth having, and shows you exactly what
|
|
67
67
|
changed in the skills you already use.
|
|
68
68
|
|
|
@@ -98,6 +98,30 @@ Agent skillscout persona add brand --about "D2C marketing: ad copy, landing pa
|
|
|
98
98
|
You claude --agent brand
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
+
### Switch persona, and the rest stays out of the way
|
|
102
|
+
|
|
103
|
+
Every installed skill's description loads into every Claude Code session, so a big collection crowds out
|
|
104
|
+
the work. Switch to the persona you're in and Claude Code lists only its skills:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
skillscout persona use brand # or say "switch to brand" to the agent
|
|
108
|
+
# active: brand
|
|
109
|
+
# hid 31: fastapi, postgres-patterns, seo-audit, ...
|
|
110
|
+
# plugins on 1: marketing@knowledge-work-plugins
|
|
111
|
+
# plugins off 2: sales@knowledge-work-plugins, android-skills@android-skills
|
|
112
|
+
skillscout persona use brand builder # several at once
|
|
113
|
+
skillscout persona start brand # switch, then start Claude Code (with brand's agent if built)
|
|
114
|
+
skillscout persona use --core # no persona: only [switch] core and unclaimed skills
|
|
115
|
+
skillscout persona use --clear # everything back as it was
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
It writes `skillOverrides` and `enabledPlugins` in `~/.claude/settings.json`: nothing moves on disk.
|
|
119
|
+
A persona claims the skills that fit its keywords, plus any you pin with `skills = [...]`, minus `exclude`.
|
|
120
|
+
Plugins switch whole, so you pin them with `plugins = [...]` (`persona add` drafts them from the plugins
|
|
121
|
+
whose skills fit). skillscout restores only what it changed, never overwrites an override you set, and a
|
|
122
|
+
skill you approve later joins whichever personas it fits. A new session picks the switch up; in an open
|
|
123
|
+
one, run `/reload-skills` and `/reload-plugins`.
|
|
124
|
+
|
|
101
125
|
From then on, each weekly report has a section per persona, and `persona build` swaps in a better skill when
|
|
102
126
|
one arrives (after you've read and approved it). `persona build` only ever rewrites the agent's `skills:` list:
|
|
103
127
|
its prompt, tone, tools, model and memory are yours.
|
|
@@ -214,6 +238,8 @@ High-risk skills need `--accept-risk`, which the bundled skill tells the agent n
|
|
|
214
238
|
| `skillscout persona add <name> --about "..." [--keywords "a=3,b=2"]` | Add a persona; keywords drafted from the line unless given |
|
|
215
239
|
| `skillscout persona list` | Each persona: keywords, best installed skills, what's worth installing |
|
|
216
240
|
| `skillscout persona build <name> \| --all [--dry-run]` | Write `~/.claude/agents/<name>.md`, or update only its `skills:` list |
|
|
241
|
+
| `skillscout persona use [<name>...] [--core] [--clear]` | List only these personas' skills and plugins in Claude Code; `--clear` undoes it |
|
|
242
|
+
| `skillscout persona start <name>...` | `persona use`, then start Claude Code |
|
|
217
243
|
| `skillscout schedule` | Set up weekly discovery: launchd on macOS, a systemd timer on Linux (cron line without systemd), a `schtasks` command on Windows. It prints the one command that turns it on |
|
|
218
244
|
|
|
219
245
|
With the index, `discover` makes no GitHub API calls at all. For `--local` and `index`, set `GITHUB_TOKEN`, or just be logged in with the GitHub CLI (`gh auth login`), for higher API rate limits (5,000 requests an hour instead of 60). Following lists costs one lookup per linked repo: up to 1,500 a run with a token, 40 without; links not yet checked go first, so later runs reach the rest. A token is optional: without it, a rate limit means waiting rather than failing. A weekly run with few upstream changes takes about 25 seconds; the first run takes a few minutes. Scheduled jobs don't see your shell's environment, so add the token to the job if you want it there.
|
|
@@ -223,7 +249,8 @@ With the index, `discover` makes no GitHub API calls at all. For `--local` and `
|
|
|
223
249
|
- The scanner catches known patterns. It can't prove a skill is safe, so read `show` before you approve.
|
|
224
250
|
- Relevance is keyword matching against your profile, not semantic search. Tune the weights.
|
|
225
251
|
- The scanner is pattern rules, deliberately: a skill is text written to steer a model, so a model judging it can be steered by it. The rules make you read; they don't decide.
|
|
226
|
-
- Every installed skill's description loads into every agent session.
|
|
252
|
+
- Every installed skill's description loads into every agent session. `persona use` keeps only the active
|
|
253
|
+
personas' skills listed; without it, keep the total to a few dozen.
|
|
227
254
|
- A persona's `skills:` load in full when its agent starts. `max_skills` (default 5) keeps that small.
|
|
228
255
|
|
|
229
256
|
## Development
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://
|
|
2
|
+
<img src="https://akshit9162.github.io/skillscout/assets/header.svg" width="760" alt="skillscout: keep your agent's skills current, for every kind of work you do" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
</p>
|
|
21
21
|
|
|
22
22
|
<p align="center">
|
|
23
|
-
<img src="https://
|
|
23
|
+
<img src="https://akshit9162.github.io/skillscout/assets/demo.svg" width="760" alt="Terminal demo: init drafts keywords from one line; persona add creates a brand persona; discover scores 23,865 skills with a section for the brand persona; persona build writes its agent; claude --agent brand starts it" />
|
|
24
24
|
</p>
|
|
25
25
|
|
|
26
26
|
```bash
|
|
@@ -34,7 +34,7 @@ installed keep changing upstream, and last month's best practice is already stal
|
|
|
34
34
|
watch hundreds of repos.
|
|
35
35
|
|
|
36
36
|
**skillscout keeps your agent current, for each kind of work you do.** Every week a shared index of every
|
|
37
|
-
public skill it can find (
|
|
37
|
+
public skill it can find (32,360 from 1,173 repos at the last build) is rebuilt. skillscout scores it on your
|
|
38
38
|
machine against what *you* work on, tells you what's new and worth having, and shows you exactly what
|
|
39
39
|
changed in the skills you already use.
|
|
40
40
|
|
|
@@ -70,6 +70,30 @@ Agent skillscout persona add brand --about "D2C marketing: ad copy, landing pa
|
|
|
70
70
|
You claude --agent brand
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
+
### Switch persona, and the rest stays out of the way
|
|
74
|
+
|
|
75
|
+
Every installed skill's description loads into every Claude Code session, so a big collection crowds out
|
|
76
|
+
the work. Switch to the persona you're in and Claude Code lists only its skills:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
skillscout persona use brand # or say "switch to brand" to the agent
|
|
80
|
+
# active: brand
|
|
81
|
+
# hid 31: fastapi, postgres-patterns, seo-audit, ...
|
|
82
|
+
# plugins on 1: marketing@knowledge-work-plugins
|
|
83
|
+
# plugins off 2: sales@knowledge-work-plugins, android-skills@android-skills
|
|
84
|
+
skillscout persona use brand builder # several at once
|
|
85
|
+
skillscout persona start brand # switch, then start Claude Code (with brand's agent if built)
|
|
86
|
+
skillscout persona use --core # no persona: only [switch] core and unclaimed skills
|
|
87
|
+
skillscout persona use --clear # everything back as it was
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
It writes `skillOverrides` and `enabledPlugins` in `~/.claude/settings.json`: nothing moves on disk.
|
|
91
|
+
A persona claims the skills that fit its keywords, plus any you pin with `skills = [...]`, minus `exclude`.
|
|
92
|
+
Plugins switch whole, so you pin them with `plugins = [...]` (`persona add` drafts them from the plugins
|
|
93
|
+
whose skills fit). skillscout restores only what it changed, never overwrites an override you set, and a
|
|
94
|
+
skill you approve later joins whichever personas it fits. A new session picks the switch up; in an open
|
|
95
|
+
one, run `/reload-skills` and `/reload-plugins`.
|
|
96
|
+
|
|
73
97
|
From then on, each weekly report has a section per persona, and `persona build` swaps in a better skill when
|
|
74
98
|
one arrives (after you've read and approved it). `persona build` only ever rewrites the agent's `skills:` list:
|
|
75
99
|
its prompt, tone, tools, model and memory are yours.
|
|
@@ -186,6 +210,8 @@ High-risk skills need `--accept-risk`, which the bundled skill tells the agent n
|
|
|
186
210
|
| `skillscout persona add <name> --about "..." [--keywords "a=3,b=2"]` | Add a persona; keywords drafted from the line unless given |
|
|
187
211
|
| `skillscout persona list` | Each persona: keywords, best installed skills, what's worth installing |
|
|
188
212
|
| `skillscout persona build <name> \| --all [--dry-run]` | Write `~/.claude/agents/<name>.md`, or update only its `skills:` list |
|
|
213
|
+
| `skillscout persona use [<name>...] [--core] [--clear]` | List only these personas' skills and plugins in Claude Code; `--clear` undoes it |
|
|
214
|
+
| `skillscout persona start <name>...` | `persona use`, then start Claude Code |
|
|
189
215
|
| `skillscout schedule` | Set up weekly discovery: launchd on macOS, a systemd timer on Linux (cron line without systemd), a `schtasks` command on Windows. It prints the one command that turns it on |
|
|
190
216
|
|
|
191
217
|
With the index, `discover` makes no GitHub API calls at all. For `--local` and `index`, set `GITHUB_TOKEN`, or just be logged in with the GitHub CLI (`gh auth login`), for higher API rate limits (5,000 requests an hour instead of 60). Following lists costs one lookup per linked repo: up to 1,500 a run with a token, 40 without; links not yet checked go first, so later runs reach the rest. A token is optional: without it, a rate limit means waiting rather than failing. A weekly run with few upstream changes takes about 25 seconds; the first run takes a few minutes. Scheduled jobs don't see your shell's environment, so add the token to the job if you want it there.
|
|
@@ -195,7 +221,8 @@ With the index, `discover` makes no GitHub API calls at all. For `--local` and `
|
|
|
195
221
|
- The scanner catches known patterns. It can't prove a skill is safe, so read `show` before you approve.
|
|
196
222
|
- Relevance is keyword matching against your profile, not semantic search. Tune the weights.
|
|
197
223
|
- The scanner is pattern rules, deliberately: a skill is text written to steer a model, so a model judging it can be steered by it. The rules make you read; they don't decide.
|
|
198
|
-
- Every installed skill's description loads into every agent session.
|
|
224
|
+
- Every installed skill's description loads into every agent session. `persona use` keeps only the active
|
|
225
|
+
personas' skills listed; without it, keep the total to a few dozen.
|
|
199
226
|
- A persona's `skills:` load in full when its agent starts. `max_skills` (default 5) keeps that small.
|
|
200
227
|
|
|
201
228
|
## Development
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "skillscout-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Keep your agent's skills current for each kind of work you do: weekly index, personas, scans, pinned installs"
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
10
|
readme = "README.md"
|
|
@@ -7,7 +7,7 @@ import subprocess
|
|
|
7
7
|
import sys
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
|
-
from . import __doc__ as ABOUT, core, github, history, index, persona, schedule
|
|
10
|
+
from . import __doc__ as ABOUT, core, github, history, index, persona, schedule, switch
|
|
11
11
|
|
|
12
12
|
EXAMPLE = Path(__file__).resolve().parent / "profile.example.toml"
|
|
13
13
|
USER_PROFILE = Path.home() / ".skillscout" / "profile.toml"
|
|
@@ -64,6 +64,12 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
64
64
|
pb.add_argument("name", nargs="?")
|
|
65
65
|
pb.add_argument("--all", action="store_true")
|
|
66
66
|
pb.add_argument("--dry-run", action="store_true")
|
|
67
|
+
pu = psub.add_parser("use", help="list only these personas' skills and plugins in Claude Code (no names: show what's active)")
|
|
68
|
+
pu.add_argument("names", nargs="*")
|
|
69
|
+
pu.add_argument("--clear", action="store_true", help="undo every change skillscout made: all skills and plugins as before")
|
|
70
|
+
pu.add_argument("--core", action="store_true", help="no persona: hide every persona's skills and plugins, keep [switch] core")
|
|
71
|
+
ps = psub.add_parser("start", help="`persona use` these personas, then start Claude Code")
|
|
72
|
+
ps.add_argument("names", nargs="+")
|
|
67
73
|
a = p.parse_args(argv)
|
|
68
74
|
|
|
69
75
|
paths = core.Paths()
|
|
@@ -123,6 +129,9 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
123
129
|
elif a.cmd == "approve":
|
|
124
130
|
c = core.find(cands, a.id)
|
|
125
131
|
print(f"installed {c['id']} -> {core.approve(c, paths, a.accept_risk, a.update, fetch=github)}")
|
|
132
|
+
if on := switch.active(paths): # a new skill for an inactive persona stays out of view
|
|
133
|
+
names = [n for n in on if n != switch.CORE]
|
|
134
|
+
say_switch(switch.use(profile, paths, names, core.installed_skills(paths), not names), on, quiet=True)
|
|
126
135
|
elif a.cmd == "remove":
|
|
127
136
|
print(f"removed {core.remove(a.id, paths)}")
|
|
128
137
|
elif a.cmd == "rollback":
|
|
@@ -147,6 +156,32 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
147
156
|
print(message)
|
|
148
157
|
|
|
149
158
|
|
|
159
|
+
def say_switch(done: dict, names: list[str], quiet: bool = False) -> None:
|
|
160
|
+
parts = [f"{label} {len(done[k])}: {', '.join(done[k][:8])}{' ...' if len(done[k]) > 8 else ''}"
|
|
161
|
+
for k, label in (("hidden", "hid"), ("shown", "showed"), ("plugins_off", "plugins off"), ("plugins_on", "plugins on"))
|
|
162
|
+
if done[k]]
|
|
163
|
+
if done["kept_yours"]:
|
|
164
|
+
parts.append(f"left your own skillOverrides alone for {', '.join(done['kept_yours'])}")
|
|
165
|
+
if quiet and not parts:
|
|
166
|
+
return
|
|
167
|
+
print((f"active: {', '.join(names)}" if names else "cleared: every skill and plugin as before skillscout switched them"))
|
|
168
|
+
for line in parts:
|
|
169
|
+
print(f" {line}")
|
|
170
|
+
if parts:
|
|
171
|
+
print("A new Claude Code session picks this up; in an open one, run /reload-skills and /reload-plugins.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def start_claude(names: list[str], known: dict, paths: core.Paths) -> None:
|
|
175
|
+
claude = shutil.which("claude")
|
|
176
|
+
if not claude:
|
|
177
|
+
raise SystemExit("claude isn't on PATH; the switch is done, start Claude Code yourself")
|
|
178
|
+
cmd = [claude]
|
|
179
|
+
agent = known[names[0]]["agent"] if len(names) == 1 else None
|
|
180
|
+
if agent and (paths.agents / f"{agent}.md").exists():
|
|
181
|
+
cmd += ["--agent", agent]
|
|
182
|
+
sys.exit(subprocess.run(cmd, check=False).returncode)
|
|
183
|
+
|
|
184
|
+
|
|
150
185
|
def vocabulary(profile: dict, paths: core.Paths) -> dict[str, int]:
|
|
151
186
|
"""Terms skills use, from the cached index (downloaded first if there isn't one)."""
|
|
152
187
|
try:
|
|
@@ -167,19 +202,34 @@ def run_persona(a, profile: dict, paths: core.Paths, cands: list[dict]) -> None:
|
|
|
167
202
|
path = a.profile or USER_PROFILE
|
|
168
203
|
if not path.exists():
|
|
169
204
|
raise SystemExit(f"no profile at {path}; run `skillscout init` first")
|
|
170
|
-
print(persona.add(path, a.name, a.about, kw, a.agent))
|
|
205
|
+
print(persona.add(path, a.name, a.about, kw, a.agent, switch.draft_plugins(paths, kw)))
|
|
171
206
|
print(f"\nadded to {path}. Next: `skillscout discover`, then `skillscout persona build {a.name}`")
|
|
172
207
|
return
|
|
208
|
+
installed = core.installed_skills(paths)
|
|
209
|
+
if a.action in ("use", "start"):
|
|
210
|
+
if a.action == "use" and not a.names and not a.clear and not a.core:
|
|
211
|
+
on = switch.active(paths)
|
|
212
|
+
print(f"active: {', '.join(on)}" if on else "no persona active: Claude Code lists every skill")
|
|
213
|
+
return
|
|
214
|
+
names = [] if a.action == "use" and a.clear else a.names
|
|
215
|
+
core_only = a.action == "use" and a.core and not a.clear
|
|
216
|
+
say_switch(switch.use(profile, paths, names, installed, core_only), names or ([switch.CORE] if core_only else []))
|
|
217
|
+
if a.action == "start":
|
|
218
|
+
start_claude(names, known, paths)
|
|
219
|
+
return
|
|
173
220
|
if not known:
|
|
174
221
|
raise SystemExit("no personas yet: add one with `skillscout persona add <name> --about \"...\"`")
|
|
175
|
-
installed = core.installed_skills(paths)
|
|
176
222
|
if a.action == "list":
|
|
223
|
+
on = switch.active(paths)
|
|
224
|
+
by = switch.claims(profile, switch.own_skills(installed))
|
|
177
225
|
for name, p in known.items():
|
|
178
226
|
fits = persona.fitting(installed, p["keywords"], p["max_skills"])
|
|
179
227
|
agent = paths.agents / f"{p['agent']}.md"
|
|
180
228
|
print(f"{name}: {p['description']}\n keywords: {', '.join(p['keywords'])}\n"
|
|
181
229
|
f" agent: {agent}{'' if agent.exists() else ' (not built yet)'}\n"
|
|
182
|
-
f" best installed skills: {', '.join(n for n, _ in fits) or 'none fit yet'}"
|
|
230
|
+
f" best installed skills: {', '.join(n for n, _ in fits) or 'none fit yet'}\n"
|
|
231
|
+
f" switches: {len(by[name])} of your skills, plugins: {', '.join(p['plugins']) or 'none'}"
|
|
232
|
+
+ (" (active)" if name in on else ""))
|
|
183
233
|
pending = [c for c in cands if core.recommended(c, profile) and c.get("personas", {}).get(name, 0) >= 0.3][:3]
|
|
184
234
|
if pending:
|
|
185
235
|
print(f" worth installing: {', '.join(c['id'] for c in pending)}")
|
|
@@ -74,7 +74,10 @@ def installed_skills(paths: Paths) -> dict[str, str]:
|
|
|
74
74
|
for root, is_plugin in ((paths.skills, False), (paths.plugins, True)):
|
|
75
75
|
if not root.exists():
|
|
76
76
|
continue
|
|
77
|
-
|
|
77
|
+
found = set(root.rglob("SKILL.md"))
|
|
78
|
+
if not is_plugin: # rglob doesn't follow folder links (Python 3.13+); skills are often linked in
|
|
79
|
+
found |= {d / "SKILL.md" for d in root.iterdir() if (d.is_symlink() or getattr(d, "is_junction", bool)()) and (d / "SKILL.md").is_file()}
|
|
80
|
+
for md in sorted(found):
|
|
78
81
|
if ".git" in md.parts or "node_modules" in md.parts:
|
|
79
82
|
continue
|
|
80
83
|
plugin = md.relative_to(root).parts[1] if is_plugin and len(md.relative_to(root).parts) > 2 else None
|
|
@@ -164,7 +167,7 @@ def crawl(s: dict, paths: Paths, fetch=github, full: bool = False) -> list[dict]
|
|
|
164
167
|
except Exception as e: # a failed search falls back to last run's repos below
|
|
165
168
|
print(f"search {q!r} failed: {e}")
|
|
166
169
|
failed = True
|
|
167
|
-
for name in s.get("trusted", []):
|
|
170
|
+
for name in [*s.get("trusted", []), *s.get("include", [])]:
|
|
168
171
|
try:
|
|
169
172
|
repos[name] = repos.get(name) or fetch.repo(name)
|
|
170
173
|
except Exception as e:
|
|
@@ -176,8 +179,8 @@ def crawl(s: dict, paths: Paths, fetch=github, full: bool = False) -> list[dict]
|
|
|
176
179
|
repos.setdefault(name, e["meta"])
|
|
177
180
|
|
|
178
181
|
now = datetime.now(timezone.utc)
|
|
179
|
-
|
|
180
|
-
keep = [r for r in repos.values() if r["full_name"] in
|
|
182
|
+
always = set(s.get("trusted", [])) | set(s.get("include", [])) # kept whatever their stars or age
|
|
183
|
+
keep = [r for r in repos.values() if r["full_name"] in always or (
|
|
181
184
|
not r["archived"] and r["stars"] >= s.get("min_stars", 0)
|
|
182
185
|
and r.get("size_kb", 0) <= s.get("max_repo_mb", 300) * 1024
|
|
183
186
|
and freshness(r["pushed_at"], now) > 1 - s.get("max_age_days", 365) / 365)]
|
|
@@ -12,11 +12,13 @@ NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,39}")
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def personas(profile: dict) -> dict[str, dict]:
|
|
15
|
-
"""name -> {"description", "keywords", "agent", "max_skills"} from the
|
|
15
|
+
"""name -> {"description", "keywords", "agent", "max_skills", "skills", "exclude", "plugins"} from the
|
|
16
|
+
profile's [personas.*] tables. `skills`/`exclude` pin skills in or out; `plugins` are switched with it."""
|
|
16
17
|
out = {}
|
|
17
18
|
for name, p in profile.get("personas", {}).items():
|
|
18
19
|
out[name] = {"description": p.get("description", ""), "keywords": p.get("keywords", {}),
|
|
19
|
-
"agent": p.get("agent", name), "max_skills": p.get("max_skills", 5)
|
|
20
|
+
"agent": p.get("agent", name), "max_skills": p.get("max_skills", 5),
|
|
21
|
+
"skills": p.get("skills", []), "exclude": p.get("exclude", []), "plugins": p.get("plugins", [])}
|
|
20
22
|
return out
|
|
21
23
|
|
|
22
24
|
|
|
@@ -26,7 +28,8 @@ def keyword_sets(profile: dict) -> dict[str, dict[str, float]]:
|
|
|
26
28
|
return sets | {n: p["keywords"] for n, p in personas(profile).items() if p["keywords"]}
|
|
27
29
|
|
|
28
30
|
|
|
29
|
-
def add(profile_path: Path, name: str, description: str, keywords: dict[str, int], agent: str | None = None
|
|
31
|
+
def add(profile_path: Path, name: str, description: str, keywords: dict[str, int], agent: str | None = None,
|
|
32
|
+
plugins: list[str] | None = None) -> str:
|
|
30
33
|
"""Append a [personas.<name>] table to the profile file. Refuses a name that's taken."""
|
|
31
34
|
if not NAME.fullmatch(name):
|
|
32
35
|
raise SystemExit(f"persona names are lowercase letters, digits and dashes: {name!r}")
|
|
@@ -38,7 +41,9 @@ def add(profile_path: Path, name: str, description: str, keywords: dict[str, int
|
|
|
38
41
|
block = [f"\n[personas.{name}]", f"description = {json.dumps(description)}"]
|
|
39
42
|
if agent and agent != name:
|
|
40
43
|
block.append(f"agent = {json.dumps(agent)}")
|
|
41
|
-
block += ["max_skills = 5",
|
|
44
|
+
block += ["max_skills = 5", "skills = [] # always part of this persona", "exclude = [] # never part of it",
|
|
45
|
+
f"plugins = {json.dumps(plugins or [])} # switched on with this persona, off when only others are active",
|
|
46
|
+
f"[personas.{name}.keywords]"] + [f"{json.dumps(k)} = {v}" for k, v in keywords.items()]
|
|
42
47
|
profile_path.write_text(text.rstrip("\n") + "\n" + "\n".join(block) + "\n", encoding="utf-8")
|
|
43
48
|
return "\n".join(block).strip()
|
|
44
49
|
|
|
@@ -62,11 +62,20 @@ trusted = [
|
|
|
62
62
|
# [personas.brand]
|
|
63
63
|
# description = "D2C brand marketing: ad copy, landing pages, Instagram reels"
|
|
64
64
|
# max_skills = 5 # skills preloaded into the agent; each loads in full, so keep it small
|
|
65
|
+
# skills = ["copy"] # always part of this persona, whatever the keywords say
|
|
66
|
+
# exclude = [] # never part of it
|
|
67
|
+
# plugins = ["marketing"] # on when this persona is active, off when only others are (`persona add` drafts these)
|
|
65
68
|
# [personas.brand.keywords]
|
|
66
69
|
# "ad copy" = 4
|
|
67
70
|
# "landing pages" = 3
|
|
68
71
|
# "instagram" = 2
|
|
69
72
|
|
|
73
|
+
# `skillscout persona use <name>...` lists only those personas' skills in Claude Code (skillOverrides) and
|
|
74
|
+
# switches pinned plugins (enabledPlugins). It never touches entries you set yourself; --clear undoes it.
|
|
75
|
+
# [switch]
|
|
76
|
+
# core = ["graphify"] # always listed, whichever persona is active
|
|
77
|
+
# others = "on" # skills no persona claims: "on" keeps them listed, "off" hides them too
|
|
78
|
+
|
|
70
79
|
[scoring]
|
|
71
80
|
recommend_at = 0.45 # combined score needed to be recommended
|
|
72
81
|
min_relevance = 0.3 # and at least this much keyword overlap with your work
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Switching personas: Claude Code lists only the skills and plugins for the work at hand.
|
|
2
|
+
|
|
3
|
+
Every installed skill's description loads into every session, so a big collection crowds the context.
|
|
4
|
+
`persona use brand` hides your own skills that belong to other personas (`skillOverrides: "off"` in
|
|
5
|
+
~/.claude/settings.json) and turns off plugins pinned only to other personas (`enabledPlugins`; plugins
|
|
6
|
+
switch whole). Nothing moves on disk.
|
|
7
|
+
|
|
8
|
+
skillscout only changes back what it changed: it remembers each entry's value before it first touched it,
|
|
9
|
+
restores that when the entry is no longer needed, and leaves alone anything you edited in between. Your own
|
|
10
|
+
skillOverrides entries are never overwritten. `persona use --clear` puts everything back."""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .persona import fitting, personas
|
|
16
|
+
from .skill import parse_frontmatter
|
|
17
|
+
|
|
18
|
+
SELF = "skillscout" # its own skill and plugin are never hidden
|
|
19
|
+
ALL = 10**6
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def plugin_skills(paths) -> dict[str, dict[str, str]]:
|
|
23
|
+
"""plugin name -> {skill: description} for every plugin in the cache, enabled or not."""
|
|
24
|
+
out: dict[str, dict[str, str]] = {}
|
|
25
|
+
if not paths.plugins.exists():
|
|
26
|
+
return out
|
|
27
|
+
for md in paths.plugins.rglob("SKILL.md"):
|
|
28
|
+
rel = md.relative_to(paths.plugins).parts
|
|
29
|
+
if len(rel) < 3 or ".git" in rel or "node_modules" in rel:
|
|
30
|
+
continue
|
|
31
|
+
meta = parse_frontmatter(md.read_text(encoding="utf-8", errors="ignore"))
|
|
32
|
+
out.setdefault(rel[1], {})[meta.get("name") or md.parent.name] = meta.get("description", "")
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def draft_plugins(paths, keywords: dict, at: float = 0.25) -> list[str]:
|
|
37
|
+
"""Plugins most of whose skills fit a persona's keywords (at least two, and a quarter of them)."""
|
|
38
|
+
out = []
|
|
39
|
+
for name, skills in sorted(plugin_skills(paths).items()):
|
|
40
|
+
if name == SELF:
|
|
41
|
+
continue
|
|
42
|
+
fit = len(fitting(skills, keywords, ALL))
|
|
43
|
+
if fit >= 2 and fit >= at * len(skills):
|
|
44
|
+
out.append(name)
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def own_skills(installed: dict[str, str]) -> dict[str, str]:
|
|
49
|
+
return {n: d for n, d in installed.items() if ":" not in n}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def claims(profile: dict, own: dict[str, str]) -> dict[str, set[str]]:
|
|
53
|
+
"""persona -> the user's own skills it claims: those fitting its keywords, plus `skills`, minus `exclude`."""
|
|
54
|
+
out = {}
|
|
55
|
+
for name, p in personas(profile).items():
|
|
56
|
+
fit = {n for n, _ in fitting(own, p["keywords"], ALL)} if p["keywords"] else set()
|
|
57
|
+
out[name] = (fit | set(p["skills"])) - set(p["exclude"])
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def plugin_keys(pins: list[str], enabled: dict) -> set[str]:
|
|
62
|
+
"""Resolve `marketing` or `marketing@market` to the keys used in enabledPlugins."""
|
|
63
|
+
keys = set()
|
|
64
|
+
for pin in pins:
|
|
65
|
+
keys |= {k for k in enabled if k == pin or k.split("@")[0] == pin}
|
|
66
|
+
return keys
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def plan(profile: dict, own: dict[str, str], enabled: dict, active: list[str]) -> tuple[set[str], dict[str, bool]]:
|
|
70
|
+
"""(own skills to hide, plugin key -> on/off) for this set of active personas."""
|
|
71
|
+
known = personas(profile)
|
|
72
|
+
cfg = profile.get("switch", {})
|
|
73
|
+
by = claims(profile, own)
|
|
74
|
+
active = [a for a in active if a in known]
|
|
75
|
+
keep = set(cfg.get("core", [])) | {SELF} | set().union(*(by[a] for a in active))
|
|
76
|
+
pool = set(own) if cfg.get("others", "on") == "off" else set().union(*by.values())
|
|
77
|
+
hide = {s for s in pool - keep if s in own}
|
|
78
|
+
pinned = {n: plugin_keys(p["plugins"], enabled) for n, p in known.items()}
|
|
79
|
+
on = set().union(*(pinned[a] for a in active))
|
|
80
|
+
plugins = {k: k in on for k in set().union(*pinned.values()) if k.split("@")[0] != SELF}
|
|
81
|
+
return hide, plugins
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _load(path: Path, default):
|
|
85
|
+
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else default
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _save(path: Path, data) -> None:
|
|
89
|
+
"""Atomic, and keeps the file's key order (settings.json is yours, not ours to reshuffle)."""
|
|
90
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
tmp = path.with_name(path.name + ".skillscout.tmp")
|
|
92
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
93
|
+
tmp.replace(path)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def apply(paths, active: list[str], hide: set[str], plugins: dict[str, bool]) -> dict:
|
|
97
|
+
"""Make settings.json match the plan and report what changed. State (~/.skillscout/switch.json) keeps,
|
|
98
|
+
for each entry skillscout set, the value it had before; an entry you changed since is yours again."""
|
|
99
|
+
state_path = paths.state / "switch.json"
|
|
100
|
+
state = _load(state_path, {"active": [], "skills": {}, "plugins": {}})
|
|
101
|
+
settings = _load(paths.settings, {})
|
|
102
|
+
over = dict(settings.get("skillOverrides", {}))
|
|
103
|
+
ep = dict(settings.get("enabledPlugins", {}))
|
|
104
|
+
done = {"hidden": [], "shown": [], "plugins_on": [], "plugins_off": [], "kept_yours": []}
|
|
105
|
+
|
|
106
|
+
for name, before in sorted(state["skills"].items()):
|
|
107
|
+
if over.get(name) != "off": # you changed it since: yours now
|
|
108
|
+
del state["skills"][name]
|
|
109
|
+
elif name not in hide: # ours and no longer needed: restore
|
|
110
|
+
over.pop(name) if before is None else over.__setitem__(name, before)
|
|
111
|
+
del state["skills"][name]
|
|
112
|
+
done["shown"].append(name)
|
|
113
|
+
for name in sorted(hide - set(state["skills"])):
|
|
114
|
+
if name in over: # your own entry: never overwritten
|
|
115
|
+
done["kept_yours"].append(name)
|
|
116
|
+
continue
|
|
117
|
+
state["skills"][name] = None
|
|
118
|
+
over[name] = "off"
|
|
119
|
+
done["hidden"].append(name)
|
|
120
|
+
|
|
121
|
+
for key, (before, was_set) in sorted(state["plugins"].items()):
|
|
122
|
+
if ep.get(key) != was_set:
|
|
123
|
+
del state["plugins"][key]
|
|
124
|
+
elif key not in plugins:
|
|
125
|
+
ep[key] = before
|
|
126
|
+
del state["plugins"][key]
|
|
127
|
+
done["plugins_on" if before else "plugins_off"].append(key)
|
|
128
|
+
for key, want in sorted(plugins.items()):
|
|
129
|
+
before = state["plugins"][key][0] if key in state["plugins"] else ep.get(key, False)
|
|
130
|
+
if ep.get(key, False) != want:
|
|
131
|
+
ep[key] = want
|
|
132
|
+
done["plugins_on" if want else "plugins_off"].append(key)
|
|
133
|
+
if want == before:
|
|
134
|
+
state["plugins"].pop(key, None) # back where it started: nothing to undo
|
|
135
|
+
else:
|
|
136
|
+
state["plugins"][key] = [before, want]
|
|
137
|
+
|
|
138
|
+
if over != settings.get("skillOverrides", {}) or ep != settings.get("enabledPlugins", {}):
|
|
139
|
+
if over:
|
|
140
|
+
settings["skillOverrides"] = over
|
|
141
|
+
else:
|
|
142
|
+
settings.pop("skillOverrides", None)
|
|
143
|
+
if ep or "enabledPlugins" in settings:
|
|
144
|
+
settings["enabledPlugins"] = ep
|
|
145
|
+
_save(paths.settings, settings)
|
|
146
|
+
state["active"] = active
|
|
147
|
+
_save(state_path, state)
|
|
148
|
+
return done
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def active(paths) -> list[str]:
|
|
152
|
+
return _load(paths.state / "switch.json", {}).get("active", [])
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
CORE = "(core only)"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def use(profile: dict, paths, names: list[str], installed: dict[str, str], core_only: bool = False) -> dict:
|
|
159
|
+
"""Activate these personas; none and not core_only means clear. core_only hides every persona's skills
|
|
160
|
+
and pinned plugins, leaving `[switch] core` and whatever no persona claims."""
|
|
161
|
+
known = personas(profile)
|
|
162
|
+
unknown = [n for n in names if n not in known]
|
|
163
|
+
if unknown:
|
|
164
|
+
raise SystemExit(f"unknown persona {', '.join(unknown)}; you have {', '.join(known) or 'none'}")
|
|
165
|
+
if not names and not core_only:
|
|
166
|
+
return apply(paths, [], set(), {})
|
|
167
|
+
enabled = _load(paths.settings, {}).get("enabledPlugins", {})
|
|
168
|
+
hide, plugins = plan(profile, own_skills(installed), enabled, names)
|
|
169
|
+
return apply(paths, names or [CORE], hide, plugins)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: skillscout-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Keep your agent's skills current for each kind of work you do: weekly index, personas, scans, pinned installs
|
|
5
5
|
Author: Akshit Gupta
|
|
6
6
|
License-Expression: MIT
|
|
@@ -27,7 +27,7 @@ Requires-Dist: pytest; extra == "dev"
|
|
|
27
27
|
Dynamic: license-file
|
|
28
28
|
|
|
29
29
|
<p align="center">
|
|
30
|
-
<img src="https://
|
|
30
|
+
<img src="https://akshit9162.github.io/skillscout/assets/header.svg" width="760" alt="skillscout: keep your agent's skills current, for every kind of work you do" />
|
|
31
31
|
</p>
|
|
32
32
|
|
|
33
33
|
<p align="center">
|
|
@@ -48,7 +48,7 @@ Dynamic: license-file
|
|
|
48
48
|
</p>
|
|
49
49
|
|
|
50
50
|
<p align="center">
|
|
51
|
-
<img src="https://
|
|
51
|
+
<img src="https://akshit9162.github.io/skillscout/assets/demo.svg" width="760" alt="Terminal demo: init drafts keywords from one line; persona add creates a brand persona; discover scores 23,865 skills with a section for the brand persona; persona build writes its agent; claude --agent brand starts it" />
|
|
52
52
|
</p>
|
|
53
53
|
|
|
54
54
|
```bash
|
|
@@ -62,7 +62,7 @@ installed keep changing upstream, and last month's best practice is already stal
|
|
|
62
62
|
watch hundreds of repos.
|
|
63
63
|
|
|
64
64
|
**skillscout keeps your agent current, for each kind of work you do.** Every week a shared index of every
|
|
65
|
-
public skill it can find (
|
|
65
|
+
public skill it can find (32,360 from 1,173 repos at the last build) is rebuilt. skillscout scores it on your
|
|
66
66
|
machine against what *you* work on, tells you what's new and worth having, and shows you exactly what
|
|
67
67
|
changed in the skills you already use.
|
|
68
68
|
|
|
@@ -98,6 +98,30 @@ Agent skillscout persona add brand --about "D2C marketing: ad copy, landing pa
|
|
|
98
98
|
You claude --agent brand
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
+
### Switch persona, and the rest stays out of the way
|
|
102
|
+
|
|
103
|
+
Every installed skill's description loads into every Claude Code session, so a big collection crowds out
|
|
104
|
+
the work. Switch to the persona you're in and Claude Code lists only its skills:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
skillscout persona use brand # or say "switch to brand" to the agent
|
|
108
|
+
# active: brand
|
|
109
|
+
# hid 31: fastapi, postgres-patterns, seo-audit, ...
|
|
110
|
+
# plugins on 1: marketing@knowledge-work-plugins
|
|
111
|
+
# plugins off 2: sales@knowledge-work-plugins, android-skills@android-skills
|
|
112
|
+
skillscout persona use brand builder # several at once
|
|
113
|
+
skillscout persona start brand # switch, then start Claude Code (with brand's agent if built)
|
|
114
|
+
skillscout persona use --core # no persona: only [switch] core and unclaimed skills
|
|
115
|
+
skillscout persona use --clear # everything back as it was
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
It writes `skillOverrides` and `enabledPlugins` in `~/.claude/settings.json`: nothing moves on disk.
|
|
119
|
+
A persona claims the skills that fit its keywords, plus any you pin with `skills = [...]`, minus `exclude`.
|
|
120
|
+
Plugins switch whole, so you pin them with `plugins = [...]` (`persona add` drafts them from the plugins
|
|
121
|
+
whose skills fit). skillscout restores only what it changed, never overwrites an override you set, and a
|
|
122
|
+
skill you approve later joins whichever personas it fits. A new session picks the switch up; in an open
|
|
123
|
+
one, run `/reload-skills` and `/reload-plugins`.
|
|
124
|
+
|
|
101
125
|
From then on, each weekly report has a section per persona, and `persona build` swaps in a better skill when
|
|
102
126
|
one arrives (after you've read and approved it). `persona build` only ever rewrites the agent's `skills:` list:
|
|
103
127
|
its prompt, tone, tools, model and memory are yours.
|
|
@@ -214,6 +238,8 @@ High-risk skills need `--accept-risk`, which the bundled skill tells the agent n
|
|
|
214
238
|
| `skillscout persona add <name> --about "..." [--keywords "a=3,b=2"]` | Add a persona; keywords drafted from the line unless given |
|
|
215
239
|
| `skillscout persona list` | Each persona: keywords, best installed skills, what's worth installing |
|
|
216
240
|
| `skillscout persona build <name> \| --all [--dry-run]` | Write `~/.claude/agents/<name>.md`, or update only its `skills:` list |
|
|
241
|
+
| `skillscout persona use [<name>...] [--core] [--clear]` | List only these personas' skills and plugins in Claude Code; `--clear` undoes it |
|
|
242
|
+
| `skillscout persona start <name>...` | `persona use`, then start Claude Code |
|
|
217
243
|
| `skillscout schedule` | Set up weekly discovery: launchd on macOS, a systemd timer on Linux (cron line without systemd), a `schtasks` command on Windows. It prints the one command that turns it on |
|
|
218
244
|
|
|
219
245
|
With the index, `discover` makes no GitHub API calls at all. For `--local` and `index`, set `GITHUB_TOKEN`, or just be logged in with the GitHub CLI (`gh auth login`), for higher API rate limits (5,000 requests an hour instead of 60). Following lists costs one lookup per linked repo: up to 1,500 a run with a token, 40 without; links not yet checked go first, so later runs reach the rest. A token is optional: without it, a rate limit means waiting rather than failing. A weekly run with few upstream changes takes about 25 seconds; the first run takes a few minutes. Scheduled jobs don't see your shell's environment, so add the token to the job if you want it there.
|
|
@@ -223,7 +249,8 @@ With the index, `discover` makes no GitHub API calls at all. For `--local` and `
|
|
|
223
249
|
- The scanner catches known patterns. It can't prove a skill is safe, so read `show` before you approve.
|
|
224
250
|
- Relevance is keyword matching against your profile, not semantic search. Tune the weights.
|
|
225
251
|
- The scanner is pattern rules, deliberately: a skill is text written to steer a model, so a model judging it can be steered by it. The rules make you read; they don't decide.
|
|
226
|
-
- Every installed skill's description loads into every agent session.
|
|
252
|
+
- Every installed skill's description loads into every agent session. `persona use` keeps only the active
|
|
253
|
+
personas' skills listed; without it, keep the total to a few dozen.
|
|
227
254
|
- A persona's `skills:` load in full when its agent starts. `max_skills` (default 5) keeps that small.
|
|
228
255
|
|
|
229
256
|
## Development
|
|
@@ -13,10 +13,12 @@ skillscout/scan.py
|
|
|
13
13
|
skillscout/schedule.py
|
|
14
14
|
skillscout/score.py
|
|
15
15
|
skillscout/skill.py
|
|
16
|
+
skillscout/switch.py
|
|
16
17
|
skillscout_cli.egg-info/PKG-INFO
|
|
17
18
|
skillscout_cli.egg-info/SOURCES.txt
|
|
18
19
|
skillscout_cli.egg-info/dependency_links.txt
|
|
19
20
|
skillscout_cli.egg-info/entry_points.txt
|
|
20
21
|
skillscout_cli.egg-info/requires.txt
|
|
21
22
|
skillscout_cli.egg-info/top_level.txt
|
|
23
|
+
tests/test_include.py
|
|
22
24
|
tests/test_skillscout.py
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from skillscout import core
|
|
2
|
+
|
|
3
|
+
from test_skillscout import PROFILE, FakeGitHub, link, make_repo, skill_md
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_include_keeps_a_repo_below_min_stars_without_trust(tmp_path):
|
|
7
|
+
paths = core.Paths(tmp_path / "state", tmp_path / "skills", tmp_path / "plugins")
|
|
8
|
+
paths.skills.mkdir()
|
|
9
|
+
new = tmp_path / "src" / "new"
|
|
10
|
+
make_repo(new, {"LICENSE": "MIT", "skills/think/SKILL.md": skill_md("think", "Mental models for a GST eval decision")})
|
|
11
|
+
gh = FakeGitHub({"me/new": (new, 0)})
|
|
12
|
+
paths.cache.mkdir(parents=True)
|
|
13
|
+
link(paths.cache / "me__new", new)
|
|
14
|
+
|
|
15
|
+
assert not core.crawl(PROFILE["search"], paths, fetch=gh, full=True)
|
|
16
|
+
|
|
17
|
+
s = {**PROFILE["search"], "include": ["me/new"]}
|
|
18
|
+
records = core.crawl(s, paths, fetch=gh, full=True)
|
|
19
|
+
assert [r["name"] for r in records] == ["think"]
|
|
20
|
+
cands = core.discover({**PROFILE, "search": s}, paths, fetch=gh)
|
|
21
|
+
assert cands and not any(c["trusted"] for c in cands)
|
|
@@ -566,3 +566,86 @@ def test_plugin_skills_use_plugin_names_and_only_enabled_plugins(tmp_path):
|
|
|
566
566
|
(paths.skills / "copy" / "SKILL.md").write_text(skill_md("copy", "ad copy"), encoding="utf-8")
|
|
567
567
|
paths.settings.write_text(json.dumps({"enabledPlugins": {"marketing@market": True, "unused@market": False}}), encoding="utf-8")
|
|
568
568
|
assert core.installed_skills(paths) == {"copy": "ad copy", "marketing:draft": "marketing drafting"}
|
|
569
|
+
real = tmp_path / "elsewhere" / "linked" # a skill folder linked in from another place
|
|
570
|
+
real.mkdir(parents=True)
|
|
571
|
+
(real / "SKILL.md").write_text(skill_md("linked", "from a link"), encoding="utf-8")
|
|
572
|
+
link(paths.skills / "linked", real)
|
|
573
|
+
assert core.installed_skills(paths)["linked"] == "from a link"
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def switch_env(tmp_path):
|
|
577
|
+
import json
|
|
578
|
+
claude = tmp_path / ".claude"
|
|
579
|
+
paths = core.Paths(tmp_path / "state", claude / "skills", claude / "plugins" / "cache")
|
|
580
|
+
for name, desc in {"copy": "ad copy for landing pages", "seo-audit": "seo audit of landing pages",
|
|
581
|
+
"fastapi": "python fastapi backend", "pg": "postgres backend queries",
|
|
582
|
+
"graphify": "knowledge graph", "skillscout": "keep skills current", "mine": "my thing"}.items():
|
|
583
|
+
(paths.skills / name).mkdir(parents=True)
|
|
584
|
+
(paths.skills / name / "SKILL.md").write_text(skill_md(name, desc), encoding="utf-8")
|
|
585
|
+
for plugin, skills in {"marketing": ["ad copy", "landing pages", "email"], "superpowers": ["planning"]}.items():
|
|
586
|
+
for s in skills:
|
|
587
|
+
md = paths.plugins / "mk" / plugin / "1.0" / "skills" / s.replace(" ", "-") / "SKILL.md"
|
|
588
|
+
md.parent.mkdir(parents=True)
|
|
589
|
+
md.write_text(skill_md(s.replace(" ", "-"), f"{s} work"), encoding="utf-8")
|
|
590
|
+
original = {"theme": "dark", "skillOverrides": {"mine": "name-only", "pg": "name-only"},
|
|
591
|
+
"enabledPlugins": {"marketing@mk": False, "superpowers@mk": True}, "z": 1}
|
|
592
|
+
paths.settings.write_text(json.dumps(original, indent=2) + "\n", encoding="utf-8")
|
|
593
|
+
profile = {"switch": {"core": ["graphify"]}, "personas": {
|
|
594
|
+
"brand": {"keywords": {"ad copy": 3, "landing pages": 2, "seo": 2}, "plugins": ["marketing"], "exclude": ["seo-audit"]},
|
|
595
|
+
"builder": {"keywords": {"backend": 3, "python": 2}, "skills": ["mine"]}}}
|
|
596
|
+
return paths, profile, original
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def test_persona_use_hides_other_personas_and_clear_restores_settings_exactly(tmp_path):
|
|
600
|
+
import json
|
|
601
|
+
from skillscout import switch
|
|
602
|
+
paths, profile, original = switch_env(tmp_path)
|
|
603
|
+
before = paths.settings.read_text(encoding="utf-8")
|
|
604
|
+
installed = core.installed_skills(paths)
|
|
605
|
+
|
|
606
|
+
done = switch.use(profile, paths, ["brand"], installed)
|
|
607
|
+
s = json.loads(paths.settings.read_text(encoding="utf-8"))
|
|
608
|
+
# builder's skills are hidden; brand's, core, skillscout and unclaimed ones stay; excluded seo-audit is unclaimed
|
|
609
|
+
assert s["skillOverrides"] == {"mine": "name-only", "pg": "name-only", "fastapi": "off"}
|
|
610
|
+
assert done["hidden"] == ["fastapi"] and done["kept_yours"] == ["mine", "pg"]
|
|
611
|
+
assert s["enabledPlugins"] == {"marketing@mk": True, "superpowers@mk": True} # unpinned plugin untouched
|
|
612
|
+
assert list(s) == ["theme", "skillOverrides", "enabledPlugins", "z"] # your key order kept
|
|
613
|
+
|
|
614
|
+
switch.use(profile, paths, ["builder"], core.installed_skills(paths))
|
|
615
|
+
s = json.loads(paths.settings.read_text(encoding="utf-8"))
|
|
616
|
+
assert s["skillOverrides"] == {"mine": "name-only", "pg": "name-only", "copy": "off"}
|
|
617
|
+
assert s["enabledPlugins"]["marketing@mk"] is False
|
|
618
|
+
assert switch.active(paths) == ["builder"]
|
|
619
|
+
|
|
620
|
+
switch.use(profile, paths, [], core.installed_skills(paths), core_only=True) # --core
|
|
621
|
+
s = json.loads(paths.settings.read_text(encoding="utf-8"))
|
|
622
|
+
assert {k for k, v in s["skillOverrides"].items() if v == "off"} == {"copy", "fastapi"}
|
|
623
|
+
assert s["enabledPlugins"]["marketing@mk"] is False and switch.active(paths) == [switch.CORE]
|
|
624
|
+
|
|
625
|
+
switch.use(profile, paths, [], core.installed_skills(paths)) # --clear
|
|
626
|
+
assert paths.settings.read_text(encoding="utf-8") == before
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def test_persona_use_leaves_entries_you_changed_in_between(tmp_path):
|
|
630
|
+
import json
|
|
631
|
+
from skillscout import switch
|
|
632
|
+
paths, profile, _ = switch_env(tmp_path)
|
|
633
|
+
switch.use(profile, paths, ["builder"], core.installed_skills(paths))
|
|
634
|
+
s = json.loads(paths.settings.read_text(encoding="utf-8"))
|
|
635
|
+
s["skillOverrides"]["copy"] = "name-only" # you set it yourself after skillscout hid it
|
|
636
|
+
paths.settings.write_text(json.dumps(s), encoding="utf-8")
|
|
637
|
+
switch.use(profile, paths, [], core.installed_skills(paths))
|
|
638
|
+
assert json.loads(paths.settings.read_text(encoding="utf-8"))["skillOverrides"]["copy"] == "name-only"
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def test_others_off_hides_unclaimed_skills_and_plugins_are_drafted(tmp_path):
|
|
642
|
+
import json
|
|
643
|
+
from skillscout import switch
|
|
644
|
+
paths, profile, _ = switch_env(tmp_path)
|
|
645
|
+
profile["switch"]["others"] = "off"
|
|
646
|
+
switch.use(profile, paths, ["brand"], core.installed_skills(paths))
|
|
647
|
+
hidden = {k for k, v in json.loads(paths.settings.read_text(encoding="utf-8"))["skillOverrides"].items() if v == "off"}
|
|
648
|
+
assert hidden == {"fastapi", "seo-audit"} # mine/pg are yours; graphify core; skillscout never
|
|
649
|
+
assert switch.draft_plugins(paths, {"ad copy": 3, "landing pages": 2}) == ["marketing"]
|
|
650
|
+
with pytest.raises(SystemExit, match="unknown persona"):
|
|
651
|
+
switch.use(profile, paths, ["nope"], {})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|