reask 0.1.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.
- reask-0.1.0/.claude-plugin/marketplace.json +18 -0
- reask-0.1.0/.claude-plugin/plugin.json +14 -0
- reask-0.1.0/.gitignore +11 -0
- reask-0.1.0/CHANGELOG.md +18 -0
- reask-0.1.0/LICENSE +21 -0
- reask-0.1.0/PKG-INFO +143 -0
- reask-0.1.0/README.md +117 -0
- reask-0.1.0/pyproject.toml +82 -0
- reask-0.1.0/scripts/__init__.py +0 -0
- reask-0.1.0/scripts/check_skill.py +123 -0
- reask-0.1.0/scripts/release_notes.py +45 -0
- reask-0.1.0/scripts/skill_zip.py +38 -0
- reask-0.1.0/skills/reask/SKILL.md +85 -0
- reask-0.1.0/src/reask/__init__.py +18 -0
- reask-0.1.0/src/reask/cli.py +125 -0
- reask-0.1.0/tests/test_check_skill.py +147 -0
- reask-0.1.0/tests/test_cli.py +187 -0
- reask-0.1.0/tests/test_release_artifacts.py +62 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/claude-code-marketplace.json",
|
|
3
|
+
"name": "reask",
|
|
4
|
+
"owner": {
|
|
5
|
+
"name": "Mikhail Nezhinsky",
|
|
6
|
+
"url": "https://github.com/Nezhinskiy"
|
|
7
|
+
},
|
|
8
|
+
"description": "Install reask as a Claude Code plugin.",
|
|
9
|
+
"plugins": [
|
|
10
|
+
{
|
|
11
|
+
"name": "reask",
|
|
12
|
+
"source": "./",
|
|
13
|
+
"description": "Re-ask a pending question as a self-contained briefing: context, a live example, options with pros and cons, a recommendation, one question.",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"keywords": ["questions", "decisions", "clarification", "briefing"]
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
|
+
"name": "reask",
|
|
4
|
+
"description": "Re-ask a pending question as a self-contained briefing: context, a live example, options with pros and cons, a recommendation, one question.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Mikhail Nezhinsky",
|
|
8
|
+
"url": "https://github.com/Nezhinskiy"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/Nezhinskiy/reask",
|
|
11
|
+
"repository": "https://github.com/Nezhinskiy/reask",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": ["questions", "decisions", "clarification", "briefing"]
|
|
14
|
+
}
|
reask-0.1.0/.gitignore
ADDED
reask-0.1.0/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Newest first. Every entry is written for users: a release's section becomes its GitHub Release
|
|
4
|
+
notes.
|
|
5
|
+
|
|
6
|
+
## 0.1.0 (2026-09-29)
|
|
7
|
+
|
|
8
|
+
First release.
|
|
9
|
+
|
|
10
|
+
- The `/reask` skill: re-asks a pending question as a five-part briefing (where we are, a live
|
|
11
|
+
example, options with pros and cons, a recommendation, one question) in under 500 words and in
|
|
12
|
+
the user's language, or as a short form when the question is a simple yes or no.
|
|
13
|
+
- Install it with the Skills CLI (`npx skills add Nezhinskiy/reask`), as a Claude Code plugin,
|
|
14
|
+
with the `reask` Python package, as a zip for claude.ai, or by copying `SKILL.md`.
|
|
15
|
+
- `reask install`, `reask uninstall` and `reask print`. Neither `install` nor `uninstall`
|
|
16
|
+
replaces or deletes a copy you have edited without `--force`, and neither follows a symbolic
|
|
17
|
+
link inside the skills directory.
|
|
18
|
+
- Every release is built in CI from its tag and carries build provenance attestations.
|
reask-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mikhail Nezhinsky
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
reask-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: reask
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An agent skill that re-asks a pending question as a self-contained briefing
|
|
5
|
+
Project-URL: Homepage, https://github.com/Nezhinskiy/reask
|
|
6
|
+
Project-URL: Source, https://github.com/Nezhinskiy/reask
|
|
7
|
+
Project-URL: Changelog, https://github.com/Nezhinskiy/reask/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/Nezhinskiy/reask/issues
|
|
9
|
+
Author: Mikhail Nezhinsky
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: agent-skills,ai-agents,claude,claude-code,llm,skill
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Software Development
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# reask
|
|
28
|
+
|
|
29
|
+
[](https://github.com/Nezhinskiy/reask/actions/workflows/ci.yml)
|
|
30
|
+
[](https://pypi.org/project/reask/)
|
|
31
|
+
[](https://github.com/Nezhinskiy/reask/blob/main/LICENSE)
|
|
32
|
+
|
|
33
|
+
**Your agent asked you something and you cannot answer it.** You lost the context, the options
|
|
34
|
+
are jargon, or you cannot see what actually differs between them. Type `/reask`.
|
|
35
|
+
|
|
36
|
+
The agent asks the same question again, written for someone who just walked in: where the work
|
|
37
|
+
stands, one real example carried through every option, the pros *and* cons of each (the one it
|
|
38
|
+
recommends included), its recommendation, and one question you can answer. Under 500 words, in
|
|
39
|
+
your language.
|
|
40
|
+
|
|
41
|
+
reask is an [Agent Skill](https://agentskills.io): one file,
|
|
42
|
+
[`SKILL.md`](https://github.com/Nezhinskiy/reask/blob/main/skills/reask/SKILL.md), that Claude
|
|
43
|
+
Code, Codex, Cursor, Gemini CLI and other agents that read the format can load.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
**Any agent, with the [Skills CLI](https://github.com/vercel-labs/skills):**
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx skills add Nezhinskiy/reask --global
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
It asks which agents to install into; `--agent claude-code` (or `--agent '*'`) skips the
|
|
54
|
+
question, and leaving off `--global` installs into the current project.
|
|
55
|
+
|
|
56
|
+
**Claude Code plugin:**
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
/plugin marketplace add Nezhinskiy/reask
|
|
60
|
+
/plugin install reask@reask
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A plugin's skills are namespaced, so this one answers to `/reask:reask`. Updates arrive with
|
|
64
|
+
`/plugin marketplace update reask`.
|
|
65
|
+
|
|
66
|
+
**Python**, for Claude Code:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
uvx reask install
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
or `pipx run reask install`. This writes `~/.claude/skills/reask/SKILL.md` (under
|
|
73
|
+
`$CLAUDE_CONFIG_DIR` if you set it); `--project` writes `./.claude/skills` instead and
|
|
74
|
+
`--skills-dir PATH` any other location. `reask print` prints the skill and `reask uninstall`
|
|
75
|
+
removes it. Neither command overwrites or deletes a copy you have edited unless you pass `--force`.
|
|
76
|
+
|
|
77
|
+
**claude.ai and Claude Desktop:** download `reask-skill.zip` from the
|
|
78
|
+
[latest release](https://github.com/Nezhinskiy/reask/releases/latest) and upload it under
|
|
79
|
+
Settings > Capabilities > Skills.
|
|
80
|
+
|
|
81
|
+
**By hand:** copy `skills/reask/SKILL.md` into a `reask/` folder in your agent's skills
|
|
82
|
+
directory.
|
|
83
|
+
|
|
84
|
+
## Example
|
|
85
|
+
|
|
86
|
+
Mid-task, the agent asks:
|
|
87
|
+
|
|
88
|
+
> Should the price cache key include the locale, or should we normalise currency at read time?
|
|
89
|
+
|
|
90
|
+
You type `/reask`, and get:
|
|
91
|
+
|
|
92
|
+
> **Where we are and why this comes up.** We are putting a cache in front of the pricing API so
|
|
93
|
+
> product pages stop taking 800 ms. The cache works; what is open is what each entry is keyed
|
|
94
|
+
> on, and nothing merges until that is decided. The pricing API already returns a
|
|
95
|
+
> country-specific price for 3 SKUs. *Decided:* Redis, 10-minute expiry. *My assumption:* the 40
|
|
96
|
+
> locales we ship today are the ones to plan for.
|
|
97
|
+
>
|
|
98
|
+
> **A live example.** SKU `B-2210`, one of those 3, costs €49 in Germany and €45 in Austria.
|
|
99
|
+
> Viewed from Berlin (`de-DE`) and then from Vienna (`de-AT`):
|
|
100
|
+
>
|
|
101
|
+
> | | Key per locale | Normalise at read |
|
|
102
|
+
> |---|---|---|
|
|
103
|
+
> | Cache entries | 2 | 1 |
|
|
104
|
+
> | API calls on first views | 2 | 1 |
|
|
105
|
+
> | Price shown in Vienna | €45 | €49 |
|
|
106
|
+
>
|
|
107
|
+
> **Options.**
|
|
108
|
+
> *Key per locale*: one entry per SKU per locale.
|
|
109
|
+
> Pros: every country sees its own price; no conversion code.
|
|
110
|
+
> Cons: 40 times the entries; 40 cold-cache API calls per SKU instead of one.
|
|
111
|
+
> *Normalise at read*: one entry per SKU in euros, converted when read.
|
|
112
|
+
> Pros: 40 times fewer entries; one warm-up per SKU.
|
|
113
|
+
> Cons: shows the wrong price for those 3 SKUs today; a rate-table lookup on every read.
|
|
114
|
+
>
|
|
115
|
+
> **What I recommend.** Key per locale. Normalising shows Vienna €49 for `B-2210` today, not
|
|
116
|
+
> someday; 40 entries per SKU is about 2 MB at our catalogue size. I would change my mind if
|
|
117
|
+
> those 3 prices turn out to be a data error.
|
|
118
|
+
>
|
|
119
|
+
> **The question.** Key the cache per locale (recommended), or normalise currency at read time?
|
|
120
|
+
|
|
121
|
+
In Claude Code the question arrives with both answers as options to pick, the recommended one
|
|
122
|
+
first. This example was written by hand to show the format; a real briefing is built from your
|
|
123
|
+
session's facts.
|
|
124
|
+
|
|
125
|
+
## Why it works
|
|
126
|
+
|
|
127
|
+
Agents ask good questions in the wrong register: they have the whole session in context and
|
|
128
|
+
you do not. reask fixes the register, not the question. It keeps the decision yours (it never
|
|
129
|
+
starts on its default), states each option's downside at full strength, and ties the
|
|
130
|
+
recommendation to facts from your work, so you can overrule it with one reply.
|
|
131
|
+
|
|
132
|
+
## Contributing and security
|
|
133
|
+
|
|
134
|
+
Behaviour reports with a transcript are the most useful contribution; see
|
|
135
|
+
[CONTRIBUTING.md](https://github.com/Nezhinskiy/reask/blob/main/CONTRIBUTING.md). Report
|
|
136
|
+
vulnerabilities privately, as described in
|
|
137
|
+
[SECURITY.md](https://github.com/Nezhinskiy/reask/blob/main/SECURITY.md). Every release is built
|
|
138
|
+
in CI and carries a
|
|
139
|
+
[build provenance attestation](https://github.com/Nezhinskiy/reask/blob/main/SECURITY.md#verifying-what-you-installed).
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
[MIT](https://github.com/Nezhinskiy/reask/blob/main/LICENSE)
|
reask-0.1.0/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# reask
|
|
2
|
+
|
|
3
|
+
[](https://github.com/Nezhinskiy/reask/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/reask/)
|
|
5
|
+
[](https://github.com/Nezhinskiy/reask/blob/main/LICENSE)
|
|
6
|
+
|
|
7
|
+
**Your agent asked you something and you cannot answer it.** You lost the context, the options
|
|
8
|
+
are jargon, or you cannot see what actually differs between them. Type `/reask`.
|
|
9
|
+
|
|
10
|
+
The agent asks the same question again, written for someone who just walked in: where the work
|
|
11
|
+
stands, one real example carried through every option, the pros *and* cons of each (the one it
|
|
12
|
+
recommends included), its recommendation, and one question you can answer. Under 500 words, in
|
|
13
|
+
your language.
|
|
14
|
+
|
|
15
|
+
reask is an [Agent Skill](https://agentskills.io): one file,
|
|
16
|
+
[`SKILL.md`](https://github.com/Nezhinskiy/reask/blob/main/skills/reask/SKILL.md), that Claude
|
|
17
|
+
Code, Codex, Cursor, Gemini CLI and other agents that read the format can load.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
**Any agent, with the [Skills CLI](https://github.com/vercel-labs/skills):**
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx skills add Nezhinskiy/reask --global
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
It asks which agents to install into; `--agent claude-code` (or `--agent '*'`) skips the
|
|
28
|
+
question, and leaving off `--global` installs into the current project.
|
|
29
|
+
|
|
30
|
+
**Claude Code plugin:**
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
/plugin marketplace add Nezhinskiy/reask
|
|
34
|
+
/plugin install reask@reask
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
A plugin's skills are namespaced, so this one answers to `/reask:reask`. Updates arrive with
|
|
38
|
+
`/plugin marketplace update reask`.
|
|
39
|
+
|
|
40
|
+
**Python**, for Claude Code:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uvx reask install
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
or `pipx run reask install`. This writes `~/.claude/skills/reask/SKILL.md` (under
|
|
47
|
+
`$CLAUDE_CONFIG_DIR` if you set it); `--project` writes `./.claude/skills` instead and
|
|
48
|
+
`--skills-dir PATH` any other location. `reask print` prints the skill and `reask uninstall`
|
|
49
|
+
removes it. Neither command overwrites or deletes a copy you have edited unless you pass `--force`.
|
|
50
|
+
|
|
51
|
+
**claude.ai and Claude Desktop:** download `reask-skill.zip` from the
|
|
52
|
+
[latest release](https://github.com/Nezhinskiy/reask/releases/latest) and upload it under
|
|
53
|
+
Settings > Capabilities > Skills.
|
|
54
|
+
|
|
55
|
+
**By hand:** copy `skills/reask/SKILL.md` into a `reask/` folder in your agent's skills
|
|
56
|
+
directory.
|
|
57
|
+
|
|
58
|
+
## Example
|
|
59
|
+
|
|
60
|
+
Mid-task, the agent asks:
|
|
61
|
+
|
|
62
|
+
> Should the price cache key include the locale, or should we normalise currency at read time?
|
|
63
|
+
|
|
64
|
+
You type `/reask`, and get:
|
|
65
|
+
|
|
66
|
+
> **Where we are and why this comes up.** We are putting a cache in front of the pricing API so
|
|
67
|
+
> product pages stop taking 800 ms. The cache works; what is open is what each entry is keyed
|
|
68
|
+
> on, and nothing merges until that is decided. The pricing API already returns a
|
|
69
|
+
> country-specific price for 3 SKUs. *Decided:* Redis, 10-minute expiry. *My assumption:* the 40
|
|
70
|
+
> locales we ship today are the ones to plan for.
|
|
71
|
+
>
|
|
72
|
+
> **A live example.** SKU `B-2210`, one of those 3, costs €49 in Germany and €45 in Austria.
|
|
73
|
+
> Viewed from Berlin (`de-DE`) and then from Vienna (`de-AT`):
|
|
74
|
+
>
|
|
75
|
+
> | | Key per locale | Normalise at read |
|
|
76
|
+
> |---|---|---|
|
|
77
|
+
> | Cache entries | 2 | 1 |
|
|
78
|
+
> | API calls on first views | 2 | 1 |
|
|
79
|
+
> | Price shown in Vienna | €45 | €49 |
|
|
80
|
+
>
|
|
81
|
+
> **Options.**
|
|
82
|
+
> *Key per locale*: one entry per SKU per locale.
|
|
83
|
+
> Pros: every country sees its own price; no conversion code.
|
|
84
|
+
> Cons: 40 times the entries; 40 cold-cache API calls per SKU instead of one.
|
|
85
|
+
> *Normalise at read*: one entry per SKU in euros, converted when read.
|
|
86
|
+
> Pros: 40 times fewer entries; one warm-up per SKU.
|
|
87
|
+
> Cons: shows the wrong price for those 3 SKUs today; a rate-table lookup on every read.
|
|
88
|
+
>
|
|
89
|
+
> **What I recommend.** Key per locale. Normalising shows Vienna €49 for `B-2210` today, not
|
|
90
|
+
> someday; 40 entries per SKU is about 2 MB at our catalogue size. I would change my mind if
|
|
91
|
+
> those 3 prices turn out to be a data error.
|
|
92
|
+
>
|
|
93
|
+
> **The question.** Key the cache per locale (recommended), or normalise currency at read time?
|
|
94
|
+
|
|
95
|
+
In Claude Code the question arrives with both answers as options to pick, the recommended one
|
|
96
|
+
first. This example was written by hand to show the format; a real briefing is built from your
|
|
97
|
+
session's facts.
|
|
98
|
+
|
|
99
|
+
## Why it works
|
|
100
|
+
|
|
101
|
+
Agents ask good questions in the wrong register: they have the whole session in context and
|
|
102
|
+
you do not. reask fixes the register, not the question. It keeps the decision yours (it never
|
|
103
|
+
starts on its default), states each option's downside at full strength, and ties the
|
|
104
|
+
recommendation to facts from your work, so you can overrule it with one reply.
|
|
105
|
+
|
|
106
|
+
## Contributing and security
|
|
107
|
+
|
|
108
|
+
Behaviour reports with a transcript are the most useful contribution; see
|
|
109
|
+
[CONTRIBUTING.md](https://github.com/Nezhinskiy/reask/blob/main/CONTRIBUTING.md). Report
|
|
110
|
+
vulnerabilities privately, as described in
|
|
111
|
+
[SECURITY.md](https://github.com/Nezhinskiy/reask/blob/main/SECURITY.md). Every release is built
|
|
112
|
+
in CI and carries a
|
|
113
|
+
[build provenance attestation](https://github.com/Nezhinskiy/reask/blob/main/SECURITY.md#verifying-what-you-installed).
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
[MIT](https://github.com/Nezhinskiy/reask/blob/main/LICENSE)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "reask"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "An agent skill that re-asks a pending question as a self-contained briefing"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Mikhail Nezhinsky" }]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
keywords = ["claude", "claude-code", "agent-skills", "skill", "ai-agents", "llm"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Topic :: Software Development",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/Nezhinskiy/reask"
|
|
27
|
+
Source = "https://github.com/Nezhinskiy/reask"
|
|
28
|
+
Changelog = "https://github.com/Nezhinskiy/reask/blob/main/CHANGELOG.md"
|
|
29
|
+
Issues = "https://github.com/Nezhinskiy/reask/issues"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
reask = "reask.cli:main"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling>=1.27"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
# SKILL.md's metadata.version is the package version, so the skill and the package cannot disagree.
|
|
39
|
+
[tool.hatch.version]
|
|
40
|
+
path = "skills/reask/SKILL.md"
|
|
41
|
+
pattern = 'version: "(?P<version>[^"]+)"'
|
|
42
|
+
|
|
43
|
+
# skills/reask/SKILL.md is the single source; the wheel carries a copy inside the package.
|
|
44
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
45
|
+
"skills/reask/SKILL.md" = "reask/SKILL.md"
|
|
46
|
+
|
|
47
|
+
# What building and testing need; the repository's CI and community files stay on GitHub.
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
include = ["/src", "/skills", "/scripts", "/tests", "/.claude-plugin", "/CHANGELOG.md"]
|
|
50
|
+
|
|
51
|
+
[tool.uv]
|
|
52
|
+
# The release attests whatever the build backend produces, so the backend is pinned too.
|
|
53
|
+
build-constraint-dependencies = ["hatchling==1.32.4"]
|
|
54
|
+
|
|
55
|
+
[dependency-groups]
|
|
56
|
+
dev = ["pytest>=9", "pytest-cov>=7", "ruff>=0.16", "mypy>=2.3"]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 100
|
|
60
|
+
target-version = "py311"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF", "PT", "C4", "PL"]
|
|
64
|
+
ignore = ["PLR2004"] # magic values: tests compare against literal exit codes and limits
|
|
65
|
+
|
|
66
|
+
[tool.mypy]
|
|
67
|
+
strict = true
|
|
68
|
+
files = ["src", "scripts", "tests"]
|
|
69
|
+
|
|
70
|
+
[tool.pytest.ini_options]
|
|
71
|
+
pythonpath = ["."]
|
|
72
|
+
addopts = ["--strict-markers", "--strict-config"]
|
|
73
|
+
|
|
74
|
+
[tool.coverage.run]
|
|
75
|
+
source = ["reask", "scripts"]
|
|
76
|
+
branch = true
|
|
77
|
+
|
|
78
|
+
[tool.coverage.report]
|
|
79
|
+
fail_under = 100
|
|
80
|
+
show_missing = true
|
|
81
|
+
skip_covered = true
|
|
82
|
+
exclude_also = ['if __name__ == "__main__":']
|
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Check the skill package: one skill folder, its frontmatter, the plugin manifests, one version.
|
|
2
|
+
|
|
3
|
+
The version is written in three places: SKILL.md's metadata.version (which is also the Python
|
|
4
|
+
package's version, through pyproject.toml's [tool.hatch.version]), .claude-plugin/plugin.json,
|
|
5
|
+
and CHANGELOG.md's newest release heading. When given, the release tag is a fourth. All must agree.
|
|
6
|
+
Run with no arguments in CI and with `--tag vX.Y.Z` in the release workflow.
|
|
7
|
+
|
|
8
|
+
The frontmatter is read with regular expressions rather than a YAML parser, to keep the project
|
|
9
|
+
free of dependencies. That is why the description must be one double-quoted line.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from scripts import release_notes
|
|
20
|
+
|
|
21
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
22
|
+
NAME = "reask"
|
|
23
|
+
SKILL = Path("skills") / NAME / "SKILL.md"
|
|
24
|
+
DESCRIPTION_LIMIT = 1024 # the Agent Skills specification's cap
|
|
25
|
+
IGNORED = {".git", ".venv", "build", "dist"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _frontmatter(skill: str) -> str | None:
|
|
29
|
+
match = re.match(r"\A---\n(.*?)\n---\n", skill, re.S)
|
|
30
|
+
return match.group(1) if match else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _field(frontmatter: str, pattern: str) -> str | None:
|
|
34
|
+
match = re.search(pattern, frontmatter, re.M)
|
|
35
|
+
return match.group(1) if match else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _json(root: Path, name: str) -> dict[str, Any]:
|
|
39
|
+
data: dict[str, Any] = json.loads((root / ".claude-plugin" / name).read_text("utf-8"))
|
|
40
|
+
return data
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def versions(root: Path) -> dict[str, str | None]:
|
|
44
|
+
frontmatter = _frontmatter((root / SKILL).read_text(encoding="utf-8")) or ""
|
|
45
|
+
return {
|
|
46
|
+
"SKILL.md metadata.version": _field(frontmatter, r'^\s+version:\s*"([^"]+)"\s*$'),
|
|
47
|
+
".claude-plugin/plugin.json": _json(root, "plugin.json").get("version"),
|
|
48
|
+
"CHANGELOG.md's newest release": release_notes.latest(
|
|
49
|
+
(root / "CHANGELOG.md").read_text(encoding="utf-8")
|
|
50
|
+
),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def structure(root: Path) -> list[str]:
|
|
55
|
+
problems = []
|
|
56
|
+
skills = sorted(
|
|
57
|
+
p.relative_to(root).as_posix()
|
|
58
|
+
for p in root.rglob("SKILL.md")
|
|
59
|
+
if not IGNORED & set(p.relative_to(root).parts)
|
|
60
|
+
)
|
|
61
|
+
if skills != [SKILL.as_posix()]:
|
|
62
|
+
problems.append(f"expected exactly one skill, {SKILL.as_posix()}; found {skills}")
|
|
63
|
+
if not (root / SKILL).is_file():
|
|
64
|
+
return problems
|
|
65
|
+
frontmatter = _frontmatter((root / SKILL).read_text(encoding="utf-8"))
|
|
66
|
+
if frontmatter is None:
|
|
67
|
+
return [*problems, "SKILL.md must begin with YAML frontmatter"]
|
|
68
|
+
if _field(frontmatter, r"^name:\s*(\S+)\s*$") != NAME:
|
|
69
|
+
problems.append(f"SKILL.md name must be {NAME!r}")
|
|
70
|
+
description = _field(frontmatter, r'^description:\s*"((?:[^"\\]|\\.)*)"\s*$')
|
|
71
|
+
if description is None:
|
|
72
|
+
problems.append("SKILL.md description must be one double-quoted line")
|
|
73
|
+
elif len(description) > DESCRIPTION_LIMIT:
|
|
74
|
+
problems.append(
|
|
75
|
+
f"SKILL.md description is {len(description)} chars, over {DESCRIPTION_LIMIT}"
|
|
76
|
+
)
|
|
77
|
+
# The plugin finds the skill through the default skills/ directory; a "skills" override
|
|
78
|
+
# would load something else.
|
|
79
|
+
plugin = _json(root, "plugin.json")
|
|
80
|
+
if plugin.get("name") != NAME or "skills" in plugin:
|
|
81
|
+
problems.append(f'plugin.json must be named {NAME!r} and must not override "skills"')
|
|
82
|
+
market = _json(root, "marketplace.json")
|
|
83
|
+
entries = [(p.get("name"), p.get("source")) for p in market.get("plugins", [])]
|
|
84
|
+
if entries != [(NAME, "./")]:
|
|
85
|
+
problems.append(f'marketplace.json must list one plugin {NAME!r} with source "./"')
|
|
86
|
+
return problems
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check(root: Path, tag: str | None = None) -> list[str]:
|
|
90
|
+
problems = structure(root)
|
|
91
|
+
if not (root / SKILL).is_file():
|
|
92
|
+
return problems
|
|
93
|
+
found = versions(root)
|
|
94
|
+
expected = found["SKILL.md metadata.version"]
|
|
95
|
+
if tag is not None:
|
|
96
|
+
found["tag"] = tag[1:] if tag.startswith("v") else None
|
|
97
|
+
problems += [
|
|
98
|
+
f"{name}: {value!r} != SKILL.md's {expected!r}"
|
|
99
|
+
for name, value in found.items()
|
|
100
|
+
if value != expected
|
|
101
|
+
]
|
|
102
|
+
changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8")
|
|
103
|
+
if expected is not None and release_notes.notes(changelog, expected) is None:
|
|
104
|
+
problems.append(f"CHANGELOG.md has no release notes for {expected}")
|
|
105
|
+
return problems
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main(argv: list[str] | None = None) -> int:
|
|
109
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
110
|
+
parser.add_argument("--tag", help="the release tag, vX.Y.Z")
|
|
111
|
+
parser.add_argument("--root", type=Path, default=ROOT)
|
|
112
|
+
args = parser.parse_args(argv)
|
|
113
|
+
problems = check(args.root, args.tag)
|
|
114
|
+
for problem in problems:
|
|
115
|
+
print(f"::error::{problem}", file=sys.stderr)
|
|
116
|
+
if not problems:
|
|
117
|
+
version = versions(args.root)["SKILL.md metadata.version"]
|
|
118
|
+
print(f"skill package ok, one version everywhere: {version}")
|
|
119
|
+
return 1 if problems else 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Read CHANGELOG.md: its newest release, and a release's section as the GitHub Release notes.
|
|
2
|
+
|
|
3
|
+
A release heading is `## X.Y.Z (YYYY-MM-DD)`; `## Unreleased` and anything else is not one. Both
|
|
4
|
+
check_skill.py and the release workflow read the changelog through this module, so a heading one
|
|
5
|
+
of them accepts the other cannot reject.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
14
|
+
HEADING = r"^## (?P<version>\d+\.\d+\.\d+) \(\d{4}-\d{2}-\d{2}\)$"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def latest(changelog: str) -> str | None:
|
|
18
|
+
"""Return the newest release's version: the first release heading."""
|
|
19
|
+
match = re.search(HEADING, changelog, re.M)
|
|
20
|
+
return match["version"] if match else None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def notes(changelog: str, version: str) -> str | None:
|
|
24
|
+
"""Return the body of `version`'s section, or None if it has no section or an empty one."""
|
|
25
|
+
for match in re.finditer(rf"{HEADING}\n(?P<body>.*?)(?=^## |\Z)", changelog, re.M | re.S):
|
|
26
|
+
if match["version"] == version:
|
|
27
|
+
return match["body"].strip() or None
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main(argv: list[str] | None = None) -> int:
|
|
32
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
33
|
+
parser.add_argument("tag", help="the release tag, vX.Y.Z")
|
|
34
|
+
parser.add_argument("--changelog", type=Path, default=ROOT / "CHANGELOG.md")
|
|
35
|
+
args = parser.parse_args(argv)
|
|
36
|
+
found = notes(args.changelog.read_text(encoding="utf-8"), args.tag.removeprefix("v"))
|
|
37
|
+
if found is None:
|
|
38
|
+
print(f"::error::{args.changelog.name} has no section for {args.tag}", file=sys.stderr)
|
|
39
|
+
return 1
|
|
40
|
+
print(found)
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Build the skill zip that claude.ai accepts: a `reask/` folder holding the skill and LICENSE.
|
|
2
|
+
|
|
3
|
+
Entries carry zipfile's fixed default timestamp rather than the files' mtimes, so the same
|
|
4
|
+
sources always give the same bytes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import zipfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
12
|
+
SKILL = ROOT / "skills" / "reask"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build(destination: Path) -> None:
|
|
16
|
+
# The whole skill folder, as the other channels ship it, plus the licence it names.
|
|
17
|
+
files = (p for p in SKILL.rglob("*") if p.is_file())
|
|
18
|
+
entries = {f"reask/{p.relative_to(SKILL).as_posix()}": p for p in files}
|
|
19
|
+
entries["reask/LICENSE"] = ROOT / "LICENSE"
|
|
20
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
with zipfile.ZipFile(destination, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
22
|
+
for name in sorted(entries):
|
|
23
|
+
info = zipfile.ZipInfo(name)
|
|
24
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
25
|
+
archive.writestr(info, entries[name].read_bytes())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(argv: list[str] | None = None) -> int:
|
|
29
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
30
|
+
parser.add_argument("destination", type=Path)
|
|
31
|
+
args = parser.parse_args(argv)
|
|
32
|
+
build(args.destination)
|
|
33
|
+
print(f"wrote {args.destination}")
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reask
|
|
3
|
+
description: "Use when the user asks for a pending question to be asked again or explained — /reask, \"ask again\", \"I don't get the question\", \"explain the options\", \"what are you asking me\" (in any language) — or when they answer a question of yours with confusion rather than with a choice."
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
version: "0.1.0"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# /reask
|
|
10
|
+
|
|
11
|
+
Ask the question you are already waiting on again, written for someone who just walked in and
|
|
12
|
+
remembers nothing. Make that decision answerable: do not decide it for the user, and do not swap
|
|
13
|
+
it for a different question.
|
|
14
|
+
|
|
15
|
+
Write in the language the user writes in, headings included; the English headings below are a
|
|
16
|
+
template.
|
|
17
|
+
|
|
18
|
+
## First, which case is this
|
|
19
|
+
|
|
20
|
+
- **Nothing is pending.** Say so in one line and ask what they want to decide.
|
|
21
|
+
- **The user has already chosen.** Confirm the choice in one line and carry on.
|
|
22
|
+
- **A yes/no question, or two options that differ in one fact.** The short form, under 100 words:
|
|
23
|
+
what is being decided and what waits on it, the fact that separates the answers, your
|
|
24
|
+
recommendation, then the question.
|
|
25
|
+
- **Anything else.** The full briefing below.
|
|
26
|
+
|
|
27
|
+
## The briefing
|
|
28
|
+
|
|
29
|
+
| Part | Contents | Budget |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| **1. "Where we are and why this comes up"** | What we are building in terms of what it does for the user; where the work stands; what stops until this is answered. Then one line of what is already **decided**, and one line, named as yours, of what you **carry over as an assumption** (a dimension copied from a neighbouring table, a component you expect to reuse), so the user can overturn it now instead of meeting it later as a decision they never made. | 3-5 sentences |
|
|
32
|
+
| **2. "A live example"** | One concrete case with real values from this work (an actual record, an actual query, an actual number) carried through every option, so the difference is something the user sees rather than infers. | ~120 words, usually a table |
|
|
33
|
+
| **3. "Options"** | Per option: one line of what it is in plain words, then **Pros** and **Cons**. At most three options in full; name any others in one line each. | up to 3 pros and 3 cons, one line each |
|
|
34
|
+
| **4. "What I recommend"** | Which one, and why from the facts of *this* situation: numbers, existing code, what already ships. Then what would change your mind. | up to 5 lines |
|
|
35
|
+
| **5. "The question"** | The decision as one answerable question. Ask it with your harness's structured-question tool if it has one (`AskUserQuestion` in Claude Code): short labels, recommended first. Otherwise ask it as plain text. | 1 question |
|
|
36
|
+
| | | **under 500 words total** |
|
|
37
|
+
|
|
38
|
+
The word total binds before any part's budget. A briefing that costs more to read than the
|
|
39
|
+
question costs to answer has failed.
|
|
40
|
+
|
|
41
|
+
## Rules
|
|
42
|
+
|
|
43
|
+
**Say it once.** Each fact lives in one part; later parts point back at the example instead of
|
|
44
|
+
re-describing it. The fact that settles the decision appears by part 2: part 4 argues from it and
|
|
45
|
+
never reveals it. Cut every sentence that restates the previous one, and every clause that adds
|
|
46
|
+
emphasis rather than information.
|
|
47
|
+
|
|
48
|
+
**Plain words.** A term the user would have to look up gets a short gloss at first use:
|
|
49
|
+
`HNSW (a kind of index: slower to build, but never needs retraining)`. A term that does not carry
|
|
50
|
+
the decision is dropped, not glossed.
|
|
51
|
+
|
|
52
|
+
**Fair to every option.** Every option in part 3 gets both lists, the one you recommend included,
|
|
53
|
+
and every con stands at full strength, written as its strongest critic would write it. Your case
|
|
54
|
+
for or against an option goes in part 4, where the user can overrule it.
|
|
55
|
+
|
|
56
|
+
**Decided means decided.** Only what was actually settled goes in the decided line. Anything you
|
|
57
|
+
inferred is an assumption, and is labelled as yours.
|
|
58
|
+
|
|
59
|
+
**End on the question**, never on an intention: no "otherwise I'll take B", no starting on your
|
|
60
|
+
default.
|
|
61
|
+
|
|
62
|
+
**A better question is an aside.** If you now think a different question should be asked, the
|
|
63
|
+
briefing still covers the original. Add one line after part 5 naming the better question and why,
|
|
64
|
+
and let the user choose which to answer.
|
|
65
|
+
|
|
66
|
+
## When a picture is faster than prose
|
|
67
|
+
|
|
68
|
+
Some differences cost more words than they are worth: screen layout and flow, what the user ends
|
|
69
|
+
up seeing, how components connect. When one of those *is* the subject and you can show the user a
|
|
70
|
+
rendered file (in Claude Code, `SendUserFile` with `display: "render"`), write one small
|
|
71
|
+
self-contained HTML file outside the user's project, in a scratch or temporary directory. Use the
|
|
72
|
+
user's language, one screen, no scrolling, the options side by side, inline CSS; no scripts, no
|
|
73
|
+
external URLs or images, no forms. Then cut part 2 to one line pointing at it, so the briefing
|
|
74
|
+
gets shorter.
|
|
75
|
+
|
|
76
|
+
Skip it when the difference is a property of data or behaviour rather than something visible, or
|
|
77
|
+
when you cannot show a file: then the example stays in prose.
|
|
78
|
+
|
|
79
|
+
## Before you send
|
|
80
|
+
|
|
81
|
+
- The short form, or all five parts in order, in the user's language.
|
|
82
|
+
- Under 500 words (under 100 for the short form).
|
|
83
|
+
- In the full briefing, every option has pros and cons, the recommended one included, and the
|
|
84
|
+
deciding fact appears before part 4.
|
|
85
|
+
- The last thing is the question, with no default action attached.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""reask: an agent skill that re-asks a pending question as a self-contained briefing."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
__version__ = version("reask")
|
|
8
|
+
|
|
9
|
+
# skills/reask/SKILL.md is the one copy; the wheel carries it as reask/SKILL.md
|
|
10
|
+
# (pyproject.toml, force-include). An editable install has no such copy, so it reads the source.
|
|
11
|
+
_CHECKOUT_SKILL = Path(__file__).resolve().parents[2] / "skills" / "reask" / "SKILL.md"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def skill_text() -> str:
|
|
15
|
+
"""Return the bundled SKILL.md."""
|
|
16
|
+
bundled = files("reask").joinpath("SKILL.md")
|
|
17
|
+
source = bundled if bundled.is_file() else _CHECKOUT_SKILL
|
|
18
|
+
return source.read_text(encoding="utf-8")
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Command line: install, print or remove the reask skill."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from reask import __version__, skill_text
|
|
10
|
+
|
|
11
|
+
NAME = "reask"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RefusedError(Exception):
|
|
15
|
+
"""An operation reask will not perform; the message says why and what to do instead."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _default_skills_dir() -> Path:
|
|
19
|
+
config = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
20
|
+
return (Path(config).expanduser() if config else Path.home() / ".claude") / "skills"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _locate(args: argparse.Namespace) -> tuple[Path, Path]:
|
|
24
|
+
"""Return the directory the user named, and the SKILL.md reask manages below it."""
|
|
25
|
+
if args.project:
|
|
26
|
+
base = Path.cwd()
|
|
27
|
+
return base, base / ".claude" / "skills" / NAME / "SKILL.md"
|
|
28
|
+
base = args.skills_dir.expanduser() if args.skills_dir else _default_skills_dir()
|
|
29
|
+
return base, base / NAME / "SKILL.md"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _refuse_links(base: Path, target: Path) -> None:
|
|
33
|
+
# Everything under a project is whatever the cloned repository put there, so a link below
|
|
34
|
+
# the named directory could aim a write or a delete anywhere. Below it, reask follows none.
|
|
35
|
+
path = base
|
|
36
|
+
for part in target.relative_to(base).parts:
|
|
37
|
+
path /= part
|
|
38
|
+
if path.is_symlink():
|
|
39
|
+
raise RefusedError(
|
|
40
|
+
f"{path} is a symbolic link, and reask does not write or delete through links. "
|
|
41
|
+
"If another tool installed the skill there (the Skills CLI links its folders), "
|
|
42
|
+
"manage it with that tool."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _refuse_edited(target: Path, data: bytes, force: bool, verb: str) -> None:
|
|
47
|
+
if not target.is_file():
|
|
48
|
+
raise RefusedError(f"{target} is not a file")
|
|
49
|
+
if not force and target.read_bytes() != data:
|
|
50
|
+
raise RefusedError(
|
|
51
|
+
f"{target} differs from reask {__version__}'s copy (edited, or another version); "
|
|
52
|
+
f"pass --force to {verb} it"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _install(args: argparse.Namespace) -> int:
|
|
57
|
+
base, target = _locate(args)
|
|
58
|
+
_refuse_links(base, target)
|
|
59
|
+
data = skill_text().encode("utf-8")
|
|
60
|
+
if target.exists():
|
|
61
|
+
_refuse_edited(target, data, args.force, "replace")
|
|
62
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
target.write_bytes(data)
|
|
64
|
+
print(f"installed {target}")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _uninstall(args: argparse.Namespace) -> int:
|
|
69
|
+
base, target = _locate(args)
|
|
70
|
+
_refuse_links(base, target)
|
|
71
|
+
if not target.exists():
|
|
72
|
+
raise RefusedError(f"{target} not found; nothing to remove")
|
|
73
|
+
_refuse_edited(target, skill_text().encode("utf-8"), args.force, "remove")
|
|
74
|
+
target.unlink()
|
|
75
|
+
folder = target.parent
|
|
76
|
+
if any(folder.iterdir()):
|
|
77
|
+
print(f"removed {target}; kept {folder}, which holds other files")
|
|
78
|
+
else:
|
|
79
|
+
folder.rmdir()
|
|
80
|
+
print(f"removed {folder}")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _print(_: argparse.Namespace) -> int:
|
|
85
|
+
# Bytes, not text: the console's encoding (cp1252 on Windows) and newline translation must
|
|
86
|
+
# not change the skill on its way to `reask print > SKILL.md`.
|
|
87
|
+
sys.stdout.flush()
|
|
88
|
+
sys.stdout.buffer.write(skill_text().encode("utf-8"))
|
|
89
|
+
sys.stdout.buffer.flush()
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main(argv: list[str] | None = None) -> int:
|
|
94
|
+
parser = argparse.ArgumentParser(prog="reask", description=__doc__)
|
|
95
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
96
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
97
|
+
|
|
98
|
+
for name, func, help_text, force_help in (
|
|
99
|
+
("install", _install, "write SKILL.md into a skills directory", "replace an edited copy"),
|
|
100
|
+
("uninstall", _uninstall, "remove the installed SKILL.md", "remove an edited copy"),
|
|
101
|
+
):
|
|
102
|
+
cmd = sub.add_parser(name, help=help_text)
|
|
103
|
+
where = cmd.add_mutually_exclusive_group()
|
|
104
|
+
where.add_argument("--project", action="store_true", help="use ./.claude/skills")
|
|
105
|
+
where.add_argument(
|
|
106
|
+
"--skills-dir",
|
|
107
|
+
type=Path,
|
|
108
|
+
help="skills directory (default: $CLAUDE_CONFIG_DIR/skills, else ~/.claude/skills)",
|
|
109
|
+
)
|
|
110
|
+
cmd.add_argument("--force", action="store_true", help=force_help)
|
|
111
|
+
cmd.set_defaults(func=func)
|
|
112
|
+
|
|
113
|
+
sub.add_parser("print", help="print SKILL.md to stdout").set_defaults(func=_print)
|
|
114
|
+
|
|
115
|
+
args = parser.parse_args(argv)
|
|
116
|
+
command: Callable[[argparse.Namespace], int] = args.func
|
|
117
|
+
try:
|
|
118
|
+
return command(args)
|
|
119
|
+
except (RefusedError, OSError) as error:
|
|
120
|
+
print(f"reask: {error}", file=sys.stderr)
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from scripts import check_skill
|
|
7
|
+
|
|
8
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
9
|
+
FILES = (
|
|
10
|
+
"CHANGELOG.md",
|
|
11
|
+
"skills/reask/SKILL.md",
|
|
12
|
+
".claude-plugin/plugin.json",
|
|
13
|
+
".claude-plugin/marketplace.json",
|
|
14
|
+
)
|
|
15
|
+
SKILL = "skills/reask/SKILL.md"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def tree(tmp_path: Path) -> Path:
|
|
20
|
+
for name in FILES:
|
|
21
|
+
(tmp_path / name).parent.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
shutil.copy(ROOT / name, tmp_path / name)
|
|
23
|
+
return tmp_path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _version(root: Path) -> str:
|
|
27
|
+
version = check_skill.versions(root)["SKILL.md metadata.version"]
|
|
28
|
+
assert version is not None
|
|
29
|
+
return version
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _edit(path: Path, old: str, new: str) -> None:
|
|
33
|
+
text = path.read_text(encoding="utf-8")
|
|
34
|
+
assert old in text
|
|
35
|
+
path.write_text(text.replace(old, new, 1), encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _problems(root: Path, prefix: str, tag: str | None = None) -> list[str]:
|
|
39
|
+
return [p for p in check_skill.check(root, tag) if p.startswith(prefix)]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_repository_passes() -> None:
|
|
43
|
+
assert check_skill.check(ROOT) == []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_matching_tag_passes(tree: Path) -> None:
|
|
47
|
+
assert check_skill.check(tree, f"v{_version(tree)}") == []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.parametrize("tag", ["v9.9.9", "0.1.0", "v0.1.0rc1"])
|
|
51
|
+
def test_wrong_tag_fails(tree: Path, tag: str) -> None:
|
|
52
|
+
assert _problems(tree, "tag:", tag)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize(
|
|
56
|
+
("path", "old", "new", "prefix"),
|
|
57
|
+
[
|
|
58
|
+
("CHANGELOG.md", "## {v} (", "## 0.0.9 (", "CHANGELOG.md's newest release"),
|
|
59
|
+
(".claude-plugin/plugin.json", '"version": "{v}"', '"version": "0.0.9"', ".claude-plugin"),
|
|
60
|
+
(SKILL, 'version: "{v}"', 'version: "0.0.9"', ".claude-plugin"),
|
|
61
|
+
],
|
|
62
|
+
)
|
|
63
|
+
def test_a_stale_version_source_fails(
|
|
64
|
+
tree: Path, path: str, old: str, new: str, prefix: str
|
|
65
|
+
) -> None:
|
|
66
|
+
_edit(tree / path, old.format(v=_version(tree)), new)
|
|
67
|
+
assert _problems(tree, prefix)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_a_release_heading_without_a_date_fails(tree: Path) -> None:
|
|
71
|
+
# The release workflow reads the notes through the same parser, after the tag is pushed.
|
|
72
|
+
_edit(tree / "CHANGELOG.md", f"## {_version(tree)} (", f"## {_version(tree)}\n\n(")
|
|
73
|
+
assert _problems(tree, "CHANGELOG.md has no release notes")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_a_release_with_empty_notes_fails(tree: Path) -> None:
|
|
77
|
+
changelog = tree / "CHANGELOG.md"
|
|
78
|
+
heading = next(
|
|
79
|
+
line
|
|
80
|
+
for line in changelog.read_text(encoding="utf-8").splitlines()
|
|
81
|
+
if line.startswith(f"## {_version(tree)} ")
|
|
82
|
+
)
|
|
83
|
+
changelog.write_text(f"# Changelog\n\n{heading}\n", encoding="utf-8")
|
|
84
|
+
assert _problems(tree, "CHANGELOG.md has no release notes")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_a_second_skill_fails(tree: Path) -> None:
|
|
88
|
+
(tree / "nested").mkdir()
|
|
89
|
+
shutil.copy(tree / SKILL, tree / "nested" / "SKILL.md")
|
|
90
|
+
assert _problems(tree, "expected exactly one skill")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_a_skill_at_the_root_fails(tree: Path) -> None:
|
|
94
|
+
# The Skills CLI copies the folder holding SKILL.md, so a root skill ships the whole repository.
|
|
95
|
+
(tree / SKILL).rename(tree / "SKILL.md")
|
|
96
|
+
assert _problems(tree, "expected exactly one skill")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_a_renamed_skill_fails(tree: Path) -> None:
|
|
100
|
+
_edit(tree / SKILL, "name: reask", "name: re-ask")
|
|
101
|
+
assert _problems(tree, "SKILL.md name")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_an_overlong_description_fails(tree: Path) -> None:
|
|
105
|
+
_edit(tree / SKILL, 'description: "', 'description: "' + "x" * 1024)
|
|
106
|
+
assert _problems(tree, "SKILL.md description is")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_a_plugin_overriding_the_skills_directory_fails(tree: Path) -> None:
|
|
110
|
+
_edit(tree / ".claude-plugin/plugin.json", '"license"', '"skills": ["./"],\n "license"')
|
|
111
|
+
assert _problems(tree, "plugin.json")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_a_marketplace_pointing_elsewhere_fails(tree: Path) -> None:
|
|
115
|
+
_edit(tree / ".claude-plugin/marketplace.json", '"source": "./"', '"source": "./other"')
|
|
116
|
+
assert _problems(tree, "marketplace.json")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_missing_frontmatter_fails(tree: Path) -> None:
|
|
120
|
+
(tree / SKILL).write_text("# reask\n", encoding="utf-8")
|
|
121
|
+
assert _problems(tree, "SKILL.md must begin with YAML frontmatter")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_a_multiline_description_fails(tree: Path) -> None:
|
|
125
|
+
_edit(tree / SKILL, 'description: "', "description: >\n ")
|
|
126
|
+
assert _problems(tree, "SKILL.md description must be one double-quoted line")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_main_reports_success(capsys: pytest.CaptureFixture[str]) -> None:
|
|
130
|
+
assert check_skill.main([]) == 0
|
|
131
|
+
assert f"one version everywhere: {_version(ROOT)}" in capsys.readouterr().out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_main_reports_each_problem_as_a_ci_error(
|
|
135
|
+
tree: Path, capsys: pytest.CaptureFixture[str]
|
|
136
|
+
) -> None:
|
|
137
|
+
assert check_skill.main(["--root", str(tree), "--tag", "v9.9.9"]) == 1
|
|
138
|
+
assert "::error::tag:" in capsys.readouterr().err
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_an_unreleased_section_above_the_release_is_allowed(tree: Path) -> None:
|
|
142
|
+
_edit(
|
|
143
|
+
tree / "CHANGELOG.md",
|
|
144
|
+
f"## {_version(tree)} (",
|
|
145
|
+
f"## Unreleased\n\n- Next.\n\n## {_version(tree)} (",
|
|
146
|
+
)
|
|
147
|
+
assert check_skill.check(tree) == []
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from reask import skill_text
|
|
7
|
+
from reask.cli import main
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _installed(skills_dir: Path) -> Path:
|
|
11
|
+
return skills_dir / "reask" / "SKILL.md"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _symlink(link: Path, target: Path) -> None:
|
|
15
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
16
|
+
try:
|
|
17
|
+
link.symlink_to(target, target_is_directory=target.is_dir())
|
|
18
|
+
except OSError: # Windows without the symlink privilege
|
|
19
|
+
pytest.skip("cannot create symbolic links here")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_install_writes_the_skill_byte_for_byte(tmp_path: Path) -> None:
|
|
23
|
+
assert main(["install", "--skills-dir", str(tmp_path)]) == 0
|
|
24
|
+
assert _installed(tmp_path).read_bytes() == skill_text().encode("utf-8")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_install_is_idempotent_over_an_identical_copy(tmp_path: Path) -> None:
|
|
28
|
+
assert main(["install", "--skills-dir", str(tmp_path)]) == 0
|
|
29
|
+
assert main(["install", "--skills-dir", str(tmp_path)]) == 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_install_project_writes_under_the_working_directory(
|
|
33
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
34
|
+
) -> None:
|
|
35
|
+
monkeypatch.chdir(tmp_path)
|
|
36
|
+
assert main(["install", "--project"]) == 0
|
|
37
|
+
assert _installed(tmp_path / ".claude" / "skills").read_text(encoding="utf-8") == skill_text()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_the_default_directory_follows_claude_config_dir(
|
|
41
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
42
|
+
) -> None:
|
|
43
|
+
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "config"))
|
|
44
|
+
assert main(["install"]) == 0
|
|
45
|
+
assert _installed(tmp_path / "config" / "skills").is_file()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_the_default_directory_is_under_the_home_directory(
|
|
49
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
50
|
+
) -> None:
|
|
51
|
+
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
|
52
|
+
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
53
|
+
assert main(["install"]) == 0
|
|
54
|
+
assert _installed(tmp_path / ".claude" / "skills").is_file()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_a_quoted_tilde_in_skills_dir_means_home(
|
|
58
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
59
|
+
) -> None:
|
|
60
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
61
|
+
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
|
62
|
+
assert main(["install", "--skills-dir", "~/skills"]) == 0
|
|
63
|
+
assert _installed(tmp_path / "skills").is_file()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_install_refuses_to_replace_an_edited_copy_without_force(
|
|
67
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
68
|
+
) -> None:
|
|
69
|
+
target = _installed(tmp_path)
|
|
70
|
+
target.parent.mkdir()
|
|
71
|
+
target.write_text("local edits", encoding="utf-8")
|
|
72
|
+
assert main(["install", "--skills-dir", str(tmp_path)]) == 1
|
|
73
|
+
assert "pass --force to replace it" in capsys.readouterr().err
|
|
74
|
+
assert target.read_text(encoding="utf-8") == "local edits"
|
|
75
|
+
assert main(["install", "--skills-dir", str(tmp_path), "--force"]) == 0
|
|
76
|
+
assert target.read_text(encoding="utf-8") == skill_text()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_a_directory_where_the_skill_goes_is_an_error_not_a_traceback(
|
|
80
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
81
|
+
) -> None:
|
|
82
|
+
_installed(tmp_path).mkdir(parents=True)
|
|
83
|
+
assert main(["install", "--skills-dir", str(tmp_path), "--force"]) == 1
|
|
84
|
+
assert "is not a file" in capsys.readouterr().err
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_a_file_where_the_skills_directory_goes_is_an_error_not_a_traceback(
|
|
88
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
89
|
+
) -> None:
|
|
90
|
+
(tmp_path / "file").write_text("", encoding="utf-8")
|
|
91
|
+
assert main(["install", "--skills-dir", str(tmp_path / "file")]) == 1
|
|
92
|
+
assert capsys.readouterr().err.startswith("reask: ")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_uninstall_removes_the_skill_and_its_empty_folder(tmp_path: Path) -> None:
|
|
96
|
+
main(["install", "--skills-dir", str(tmp_path)])
|
|
97
|
+
assert main(["uninstall", "--skills-dir", str(tmp_path)]) == 0
|
|
98
|
+
assert not (tmp_path / "reask").exists()
|
|
99
|
+
assert main(["uninstall", "--skills-dir", str(tmp_path)]) == 1
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_uninstall_keeps_an_edited_copy_without_force(tmp_path: Path) -> None:
|
|
103
|
+
target = _installed(tmp_path)
|
|
104
|
+
target.parent.mkdir()
|
|
105
|
+
target.write_text("local edits", encoding="utf-8")
|
|
106
|
+
assert main(["uninstall", "--skills-dir", str(tmp_path)]) == 1
|
|
107
|
+
assert target.read_text(encoding="utf-8") == "local edits"
|
|
108
|
+
assert main(["uninstall", "--skills-dir", str(tmp_path), "--force"]) == 0
|
|
109
|
+
assert not target.parent.exists()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_uninstall_never_deletes_files_it_did_not_write(tmp_path: Path) -> None:
|
|
113
|
+
main(["install", "--skills-dir", str(tmp_path)])
|
|
114
|
+
notes = tmp_path / "reask" / "NOTES.md"
|
|
115
|
+
notes.write_text("mine", encoding="utf-8")
|
|
116
|
+
assert main(["uninstall", "--skills-dir", str(tmp_path), "--force"]) == 0
|
|
117
|
+
assert not _installed(tmp_path).exists()
|
|
118
|
+
assert notes.read_text(encoding="utf-8") == "mine"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@pytest.mark.parametrize("command", ["install", "uninstall"])
|
|
122
|
+
@pytest.mark.parametrize("linked", [".claude", ".claude/skills", ".claude/skills/reask"])
|
|
123
|
+
def test_project_commands_refuse_a_linked_folder(
|
|
124
|
+
tmp_path: Path,
|
|
125
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
126
|
+
capsys: pytest.CaptureFixture[str],
|
|
127
|
+
command: str,
|
|
128
|
+
linked: str,
|
|
129
|
+
) -> None:
|
|
130
|
+
# A cloned repository can ship any of these folders as a link out of the project.
|
|
131
|
+
outside = tmp_path / "outside"
|
|
132
|
+
(outside / "skills" / "reask").mkdir(parents=True)
|
|
133
|
+
(outside / "skills" / "reask" / "SKILL.md").write_text(skill_text(), encoding="utf-8")
|
|
134
|
+
(outside / "skills" / "reask" / "other").write_text("keep", encoding="utf-8")
|
|
135
|
+
project = tmp_path / "project"
|
|
136
|
+
depth = linked.count("/")
|
|
137
|
+
_symlink(project / linked, outside / ["", "skills", "skills/reask"][depth])
|
|
138
|
+
monkeypatch.chdir(project)
|
|
139
|
+
assert main([command, "--project", "--force"]) == 1
|
|
140
|
+
assert "symbolic link" in capsys.readouterr().err
|
|
141
|
+
assert sorted(p.name for p in (outside / "skills" / "reask").iterdir()) == ["SKILL.md", "other"]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@pytest.mark.parametrize("dangling", [False, True])
|
|
145
|
+
def test_install_refuses_a_linked_skill_file(
|
|
146
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, dangling: bool
|
|
147
|
+
) -> None:
|
|
148
|
+
victim = tmp_path / "home" / ".zshrc"
|
|
149
|
+
victim.parent.mkdir()
|
|
150
|
+
if not dangling:
|
|
151
|
+
victim.write_text("export PATH", encoding="utf-8")
|
|
152
|
+
_symlink(tmp_path / "project" / ".claude" / "skills" / "reask" / "SKILL.md", victim)
|
|
153
|
+
monkeypatch.chdir(tmp_path / "project")
|
|
154
|
+
assert main(["install", "--project", "--force"]) == 1
|
|
155
|
+
assert victim.exists() is not dangling
|
|
156
|
+
if not dangling:
|
|
157
|
+
assert victim.read_text(encoding="utf-8") == "export PATH"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_a_skills_cli_install_is_left_to_the_skills_cli(
|
|
161
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
162
|
+
) -> None:
|
|
163
|
+
# `npx skills add` for several agents links ~/.claude/skills/reask to one shared folder.
|
|
164
|
+
shared = tmp_path / "agents" / "reask"
|
|
165
|
+
shared.mkdir(parents=True)
|
|
166
|
+
(shared / "SKILL.md").write_text(skill_text(), encoding="utf-8")
|
|
167
|
+
_symlink(tmp_path / "claude" / "reask", shared)
|
|
168
|
+
assert main(["uninstall", "--skills-dir", str(tmp_path / "claude")]) == 1
|
|
169
|
+
assert "manage it with that tool" in capsys.readouterr().err
|
|
170
|
+
assert (shared / "SKILL.md").is_file()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_print_writes_the_skill_as_utf8_bytes(capsysbinary: pytest.CaptureFixture[bytes]) -> None:
|
|
174
|
+
assert main(["print"]) == 0
|
|
175
|
+
assert capsysbinary.readouterr().out == skill_text().encode("utf-8")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_the_symlink_check_does_not_follow_links_above_the_named_directory(
|
|
179
|
+
tmp_path: Path,
|
|
180
|
+
) -> None:
|
|
181
|
+
# A dotfiles setup often links ~/.claude itself; that is the user's choice, not a trap.
|
|
182
|
+
real = tmp_path / "dotfiles" / "claude"
|
|
183
|
+
real.mkdir(parents=True)
|
|
184
|
+
_symlink(tmp_path / "home" / ".claude", real)
|
|
185
|
+
assert main(["install", "--skills-dir", str(tmp_path / "home" / ".claude" / "skills")]) == 0
|
|
186
|
+
assert _installed(real / "skills").is_file()
|
|
187
|
+
assert os.path.islink(tmp_path / "home" / ".claude")
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import zipfile
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from scripts import release_notes, skill_zip
|
|
7
|
+
|
|
8
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
9
|
+
|
|
10
|
+
CHANGELOG = """# Changelog
|
|
11
|
+
|
|
12
|
+
## Unreleased
|
|
13
|
+
|
|
14
|
+
- Next.
|
|
15
|
+
|
|
16
|
+
## 0.2.0 (2026-10-01)
|
|
17
|
+
|
|
18
|
+
- Second.
|
|
19
|
+
|
|
20
|
+
## 0.1.0 (2026-09-29)
|
|
21
|
+
|
|
22
|
+
- First.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_latest_is_the_first_release_heading() -> None:
|
|
27
|
+
assert release_notes.latest(CHANGELOG) == "0.2.0"
|
|
28
|
+
assert release_notes.latest("# Changelog\n\n## Unreleased\n") is None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_notes_are_the_section_body_without_its_heading() -> None:
|
|
32
|
+
assert release_notes.notes(CHANGELOG, "0.2.0") == "- Second."
|
|
33
|
+
assert release_notes.notes(CHANGELOG, "0.1.0") == "- First."
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize("version", ["0.3.0", "0.1", "Unreleased"])
|
|
37
|
+
def test_notes_for_anything_but_a_release_are_none(version: str) -> None:
|
|
38
|
+
assert release_notes.notes(CHANGELOG, version) is None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_a_heading_at_the_end_of_the_file_is_no_release() -> None:
|
|
42
|
+
assert release_notes.notes("## 0.1.0 (2026-09-29)", "0.1.0") is None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_release_notes_main_prints_or_refuses(
|
|
46
|
+
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
47
|
+
) -> None:
|
|
48
|
+
changelog = tmp_path / "CHANGELOG.md"
|
|
49
|
+
changelog.write_text(CHANGELOG, encoding="utf-8")
|
|
50
|
+
assert release_notes.main(["v0.2.0", "--changelog", str(changelog)]) == 0
|
|
51
|
+
assert capsys.readouterr().out.strip() == "- Second."
|
|
52
|
+
assert release_notes.main(["v9.9.9", "--changelog", str(changelog)]) == 1
|
|
53
|
+
assert "::error::" in capsys.readouterr().err
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_skill_zip_holds_the_skill_folder_and_licence(tmp_path: Path) -> None:
|
|
57
|
+
destination = tmp_path / "out" / "reask-skill.zip"
|
|
58
|
+
assert skill_zip.main([str(destination)]) == 0
|
|
59
|
+
with zipfile.ZipFile(destination) as archive:
|
|
60
|
+
assert archive.namelist() == ["reask/LICENSE", "reask/SKILL.md"]
|
|
61
|
+
assert archive.read("reask/SKILL.md") == (ROOT / "skills/reask/SKILL.md").read_bytes()
|
|
62
|
+
assert archive.read("reask/LICENSE") == (ROOT / "LICENSE").read_bytes()
|