Repository navigation
feat(skills): developer-only audience — keep dev skills out of ordinary installs - #5725
Conversation
…y installs A manifest may set "audience": "developer"; absent or "user" means every install. Any other value, or an unreadable manifest, counts as developer so a typo never ships a developer skill to users. src/skill_audience.py owns the decision and every installer calls it: skills/install.sh (both roots, Claude and Codex targets), skills/install.ps1, and health-check's skill-symlinks check, whose --fix would otherwise link them. The opt-in is the sutando config key skills.developer (JSON true only). A developer skill already linked on a host without the opt-in is left in place with a note. lint-skill rejects an unknown audience, and refuses runtime-activated fields (tools, hooks, claude_plugin, supervised_worker, pending_questions_store, documented_for_core) on a developer-only skill, since those scans read the repo skills tree directly and do not pass through an installer. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
liususan091219
left a comment
There was a problem hiding this comment.
Reviewed at 08d86560. Verdict: the code is ready from my side, no blockers. This is a COMMENT, not an approval, only because nothing on this PR counts toward the approval bar yet: there are no reviews at this head, and CI was still running when I checked.
What I checked, in a scratch worktree at the head (merge-base is current main, 0 behind):
-
One owner for the rule.
src/skill_audience.pydecides.install.sh(repo and workspace roots, Claude and Codex targets),install.ps1and health-checkskill-symlinksall call it, and none of them readsaudienceon its own. The negative scantest_no_other_code_reads_the_audience_fieldcan actually fire: I added a control file insrc/that doesm.get("audience")next to amanifest, and the test failed. -
Unknown values fail closed. In the helper, a typo, wrong case, empty, null, a list, broken JSON or a non-object manifest all count as developer.
install.shexits 1 and links nothing when no python runs or the helper fails.install.ps1does the same:Resolve-SutandoPythonthrows when no python is found, and a non-zero$LASTEXITCODEis thrown too, and both land in thecatchthat runsexit 1. Existing links are untouched in every one of these cases. -
Tests fail when the code is reverted. I ran 16/16 green at the head. Then I reverted one thing at a time:
install.shback tomain: 5 failures.- the health-check hunk back to
main: 2 failures, including the behaviouraltest_without_opt_in. - unknown audience mapped to
user: 4 failures. - unreadable manifest mapped to
user: 1 failure. - a truthy
skills.developer(so"true"would count): 2 failures. - the Codex target with the gate removed: 1 failure.
The delegation pins (
test_installers_call_the_helper) read source text. That is the structural exception in REVIEW.md #14, and here it sits beside behavioural tests of the helper,install.shend to end, and health-check--fix. -
Health-check. With the opt-in off,
--fixno longer adds a developer skill. With it on,--fixlinks it. This is tested by behaviour, not by reading the source. -
Config key.
skills.developeris a host setting rather than skill config, so I agree that the manifest-config precedence does not apply. No env var was invented, and the key was added to both loaders and to the schema. Only a real JSONtrueopts in; I verified that. -
Which skills are marked. None in this PR (
grep '"audience"' skills/*/manifest.jsonis empty), so no skill that users rely on is affected. I checked #5724'sprocess-hygienemanifest with"audience": "developer"added: it lints clean (it declares no tools, hooks or other fields that runtime scans pick up), and the helper excludes it. -
Hardcoded paths.
review-checks.sh --diffPASS. The only addedSUTANDO_WORKSPACElines set test environment variables to temp dirs, not a live default. -
Related suites green: skills-install-codex, skills-install-workspace-skills, health-check-skill-symlinks (+live, +dangling), config-known-keys-parity, sutando-config-twin-contract, lint-skill-supervised-worker, skill-roots, src-map.
Non-blocking notes:
- The "left in place" policy combined with #5724's order is the real worst case. #5724 already has two approvals at its head and does not yet carry the
audienceline. If it merges first,startup.shlinksprocess-hygieneon every host. When the marking lands later, it stays installed for good, and the only notice is a stdout line from an install thatstartup.shruns as2>/dev/null || true. The simplest mitigation is to put"audience": "developer"into #5724 itself, rebased after this PR lands, rather than as a follow-up. A health-check line for "installed but excluded" would also make any leftover visible. - Two fail-closed paths are not pinned by tests. First, the "no runnable python, so link nothing" branch in
install.sh: if I drop theexit 1there, the suite still passes. Second,install.ps1's behaviour is pinned only by the source-text check, and the repo already haswindows-*.test.ps1suites it could join. The code is correct as written; only the coverage is missing. - Nit:
scripts/lint-skill.pykeeps its ownAUDIENCES = {"user", "developer"}. Importing it fromskill_audiencewould leave a single list of values. - The desktop bundle gap is named, not blocking. On a desktop install the skills dir is linked by
startup.sh→install.sh(I checked: the links on this host point into the engine checkout), so the gate already applies there at link time. What is still open is only whether the bundle should ship the files at all.
Missing before merge: two approvals at this head from people outside the author's party, and CI finishing green: at submit time 32 checks pass, and "tsc + tests (clean install)" and "diff coverage >= 95% (python)" are still pending. CLA: license/cla=success (1 context). Unresolved threads: 0.
— Sutando (susans-MacBook-Pro)
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 92%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
…ce field The skill is for Sutando developers; with the field, ordinary installs skip it unless skills.developer is set. Stand: Echo Act IV Pro Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
liususan091219
left a comment
There was a problem hiding this comment.
Re-review at b9f0efea. Verdict: still ready from my side, no blockers. This is a COMMENT, not an approval, because nothing at this head counts toward the approval bar yet: there are no approvals, and CI is still running.
1. What changed since 08d86560. Two commits: a clean merge of main (21a569c0, which has no conflict-resolution hunks) and b9f0efea, which adds exactly one line, "audience": "developer", to skills/process-hygiene/manifest.json. The merge brought in #5724 and #5727 only. Neither one touches skill_audience.py, install.sh/install.ps1, health-check.py, sutando_config.py, lint-skill.py, MANIFEST.md, python-binary.* or the new test. Compared with main, the PR is still the same 15 files plus the manifest line.
2. process-hygiene is now excluded.
skill_audience.excluded(skills/*/, developer=False)returns only[('process-hygiene', 'developer-only')].lint-skill.py --allreports 64 manifests, 0 errors.- The new suite passes 16/16.
- I also ran
install.shend to end in a throwaway repo with the realprocess-hygieneandanswer-formatskills, a temp Claude home and a minimal env. It printed- process-hygiene (developer-only; not installed …)and linked onlyanswer-format.
3. The "left in place" window is now real.
- #5724 has been on
mainsince 18:43:58Z today, which was a few minutes before this review. - No release tag contains it yet (
git tag --contains 2360e1f7is empty). So desktop installs that update from releases are not exposed. The exposed hosts are the ones that trackmain, such as developer-mode checkouts and source installs that pull. - On those hosts the normal path does link it:
startup.shrunsskills/install.shon every start, and health-check--fixwould link it too. - I reproduced the leftover case with a link created before this PR. After this PR it stays, with only the
⚠ … already installed, left in placeline on stdout.startup.shruns that install with2>/dev/null || true, and health-checkskill-symlinksnow skips the skill silently. So nothing a person reads would flag it.
I'm keeping this non-blocking, for two reasons. The exposed group is minutes of main-tracking hosts, and those are almost all developers, who are the skill's intended audience anyway. And a blanket delete would risk user state. But the window closes only when this PR merges, and it must merge before the next release is cut. Otherwise the release ships process-hygiene unmarked to every desktop install, and this policy then keeps it there for good. I'd suggest a narrow, safe follow-up, either here or right after:
- When the opt-in is off, remove a developer skill's link only if it is a symlink whose target resolves to this checkout's own
skills/<name>, i.e. the linkinstall.shitself made. Real directories and links pointing anywhere else stay untouched. - Separately, have
skill-symlinksreport "installed but excluded" as a warn, so a leftover is visible.
4. The two fail-closed paths are still untested. If I drop the exit 1 after the "no runnable python3" check in install.sh, the suite still passes. install.ps1 is still pinned only by the source-text delegation check. The code is correct as written; only the coverage is missing.
Gates:
- CLA:
license/cla=success(1 context). - Unresolved threads: 0.
- Mergeable: yes.
- CI at submit time: 23 pass, 9 pending (the python standalone shards, node + out-of-tree python tests, shell lanes).
- No change-request condition fires: tests are behavioural and fail on revert, nothing claimed is missing, and there are no hardcoded paths.
Missing before merge: two approvals at this head from people outside the author's party, and CI finishing green.
— Sutando (susans-MacBook-Pro)
main() takes the repo root it reads the opt-in from, so the test drives it against a temp config instead of only through install.sh subprocesses, which the coverage run does not trace. Stand: Echo Act IV Pro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@cla-assistant check |
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed at b9f0efe, covering the main merge and the audience: developer line in process-hygiene. The merge resolved no conflicts and touched none of this PR's files. The manifest line matches skills/MANIFEST.md, and process-hygiene is the only skill main added since the merge base. The audience tests pass 16/16, skills-install-codex passes 2/2, and lint-skill on process-hygiene is clean.
One blocker: diff coverage >= 95% (python) fails at 87% on src/skill_audience.py, missing lines 57 and 82-87. Line 57 is the sys.path.insert in developer_opt_in. Lines 82-87 are main(), the usage error plus the excluded print loop. Those paths most likely run only as a subprocess from install.sh, which coverage doesn't trace. An in-process test that calls main([]) (expect 2) and main(["excluded", <dev skill>, <user skill>]) with developer_opt_in patched, plus one developer_opt_in call with src removed from sys.path, should cover all seven lines. I'll approve once that check is green.
qingyun-wu
left a comment
There was a problem hiding this comment.
Approve at ea88a12. CI: 33 success, 1 skipped, and CLA passes. After a shard rerun, the only red was the browser flake tracked in #4999.
- Coverage. The coverage gap is closed. In-process tests now cover the helper CLI (usage error → 2, and
excludedprinting the reason line) anddeveloper_opt_inwithsrcremoved fromsys.path. Those were the 7 missing lines insrc/skill_audience.py. The diff-coverage check passes. - Earlier verification still holds from my 08d8656 review:
- Fail-closed audience: a missing field means user; an unknown or invalid value means developer.
skills.developeris read throughload_configand accepts onlytrue.- A single decision point serves install.sh, install.ps1 and health-check.
- No silent removal.
- Merge timing. This needs to merge before the next release is cut. #5724 is on main, so a release before this lands would ship process-hygiene unmarked, and this policy would then keep it installed.
- Follow-up (Susan's suggestion). When the opt-in is off, unlink a developer skill only when the link points at this checkout's own
skills/<name>. Separately, have skill-symlinks report "installed but excluded".
Priority
Level: medium
Reason: owner decision: skills marked developer-only must be kept out of ordinary installs; a developer-only skill is already in review and needs this to land first.
Closes
N/A (owner decision, no issue).
Summary
Adds a manifest field
audience("user", the default, or"developer") and a host opt-inskills.developerin sutando config. Every path that links repo or workspace skills into the core's skills dirs now asks one helper,src/skill_audience.py, and skips developer-only skills unless the host opted in.What changed
src/skill_audience.py(new, stdlib +sutando_config): the one decision.audience(skill_dir),installable(skill_dir, developer),excluded(dirs, developer), and a CLIskill_audience.py excluded <dir>...printingname<TAB>reasonfor shell installers.audienceother than"user"/"developer"(typo, wrong case, empty, non-string), and amanifest.jsonthat is unreadable or not an object, count as developer-only. A typo in a developer skill must keep it away from users, not ship it. No manifest, or no field, meansuser, so every existing skill is unaffected.scripts/lint-skill.pyrejects an unknown value, so a repo skill cannot merge with one.skills.developer(new top-level config object, JSON boolean, default false), read byresolve_skills_developer()insrc/sutando_config.pyfrom the merged repo / repo-local / workspace-local config. Only JSONtrueopts in;"true",1, or an unreadable config are false with a stderr note. Added to both loaders' known/object key sets and todocs/sutando-config.schema.json. No env var or CLI flag: this is a host setting, not skill config, so theCLI > env > manifest > config-file > stateprecedence does not apply (documented inskills/MANIFEST.md).skills/install.sh: oneexcludedcall per root (<repo>/skills,<workspace>/skills); the same skip applies to the Claude target and, on a Codex core, the Codex target. If no runnable python is found, or the helper fails, it links nothing and exits 1 (fail closed;startup.shalready runs it with|| true, so existing links are untouched).skills/install.ps1(Windows twin): sameexcludedcall viascripts/python-binary.ps1; fails closed the same way.src/health-check.pyskill-symlinks: skips skills the helper excludes, so it no longer reports them as "unlinked" and--fixno longer links them.install.sh/install.ps1print<name> (developer-only; already installed, left in place — remove it yourself, or set skills.developer).tools,hooks,claude_plugin,supervised_worker,pending_questions_storeanddocumented_for_coreare activated by loaders that read<repo>/skillsdirectly, not through an installer. Rather than add the audience check to each of those loaders (TS and shell included),lint-skill.pyrefuses those fields on a developer-only skill.skills/MANIFEST.md"Audience" section + field-table row;schemas/skill-manifest.schema.jsongainsaudience;docs/src-map.mdregenerated.Paths reviewed that need no change
skills/refresh-skill.sh: only refreshes links that already exist ("NOT INSTALLED — nothing to refresh" otherwise), so it never installs; an already-installed developer skill is treated like any other existing link.src/init.sh: copiescrons.example.jsononly.src/startup.sh,src/migrate.sh,src/startup.ps1callinstall.sh/install.ps1.src/skill_install.py(marketplace, trusted-capabilities): installs a third-party bundle the owner names explicitly, not repo skills; an explicit named install is itself the opt-in.Stated gaps
skill_audience.py excludedcall (or must runskills/install.sh); not done in this PR."audience": "developer"inskills/process-hygiene/manifest.json.Files to read first
src/skill_audience.py, thenskills/install.sh, then theskill-symlinkshunk insrc/health-check.py.Before / after evidence
Same throwaway repo (copies of
skills/install.shand its helpers at each revision), two skills:plain(no manifest) anddev-tool({"audience": "developer"}), minimal env, temp Claude home.At
origin/main, the developer skill is installed:At HEAD, default install:
At HEAD, with
{"skills": {"developer": true}}in the workspace'ssutando.config.local.json:Mutation check: removing the
continueafter the repo-root skip ininstall.shfails 4 of the end-to-end tests.Test plan
New
tests/skill-audience-developer-only.test.py(16 tests, hermetic: temp dirs, minimal env,SUTANDO_*cleared, tempCLAUDE_CONFIG_DIR/CODEX_HOME/HOME):user→ user;developer, typo, wrong case, empty, null, list, broken JSON, non-object → developer; opt-in lifts exclusion.true; string/int/broken config → false; the workspace layer opts in;skillsis a known object key.install.shend to end in a throwaway repo: developer and typo'd skills not linked by default (both roots); Codex target gated; opt-in links all; string opt-in ignored; an already-linked developer skill left in place with the note.health-checkskill-symlinks+fix_skill_symlinks: developer skill not linked without opt-in, linked with it.tools/documented_for_corerejected.install.shandinstall.ps1callsrc/skill_audience.py excluded;health-check.pyimports the helper; no other code undersrc/,scripts/,skills/readsaudiencealongside a manifest.Existing tests run one file at a time, all green:
skills-install-codex(copy list gains the helper),skills-install-workspace-skills,health-check-skill-symlinks,health-check-skill-symlinks-live,health-check-dangling-symlinks,health-check-skill-junctions,skill-symlink-ephemeral-target,config-known-keys-parity,sutando-config-twin-contract,lint-skill-supervised-worker,skill-hooks-discovery,skill-roots,src-map,ci-covers-every-python-test,test-discovery-has-one-owner,installer-python-probe.test.sh. Also:python3 scripts/lint-skill.py --all(63 manifests, 0 errors),lint-hermetic-bridge-tests.pyok,review-checks.shPASS,git diff --checkclean,shellcheck -S error skills/install.shclean.install.ps1was not executed (no PowerShell on the test host); its delegation is pinned by the contract test only.Live path: N/A for delivery;
install.shruns at startup, and the before/after above is that script run end to end.Checklist
mainDocs:
skills/MANIFEST.mdis the canonical doc for manifest fields (not indocs/catalog.json). CHANGELOG-PENDING entry added.🤖 Generated with Claude Code