skillscout-cli 0.2.0__tar.gz → 0.3.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 (24) hide show
  1. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/PKG-INFO +60 -12
  2. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/README.md +57 -9
  3. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/pyproject.toml +3 -3
  4. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/cli.py +89 -4
  5. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/core.py +33 -8
  6. skillscout_cli-0.3.0/skillscout/history.py +153 -0
  7. skillscout_cli-0.3.0/skillscout/persona.py +98 -0
  8. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/profile.example.toml +12 -0
  9. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout_cli.egg-info/PKG-INFO +60 -12
  10. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout_cli.egg-info/SOURCES.txt +2 -0
  11. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/tests/test_skillscout.py +95 -1
  12. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/LICENSE +0 -0
  13. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/setup.cfg +0 -0
  14. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/__init__.py +0 -0
  15. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/github.py +0 -0
  16. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/index.py +0 -0
  17. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/scan.py +0 -0
  18. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/schedule.py +0 -0
  19. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/score.py +0 -0
  20. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/skill.py +0 -0
  21. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout_cli.egg-info/dependency_links.txt +0 -0
  22. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout_cli.egg-info/entry_points.txt +0 -0
  23. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout_cli.egg-info/requires.txt +0 -0
  24. {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout_cli.egg-info/top_level.txt +0 -0
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillscout-cli
3
- Version: 0.2.0
4
- Summary: Keep your agent's skills current: weekly discovery, upstream diffs, safety scans, pinned installs
3
+ Version: 0.3.0
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
7
7
  Project-URL: Homepage, https://akshit9162.github.io/skillscout
8
8
  Project-URL: Source, https://github.com/akshit9162/skillscout
9
9
  Project-URL: Issues, https://github.com/akshit9162/skillscout/issues
10
10
  Project-URL: Report, https://akshit9162.github.io/skillscout/report.html
11
- Keywords: claude,claude-code,agent-skills,security,supply-chain,llm
11
+ Keywords: claude,claude-code,agent-skills,personas,subagents,security,supply-chain,llm
12
12
  Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Environment :: Console
14
14
  Classifier: Intended Audience :: Developers
@@ -34,6 +34,7 @@ Dynamic: license-file
34
34
  <a href="https://akshit9162.github.io/skillscout">Website</a> &ensp;·&ensp;
35
35
  <a href="https://akshit9162.github.io/skillscout/report.html">Report: what's inside 6,726 skills</a> &ensp;·&ensp;
36
36
  <a href="#install">Install</a> &ensp;·&ensp;
37
+ <a href="#personas">Personas</a> &ensp;·&ensp;
37
38
  <a href="#a-week-with-skillscout">A week with it</a> &ensp;·&ensp;
38
39
  <a href="#how-it-works">How it works</a> &ensp;·&ensp;
39
40
  <a href="#what-gets-flagged">What gets flagged</a>
@@ -47,7 +48,7 @@ Dynamic: license-file
47
48
  </p>
48
49
 
49
50
  <p align="center">
50
- <img src="https://raw.githubusercontent.com/akshit9162/skillscout/main/docs/assets/demo.svg" width="760" alt="Terminal demo: discover finds 216 recommended skills with 89 flagged high-risk; a high-risk skill is refused without --accept-risk; a low-risk one installs pinned to a commit" />
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
52
  </p>
52
53
 
53
54
  ```bash
@@ -60,28 +61,68 @@ AI tooling moves weekly. New skills, plugins and agent workflows land on GitHub
60
61
  installed keep changing upstream, and last month's best practice is already stale. Nobody has time to
61
62
  watch hundreds of repos.
62
63
 
63
- **skillscout keeps your agent current.** Once a week it checks the skill ecosystem against what *you*
64
- work on, tells you what's new and worth having, and shows you exactly what changed in the skills you
65
- already use.
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
66
+ machine against what *you* work on, tells you what's new and worth having, and shows you exactly what
67
+ changed in the skills you already use.
68
+
69
+ You probably don't do one kind of work. Coding on Monday, a landing page on Tuesday, investor emails on
70
+ Friday. So skillscout keeps **personas**: one per kind of work, each with its own search and its own Claude
71
+ Code agent, loaded with the best skills for that work and kept up to date as better ones appear.
66
72
 
67
73
  Keeping up fast is only useful if it's safe. A skill is a set of instructions your agent follows
68
74
  **with your permissions**, so skillscout scans every file for dangerous patterns, installs nothing without
69
75
  your approval, pins each install to an exact commit, and re-scans every update before it can replace anything.
70
76
 
71
- In one run over the public ecosystem it found **6,726 skills in 95 repos**. 198 had at least one high-risk flag,
77
+ In a hand-checked run on 27 Oct 2026 over **6,726 skills in 95 repos**, 198 had at least one high-risk flag,
72
78
  mostly `sudo` and `curl | sh` install steps, plus prompt-injection-style text and hidden Unicode.
73
79
  The [report](https://akshit9162.github.io/skillscout/report.html) reads every one of the scariest flags: they were defenders.
74
80
 
75
81
  It's a zero-dependency Python CLI, and it also ships as a skill, so your agent can run the whole loop for you
76
82
  and still ask before every install.
77
83
 
84
+ ## Personas
85
+
86
+ A persona is one side of your work, in your words. skillscout doesn't ship any: yours might be "builder",
87
+ "brand" and "outreach", someone else's "trading" and "thesis".
88
+
89
+ ```text
90
+ You "set up personas for me" (in Claude Code, with the plugin)
91
+ Agent reads your recent sessions on this machine, with your OK (skillscout history)
92
+ → proposes: builder · brand · outreach, each with the sessions it came from
93
+ You keep brand and builder, rename outreach to launches
94
+ Agent skillscout persona add brand --about "D2C marketing: ad copy, landing pages, Instagram reels"
95
+ → keywords drafted from terms skills actually use: instagram reels, ad copy, landing pages...
96
+ skillscout discover → a "For your brand work" section in the report
97
+ skillscout persona build brand → ~/.claude/agents/brand.md, skills: copy, landing-page-design, script...
98
+ You claude --agent brand
99
+ ```
100
+
101
+ From then on, each weekly report has a section per persona, and `persona build` swaps in a better skill when
102
+ one arrives (after you've read and approved it). `persona build` only ever rewrites the agent's `skills:` list:
103
+ its prompt, tone, tools, model and memory are yours.
104
+
105
+ The CLI works without the agent too:
106
+
107
+ ```sh
108
+ skillscout persona add brand --about "D2C marketing: ad copy, landing pages, Instagram reels"
109
+ skillscout persona list # keywords, best installed skills, what's worth installing
110
+ skillscout persona build --all # write or update ~/.claude/agents/<name>.md
111
+ ```
112
+
113
+ **Your history stays on your machine.** `skillscout history` reads `~/.claude/projects` only when you run it,
114
+ keeps only what you typed (no tool output, no pasted blocks, no links) and sends nothing anywhere. Grouping
115
+ sessions into personas is left to you or the agent in your own session: counting words can't tell short chat
116
+ messages apart reliably, and a wrong persona is worse than none.
117
+
78
118
  ## A week with skillscout
79
119
 
80
120
  Example session (counts vary week to week):
81
121
 
82
122
  ```text
83
123
  Monday 10:00 skillscout discover --notify (weekly, via schedule)
84
- → "4 new, 23 recommended, 11 flagged high-risk"
124
+ → "23 recommended for you (4 new this run), 11 relevant but flagged high-risk"
125
+ → plus a section per persona: "For your brand work", "For your builder work"
85
126
  You skillscout show github/awesome-copilot:eval-driven-dev
86
127
  → flags with file:line, the full SKILL.md, overlaps with what you have
87
128
  You skillscout approve eval-driven-dev → copied, pinned to its commit
@@ -98,17 +139,18 @@ claude plugin marketplace add akshit9162/skillscout
98
139
  claude plugin install skillscout@skillscout
99
140
  ```
100
141
 
101
- Then ask: *"find me skills for my Python and React work"*. The agent installs the CLI, runs the search,
142
+ Then ask: *"find me skills for my Python and React work"*, or *"set up personas for me"*. The agent installs the CLI, runs the search,
102
143
  summarises the report and shows you each skill's flags before asking to install it.
103
144
 
104
145
  ### As a CLI
105
146
 
106
147
  ```sh
107
148
  pipx install skillscout-cli # or: uv tool install skillscout-cli
108
- skillscout init # writes ~/.skillscout/profile.toml
149
+ skillscout init --about "backend in Python, code review, RL evals, Postgres"
109
150
  ```
110
151
 
111
- Edit `[keywords]` in `~/.skillscout/profile.toml` with the words from your own work, then:
152
+ `init` writes `~/.skillscout/profile.toml` and drafts `[keywords]` from your line, using only terms that skills
153
+ actually use. Check them, then:
112
154
 
113
155
  ```sh
114
156
  skillscout discover # score this week's index; writes ~/.skillscout/reports/<date>.md
@@ -167,6 +209,11 @@ High-risk skills need `--accept-risk`, which the bundled skill tells the agent n
167
209
  | `skillscout approve <id> [--update] [--accept-risk] [--to DIR]` | Install and pin. `--to`, or `dir` under `[install]` in your profile, installs into another agent's skills folder |
168
210
  | `skillscout installed` · `skillscout remove <id>` · `skillscout rollback <id>` | Pinned skills · uninstall · undo the last update |
169
211
  | `skillscout updates` | Diff pinned skills against upstream |
212
+ | `skillscout init [--about "..."]` | Write the profile; `--about` drafts your keywords from one line |
213
+ | `skillscout history [--days N]` | A local digest of your recent Claude Code sessions, to draft personas from |
214
+ | `skillscout persona add <name> --about "..." [--keywords "a=3,b=2"]` | Add a persona; keywords drafted from the line unless given |
215
+ | `skillscout persona list` | Each persona: keywords, best installed skills, what's worth installing |
216
+ | `skillscout persona build <name> \| --all [--dry-run]` | Write `~/.claude/agents/<name>.md`, or update only its `skills:` list |
170
217
  | `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 |
171
218
 
172
219
  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.
@@ -177,6 +224,7 @@ With the index, `discover` makes no GitHub API calls at all. For `--local` and `
177
224
  - Relevance is keyword matching against your profile, not semantic search. Tune the weights.
178
225
  - 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.
179
226
  - Every installed skill's description loads into every agent session. Keep the total to a few dozen.
227
+ - A persona's `skills:` load in full when its agent starts. `max_skills` (default 5) keeps that small.
180
228
 
181
229
  ## Development
182
230
 
@@ -6,6 +6,7 @@
6
6
  <a href="https://akshit9162.github.io/skillscout">Website</a> &ensp;·&ensp;
7
7
  <a href="https://akshit9162.github.io/skillscout/report.html">Report: what's inside 6,726 skills</a> &ensp;·&ensp;
8
8
  <a href="#install">Install</a> &ensp;·&ensp;
9
+ <a href="#personas">Personas</a> &ensp;·&ensp;
9
10
  <a href="#a-week-with-skillscout">A week with it</a> &ensp;·&ensp;
10
11
  <a href="#how-it-works">How it works</a> &ensp;·&ensp;
11
12
  <a href="#what-gets-flagged">What gets flagged</a>
@@ -19,7 +20,7 @@
19
20
  </p>
20
21
 
21
22
  <p align="center">
22
- <img src="https://raw.githubusercontent.com/akshit9162/skillscout/main/docs/assets/demo.svg" width="760" alt="Terminal demo: discover finds 216 recommended skills with 89 flagged high-risk; a high-risk skill is refused without --accept-risk; a low-risk one installs pinned to a commit" />
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
24
  </p>
24
25
 
25
26
  ```bash
@@ -32,28 +33,68 @@ AI tooling moves weekly. New skills, plugins and agent workflows land on GitHub
32
33
  installed keep changing upstream, and last month's best practice is already stale. Nobody has time to
33
34
  watch hundreds of repos.
34
35
 
35
- **skillscout keeps your agent current.** Once a week it checks the skill ecosystem against what *you*
36
- work on, tells you what's new and worth having, and shows you exactly what changed in the skills you
37
- already use.
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
38
+ machine against what *you* work on, tells you what's new and worth having, and shows you exactly what
39
+ changed in the skills you already use.
40
+
41
+ You probably don't do one kind of work. Coding on Monday, a landing page on Tuesday, investor emails on
42
+ Friday. So skillscout keeps **personas**: one per kind of work, each with its own search and its own Claude
43
+ Code agent, loaded with the best skills for that work and kept up to date as better ones appear.
38
44
 
39
45
  Keeping up fast is only useful if it's safe. A skill is a set of instructions your agent follows
40
46
  **with your permissions**, so skillscout scans every file for dangerous patterns, installs nothing without
41
47
  your approval, pins each install to an exact commit, and re-scans every update before it can replace anything.
42
48
 
43
- In one run over the public ecosystem it found **6,726 skills in 95 repos**. 198 had at least one high-risk flag,
49
+ In a hand-checked run on 27 Oct 2026 over **6,726 skills in 95 repos**, 198 had at least one high-risk flag,
44
50
  mostly `sudo` and `curl | sh` install steps, plus prompt-injection-style text and hidden Unicode.
45
51
  The [report](https://akshit9162.github.io/skillscout/report.html) reads every one of the scariest flags: they were defenders.
46
52
 
47
53
  It's a zero-dependency Python CLI, and it also ships as a skill, so your agent can run the whole loop for you
48
54
  and still ask before every install.
49
55
 
56
+ ## Personas
57
+
58
+ A persona is one side of your work, in your words. skillscout doesn't ship any: yours might be "builder",
59
+ "brand" and "outreach", someone else's "trading" and "thesis".
60
+
61
+ ```text
62
+ You "set up personas for me" (in Claude Code, with the plugin)
63
+ Agent reads your recent sessions on this machine, with your OK (skillscout history)
64
+ → proposes: builder · brand · outreach, each with the sessions it came from
65
+ You keep brand and builder, rename outreach to launches
66
+ Agent skillscout persona add brand --about "D2C marketing: ad copy, landing pages, Instagram reels"
67
+ → keywords drafted from terms skills actually use: instagram reels, ad copy, landing pages...
68
+ skillscout discover → a "For your brand work" section in the report
69
+ skillscout persona build brand → ~/.claude/agents/brand.md, skills: copy, landing-page-design, script...
70
+ You claude --agent brand
71
+ ```
72
+
73
+ From then on, each weekly report has a section per persona, and `persona build` swaps in a better skill when
74
+ one arrives (after you've read and approved it). `persona build` only ever rewrites the agent's `skills:` list:
75
+ its prompt, tone, tools, model and memory are yours.
76
+
77
+ The CLI works without the agent too:
78
+
79
+ ```sh
80
+ skillscout persona add brand --about "D2C marketing: ad copy, landing pages, Instagram reels"
81
+ skillscout persona list # keywords, best installed skills, what's worth installing
82
+ skillscout persona build --all # write or update ~/.claude/agents/<name>.md
83
+ ```
84
+
85
+ **Your history stays on your machine.** `skillscout history` reads `~/.claude/projects` only when you run it,
86
+ keeps only what you typed (no tool output, no pasted blocks, no links) and sends nothing anywhere. Grouping
87
+ sessions into personas is left to you or the agent in your own session: counting words can't tell short chat
88
+ messages apart reliably, and a wrong persona is worse than none.
89
+
50
90
  ## A week with skillscout
51
91
 
52
92
  Example session (counts vary week to week):
53
93
 
54
94
  ```text
55
95
  Monday 10:00 skillscout discover --notify (weekly, via schedule)
56
- → "4 new, 23 recommended, 11 flagged high-risk"
96
+ → "23 recommended for you (4 new this run), 11 relevant but flagged high-risk"
97
+ → plus a section per persona: "For your brand work", "For your builder work"
57
98
  You skillscout show github/awesome-copilot:eval-driven-dev
58
99
  → flags with file:line, the full SKILL.md, overlaps with what you have
59
100
  You skillscout approve eval-driven-dev → copied, pinned to its commit
@@ -70,17 +111,18 @@ claude plugin marketplace add akshit9162/skillscout
70
111
  claude plugin install skillscout@skillscout
71
112
  ```
72
113
 
73
- Then ask: *"find me skills for my Python and React work"*. The agent installs the CLI, runs the search,
114
+ Then ask: *"find me skills for my Python and React work"*, or *"set up personas for me"*. The agent installs the CLI, runs the search,
74
115
  summarises the report and shows you each skill's flags before asking to install it.
75
116
 
76
117
  ### As a CLI
77
118
 
78
119
  ```sh
79
120
  pipx install skillscout-cli # or: uv tool install skillscout-cli
80
- skillscout init # writes ~/.skillscout/profile.toml
121
+ skillscout init --about "backend in Python, code review, RL evals, Postgres"
81
122
  ```
82
123
 
83
- Edit `[keywords]` in `~/.skillscout/profile.toml` with the words from your own work, then:
124
+ `init` writes `~/.skillscout/profile.toml` and drafts `[keywords]` from your line, using only terms that skills
125
+ actually use. Check them, then:
84
126
 
85
127
  ```sh
86
128
  skillscout discover # score this week's index; writes ~/.skillscout/reports/<date>.md
@@ -139,6 +181,11 @@ High-risk skills need `--accept-risk`, which the bundled skill tells the agent n
139
181
  | `skillscout approve <id> [--update] [--accept-risk] [--to DIR]` | Install and pin. `--to`, or `dir` under `[install]` in your profile, installs into another agent's skills folder |
140
182
  | `skillscout installed` · `skillscout remove <id>` · `skillscout rollback <id>` | Pinned skills · uninstall · undo the last update |
141
183
  | `skillscout updates` | Diff pinned skills against upstream |
184
+ | `skillscout init [--about "..."]` | Write the profile; `--about` drafts your keywords from one line |
185
+ | `skillscout history [--days N]` | A local digest of your recent Claude Code sessions, to draft personas from |
186
+ | `skillscout persona add <name> --about "..." [--keywords "a=3,b=2"]` | Add a persona; keywords drafted from the line unless given |
187
+ | `skillscout persona list` | Each persona: keywords, best installed skills, what's worth installing |
188
+ | `skillscout persona build <name> \| --all [--dry-run]` | Write `~/.claude/agents/<name>.md`, or update only its `skills:` list |
142
189
  | `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 |
143
190
 
144
191
  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.
@@ -149,6 +196,7 @@ With the index, `discover` makes no GitHub API calls at all. For `--local` and `
149
196
  - Relevance is keyword matching against your profile, not semantic search. Tune the weights.
150
197
  - 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.
151
198
  - Every installed skill's description loads into every agent session. Keep the total to a few dozen.
199
+ - A persona's `skills:` load in full when its agent starts. `max_skills` (default 5) keeps that small.
152
200
 
153
201
  ## Development
154
202
 
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "skillscout-cli"
7
- version = "0.2.0"
8
- description = "Keep your agent's skills current: weekly discovery, upstream diffs, safety scans, pinned installs"
7
+ version = "0.3.0"
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"
11
11
  license = "MIT"
12
12
  license-files = ["LICENSE"]
13
13
  authors = [{ name = "Akshit Gupta" }]
14
- keywords = ["claude", "claude-code", "agent-skills", "security", "supply-chain", "llm"]
14
+ keywords = ["claude", "claude-code", "agent-skills", "personas", "subagents", "security", "supply-chain", "llm"]
15
15
  classifiers = [
16
16
  "Development Status :: 4 - Beta",
17
17
  "Environment :: Console",
@@ -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, index, schedule
10
+ from . import __doc__ as ABOUT, core, github, history, index, persona, schedule
11
11
 
12
12
  EXAMPLE = Path(__file__).resolve().parent / "profile.example.toml"
13
13
  USER_PROFILE = Path.home() / ".skillscout" / "profile.toml"
@@ -24,7 +24,8 @@ def main(argv: list[str] | None = None) -> None:
24
24
  p = argparse.ArgumentParser(prog="skillscout", description=ABOUT)
25
25
  p.add_argument("--profile", type=Path, default=None, help="default: ~/.skillscout/profile.toml, else the example")
26
26
  sub = p.add_subparsers(dest="cmd", required=True)
27
- sub.add_parser("init", help="copy the example profile to ~/.skillscout/profile.toml")
27
+ it = sub.add_parser("init", help="write ~/.skillscout/profile.toml")
28
+ it.add_argument("--about", help="one line about your work; becomes your keywords (e.g. \"backend in Python, RL evals, Postgres\")")
28
29
  d = sub.add_parser("discover", help="score this week's shared index against your profile and write a report")
29
30
  d.add_argument("--local", action="store_true", help="crawl and scan GitHub on this machine instead of using the index")
30
31
  d.add_argument("--full", action="store_true", help="with --local: ignore caches, fetch every repo and re-scan every skill")
@@ -49,15 +50,40 @@ def main(argv: list[str] | None = None) -> None:
49
50
  sub.add_parser("installed", help="show pinned skills")
50
51
  sub.add_parser("updates", help="diff pinned skills against upstream; applies nothing")
51
52
  sub.add_parser("schedule", help="set up weekly discovery for this OS (you turn it on)")
53
+ hi = sub.add_parser("history", help="summarise your recent Claude Code sessions, read locally, to draft personas from")
54
+ hi.add_argument("--days", type=int, default=60)
55
+ pe = sub.add_parser("persona", help="kinds of work, each with its own keywords and an agent kept current")
56
+ psub = pe.add_subparsers(dest="action", required=True)
57
+ pa = psub.add_parser("add", help="add a persona from one line about that work")
58
+ pa.add_argument("name")
59
+ pa.add_argument("--about", required=True, help="what this side of your work is, in your words")
60
+ pa.add_argument("--keywords", help="instead of drafting them: \"cold email=4,investor update=3\"")
61
+ pa.add_argument("--agent", help="agent file name, if not the persona's name")
62
+ psub.add_parser("list", help="each persona, its keywords, and the installed skills that fit it")
63
+ pb = psub.add_parser("build", help="write or update the persona's agent in ~/.claude/agents (only its skills: list)")
64
+ pb.add_argument("name", nargs="?")
65
+ pb.add_argument("--all", action="store_true")
66
+ pb.add_argument("--dry-run", action="store_true")
52
67
  a = p.parse_args(argv)
53
68
 
54
69
  paths = core.Paths()
55
70
  if a.cmd == "init":
56
71
  if USER_PROFILE.exists():
57
72
  raise SystemExit(f"{USER_PROFILE} already exists")
73
+ text = EXAMPLE.read_text(encoding="utf-8")
74
+ if a.about:
75
+ kw = history.keywords_from(a.about, vocabulary(core.load_profile(EXAMPLE), paths))
76
+ if not kw:
77
+ raise SystemExit("none of those words appear in skill descriptions; try more specific ones")
78
+ head, rest = text.split("[keywords]\n", 1)
79
+ lines = rest.splitlines(keepends=True)
80
+ keep = next(i for i, line in enumerate(lines) if not line.startswith('"') and line.strip())
81
+ tail = "".join(lines[keep:])
82
+ text = head + "[keywords]\n" + f"# drafted from: {a.about}\n" + "".join(f"{json.dumps(k)} = {v}\n" for k, v in kw.items()) + "\n" + tail
58
83
  USER_PROFILE.parent.mkdir(parents=True, exist_ok=True)
59
- USER_PROFILE.write_text(EXAMPLE.read_text(encoding="utf-8"), encoding="utf-8")
60
- print(f"wrote {USER_PROFILE}; edit [keywords] to match your work, then run `skillscout discover`")
84
+ USER_PROFILE.write_text(text, encoding="utf-8")
85
+ print(f"wrote {USER_PROFILE}; " + ("check the drafted [keywords]" if a.about else "edit [keywords] to match your work")
86
+ + ", then run `skillscout discover`")
61
87
  return
62
88
  profile = core.load_profile(a.profile or (USER_PROFILE if USER_PROFILE.exists() else EXAMPLE))
63
89
  if folder := getattr(a, "to", None) or profile.get("install", {}).get("dir"):
@@ -109,11 +135,70 @@ def main(argv: list[str] | None = None) -> None:
109
135
  for cid, commit, diff in found:
110
136
  print(f"\n=== {cid} -> {commit[:10]}\n{diff}")
111
137
  print(f"\n{len(found)} pinned skill(s) changed upstream." + (" Re-run `approve <id> --update` after reading the diff." if found else ""))
138
+ elif a.cmd == "history":
139
+ text = history.digest(history.episodes(days=a.days))
140
+ print(text or "no Claude Code history found in ~/.claude/projects")
141
+ print(f"(read from ~/.claude/projects on this machine, last {a.days} days; nothing is sent anywhere)")
142
+ elif a.cmd == "persona":
143
+ run_persona(a, profile, paths, cands)
112
144
  elif a.cmd == "schedule":
113
145
  files, message = schedule.here(paths.state)
114
146
  schedule.write(files)
115
147
  print(message)
116
148
 
117
149
 
150
+ def vocabulary(profile: dict, paths: core.Paths) -> dict[str, int]:
151
+ """Terms skills use, from the cached index (downloaded first if there isn't one)."""
152
+ try:
153
+ if not paths.index.exists():
154
+ index.fetch(profile.get("index", {}).get("url") or index.URL, paths.index)
155
+ return history.vocabulary(index.load(paths.index)["skills"])
156
+ except index.Unavailable as e:
157
+ raise SystemExit(f"{e}\n(drafting keywords needs the skill index; or write them by hand)")
158
+
159
+
160
+ def run_persona(a, profile: dict, paths: core.Paths, cands: list[dict]) -> None:
161
+ known = persona.personas(profile)
162
+ if a.action == "add":
163
+ if a.keywords:
164
+ kw = {k.strip(): int(v) for k, v in (pair.rsplit("=", 1) for pair in a.keywords.split(","))}
165
+ else:
166
+ kw = history.keywords_from(a.about, vocabulary(profile, paths))
167
+ path = a.profile or USER_PROFILE
168
+ if not path.exists():
169
+ raise SystemExit(f"no profile at {path}; run `skillscout init` first")
170
+ print(persona.add(path, a.name, a.about, kw, a.agent))
171
+ print(f"\nadded to {path}. Next: `skillscout discover`, then `skillscout persona build {a.name}`")
172
+ return
173
+ if not known:
174
+ raise SystemExit("no personas yet: add one with `skillscout persona add <name> --about \"...\"`")
175
+ installed = core.installed_skills(paths)
176
+ if a.action == "list":
177
+ for name, p in known.items():
178
+ fits = persona.fitting(installed, p["keywords"], p["max_skills"])
179
+ agent = paths.agents / f"{p['agent']}.md"
180
+ print(f"{name}: {p['description']}\n keywords: {', '.join(p['keywords'])}\n"
181
+ 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'}")
183
+ pending = [c for c in cands if core.recommended(c, profile) and c.get("personas", {}).get(name, 0) >= 0.3][:3]
184
+ if pending:
185
+ print(f" worth installing: {', '.join(c['id'] for c in pending)}")
186
+ return
187
+ names = list(known) if a.all else [a.name] if a.name in known else None
188
+ if not names:
189
+ raise SystemExit(f"name a persona ({', '.join(known)}) or pass --all")
190
+ for name in names:
191
+ p = known[name]
192
+ skills = [n for n, _ in persona.fitting(installed, p["keywords"], p["max_skills"])]
193
+ path = paths.agents / f"{p['agent']}.md"
194
+ if a.dry_run:
195
+ print(f"{path}: skills would be {', '.join(skills) or '(none fit yet)'}")
196
+ continue
197
+ what, added, dropped = persona.write_agent(path, p["agent"], p["description"], skills)
198
+ print(f"{what} {path}" + (f"; added {', '.join(added)}" if added else "") + (f"; dropped {', '.join(dropped)}" if dropped else "")
199
+ + ("" if added or dropped or what == "created" else "; skills unchanged"))
200
+ print("Start one with `claude --agent <name>`, or ask for it by name in any session.")
201
+
202
+
118
203
  if __name__ == "__main__":
119
204
  main()
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
 
13
13
  import os
14
14
 
15
- from . import github, index
15
+ from . import github, index, persona
16
16
  from .scan import HIGH, RULES, scan, worst
17
17
  from .score import combined, conflicts, freshness, popularity, relevance
18
18
  from .skill import find_skills, parse_frontmatter, safe_name
@@ -26,6 +26,9 @@ PLUGIN_CACHE = HOME / ".claude" / "plugins" / "cache"
26
26
  class Paths:
27
27
  def __init__(self, state: Path = STATE, skills: Path = SKILLS_DIR, plugins: Path = PLUGIN_CACHE):
28
28
  self.state, self.skills, self.plugins = state, skills, plugins
29
+ claude = plugins.parent.parent # ~/.claude
30
+ self.agents = claude / "agents"
31
+ self.settings = claude / "settings.json"
29
32
  self.cache = state / "repos"
30
33
  self.candidates = state / "candidates.json"
31
34
  self.crawl = state / "crawl.json" # the user-independent part of the last crawl (what the index publishes)
@@ -60,17 +63,26 @@ def digest(folder: Path) -> str:
60
63
 
61
64
 
62
65
  def installed_skills(paths: Paths) -> dict[str, str]:
63
- """name -> description for user skills and plugin skills (plugin ones as `plugin:name`)."""
66
+ """name -> description for your skills and for skills of enabled plugins, which are named the way Claude
67
+ Code names them: `<plugin>:<skill>`. The plugin cache is laid out <marketplace>/<plugin>/<version>/..."""
68
+ try:
69
+ enabled = {k.split("@")[0] for k, on in json.loads(paths.settings.read_text(encoding="utf-8"))
70
+ .get("enabledPlugins", {}).items() if on}
71
+ except (OSError, ValueError):
72
+ enabled = None # no settings file: count every cached plugin
64
73
  out = {}
65
- for root, prefix in ((paths.skills, ""), (paths.plugins, "plugin")):
74
+ for root, is_plugin in ((paths.skills, False), (paths.plugins, True)):
66
75
  if not root.exists():
67
76
  continue
68
77
  for md in root.rglob("SKILL.md"):
69
78
  if ".git" in md.parts or "node_modules" in md.parts:
70
79
  continue
80
+ plugin = md.relative_to(root).parts[1] if is_plugin and len(md.relative_to(root).parts) > 2 else None
81
+ if is_plugin and (plugin is None or (enabled is not None and plugin not in enabled)):
82
+ continue
71
83
  meta = parse_frontmatter(md.read_text(encoding="utf-8", errors="ignore"))
72
84
  name = meta.get("name") or md.parent.name
73
- out[f"{prefix}:{name}" if prefix else name] = meta.get("description", "")
85
+ out[f"{plugin}:{name}" if plugin else name] = meta.get("description", "")
74
86
  return out
75
87
 
76
88
 
@@ -238,10 +250,15 @@ def score(records: list[dict], profile: dict, paths: Paths) -> list[dict]:
238
250
  pinned = {(e["repo_id"], e["path"]) for e in locked.values() if e.get("repo_id")}
239
251
  previous = {c["id"] for c in _read(paths.candidates, [])}
240
252
  candidates = []
253
+ sets = persona.keyword_sets(profile)
241
254
  for r in records:
242
- rel, hits = relevance(f"{r['name']} {r['description']}", profile["keywords"])
255
+ text = f"{r['name']} {r['description']}"
256
+ fit = {name: relevance(text, kw) for name, kw in sets.items()}
257
+ rel = max((f[0] for f in fit.values()), default=0.0)
258
+ hits = sorted({h for f in fit.values() for h in f[1]})
243
259
  candidates.append({
244
260
  **r, "trusted": r["repo"] in trusted, "relevance": round(rel, 3), "matched": hits,
261
+ "personas": {name: round(f[0], 3) for name, f in fit.items() if name and f[0] > 0},
245
262
  "score": combined(rel, popularity(r["stars"]), freshness(r["pushed_at"], now), r["repo"] in trusted),
246
263
  "conflicts": conflicts(r["name"], r["description"], installed, profile["scoring"]["overlap_at"]),
247
264
  "installed": r["id"] in locked or (r.get("repo_id"), r["path"]) in pinned, "new": r["id"] not in previous,
@@ -296,10 +313,12 @@ def report(candidates: list[dict], profile: dict, paths: Paths, top: int = 25) -
296
313
  rec = [c for c in candidates if recommended(c, profile)]
297
314
  flagged = [c for c in candidates if c["risk"] == HIGH and c["relevance"] > 0]
298
315
  new = [c for c in candidates if c["new"]]
299
- summary = f"{len(new)} new, {len(rec)} recommended, {len(flagged)} flagged high-risk"
300
- lines = [f"# Skill report {datetime.now():%Y-%m-%d}", "", summary, "", "## New since last run", ""]
301
316
  fresh = [c for c in rec if c["new"]]
302
- if new and len(new) == len(candidates):
317
+ first = bool(new) and len(new) == len(candidates)
318
+ summary = (f"{len(rec)} recommended for you" + ("" if first else f" ({len(fresh)} new this run)")
319
+ + f", {len(flagged)} relevant but flagged high-risk; {len(candidates):,} skills checked")
320
+ lines = [f"# Skill report {datetime.now():%Y-%m-%d}", "", summary, "", "## New since last run", ""]
321
+ if first:
303
322
  lines.append("First run: everything is new. From the next run, this section lists only what appeared since.")
304
323
  elif fresh:
305
324
  lines += [f"- **`{c['id']}`** ({c['stars']}★, score {c['score']:.2f}): {c['description'][:140]}" for c in fresh[:top]]
@@ -310,6 +329,12 @@ def report(candidates: list[dict], profile: dict, paths: Paths, top: int = 25) -
310
329
  for c in rec[:top]:
311
330
  lines.append(f"| {c['score']:.2f} | `{c['id']}`{' 🆕' if c['new'] else ''} | {c['stars']} | {c['risk'] or 'none'} "
312
331
  f"| {', '.join(c['matched'][:4])} | {c['description'][:110]} |")
332
+ for name in persona.personas(profile):
333
+ mine = sorted((c for c in rec if c["personas"].get(name, 0) >= profile["scoring"].get("min_relevance", 0.3)),
334
+ key=lambda c: (-c["new"], -c["personas"][name], -c["score"]))[:8]
335
+ lines += ["", f"## For your {name} work", ""]
336
+ lines += [f"- `{c['id']}`{' 🆕' if c['new'] else ''} ({c['stars']}★): {c['description'][:120]}" for c in mine] \
337
+ or ["Nothing recommended yet; widen its keywords in your profile."]
313
338
  lines += ["", "Install one with `skillscout approve <id>` after reading it with `skillscout show <id>`.", "",
314
339
  "## Relevant but blocked", ""]
315
340
  for c in [c for c in candidates if c["relevance"] > 0 and not recommended(c, profile) and not c["installed"]][:top]:
@@ -0,0 +1,153 @@
1
+ """Your Claude Code history, read on this machine only and only when you ask: a digest of what you've worked
2
+ on, for you (or the agent in your session) to draft personas from, and keywords from a one-line description.
3
+
4
+ Word counting can't group short chat messages into kinds of work reliably, so grouping is left to a reader
5
+ who understands them; this module only gathers and condenses."""
6
+
7
+ import json
8
+ import math
9
+ import re
10
+ from collections import Counter
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+
14
+ from .score import STOP, WORD, stem
15
+
16
+ HISTORY = Path.home() / ".claude" / "projects"
17
+ GAP = 45 * 60 # seconds of silence that start a new episode
18
+ URL = re.compile(r"https?://\S+|www\.\S+")
19
+ PASTE = re.compile(r"<pasted_content[^>]*>.*?</pasted_content[^>]*>", re.S)
20
+ # Words that say nothing about the kind of work. Common topic words (`page`, `code`) aren't here: alone they're
21
+ # too frequent to survive vocabulary(), but they must stay available for phrases like `landing page`.
22
+ CHATTER = set("""make give can could would should also now just want need like please yes okay thanks thank
23
+ then there here what which why how does doing done did lets let get got put see look check run try use
24
+ using used new add added change changed fix fixed update updated same other more most much very really
25
+ still again first last next back way thing things something everything anything all every each one two
26
+ three good best better bad right wrong sure think know mean means said say tell told keep kept stop start
27
+ working show wrote
28
+ take didn don isn doesn wasn will been being have has had into onto over under about after before above
29
+ below between only well even ever never always too our ours their them they him her his she its
30
+ hello hey yeah dont doesnt cant wont whatever able
31
+ goes made makes gave given took actually maybe already help helps helping based specific various
32
+ http https www com org html url link links we my me up do so if at no go us am he ok oh hi""".split())
33
+ CLAUSE = re.compile(r"[,.;:!?()\[\]/|]+|\s(?:and|or|for|with|in|on|of|to|the)\s")
34
+
35
+
36
+ def episodes(root: Path = HISTORY, days: int | None = None) -> list[dict]:
37
+ """Your typed messages, grouped into episodes (same folder, no gap over 45 minutes), newest first:
38
+ {"folder", "start", "end", "messages", "title"}. Tool output, system text and pasted blocks are left out."""
39
+ since = datetime.now().timestamp() - days * 86400 if days else None
40
+ out = []
41
+ for f in sorted(root.glob("*/*.jsonl")):
42
+ title, mine, current = None, [], None
43
+ try:
44
+ lines = f.read_text(encoding="utf-8", errors="ignore").splitlines()
45
+ except OSError:
46
+ continue
47
+ for line in lines:
48
+ try:
49
+ d = json.loads(line)
50
+ except ValueError:
51
+ continue
52
+ if d.get("type") == "ai-title":
53
+ title = title or d.get("aiTitle")
54
+ if d.get("type") != "user" or d.get("isMeta"):
55
+ continue
56
+ text = typed(d.get("message", {}).get("content"))
57
+ when = _time(d.get("timestamp"))
58
+ if not text or (since and when and when < since):
59
+ continue
60
+ folder = d.get("cwd") or ""
61
+ if not current or current["folder"] != folder or (when and current["end"] and when - current["end"] > GAP):
62
+ current = {"folder": folder, "start": when, "end": when, "messages": [], "title": None}
63
+ mine.append(current)
64
+ current["messages"].append(text)
65
+ current["end"] = when or current["end"]
66
+ if mine and title:
67
+ mine[0]["title"] = title
68
+ out += mine
69
+ return sorted(out, key=lambda e: -(e["start"] or 0))
70
+
71
+
72
+ def typed(content) -> str:
73
+ """The part of a user record you typed: not tool results, command wrappers, system notes or pastes."""
74
+ if isinstance(content, list):
75
+ content = " ".join(c.get("text", "") for c in content if isinstance(c, dict) and c.get("type") == "text")
76
+ if not isinstance(content, str):
77
+ return ""
78
+ text = " ".join(PASTE.sub(" ", URL.sub(" ", content)).split())
79
+ if not text or text.startswith(("<", "[Request interrupted", "Caveat:", "This session is being continued")):
80
+ return ""
81
+ return text[:300]
82
+
83
+
84
+ def _time(stamp) -> float | None:
85
+ try:
86
+ return datetime.fromisoformat(stamp.replace("Z", "+00:00")).timestamp()
87
+ except (AttributeError, ValueError):
88
+ return None
89
+
90
+
91
+ def digest(eps: list[dict], per_episode: int = 4, limit: int = 150) -> str:
92
+ """A compact plain-text summary to read or hand to the agent: one block per episode, short messages only."""
93
+ lines = []
94
+ for e in eps[:limit]:
95
+ when = datetime.fromtimestamp(e["start"]).strftime("%Y-%m-%d") if e["start"] else "?"
96
+ folder = Path(e["folder"]).name or "~"
97
+ head = f"## {when} · {folder}" + (f" · {e['title']}" if e["title"] else "") + f" · {len(e['messages'])} messages"
98
+ msgs = [m[:140] for m in e["messages"] if len(m.split()) >= 3][:per_episode]
99
+ if msgs:
100
+ lines += [head] + [f"- {m}" for m in msgs] + [""]
101
+ return "\n".join(lines)
102
+
103
+
104
+ def vocabulary(records: list[dict], low: int = 3, high: float = 0.04) -> dict[str, int]:
105
+ """Terms (single words and two-word phrases) that skills use in their names and descriptions, with how
106
+ many skills use each. Kept: at least `low` skills (so a keyword can match something) and at most `high`
107
+ of them (`build`, `create` and the like match everything and so say nothing)."""
108
+ df = Counter()
109
+ for r in records:
110
+ df.update(set(terms(f"{r['name'].replace('-', ' ')} {r['description']}")))
111
+ cap = max(low, high * len(records))
112
+ return {t: n for t, n in df.items() if low <= n <= cap}
113
+
114
+
115
+ def terms(text: str, surface: dict[str, str] | None = None) -> list[str]:
116
+ """Content words (plural folded to singular) and adjacent pairs within a clause. `surface`, if given,
117
+ collects each term's first spelling as written (`postgres`, not the folded `postgre`)."""
118
+ out = []
119
+ for clause in CLAUSE.split(f" {text.lower()} "):
120
+ words = []
121
+ for w in WORD.findall(clause):
122
+ if w in CHATTER or w in STOP or w.isdigit() or len(w) < 2:
123
+ continue
124
+ s = stem(w)
125
+ if s in CHATTER:
126
+ continue
127
+ words.append(s)
128
+ if surface is not None:
129
+ surface.setdefault(s, w)
130
+ out += words
131
+ for a, b in zip(words, words[1:]):
132
+ out.append(f"{a} {b}")
133
+ if surface is not None:
134
+ surface.setdefault(f"{a} {b}", f"{surface[a]} {surface[b]}")
135
+ return out
136
+
137
+
138
+ def keywords_from(about: str, vocab: dict[str, int], k: int = 10) -> dict[str, int]:
139
+ """Profile keywords from a sentence about the work: its terms that skills also use, rarer ones weighted
140
+ higher (`meta ads` says more than `marketing`), a phrase preferred over its own words. Weights 1 to 4."""
141
+ surface: dict[str, str] = {}
142
+ found = [t for t in dict.fromkeys(terms(about, surface)) if t in vocab]
143
+ total = max(vocab.values(), default=1) / 0.04
144
+ scored = sorted(((t, math.log(total / vocab[t]) * (1.5 if " " in t else 1)) for t in found), key=lambda x: -x[1])
145
+ out: dict[str, float] = {}
146
+ for t, w in scored:
147
+ if any(t in o.split() for o in out): # its phrase is already in
148
+ continue
149
+ out = {o: v for o, v in out.items() if o not in t.split()} | {t: w}
150
+ if len(out) == k:
151
+ break
152
+ hi = max(out.values(), default=1)
153
+ return {surface[t]: max(1, round(4 * w / hi)) for t, w in out.items()}
@@ -0,0 +1,98 @@
1
+ """Personas: named kinds of work, each with its own keywords and, optionally, a Claude Code agent whose
2
+ `skills:` list skillscout keeps current. Everything else in the agent file is yours and is never touched."""
3
+
4
+ import json
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from .score import relevance
9
+ from .skill import parse_frontmatter
10
+
11
+ NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,39}")
12
+
13
+
14
+ def personas(profile: dict) -> dict[str, dict]:
15
+ """name -> {"description", "keywords", "agent", "max_skills"} from the profile's [personas.*] tables."""
16
+ out = {}
17
+ for name, p in profile.get("personas", {}).items():
18
+ out[name] = {"description": p.get("description", ""), "keywords": p.get("keywords", {}),
19
+ "agent": p.get("agent", name), "max_skills": p.get("max_skills", 5)}
20
+ return out
21
+
22
+
23
+ def keyword_sets(profile: dict) -> dict[str, dict[str, float]]:
24
+ """Every keyword set to score against: the main [keywords] (as "") and each persona's."""
25
+ sets = {"": profile.get("keywords", {})} if profile.get("keywords") else {}
26
+ return sets | {n: p["keywords"] for n, p in personas(profile).items() if p["keywords"]}
27
+
28
+
29
+ def add(profile_path: Path, name: str, description: str, keywords: dict[str, int], agent: str | None = None) -> str:
30
+ """Append a [personas.<name>] table to the profile file. Refuses a name that's taken."""
31
+ if not NAME.fullmatch(name):
32
+ raise SystemExit(f"persona names are lowercase letters, digits and dashes: {name!r}")
33
+ text = profile_path.read_text(encoding="utf-8")
34
+ if re.search(rf"^\[personas\.{re.escape(name)}\]", text, re.M):
35
+ raise SystemExit(f"{name} is already in {profile_path}; edit it there")
36
+ if not keywords:
37
+ raise SystemExit("no keywords: describe the work in more specific words, or pass --keywords")
38
+ block = [f"\n[personas.{name}]", f"description = {json.dumps(description)}"]
39
+ if agent and agent != name:
40
+ 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()]
42
+ profile_path.write_text(text.rstrip("\n") + "\n" + "\n".join(block) + "\n", encoding="utf-8")
43
+ return "\n".join(block).strip()
44
+
45
+
46
+ def fitting(installed: dict[str, str], keywords: dict[str, float], k: int, at: float = 0.2) -> list[tuple[str, float]]:
47
+ """The installed skills (name -> description) that best fit a keyword set, best first."""
48
+ scored = []
49
+ for name, desc in installed.items():
50
+ rel, _ = relevance(f"{name.split(':')[-1].replace('-', ' ')} {desc}", keywords)
51
+ if rel >= at:
52
+ scored.append((name, rel))
53
+ return sorted(scored, key=lambda x: (-x[1], x[0]))[:k]
54
+
55
+
56
+ def write_agent(path: Path, name: str, description: str, skills: list[str]) -> tuple[str, list[str], list[str]]:
57
+ """Create the agent file, or replace only the `skills:` list in an existing one.
58
+ Returns (what happened, skills added, skills dropped)."""
59
+ if path.exists():
60
+ text = path.read_text(encoding="utf-8")
61
+ if not text.startswith("---"):
62
+ raise SystemExit(f"{path} has no frontmatter; add `---` blocks or remove the file")
63
+ end = text.index("\n---", 3)
64
+ head, body = text[: end], text[end:]
65
+ before = _skills(head)
66
+ head = _strip_skills(head).rstrip("\n") + "\n" + _skills_block(skills)
67
+ path.write_text(head.rstrip("\n") + body, encoding="utf-8")
68
+ return "updated", [s for s in skills if s not in before], [s for s in before if s not in skills]
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ path.write_text(f"""---
71
+ name: {name}
72
+ description: {json.dumps(description or f"The {name} side of my work")}
73
+ {_skills_block(skills)}---
74
+
75
+ {description}
76
+
77
+ The skills listed above load when this agent starts; `skillscout persona build {name}` keeps that list
78
+ current with the best installed skills for this work. Everything else in this file is yours to edit:
79
+ role, tone, rules, tools, model.
80
+ """, encoding="utf-8")
81
+ return "created", skills, []
82
+
83
+
84
+ def _skills(head: str) -> list[str]:
85
+ m = re.search(r"^skills:[ \t]*\n((?:[ \t]+-.*\n?)*)", head + "\n", re.M)
86
+ if m:
87
+ return [line.strip()[1:].strip().strip("\"'") for line in m.group(1).splitlines() if line.strip()]
88
+ inline = parse_frontmatter(head + "\n---").get("skills", "")
89
+ return [s.strip().strip("\"'") for s in inline.strip("[]").split(",") if s.strip()]
90
+
91
+
92
+ def _strip_skills(head: str) -> str:
93
+ head = re.sub(r"^skills:[ \t]*\n(?:[ \t]+-.*\n?)*", "", head + "\n", flags=re.M)
94
+ return re.sub(r"^skills:.*\n?", "", head, flags=re.M)
95
+
96
+
97
+ def _skills_block(skills: list[str]) -> str:
98
+ return "skills:\n" + "".join(f" - {s}\n" for s in skills) if skills else ""
@@ -55,6 +55,18 @@ trusted = [
55
55
  "security" = 1
56
56
  "design" = 1
57
57
 
58
+ # Personas: kinds of work, each with its own keywords and a Claude Code agent (~/.claude/agents/<name>.md)
59
+ # whose skills: list `skillscout persona build <name>` keeps current. Add one with
60
+ # `skillscout persona add <name> --about "what this work is"`, or by hand:
61
+ #
62
+ # [personas.brand]
63
+ # description = "D2C brand marketing: ad copy, landing pages, Instagram reels"
64
+ # max_skills = 5 # skills preloaded into the agent; each loads in full, so keep it small
65
+ # [personas.brand.keywords]
66
+ # "ad copy" = 4
67
+ # "landing pages" = 3
68
+ # "instagram" = 2
69
+
58
70
  [scoring]
59
71
  recommend_at = 0.45 # combined score needed to be recommended
60
72
  min_relevance = 0.3 # and at least this much keyword overlap with your work
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillscout-cli
3
- Version: 0.2.0
4
- Summary: Keep your agent's skills current: weekly discovery, upstream diffs, safety scans, pinned installs
3
+ Version: 0.3.0
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
7
7
  Project-URL: Homepage, https://akshit9162.github.io/skillscout
8
8
  Project-URL: Source, https://github.com/akshit9162/skillscout
9
9
  Project-URL: Issues, https://github.com/akshit9162/skillscout/issues
10
10
  Project-URL: Report, https://akshit9162.github.io/skillscout/report.html
11
- Keywords: claude,claude-code,agent-skills,security,supply-chain,llm
11
+ Keywords: claude,claude-code,agent-skills,personas,subagents,security,supply-chain,llm
12
12
  Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Environment :: Console
14
14
  Classifier: Intended Audience :: Developers
@@ -34,6 +34,7 @@ Dynamic: license-file
34
34
  <a href="https://akshit9162.github.io/skillscout">Website</a> &ensp;·&ensp;
35
35
  <a href="https://akshit9162.github.io/skillscout/report.html">Report: what's inside 6,726 skills</a> &ensp;·&ensp;
36
36
  <a href="#install">Install</a> &ensp;·&ensp;
37
+ <a href="#personas">Personas</a> &ensp;·&ensp;
37
38
  <a href="#a-week-with-skillscout">A week with it</a> &ensp;·&ensp;
38
39
  <a href="#how-it-works">How it works</a> &ensp;·&ensp;
39
40
  <a href="#what-gets-flagged">What gets flagged</a>
@@ -47,7 +48,7 @@ Dynamic: license-file
47
48
  </p>
48
49
 
49
50
  <p align="center">
50
- <img src="https://raw.githubusercontent.com/akshit9162/skillscout/main/docs/assets/demo.svg" width="760" alt="Terminal demo: discover finds 216 recommended skills with 89 flagged high-risk; a high-risk skill is refused without --accept-risk; a low-risk one installs pinned to a commit" />
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
52
  </p>
52
53
 
53
54
  ```bash
@@ -60,28 +61,68 @@ AI tooling moves weekly. New skills, plugins and agent workflows land on GitHub
60
61
  installed keep changing upstream, and last month's best practice is already stale. Nobody has time to
61
62
  watch hundreds of repos.
62
63
 
63
- **skillscout keeps your agent current.** Once a week it checks the skill ecosystem against what *you*
64
- work on, tells you what's new and worth having, and shows you exactly what changed in the skills you
65
- already use.
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
66
+ machine against what *you* work on, tells you what's new and worth having, and shows you exactly what
67
+ changed in the skills you already use.
68
+
69
+ You probably don't do one kind of work. Coding on Monday, a landing page on Tuesday, investor emails on
70
+ Friday. So skillscout keeps **personas**: one per kind of work, each with its own search and its own Claude
71
+ Code agent, loaded with the best skills for that work and kept up to date as better ones appear.
66
72
 
67
73
  Keeping up fast is only useful if it's safe. A skill is a set of instructions your agent follows
68
74
  **with your permissions**, so skillscout scans every file for dangerous patterns, installs nothing without
69
75
  your approval, pins each install to an exact commit, and re-scans every update before it can replace anything.
70
76
 
71
- In one run over the public ecosystem it found **6,726 skills in 95 repos**. 198 had at least one high-risk flag,
77
+ In a hand-checked run on 27 Oct 2026 over **6,726 skills in 95 repos**, 198 had at least one high-risk flag,
72
78
  mostly `sudo` and `curl | sh` install steps, plus prompt-injection-style text and hidden Unicode.
73
79
  The [report](https://akshit9162.github.io/skillscout/report.html) reads every one of the scariest flags: they were defenders.
74
80
 
75
81
  It's a zero-dependency Python CLI, and it also ships as a skill, so your agent can run the whole loop for you
76
82
  and still ask before every install.
77
83
 
84
+ ## Personas
85
+
86
+ A persona is one side of your work, in your words. skillscout doesn't ship any: yours might be "builder",
87
+ "brand" and "outreach", someone else's "trading" and "thesis".
88
+
89
+ ```text
90
+ You "set up personas for me" (in Claude Code, with the plugin)
91
+ Agent reads your recent sessions on this machine, with your OK (skillscout history)
92
+ → proposes: builder · brand · outreach, each with the sessions it came from
93
+ You keep brand and builder, rename outreach to launches
94
+ Agent skillscout persona add brand --about "D2C marketing: ad copy, landing pages, Instagram reels"
95
+ → keywords drafted from terms skills actually use: instagram reels, ad copy, landing pages...
96
+ skillscout discover → a "For your brand work" section in the report
97
+ skillscout persona build brand → ~/.claude/agents/brand.md, skills: copy, landing-page-design, script...
98
+ You claude --agent brand
99
+ ```
100
+
101
+ From then on, each weekly report has a section per persona, and `persona build` swaps in a better skill when
102
+ one arrives (after you've read and approved it). `persona build` only ever rewrites the agent's `skills:` list:
103
+ its prompt, tone, tools, model and memory are yours.
104
+
105
+ The CLI works without the agent too:
106
+
107
+ ```sh
108
+ skillscout persona add brand --about "D2C marketing: ad copy, landing pages, Instagram reels"
109
+ skillscout persona list # keywords, best installed skills, what's worth installing
110
+ skillscout persona build --all # write or update ~/.claude/agents/<name>.md
111
+ ```
112
+
113
+ **Your history stays on your machine.** `skillscout history` reads `~/.claude/projects` only when you run it,
114
+ keeps only what you typed (no tool output, no pasted blocks, no links) and sends nothing anywhere. Grouping
115
+ sessions into personas is left to you or the agent in your own session: counting words can't tell short chat
116
+ messages apart reliably, and a wrong persona is worse than none.
117
+
78
118
  ## A week with skillscout
79
119
 
80
120
  Example session (counts vary week to week):
81
121
 
82
122
  ```text
83
123
  Monday 10:00 skillscout discover --notify (weekly, via schedule)
84
- → "4 new, 23 recommended, 11 flagged high-risk"
124
+ → "23 recommended for you (4 new this run), 11 relevant but flagged high-risk"
125
+ → plus a section per persona: "For your brand work", "For your builder work"
85
126
  You skillscout show github/awesome-copilot:eval-driven-dev
86
127
  → flags with file:line, the full SKILL.md, overlaps with what you have
87
128
  You skillscout approve eval-driven-dev → copied, pinned to its commit
@@ -98,17 +139,18 @@ claude plugin marketplace add akshit9162/skillscout
98
139
  claude plugin install skillscout@skillscout
99
140
  ```
100
141
 
101
- Then ask: *"find me skills for my Python and React work"*. The agent installs the CLI, runs the search,
142
+ Then ask: *"find me skills for my Python and React work"*, or *"set up personas for me"*. The agent installs the CLI, runs the search,
102
143
  summarises the report and shows you each skill's flags before asking to install it.
103
144
 
104
145
  ### As a CLI
105
146
 
106
147
  ```sh
107
148
  pipx install skillscout-cli # or: uv tool install skillscout-cli
108
- skillscout init # writes ~/.skillscout/profile.toml
149
+ skillscout init --about "backend in Python, code review, RL evals, Postgres"
109
150
  ```
110
151
 
111
- Edit `[keywords]` in `~/.skillscout/profile.toml` with the words from your own work, then:
152
+ `init` writes `~/.skillscout/profile.toml` and drafts `[keywords]` from your line, using only terms that skills
153
+ actually use. Check them, then:
112
154
 
113
155
  ```sh
114
156
  skillscout discover # score this week's index; writes ~/.skillscout/reports/<date>.md
@@ -167,6 +209,11 @@ High-risk skills need `--accept-risk`, which the bundled skill tells the agent n
167
209
  | `skillscout approve <id> [--update] [--accept-risk] [--to DIR]` | Install and pin. `--to`, or `dir` under `[install]` in your profile, installs into another agent's skills folder |
168
210
  | `skillscout installed` · `skillscout remove <id>` · `skillscout rollback <id>` | Pinned skills · uninstall · undo the last update |
169
211
  | `skillscout updates` | Diff pinned skills against upstream |
212
+ | `skillscout init [--about "..."]` | Write the profile; `--about` drafts your keywords from one line |
213
+ | `skillscout history [--days N]` | A local digest of your recent Claude Code sessions, to draft personas from |
214
+ | `skillscout persona add <name> --about "..." [--keywords "a=3,b=2"]` | Add a persona; keywords drafted from the line unless given |
215
+ | `skillscout persona list` | Each persona: keywords, best installed skills, what's worth installing |
216
+ | `skillscout persona build <name> \| --all [--dry-run]` | Write `~/.claude/agents/<name>.md`, or update only its `skills:` list |
170
217
  | `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 |
171
218
 
172
219
  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.
@@ -177,6 +224,7 @@ With the index, `discover` makes no GitHub API calls at all. For `--local` and `
177
224
  - Relevance is keyword matching against your profile, not semantic search. Tune the weights.
178
225
  - 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.
179
226
  - Every installed skill's description loads into every agent session. Keep the total to a few dozen.
227
+ - A persona's `skills:` load in full when its agent starts. `max_skills` (default 5) keeps that small.
180
228
 
181
229
  ## Development
182
230
 
@@ -5,7 +5,9 @@ skillscout/__init__.py
5
5
  skillscout/cli.py
6
6
  skillscout/core.py
7
7
  skillscout/github.py
8
+ skillscout/history.py
8
9
  skillscout/index.py
10
+ skillscout/persona.py
9
11
  skillscout/profile.example.toml
10
12
  skillscout/scan.py
11
13
  skillscout/schedule.py
@@ -128,7 +128,7 @@ def test_discover_ranks_flags_and_reports(env):
128
128
  assert by["cooking"]["relevance"] == 0
129
129
  out, summary = core.report(cands, PROFILE, paths)
130
130
  text = out.read_text(encoding="utf-8")
131
- assert "me/good:gst-eval" in text and "pipes a download" in text and summary.startswith("3 new, 1 recommended, 1 flagged")
131
+ assert "me/good:gst-eval" in text and "pipes a download" in text and summary.startswith("1 recommended for you, 1 relevant but flagged")
132
132
  # second run: nothing is new
133
133
  assert not any(c["new"] for c in core.discover(PROFILE, paths, fetch=gh))
134
134
 
@@ -472,3 +472,97 @@ def test_index_verification_needs_a_github_release_and_gh(tmp_path, monkeypatch)
472
472
  monkeypatch.setattr(index.shutil, "which", lambda _: None)
473
473
  with pytest.raises(index.Unavailable, match="GitHub CLI"):
474
474
  index.verify(tmp_path / "i.json.gz", index.URL)
475
+
476
+
477
+ def transcript(path: Path, records: list[dict]) -> None:
478
+ import json
479
+ path.parent.mkdir(parents=True, exist_ok=True)
480
+ path.write_text("\n".join(json.dumps(r) for r in records) + "\n", encoding="utf-8")
481
+
482
+
483
+ def said(text, when, cwd="/work/shop", **extra):
484
+ return {"type": "user", "cwd": cwd, "timestamp": when, "message": {"role": "user", "content": text}, **extra}
485
+
486
+
487
+ def test_history_keeps_only_what_you_typed_and_splits_episodes(tmp_path):
488
+ from skillscout import history
489
+ transcript(tmp_path / "proj" / "s1.jsonl", [
490
+ {"type": "ai-title", "aiTitle": "Shop launch"},
491
+ said("write ad copy for the summer sale", "2026-10-01T10:00:00Z"),
492
+ said("<command-name>/clear</command-name>", "2026-10-01T10:01:00Z"),
493
+ said("noise", "2026-10-01T10:02:00Z", isMeta=True),
494
+ {"type": "user", "cwd": "/work/shop", "timestamp": "2026-10-01T10:03:00Z",
495
+ "message": {"content": [{"type": "tool_result", "content": "secret tool output"}]}},
496
+ said("see https://example.com/x and <pasted_content id=\"1\">pasted words</pasted_content id=\"1\"> now landing page", "2026-10-01T10:04:00Z"),
497
+ said("fix the failing postgres test", "2026-10-01T12:00:00Z"), # two hours later
498
+ said("and the migration too", "2026-10-01T12:05:00Z", cwd="/work/api"), # another folder
499
+ ])
500
+ eps = history.episodes(tmp_path)
501
+ assert [len(e["messages"]) for e in sorted(eps, key=lambda e: e["start"])] == [2, 1, 1]
502
+ first = min(eps, key=lambda e: e["start"])
503
+ assert first["title"] == "Shop launch" and first["messages"][1] == "see and now landing page"
504
+ text = history.digest(eps)
505
+ assert "secret tool output" not in text and "pasted words" not in text and "example.com" not in text
506
+ assert "· shop · Shop launch ·" in text
507
+
508
+
509
+ def test_keywords_from_a_sentence_use_only_terms_skills_use(tmp_path):
510
+ from skillscout import history
511
+ records = [{"name": f"s{i}", "description": d} for i, d in enumerate(
512
+ ["write google ads copy"] * 3 + ["cold email sequences"] * 4 + ["postgres query tuning"] * 3 + ["landing pages"] * 3
513
+ + ["generic helper"] * 200)]
514
+ vocab = history.vocabulary(records)
515
+ kw = history.keywords_from("Google ads, cold email and Postgres; also knitting", vocab)
516
+ assert set(kw) == {"google ads", "cold email", "postgres"} # phrases kept whole, across no comma
517
+ assert "knitting" not in kw and history.keywords_from("knitting", vocab) == {}
518
+
519
+
520
+ def test_personas_are_added_scored_and_reported(env, tmp_path):
521
+ from skillscout import persona
522
+ paths, gh = env
523
+ prof = tmp_path / "profile.toml"
524
+ import tomllib
525
+ base = Path(core.__file__).with_name("profile.example.toml").read_text(encoding="utf-8")
526
+ prof.write_text(base, encoding="utf-8")
527
+ persona.add(prof, "taxes", "GST filing", {"gst": 4, "rl environment": 2})
528
+ with pytest.raises(SystemExit, match="already"):
529
+ persona.add(prof, "taxes", "again", {"gst": 1})
530
+ with pytest.raises(SystemExit, match="lowercase"):
531
+ persona.add(prof, "../x", "bad", {"gst": 1})
532
+ profile = core.load_profile(prof) | {"scoring": PROFILE["scoring"] | {"recommend_at": 0.2}}
533
+ assert persona.personas(profile)["taxes"]["keywords"] == {"gst": 4, "rl environment": 2}
534
+ cands = core.discover(profile, paths, fetch=gh)
535
+ gst = core.find(cands, "gst-eval")
536
+ assert gst["personas"]["taxes"] == 1.0 and gst["relevance"] == 1.0
537
+ assert "## For your taxes work" in core.report(cands, profile, paths)[0].read_text(encoding="utf-8")
538
+
539
+
540
+ def test_agent_file_is_created_then_only_its_skills_change(tmp_path):
541
+ from skillscout import persona
542
+ path = tmp_path / "agents" / "brand.md"
543
+ assert persona.write_agent(path, "brand", "Ads and copy", ["copy", "seo"])[0] == "created"
544
+ text = path.read_text(encoding="utf-8")
545
+ assert text.startswith("---\nname: brand\n") and " - copy\n - seo\n" in text
546
+ path.write_text(text.replace("Ads and copy\n\nThe skills", "Ads and copy\n\nMY OWN RULES\n\nThe skills")
547
+ .replace("name: brand\n", "name: brand\nmodel: opus\n"), encoding="utf-8")
548
+ what, added, dropped = persona.write_agent(path, "brand", "ignored now", ["copy", "viral"])
549
+ text = path.read_text(encoding="utf-8")
550
+ assert (what, added, dropped) == ("updated", ["viral"], ["seo"])
551
+ assert "MY OWN RULES" in text and "model: opus" in text and " - viral\n" in text and "seo" not in text
552
+ path.write_text("---\nname: x\nskills: [a, b]\ntools: Read\n---\nbody\n", encoding="utf-8") # inline list form
553
+ assert persona.write_agent(path, "x", "", ["b", "c"])[1:] == (["c"], ["a"])
554
+ assert path.read_text(encoding="utf-8") == "---\nname: x\ntools: Read\nskills:\n - b\n - c\n---\nbody\n"
555
+
556
+
557
+ def test_plugin_skills_use_plugin_names_and_only_enabled_plugins(tmp_path):
558
+ import json
559
+ claude = tmp_path / ".claude"
560
+ paths = core.Paths(tmp_path / "state", claude / "skills", claude / "plugins" / "cache")
561
+ for plugin in ("marketing", "unused"):
562
+ md = paths.plugins / "market" / plugin / "1.0.0" / "skills" / "draft" / "SKILL.md"
563
+ md.parent.mkdir(parents=True)
564
+ md.write_text(skill_md("draft", f"{plugin} drafting"), encoding="utf-8")
565
+ (paths.skills / "copy").mkdir(parents=True)
566
+ (paths.skills / "copy" / "SKILL.md").write_text(skill_md("copy", "ad copy"), encoding="utf-8")
567
+ paths.settings.write_text(json.dumps({"enabledPlugins": {"marketing@market": True, "unused@market": False}}), encoding="utf-8")
568
+ assert core.installed_skills(paths) == {"copy": "ad copy", "marketing:draft": "marketing drafting"}
File without changes
File without changes