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.
Files changed (26) hide show
  1. {skillscout_cli-0.3.0/skillscout_cli.egg-info → skillscout_cli-0.4.0}/PKG-INFO +32 -5
  2. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/README.md +31 -4
  3. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/pyproject.toml +1 -1
  4. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/cli.py +54 -4
  5. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/core.py +7 -4
  6. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/persona.py +9 -4
  7. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/profile.example.toml +9 -0
  8. skillscout_cli-0.4.0/skillscout/switch.py +169 -0
  9. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0/skillscout_cli.egg-info}/PKG-INFO +32 -5
  10. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout_cli.egg-info/SOURCES.txt +2 -0
  11. skillscout_cli-0.4.0/tests/test_include.py +21 -0
  12. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/tests/test_skillscout.py +83 -0
  13. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/LICENSE +0 -0
  14. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/setup.cfg +0 -0
  15. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/__init__.py +0 -0
  16. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/github.py +0 -0
  17. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/history.py +0 -0
  18. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/index.py +0 -0
  19. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/scan.py +0 -0
  20. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/schedule.py +0 -0
  21. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/score.py +0 -0
  22. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout/skill.py +0 -0
  23. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout_cli.egg-info/dependency_links.txt +0 -0
  24. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout_cli.egg-info/entry_points.txt +0 -0
  25. {skillscout_cli-0.3.0 → skillscout_cli-0.4.0}/skillscout_cli.egg-info/requires.txt +0 -0
  26. {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.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://raw.githubusercontent.com/akshit9162/skillscout/main/docs/assets/header.svg" width="760" alt="skillscout: keep your agent's skills current, safely" />
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://raw.githubusercontent.com/akshit9162/skillscout/main/docs/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" />
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 (23,865 from 1,092 repos at the last build) is rebuilt. skillscout scores it on your
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. Keep the total to a few dozen.
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://raw.githubusercontent.com/akshit9162/skillscout/main/docs/assets/header.svg" width="760" alt="skillscout: keep your agent's skills current, safely" />
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://raw.githubusercontent.com/akshit9162/skillscout/main/docs/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" />
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 (23,865 from 1,092 repos at the last build) is rebuilt. skillscout scores it on your
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. Keep the total to a few dozen.
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.3.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
- for md in root.rglob("SKILL.md"):
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
- trusted = set(s.get("trusted", []))
180
- keep = [r for r in repos.values() if r["full_name"] in trusted or (
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 profile's [personas.*] tables."""
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) -> str:
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", f"[personas.{name}.keywords]"] + [f"{json.dumps(k)} = {v}" for k, v in keywords.items()]
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.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://raw.githubusercontent.com/akshit9162/skillscout/main/docs/assets/header.svg" width="760" alt="skillscout: keep your agent's skills current, safely" />
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://raw.githubusercontent.com/akshit9162/skillscout/main/docs/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" />
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 (23,865 from 1,092 repos at the last build) is rebuilt. skillscout scores it on your
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. Keep the total to a few dozen.
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