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.
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/PKG-INFO +60 -12
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/README.md +57 -9
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/pyproject.toml +3 -3
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/cli.py +89 -4
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/core.py +33 -8
- skillscout_cli-0.3.0/skillscout/history.py +153 -0
- skillscout_cli-0.3.0/skillscout/persona.py +98 -0
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/profile.example.toml +12 -0
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout_cli.egg-info/PKG-INFO +60 -12
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout_cli.egg-info/SOURCES.txt +2 -0
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/tests/test_skillscout.py +95 -1
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/LICENSE +0 -0
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/setup.cfg +0 -0
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/__init__.py +0 -0
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/github.py +0 -0
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/index.py +0 -0
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/scan.py +0 -0
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/schedule.py +0 -0
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/score.py +0 -0
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout/skill.py +0 -0
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout_cli.egg-info/dependency_links.txt +0 -0
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout_cli.egg-info/entry_points.txt +0 -0
- {skillscout_cli-0.2.0 → skillscout_cli-0.3.0}/skillscout_cli.egg-info/requires.txt +0 -0
- {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.
|
|
4
|
-
Summary: Keep your agent's skills current: weekly
|
|
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>  · 
|
|
35
35
|
<a href="https://akshit9162.github.io/skillscout/report.html">Report: what's inside 6,726 skills</a>  · 
|
|
36
36
|
<a href="#install">Install</a>  · 
|
|
37
|
+
<a href="#personas">Personas</a>  · 
|
|
37
38
|
<a href="#a-week-with-skillscout">A week with it</a>  · 
|
|
38
39
|
<a href="#how-it-works">How it works</a>  · 
|
|
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:
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
|
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
|
|
149
|
+
skillscout init --about "backend in Python, code review, RL evals, Postgres"
|
|
109
150
|
```
|
|
110
151
|
|
|
111
|
-
|
|
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>  · 
|
|
7
7
|
<a href="https://akshit9162.github.io/skillscout/report.html">Report: what's inside 6,726 skills</a>  · 
|
|
8
8
|
<a href="#install">Install</a>  · 
|
|
9
|
+
<a href="#personas">Personas</a>  · 
|
|
9
10
|
<a href="#a-week-with-skillscout">A week with it</a>  · 
|
|
10
11
|
<a href="#how-it-works">How it works</a>  · 
|
|
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:
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
|
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
|
|
121
|
+
skillscout init --about "backend in Python, code review, RL evals, Postgres"
|
|
81
122
|
```
|
|
82
123
|
|
|
83
|
-
|
|
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.
|
|
8
|
-
description = "Keep your agent's skills current: weekly
|
|
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="
|
|
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(
|
|
60
|
-
print(f"wrote {USER_PROFILE}; edit [keywords] to match your work
|
|
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
|
|
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,
|
|
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"{
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
Summary: Keep your agent's skills current: weekly
|
|
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>  · 
|
|
35
35
|
<a href="https://akshit9162.github.io/skillscout/report.html">Report: what's inside 6,726 skills</a>  · 
|
|
36
36
|
<a href="#install">Install</a>  · 
|
|
37
|
+
<a href="#personas">Personas</a>  · 
|
|
37
38
|
<a href="#a-week-with-skillscout">A week with it</a>  · 
|
|
38
39
|
<a href="#how-it-works">How it works</a>  · 
|
|
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:
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
|
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
|
|
149
|
+
skillscout init --about "backend in Python, code review, RL evals, Postgres"
|
|
109
150
|
```
|
|
110
151
|
|
|
111
|
-
|
|
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
|
|
|
@@ -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("
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|