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.
@@ -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
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ .coverage
7
+ .coverage.*
8
+ coverage.xml
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ build/
@@ -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
+ [![ci](https://github.com/Nezhinskiy/reask/actions/workflows/ci.yml/badge.svg)](https://github.com/Nezhinskiy/reask/actions/workflows/ci.yml)
30
+ [![PyPI](https://img.shields.io/pypi/v/reask)](https://pypi.org/project/reask/)
31
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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
+ [![ci](https://github.com/Nezhinskiy/reask/actions/workflows/ci.yml/badge.svg)](https://github.com/Nezhinskiy/reask/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/reask)](https://pypi.org/project/reask/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](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()