medsci-skills 5.25.0 → 5.26.1
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.
- package/README.md +22 -1
- package/installers/install.py +245 -17
- package/installers/medsci_txn.py +29 -7
- package/metadata/distribution_files.json +104 -89
- package/metadata/distribution_manifest.json +1 -1
- package/package.json +1 -1
- package/skills/check-reporting/SKILL.md +5 -5
- package/skills/check-reporting/references/step4d_prisma_figure_audit.md +0 -1
- package/skills/fill-icmje-coi/SKILL.md +2 -2
- package/skills/intake-project/SKILL.md +1 -1
- package/skills/manage-refs/references/check_xref_symptoms.md +4 -4
- package/skills/manage-refs/scripts/check_xref.py +5 -5
- package/skills/manage-refs/scripts/render_pandoc.sh +4 -1
- package/skills/manage-refs/scripts/strip_source_metadata.lua +7 -0
- package/skills/manage-refs/tests/test_csl_render.sh +24 -0
- package/skills/meta-analysis/SKILL.md +10 -10
- package/skills/meta-analysis/references/phase3_screening_detail.md +9 -10
- package/skills/meta-analysis/references/phase4_extraction_detail.md +1 -1
- package/skills/peer-review/references/reviewer_profiles/EURE.md +3 -4
- package/skills/peer-review/references/reviewer_profiles/INSI.md +1 -1
- package/skills/peer-review/references/reviewer_profiles/README.md +14 -11
- package/skills/peer-review/references/reviewer_profiles/RYAI.md +4 -5
- package/skills/peer-review/scripts/check_review_boxes.py +5 -5
- package/skills/present-paper/SKILL.md +11 -0
- package/skills/present-paper/references/generated_illustrations.md +133 -0
- package/skills/present-paper/references/spoken_notes_and_bilingual.md +156 -0
- package/skills/revise/SKILL.md +5 -5
- package/skills/revise/scripts/check_density_complaint.py +14 -14
- package/skills/revise/scripts/density_complaint_challenge/verify.sh +5 -6
- package/skills/self-review/references/exemplar_findings/estimand_drift_posthoc_primary.md +1 -1
- package/skills/self-review/references/phases/confounding_completeness.md +4 -4
- package/skills/self-review/references/phases/phase2_5a2_design_power.md +4 -4
- package/skills/self-review/references/phases/phase2_5a_source_fidelity.md +1 -1
- package/skills/self-review/references/phases/phase2_5b_screening_counts.md +8 -9
- package/skills/self-review/references/phases/phase2_5d_xref_qc.md +5 -4
- package/skills/self-review/references/phases/phase2_5f_claim_artifact.md +5 -5
- package/skills/self-review/scripts/_frontmatter.py +3 -3
- package/skills/self-review/scripts/check_citation_order.py +1 -1
- package/skills/self-review/scripts/check_effect_stability.py +2 -2
- package/skills/self-review/scripts/check_effect_stability_challenge/expected/bad.txt +2 -2
- package/skills/self-review/scripts/check_effect_stability_challenge/fixture/effect_bad.md +2 -2
- package/skills/self-review/scripts/check_effect_stability_challenge/fixture/effect_ok.md +1 -1
- package/skills/self-review/scripts/check_effect_stability_challenge/problem.md +5 -5
- package/skills/self-review/scripts/check_effect_stability_challenge/verify.sh +3 -3
- package/skills/self-review/scripts/check_incorporation_bias.py +4 -8
- package/skills/self-review/scripts/check_incorporation_bias_challenge/fixture/incorp_bad.md +1 -1
- package/skills/self-review/scripts/check_incorporation_bias_challenge/problem.md +2 -2
- package/skills/self-review/scripts/check_incorporation_bias_challenge/verify.sh +1 -1
- package/skills/self-review/scripts/check_paren_spans.py +2 -2
- package/skills/self-review/tests/fixtures/citation_order_bad.md +9 -12
- package/skills/self-review/tests/fixtures/claim_manuscript.md +1 -1
- package/skills/self-review/tests/fixtures/claim_manuscript_single_primary.md +1 -1
- package/skills/self-review/tests/fixtures/paren_clean.md +1 -1
- package/skills/self-review/tests/fixtures/paren_corrupt.md +1 -1
- package/skills/self-review/tests/test_citation_order.sh +2 -2
- package/skills/self-review/tests/test_claim_artifact.sh +4 -4
- package/skills/sync-submission/SKILL.md +5 -5
- package/skills/sync-submission/scripts/check_asset_anonymization.py +15 -12
- package/skills/sync-submission/scripts/cover_letter_drift_check.py +4 -8
- package/skills/sync-submission/tests/test_asset_anonymization.sh +22 -0
- package/skills/verify-refs/SKILL.md +5 -8
- package/skills/verify-refs/scripts/verify_refs.py +3 -5
- package/skills/verify-refs/tests/test_fabricated_author.sh +2 -3
- package/skills/write-paper/references/journal_profiles/KJR.md +1 -1
- package/skills/write-paper/references/phase7_integrity_audits.md +5 -5
- package/skills/write-paper/references/phase7_polish_detail.md +3 -3
package/README.md
CHANGED
|
@@ -137,6 +137,8 @@ Install a single category and invoke its skills under that namespace:
|
|
|
137
137
|
|
|
138
138
|
All nine plugins share the same repository source, so this groups and enables skills by category — it is not a partial download. The marketplace tracks `main`, so a plugin's version is its git commit.
|
|
139
139
|
|
|
140
|
+
**Note the name.** A skill installed as a plugin is invoked under its plugin's namespace (`/medsci-analysis:analyze-stats`); the same skill installed into the skills folder by the `npx`, `gh skill`, classroom, or manual paths is invoked bare (`/analyze-stats`). Both run the same skill — press `/` and use Tab completion rather than typing the long form.
|
|
141
|
+
|
|
140
142
|
**Want just one capability?** Two skills are also published as focused standalone repos (generated mirrors; this repo stays the source of truth), each installable on its own with `/plugin marketplace add Aperivue/<repo>`:
|
|
141
143
|
|
|
142
144
|
- [`Aperivue/verify-refs`](https://github.com/Aperivue/verify-refs) — catch fabricated/mismatched citations (PubMed + CrossRef).
|
|
@@ -681,12 +683,31 @@ gh skill install --all Aperivue/medsci-skills # or install all of the
|
|
|
681
683
|
|
|
682
684
|
- Claude Code: skills are copied to `~/.claude/skills/` (also read by GitHub Copilot and Cursor).
|
|
683
685
|
- Codex: skills are copied to `~/.agents/skills/` (also read by Cursor and GitHub Copilot).
|
|
684
|
-
- Cursor: no separate step needed — Cursor reads `~/.claude/skills/` and `~/.agents/skills/` directly. The installer can still write an optional `.cursor/rules/` steering rule with `--cursor-project
|
|
686
|
+
- Cursor: no separate step needed — Cursor reads `~/.claude/skills/` and `~/.agents/skills/` directly. The installer can still write an optional `.cursor/rules/` steering rule with `--cursor-project`; it carries no machine-specific path, so it is safe to commit.
|
|
685
687
|
- See [`docs/host_compatibility.md`](docs/host_compatibility.md) for the verified per-host install paths and their official sources.
|
|
686
688
|
- Windows users do not need WSL for the basic classroom workflow. Use WSL only for advanced reproducible Linux toolchains.
|
|
687
689
|
|
|
688
690
|
See [docs/classroom_distribution_plan.md](docs/classroom_distribution_plan.md) and [docs/classroom_materials.md](docs/classroom_materials.md) for instructor distribution, email templates, and first-class exercises.
|
|
689
691
|
|
|
692
|
+
### Optional: let plain-language requests find the skills
|
|
693
|
+
|
|
694
|
+
Typing `/orchestrate` is the reliable way in. If you would rather just describe what you want, the
|
|
695
|
+
installer can write a short routing table into a project's `CLAUDE.md` so Claude Code reaches for
|
|
696
|
+
the right skill on its own whenever you work in that folder:
|
|
697
|
+
|
|
698
|
+
```bash
|
|
699
|
+
python3 installers/install.py --claude-project ~/research/my-study # this project only
|
|
700
|
+
python3 installers/install.py --claude-user # every project (larger footprint)
|
|
701
|
+
python3 installers/install.py --claude-project ~/research/my-study --remove-routing
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
Both are **off by default**. The block is ~25 lines between `<!-- BEGIN/END medsci-skills routing -->`
|
|
705
|
+
markers and carries no machine-specific paths. It is spliced in, not written over: an existing
|
|
706
|
+
`CLAUDE.md` keeps its bytes outside the markers — line endings and file permissions included — the
|
|
707
|
+
write is atomic so an interrupted run cannot leave a truncated file, re-running updates the block in
|
|
708
|
+
place, and `--remove-routing` takes it back out. Prefer `--claude-project` — `--claude-user` loads in every project you ever open,
|
|
709
|
+
including work unrelated to research.
|
|
710
|
+
|
|
690
711
|
> **Tip:** Not sure which skill to use? Start with `/orchestrate` -- it will classify your request and route you to the right tool.
|
|
691
712
|
|
|
692
713
|
## Updating
|
package/installers/install.py
CHANGED
|
@@ -86,41 +86,229 @@ def copy_skills(target: str, dest: Path, log_lines: list[str], dry_run: bool) ->
|
|
|
86
86
|
|
|
87
87
|
|
|
88
88
|
def install_cursor_rule(project: Path, log_lines: list[str], dry_run: bool) -> None:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
"""Write the optional Cursor steering rule into <project>/.cursor/rules/medsci-skills.mdc.
|
|
90
|
+
|
|
91
|
+
Two things this deliberately does NOT do any more:
|
|
92
|
+
|
|
93
|
+
* It does not embed the repository path. `.cursor/rules/` is a directory people commit, and the
|
|
94
|
+
absolute path of a home directory is the installer's name, so it travelled into shared repos.
|
|
95
|
+
It was also pointing at the wrong place: per docs/host_compatibility.md this rule is legacy
|
|
96
|
+
for discovery -- Cursor reads `~/.claude/skills/` and `~/.agents/skills/` directly -- so the
|
|
97
|
+
rule steers, and the skills are found where they are installed rather than in a checkout.
|
|
98
|
+
* It does not call `Path.write_text`, which opens in "w" mode and truncates before writing. This
|
|
99
|
+
file is ours to replace, but a half-written one after an interrupt is nobody's.
|
|
100
|
+
"""
|
|
101
|
+
rule_path = project / ".cursor" / "rules" / "medsci-skills.mdc"
|
|
102
|
+
body = """---
|
|
92
103
|
description: Use MedSci Skills for medical research writing, literature search, statistics, figures, and submission workflows.
|
|
93
104
|
alwaysApply: false
|
|
94
105
|
---
|
|
95
106
|
|
|
96
107
|
# MedSci Skills
|
|
97
108
|
|
|
98
|
-
|
|
99
|
-
`skills/<skill-name>/SKILL.md` file in this repository before acting.
|
|
109
|
+
This file is written by the MedSci Skills installer and is replaced whenever it runs.
|
|
100
110
|
|
|
101
|
-
|
|
111
|
+
When the user asks for a medical research workflow, use the installed MedSci skill rather than
|
|
112
|
+
answering from scratch. Cursor reads them from `~/.claude/skills/` and `~/.agents/skills/`; open the
|
|
113
|
+
relevant `<skill-name>/SKILL.md` before acting.
|
|
102
114
|
|
|
103
|
-
|
|
104
|
-
- `skills/analyze-stats/SKILL.md` for statistical tables and analysis code
|
|
105
|
-
- `skills/make-figures/SKILL.md` for publication figures
|
|
106
|
-
- `skills/write-paper/SKILL.md` for manuscript sections
|
|
107
|
-
- `skills/check-reporting/SKILL.md` for reporting guideline audits
|
|
115
|
+
Start with these entry points:
|
|
108
116
|
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
- `orchestrate` — unsure which one fits; it classifies the request and routes
|
|
118
|
+
- `search-lit` — literature search and verified citations
|
|
119
|
+
- `analyze-stats` — statistical tables and analysis code
|
|
120
|
+
- `make-figures` — publication figures
|
|
121
|
+
- `write-paper` — manuscript sections
|
|
122
|
+
- `check-reporting` — reporting-guideline audits
|
|
111
123
|
|
|
112
|
-
|
|
113
|
-
|
|
124
|
+
Prefer small single-skill tasks. Do not run the full end-to-end pipeline unless the user asks for it
|
|
125
|
+
and provides the required project files.
|
|
114
126
|
"""
|
|
115
127
|
log(f"\n[cursor] writing project rule to {rule_path}", log_lines)
|
|
116
128
|
if dry_run:
|
|
117
129
|
log(" DRY RUN write Cursor rule", log_lines)
|
|
118
130
|
return
|
|
119
|
-
|
|
120
|
-
|
|
131
|
+
prev_mode = os.stat(rule_path).st_mode & 0o777 if rule_path.is_file() else None
|
|
132
|
+
medsci_txn.atomic_write_bytes(rule_path, body.encode("utf-8"))
|
|
133
|
+
if prev_mode is not None:
|
|
134
|
+
try:
|
|
135
|
+
os.chmod(rule_path, prev_mode)
|
|
136
|
+
except OSError:
|
|
137
|
+
pass
|
|
121
138
|
log(" installed Cursor project rule", log_lines)
|
|
122
139
|
|
|
123
140
|
|
|
141
|
+
ROUTING_BEGIN = "<!-- BEGIN medsci-skills routing -->"
|
|
142
|
+
ROUTING_END = "<!-- END medsci-skills routing -->"
|
|
143
|
+
|
|
144
|
+
# Deliberately carries no local path. The Cursor rule above embeds REPO_ROOT, which is fine for a
|
|
145
|
+
# file nobody shares — but a CLAUDE.md sits in a project folder and gets committed, and the absolute
|
|
146
|
+
# path of a home directory is the installer's name. Nothing here is machine-specific.
|
|
147
|
+
ROUTING_BLOCK = f"""{ROUTING_BEGIN}
|
|
148
|
+
## MedSci Skills
|
|
149
|
+
|
|
150
|
+
MedSci Skills is installed. When a request matches a row below, invoke that skill rather than
|
|
151
|
+
answering from scratch. When the right one is not obvious, invoke `orchestrate` — it classifies the
|
|
152
|
+
request and routes to the rest.
|
|
153
|
+
|
|
154
|
+
| Request | Skill |
|
|
155
|
+
|---|---|
|
|
156
|
+
| Find papers; check that a citation is real; build a reference list | `search-lit`, `verify-refs`, `manage-refs` |
|
|
157
|
+
| Plan a study; sample size; define variables; de-identify data | `design-study`, `calc-sample-size`, `define-variables`, `deidentify` |
|
|
158
|
+
| Run statistics; draw a publication figure | `analyze-stats`, `make-figures` |
|
|
159
|
+
| Draft or revise a manuscript; write an IRB protocol | `write-paper`, `revise`, `write-protocol` |
|
|
160
|
+
| Audit against a reporting guideline (STROBE, PRISMA, CONSORT, STARD, TRIPOD) or a risk-of-bias tool | `check-reporting` |
|
|
161
|
+
| Self-review before submitting; answer reviewers; review someone else's paper | `self-review`, `revise`, `peer-review` |
|
|
162
|
+
| Choose a journal; assemble a submission package | `find-journal`, `sync-submission` |
|
|
163
|
+
| Medical-research work that is not obviously one of the above | `orchestrate` |
|
|
164
|
+
|
|
165
|
+
How a skill is invoked depends on how it was installed: bare (`/write-paper`) for a skills-folder
|
|
166
|
+
install, namespaced (`/medsci-writing:write-paper`) for a plugin install. Skip any skill that is not
|
|
167
|
+
installed, and never invent one.
|
|
168
|
+
|
|
169
|
+
These skills draft and audit. They do not replace authors, statisticians, reviewers, or an IRB, and
|
|
170
|
+
every output needs human-expert verification.
|
|
171
|
+
|
|
172
|
+
Everything between the two markers above and below is managed by the MedSci Skills installer and is
|
|
173
|
+
replaced or removed wholesale when it runs. Put your own notes outside them.
|
|
174
|
+
{ROUTING_END}
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
LF = "\n"
|
|
179
|
+
CRLF = "\r\n"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _dominant_newline(text: str) -> str:
|
|
183
|
+
"""Which line ending this file already uses, so the block matches it instead of mixing."""
|
|
184
|
+
crlf = text.count(CRLF)
|
|
185
|
+
return CRLF if crlf > text.count(LF) - crlf else LF
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _write_preserving_mode(path: Path, text: str, newline: str) -> None:
|
|
189
|
+
"""Atomically replace `path`, keeping the permissions it already had.
|
|
190
|
+
|
|
191
|
+
The mode step mirrors update._write_settings: a user who ran `chmod 600` on their CLAUDE.md
|
|
192
|
+
should not have it widened to the umask default as a side effect of an install.
|
|
193
|
+
"""
|
|
194
|
+
prev_mode = os.stat(path).st_mode & 0o777 if path.is_file() else None
|
|
195
|
+
medsci_txn.atomic_write_bytes(path, text.encode("utf-8"))
|
|
196
|
+
if prev_mode is not None:
|
|
197
|
+
try:
|
|
198
|
+
os.chmod(path, prev_mode)
|
|
199
|
+
except OSError:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def apply_routing(md_path: Path, remove: bool, dry_run: bool, log_lines: list[str]) -> str:
|
|
204
|
+
"""Splice the routing block into a CLAUDE.md. Returns: added | updated | unchanged | removed | absent.
|
|
205
|
+
|
|
206
|
+
What is actually guaranteed, because the first version of this docstring claimed more than the
|
|
207
|
+
code delivered and an external review said so:
|
|
208
|
+
|
|
209
|
+
* **The write is atomic.** Content goes to a temp file beside the target and is `os.replace`d
|
|
210
|
+
into position (medsci_txn.atomic_write_bytes), so an interrupted run leaves the previous file
|
|
211
|
+
intact. The previous version called `Path.write_text`, which opens in "w" mode and therefore
|
|
212
|
+
truncates before writing -- an interrupt there left a zero-byte CLAUDE.md.
|
|
213
|
+
* **Bytes outside the two markers are preserved, line endings included.** The file is read with
|
|
214
|
+
`read_bytes` and decoded here, so nothing on the path performs universal-newline translation;
|
|
215
|
+
a CRLF file stays CRLF, and the block is emitted with the file's own line ending rather than
|
|
216
|
+
mixing one in.
|
|
217
|
+
* **Permissions are preserved.**
|
|
218
|
+
|
|
219
|
+
The one thing it does NOT preserve, stated rather than hidden: a file with no trailing newline
|
|
220
|
+
gains one, and removal does not take it back. The newline is needed to put the block on its own
|
|
221
|
+
line, and nothing in the file records that it was ours.
|
|
222
|
+
|
|
223
|
+
A CLAUDE.md is where a user keeps their own standing instructions. Anything less than the above
|
|
224
|
+
is a data-loss bug, not an install step.
|
|
225
|
+
"""
|
|
226
|
+
# A symlinked CLAUDE.md must be edited through to its target: `os.replace` onto the link would
|
|
227
|
+
# replace the link itself with a regular file, and unlinking it would delete the user's link
|
|
228
|
+
# while leaving the block sitting in the file it pointed at.
|
|
229
|
+
linked = md_path.is_symlink()
|
|
230
|
+
target = md_path.resolve() if linked else md_path
|
|
231
|
+
|
|
232
|
+
raw = target.read_bytes() if target.is_file() else None
|
|
233
|
+
head = raw.decode("utf-8") if raw is not None else ""
|
|
234
|
+
nl = _dominant_newline(head)
|
|
235
|
+
block = ROUTING_BLOCK if nl == LF else ROUTING_BLOCK.replace(LF, nl)
|
|
236
|
+
region = block[: -len(nl)] # the block without its own trailing newline
|
|
237
|
+
|
|
238
|
+
# Count every marker rather than finding the first of each. Reasoning from `find()` alone
|
|
239
|
+
# gets three cases wrong, and all three were reachable: markers in reverse order spliced a
|
|
240
|
+
# duplicate of the user's text and left a dangling fence; two blocks in one file never
|
|
241
|
+
# converged; and `--remove-routing` on two blocks reported "removed" while one survived --
|
|
242
|
+
# a success message that was not true. Anything other than one clean fence is refused, which
|
|
243
|
+
# is what the half-marker case already did.
|
|
244
|
+
n_begin, n_end = head.count(ROUTING_BEGIN), head.count(ROUTING_END)
|
|
245
|
+
if n_begin != n_end:
|
|
246
|
+
raise RuntimeError(
|
|
247
|
+
f"{md_path} has {n_begin} '{ROUTING_BEGIN}' and {n_end} '{ROUTING_END}'; refusing to "
|
|
248
|
+
f"guess where the block ends. Fix the markers by hand, then re-run."
|
|
249
|
+
)
|
|
250
|
+
if n_begin > 1:
|
|
251
|
+
raise RuntimeError(
|
|
252
|
+
f"{md_path} contains {n_begin} routing blocks. Refusing to touch it: removing one "
|
|
253
|
+
f"would leave the rest while reporting success. Delete the extra blocks by hand, "
|
|
254
|
+
f"then re-run."
|
|
255
|
+
)
|
|
256
|
+
begin, end = head.find(ROUTING_BEGIN), head.find(ROUTING_END)
|
|
257
|
+
if begin != -1 and begin > end:
|
|
258
|
+
raise RuntimeError(
|
|
259
|
+
f"{md_path} has the routing markers in reverse order (END before BEGIN). Splicing "
|
|
260
|
+
f"that would duplicate the text between them. Fix the markers by hand, then re-run."
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if remove:
|
|
264
|
+
if begin == -1:
|
|
265
|
+
return "absent"
|
|
266
|
+
# Exact slice. The previous version ran rstrip("\n") + "\n" over the head, which silently
|
|
267
|
+
# collapsed the user's own blank lines: "KEEP\n\n" came back as "KEEP\n".
|
|
268
|
+
tail = head[end + len(ROUTING_END):]
|
|
269
|
+
if tail.startswith(nl):
|
|
270
|
+
tail = tail[len(nl):]
|
|
271
|
+
rest = head[:begin] + tail
|
|
272
|
+
if dry_run:
|
|
273
|
+
return "removed"
|
|
274
|
+
if rest.strip() or linked or not target.is_file():
|
|
275
|
+
_write_preserving_mode(target, rest, nl)
|
|
276
|
+
else:
|
|
277
|
+
target.unlink()
|
|
278
|
+
return "removed"
|
|
279
|
+
|
|
280
|
+
if begin != -1:
|
|
281
|
+
if head[begin:end + len(ROUTING_END)] == region:
|
|
282
|
+
return "unchanged"
|
|
283
|
+
merged = head[:begin] + region + head[end + len(ROUTING_END):]
|
|
284
|
+
outcome = "updated"
|
|
285
|
+
else:
|
|
286
|
+
merged = (head if not head or head.endswith(nl) else head + nl) + block
|
|
287
|
+
outcome = "added"
|
|
288
|
+
|
|
289
|
+
if not dry_run:
|
|
290
|
+
_write_preserving_mode(target, merged, nl)
|
|
291
|
+
return outcome
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def install_claude_routing(md_path: Path, remove: bool, log_lines: list[str], dry_run: bool) -> None:
|
|
295
|
+
"""Add or remove the routing block in one CLAUDE.md, reporting exactly what happened."""
|
|
296
|
+
verb = "removing" if remove else "writing"
|
|
297
|
+
log(f"\n[routing] {verb} the routing block in {md_path}", log_lines)
|
|
298
|
+
if dry_run:
|
|
299
|
+
outcome = apply_routing(md_path, remove, True, log_lines)
|
|
300
|
+
log(f" DRY RUN would report: {outcome}", log_lines)
|
|
301
|
+
return
|
|
302
|
+
outcome = apply_routing(md_path, remove, False, log_lines)
|
|
303
|
+
log({
|
|
304
|
+
"added": " added the routing block (the rest of the file was left as it was)",
|
|
305
|
+
"updated": " updated the existing routing block (nothing outside the markers changed)",
|
|
306
|
+
"unchanged": " already present and identical; no write",
|
|
307
|
+
"removed": " removed the routing block",
|
|
308
|
+
"absent": " no routing block was there; nothing to remove",
|
|
309
|
+
}[outcome], log_lines)
|
|
310
|
+
|
|
311
|
+
|
|
124
312
|
def run_self_test() -> int:
|
|
125
313
|
"""Simulate installs into throwaway temp dirs, assert every skill is discoverable, and
|
|
126
314
|
prove no real host directory is touched. Returns 0 on pass, 1 on failure. Writes nothing
|
|
@@ -215,6 +403,27 @@ def parse_args() -> argparse.Namespace:
|
|
|
215
403
|
default=None,
|
|
216
404
|
help="Project folder where a .cursor/rules/medsci-skills.mdc rule should be written.",
|
|
217
405
|
)
|
|
406
|
+
parser.add_argument(
|
|
407
|
+
"--claude-project",
|
|
408
|
+
type=Path,
|
|
409
|
+
default=None,
|
|
410
|
+
help="Opt in: add a short skill-routing block to <folder>/CLAUDE.md so plain-language "
|
|
411
|
+
"requests in that project reach the right skill. Scoped to that folder; nothing else "
|
|
412
|
+
"in the file is touched.",
|
|
413
|
+
)
|
|
414
|
+
parser.add_argument(
|
|
415
|
+
"--claude-user",
|
|
416
|
+
action="store_true",
|
|
417
|
+
help="Opt in: add the same routing block to ~/.claude/CLAUDE.md, which Claude Code loads in "
|
|
418
|
+
"EVERY project. Larger footprint than --claude-project; prefer that one unless you "
|
|
419
|
+
"want it everywhere.",
|
|
420
|
+
)
|
|
421
|
+
parser.add_argument(
|
|
422
|
+
"--remove-routing",
|
|
423
|
+
action="store_true",
|
|
424
|
+
help="Remove the routing block from whichever target you name with --claude-project / "
|
|
425
|
+
"--claude-user. Leaves the rest of the file alone.",
|
|
426
|
+
)
|
|
218
427
|
parser.add_argument("--dry-run", action="store_true", help="Print actions without changing files.")
|
|
219
428
|
parser.add_argument(
|
|
220
429
|
"--self-test",
|
|
@@ -343,6 +552,25 @@ def main() -> int:
|
|
|
343
552
|
failures.append("cursor")
|
|
344
553
|
log(f"\n[cursor] FAILED: {exc}", log_lines)
|
|
345
554
|
|
|
555
|
+
try:
|
|
556
|
+
if args.claude_project:
|
|
557
|
+
install_claude_routing(
|
|
558
|
+
args.claude_project.expanduser().resolve() / "CLAUDE.md",
|
|
559
|
+
args.remove_routing, log_lines, args.dry_run,
|
|
560
|
+
)
|
|
561
|
+
if args.claude_user:
|
|
562
|
+
install_claude_routing(
|
|
563
|
+
Path.home() / ".claude" / "CLAUDE.md",
|
|
564
|
+
args.remove_routing, log_lines, args.dry_run,
|
|
565
|
+
)
|
|
566
|
+
if args.remove_routing and not (args.claude_project or args.claude_user):
|
|
567
|
+
log("\n[routing] --remove-routing needs a target: pass --claude-project <folder> "
|
|
568
|
+
"and/or --claude-user.", log_lines)
|
|
569
|
+
except Exception as exc: # noqa: BLE001
|
|
570
|
+
failures.append("routing")
|
|
571
|
+
log(f"\n[routing] FAILED: {exc}", log_lines)
|
|
572
|
+
log(" the CLAUDE.md was left exactly as it was.", log_lines)
|
|
573
|
+
|
|
346
574
|
# Place the one-click updater under ~/.medsci-skills/updater/ so a future update needs no
|
|
347
575
|
# GitHub/terminal even if this download folder is deleted (best-effort; never fatal).
|
|
348
576
|
if not args.dry_run:
|
package/installers/medsci_txn.py
CHANGED
|
@@ -84,15 +84,33 @@ def assert_contained(path: Path, container: Path) -> None:
|
|
|
84
84
|
|
|
85
85
|
# ---------------------------------------------------------------- json io
|
|
86
86
|
|
|
87
|
-
def
|
|
87
|
+
def atomic_write_bytes(path: Path, data: bytes) -> None:
|
|
88
|
+
"""Replace `path` with `data`, atomically: a reader sees either the old file or the whole new
|
|
89
|
+
one, never a half-written or truncated file.
|
|
90
|
+
|
|
91
|
+
Extracted from `atomic_write_json` so there is one fsync implementation in the repo rather than
|
|
92
|
+
two. The caller that forced the split writes a user's CLAUDE.md, where the alternative --
|
|
93
|
+
`Path.write_text`, which opens in "w" mode and truncates before writing -- turns an interrupted
|
|
94
|
+
install into a lost file.
|
|
95
|
+
|
|
96
|
+
Bytes, not text: the caller decides the encoding and the line endings, so nothing here can
|
|
97
|
+
translate a CRLF file into an LF one on the way through.
|
|
98
|
+
"""
|
|
88
99
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
100
|
tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
try:
|
|
102
|
+
with open(tmp, "wb") as f:
|
|
103
|
+
f.write(data)
|
|
104
|
+
f.flush()
|
|
105
|
+
os.fsync(f.fileno())
|
|
106
|
+
os.replace(tmp, path)
|
|
107
|
+
except BaseException:
|
|
108
|
+
# Leave nothing behind: the destination is still whatever it was before this call.
|
|
109
|
+
try:
|
|
110
|
+
os.unlink(tmp)
|
|
111
|
+
except OSError:
|
|
112
|
+
pass
|
|
113
|
+
raise
|
|
96
114
|
# fsync the directory so the rename is durable.
|
|
97
115
|
try:
|
|
98
116
|
dfd = os.open(str(path.parent), os.O_RDONLY)
|
|
@@ -104,6 +122,10 @@ def atomic_write_json(path: Path, obj) -> None:
|
|
|
104
122
|
pass # directory fsync unsupported (e.g. Windows) — os.replace is still atomic.
|
|
105
123
|
|
|
106
124
|
|
|
125
|
+
def atomic_write_json(path: Path, obj) -> None:
|
|
126
|
+
atomic_write_bytes(path, (json.dumps(obj, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
127
|
+
|
|
128
|
+
|
|
107
129
|
def read_json_strict(path: Path):
|
|
108
130
|
"""Read JSON; raise TxnError on a corrupt/unreadable file (callers fail closed)."""
|
|
109
131
|
try:
|