26 KiB
title, type, status, date, deepened
| title | type | status | date | deepened |
|---|---|---|---|---|
| Fix skill upload 200-file limit + packaging hygiene (public repo) | fix | completed | 2026-04-14 | 2026-04-14 |
Fix skill upload 200-file limit + packaging hygiene (public repo)
Overview
claude.ai's "Upload skill" UI rejects zips with more than 200 files. Zipping the public mvanhorn/last30days-skill repo produces 406 files, so the upload fails outright (evidence: Trevin's 2026-04-14 report). Root cause is an accidentally committed npm package under vendor/ (215 files of dead weight from PR #48) plus the absence of a user-facing packaging path that matches Anthropic's canonical .skill format.
Goal: let any user produce a compliant last30days.skill file in one command, matching Anthropic's skill-creator packaging contract, while also removing genuine dead weight from the repo (unused vendor, legacy plans).
Problem Frame
- Trevin tried to upload the public repo as a Claude Skill and hit the 200-file limit
- 215 of 406 files are
vendor/package/- an extractedsteipete-bird-0.8.0.tgzthat no code imports - The real runtime X client lives at
scripts/lib/vendor/bird-search/(15 files, referenced byscripts/lib/bird_x.py:5andtests/test_bird_x.py:133) .clawhubignoreis ClawHub-specific and does not affect a hand-rolled zip upload- Users have no documented path to produce a compliant upload zip
- Legacy top-level
plans/folder holds pre-docs/plans/planning artifacts (confirmed waste by Matt, 2026-04-14 chat)
Requirements Trace
- R1. After this plan lands, the produced upload zip is =200 files
- R2. The X/bird-search runtime still works - no regression in
tests/test_bird_x.py - R3. A contributor following README instructions can produce a Claude-Skill-upload-compatible
.skillfile in one command - R4. Re-introduction of a root
vendor/directory is prevented via.gitignore - R5. No runtime behavior changes for existing skill consumers (Claude Code plugin, ClawHub, Gemini)
- R6. Produced zip matches Anthropic's canonical skill-folder layout: top-level directory named exactly
last30dayscontainingSKILL.mdat its root, with YAML frontmattername: last30days - R7. Root
SKILL.mdfrontmatter passes Anthropic's documented limits:name=64 chars (currently 10),description=200 chars (currently 228, needs trimming) - R8. Produced zip contains exactly one
SKILL.md(atlast30days/SKILL.md) - no conflicting second skill spec, no symlinks that the uploader may reject or break - R9. No runtime import reaches an excluded path (proven by import-graph audit, not just asserted)
Scope Boundaries
Non-goals:
- Not touching the private repo or ClawHub publish flow (those have their own strip script)
- Not resolving the adjacent open issues (#239 plugin loader path-escape, #236 OpenClaw paths, #231 security scan, #190 version drift, #184 Gemini install) - each deserves its own plan
- Not redesigning the skill into self-contained subfolders or splitting scripts into a separate package
- Not adding CI enforcement of the 200-file cap (possible follow-up)
Context and Research
Anthropic's canonical skill-upload contract
Sourced from Anthropic's skill-creator repo (anthropics/skills/skills/skill-creator/scripts/package_skill.py) and help-center docs:
- Output format: a
.skillfile, which is a standard zip with the.skillextension. - Top-level entry in the zip must be a single directory whose name matches
name:in the skill's YAML frontmatter. Anthropic's packager usesarcname = file_path.relative_to(skill_path.parent), so the zip always contains<skill_name>/.... - That directory must contain
SKILL.mdat its root (the packager explicitly validates this). - Required YAML frontmatter:
name(=64 chars, lowercase + hyphens) anddescription(=200 chars). Our root SKILL.md already satisfies both. - Canonical exclusions applied by Anthropic's packager:
- Directories:
__pycache__,node_modules - Root-only:
evals/ - File globs:
*.pyc - Files:
.DS_Store
- Directories:
- Empirical limit: the upload UI rejects =200 files (screenshot 2026-04-14). Not documented, but confirmed.
- Per-file size cap is not publicly documented; general claude.ai uploads cap at 30MB per file. Conservative target: keep any single file under 10MB.
Relevant code and patterns in this repo
SKILL.md(root, 1382 lines, 80KB) -name: last30days,user-invocable: true. This is the skill.skills/last30days/SKILL.md(230 lines) -name: last30days-v3-spec,user-invocable: false. Internal architecture spec, separate skill name - not the upload target.vendor/package/- accidental commit from PR #48, 215 files, zero importers.vendor/steipete-bird-0.8.0.tgz- source tarball, also unused at runtime.scripts/lib/vendor/bird-search/- the ACTUAL vendored bird-search client (15 files). Keep.plans/(top-level, 2 files:feat-add-websearch-source.md,fix-strict-date-filtering.md) - legacy, pre-docs/plans/convention. Matt confirmed delete.scripts/sync.sh- deploys skill to~/.claude,~/.agents,~/.codex. Reference for runtime-required files..clawhubignore- existing exclude list for the ClawHub path. Not used here, but good cross-reference for what is runtime-irrelevant..gitignore- current dev excludes (.venv/,__pycache__/,.DS_Store, etc).
Institutional learnings
- Private repo has
scripts/clawhub-publish.sh+scripts/strip_for_openclaw.pythat build a staging dir with only OpenClaw-safe files. Not needed for this public-path upload;git archivewith--prefixis sufficient and dependency-free. - PR #48 introduced
vendor/package/unintentionally. No code imports from it.
File count math (verified via dry run)
| Strategy | File count | Under cap? |
|---|---|---|
| Current repo, zip as-is | 406 | No |
After vendor/ deleted |
191 | Yes (thin margin) |
After vendor/ + plans/ deleted, no further excludes |
189 | Yes |
| With full planned excludes (Anthropic canonical + tests/docs/fixtures/assets/dev manifests/nested skill dirs) | 81 | Comfortable headroom |
Dry run run on 2026-04-14 against the current working tree. Simulated the proposed .gitattributes with a find filter matching the intended exclude list. Result: 81 files, 868KB uncompressed. Actual git archive output may differ slightly (by 1-2 files) but will land well under 200.
Runtime import audit (proves core experience unchanged)
Grepped all import/from statements in scripts/**/*.py. Non-stdlib imports resolve to only:
lib.*(internal package atscripts/lib/)store(internal module atscripts/store.py)scripts.*(internal)
No runtime import reaches tests/, docs/, fixtures/, vendor/ (root), plans/, assets/, .agents/, .codex-plugin/, .hermes-plugin/, or any other excluded path. The shipped .skill file contains everything the runtime needs and nothing it does not.
Symlink and multi-SKILL.md audit
The repo contains one symlink: skills/last30days-nux/SKILL.md -> ../../SKILL.md. Three SKILL.md files in total:
SKILL.md(root,name: last30days,user-invocable: true) - the actual skillskills/last30days/SKILL.md(name: last30days-v3-spec,user-invocable: false) - internal architecture docskills/last30days-nux/SKILL.md(symlink to root) - nux variant reference
Shipping all three inside one zip creates two rejection risks:
- Uploader sees multiple
SKILL.mdwith conflictingname:values and refuses or misbinds git archivestores the symlink as a symlink entry; the uploader may reject symlinked entries on principle
Both risks disappear by excluding skills/ entirely from the zip. The two internal skill definitions are not needed for claude.ai skill execution - they serve the repo as documentation / Claude Code plugin layout, not the direct upload path.
Sources consulted
- Anthropic skill help center article (general upload guidance, no file-count number documented)
- anthropics/skills README - YAML frontmatter requirements
- anthropics/skills package_skill.py - canonical exclusions and arcname shape
- Trevin's 2026-04-14 chat screenshot (empirical 200-file cap)
- Adjacent issues #239, #236, #190 for context on current packaging mess
Key Technical Decisions
- Delete
vendor/outright rather than gitignore-and-leave. Pure dead weight. Rationale: the real vendored client is atscripts/lib/vendor/bird-search/, rootvendor/has zero importers; keeping it invites re-upload. - Delete top-level
plans/(Matt confirmed). Rationale: superseded bydocs/plans/. Moving content intodocs/plans/if any is still relevant; otherwise just delete. - Produce a
.skillfile (not a plain.zip) viagit archive --format=zip --prefix=last30days/ -o dist/last30days.skill HEAD. Rationale: matches Anthropic's canonical contract - zip extension is cosmetic, but the.skillaffordance is what the upload UI expects. - Use
git archive+.gitattributes export-ignorerather than a Python packager. Rationale: no Python dependency at build time, honors git's declarative exclude model, reusable by anyone runninggit archivedirectly. - Mirror Anthropic's canonical exclusions in
.gitattributes(__pycache__,node_modules,*.pyc,.DS_Store,evals/) alongside our repo-specific excludes. Rationale: future-proof if a contributor adds node deps; keeps us aligned with the Anthropic baseline. - Exclude
skills/from the upload zip (coversskills/last30days/SKILL.mdandskills/last30days-nux/SKILL.md). Rationale: shipping multiple SKILL.md files with differentname:values is a likely uploader-rejection cause, and the symlink atskills/last30days-nux/SKILL.mdis an independent rejection risk. Repo contents stay intact - Claude Code plugin and GitHub viewers still see the directory. - Keep
.clawhubignoreas-is - it serves the ClawHub publish path separately. Do not merge the two lists; different consumers, different exclusions. - Prevent regression with a
/vendor/entry in.gitignore(leading slash, soscripts/lib/vendor/is unaffected). - Do not address #239
"skills": ["./"]path-escape here. That is a plugin.json change, not a zip-packaging change. Separate plan.
Open Questions
Resolved during planning
- Is root
vendor/used? No. Grep forvendor/package,vendor/steipete,from vendorreturns zero hits outsidescripts/lib/vendor/. - Is
scripts/lib/vendor/bird-search/safe? Yes. Referenced byscripts/lib/bird_x.py:5andtests/test_bird_x.py:133. - What name does the top-level zip directory need?
last30days- matchesname: last30daysin the rootSKILL.mdfrontmatter. - Does
skills/last30days/SKILL.mdconflict? No. It declares a different skill name (last30days-v3-spec) and isuser-invocable: false. Not the upload target, and safe to ship inside the zip. - Is there a documented file-count cap? No. 200 is empirical from the UI error screenshot.
- Should we gate this on a version bump? Yes, 3.0.0 - 3.0.1. Same API, same runtime, smaller and uploadable package.
Deferred to implementation
- Exact
.gitattributesexport-ignore entries may need one tuning pass ifgit archivesurfaces a file we forgot. Verification step catches it. - Whether to delete
SKILL-original.mdfrom the repo entirely or just export-ignore. Leaning export-ignore to preserve git history context. - Whether any content in
plans/*.mdis still live reference material. If so, move todocs/plans/under new naming convention; if not, delete outright.
Implementation Units
- Unit 1: Remove accidental
vendor/commit
Goal: Delete the root vendor/ directory and the stray .tgz, both unused at runtime.
Requirements: R1, R2, R5
Dependencies: None
Files:
- Delete:
vendor/(entire tree, 215 files) - Delete:
vendor/steipete-bird-0.8.0.tgz - Modify:
.gitignore(add/vendor/to prevent regression - leading slash to avoid matchingscripts/lib/vendor/)
Approach:
- Single commit:
chore: remove unused root vendor/ directory (215 files from PR #48) - Verify
scripts/lib/vendor/bird-search/is untouched - Verify no
from vendororvendor/packagereferences appear in the diff
Patterns to follow:
- Commit message style matches recent history
Test scenarios:
- Happy path:
find . -type f -not -path './.git/*' | wc -lreturns =200 after commit - Integration:
python -m pytest tests/test_bird_x.py -qpasses - confirms the real vendored client still resolves - Integration:
bash scripts/sync.shcompletes without error
Verification:
-
Zero files remain under
vendor/onmain -
tests/test_bird_x.pystill passes -
.gitignorenow contains/vendor/ -
Unit 2: Remove legacy top-level
plans/directory
Goal: Delete the pre-docs/plans/ folder (Matt confirmed waste).
Requirements: R1, R5
Dependencies: None (independent of Unit 1)
Files:
- Delete:
plans/feat-add-websearch-source.md - Delete:
plans/fix-strict-date-filtering.md - Delete:
plans/(now empty)
Approach:
- Skim both files first. If either still reflects real upcoming work, port it to
docs/plans/YYYY-MM-DD-NNN-<type>-*-plan.mdbefore deletion. If not, delete. - Commit:
chore: remove legacy plans/ directory (superseded by docs/plans/)
Test scenarios:
- Test expectation: none - pure housekeeping, no code paths affected
Verification:
-
plans/does not exist onmain -
Nothing in the repo references
plans/feat-add-websearch-source.mdorplans/fix-strict-date-filtering.md(grep to confirm) -
Unit 3: Declare zip-time excludes via
.gitattributes
Goal: Use export-ignore so git archive produces a skill-shaped zip without hand-filtering.
Requirements: R1, R3, R6
Dependencies: Unit 1, Unit 2
Files:
- Create:
.gitattributes
Approach:
- Anthropic canonical exclusions (match
package_skill.py):__pycache__/export-ignorenode_modules/export-ignore*.pycexport-ignore.DS_Storeexport-ignoreevals/export-ignore
- Repo-specific exclusions (dev/docs/build artifacts not needed at runtime):
tests/(64 files)docs/(17 files includingdocs/test-results/)fixtures/(7 files)assets/(5 files, 14MB of README media)SKILL-original.md(historical)SPEC.md,TASKS.md,test-run.log,CONTRIBUTORS.md,HERMES_SETUP.md,release-notes.md,CHANGELOG.mduv.lock.agents/,.codex-plugin/,.hermes-plugin/,.claude-plugin/(platform adapters - skill-upload path is platform-agnostic).clawhubignore,.gitignore,.gitattributesskills/(avoid second SKILL.md with conflictingname:; also drops the symlink atskills/last30days-nux/SKILL.md)
- Keep in archive:
scripts/(runtime), rootSKILL.md,README.md,LICENSE,pyproject.toml,CLAUDE.md,gemini-extension.json,agents/,hooks/
Technical design: (directional guidance, not implementation spec)
# Anthropic canonical skill-packaging excludes
__pycache__/ export-ignore
node_modules/ export-ignore
*.pyc export-ignore
.DS_Store export-ignore
evals/ export-ignore
# Repo-specific: tests + docs + media (not runtime)
tests/ export-ignore
docs/ export-ignore
fixtures/ export-ignore
assets/ export-ignore
# Repo-specific: historical + dev manifests
SKILL-original.md export-ignore
SPEC.md export-ignore
...
Patterns to follow:
.gitattributesexport-ignore syntax per git docs
Test scenarios:
- Happy path:
git archive --format=zip HEAD | zipinfo -1 - | wc -lreturns =200 - Happy path: zip contains
SKILL.md,scripts/last30days.py,scripts/lib/bird_x.py,scripts/lib/vendor/bird-search/lib/cookies.js - Happy path: zip contains exactly one
SKILL.mdentry at the top level (not multiple, not a symlink) - Edge case: zip does NOT contain
tests/,docs/,assets/*.jpeg,*.mp3,skills/ - Edge case: no symlink entries in the zip (
unzip -llines starting withl) - Edge case: zip size stays under ~2MB (if over 5MB an unintended large file slipped through)
Verification:
-
Running
git archive --format=zip --output=/tmp/test.zip HEAD && unzip -l /tmp/test.zip | tail -1reports =200 files and a sane byte count -
Unit 4: Add
scripts/build-skill.shuser-facing builder
Goal: One-command path to produce a Claude-upload-compatible .skill file.
Requirements: R3, R6
Dependencies: Unit 3
Files:
- Create:
scripts/build-skill.sh - Modify:
.gitignore(add/dist/for build artifact)
Approach:
- Bash, executable,
set -euo pipefail git archive --format=zip --prefix=last30days/ --output=dist/last30days.skill HEAD- The
--prefix=last30days/nests everything underlast30days/inside the zip, matching Anthropic's arcname contract - Refuse to build if working tree is dirty (
git diff --quiet && git diff --cached --quiet) - Print file count, archive size, and path to paste into the upload UI
- Fail with a clear error if count exceeds 200 (defensive check)
Technical design: (directional guidance, not implementation spec)
#!/usr/bin/env bash
# build-skill.sh - package repo as a Claude-upload-ready .skill file
# Usage: bash scripts/build-skill.sh
set -euo pipefail
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "error: working tree is dirty - commit or stash first" >&2; exit 1
fi
mkdir -p dist
out="dist/last30days.skill"
git archive --format=zip --prefix=last30days/ --output="$out" HEAD
count=$(unzip -l "$out" | tail -1 | awk '{print $2}')
[ "$count" -le 200 ] || { echo "error: $count files in zip, cap is 200" >&2; exit 1; }
echo "built $out ($count files, $(du -h "$out" | cut -f1))"
Patterns to follow:
- Style of
scripts/sync.sh(bash, top-of-file comment,set -euo pipefail)
Test scenarios:
- Happy path: clean tree,
bash scripts/build-skill.shproducesdist/last30days.skillwith =200 files and the top-level entry islast30days/ - Happy path:
unzip -p dist/last30days.skill last30days/SKILL.md | head -2shows---(frontmatter start) confirming SKILL.md is at the right location - Edge case: dirty working tree - script exits non-zero with clear error
- Edge case: idempotent - running twice overwrites cleanly
- Error path: if a future change inflates file count past 200, the defensive
[ "$count" -le 200 ]check fails and the script refuses to produce a broken output
Verification:
-
bash scripts/build-skill.sh && unzip -l dist/last30days.skill | grep "^ 0 .* last30days/$"confirms the prefix directory exists -
unzip -l dist/last30days.skill | grep "last30days/SKILL.md"confirms SKILL.md is at the expected path -
unzip -l dist/last30days.skill | grep -c "SKILL.md"returns exactly 1 -
unzip -l dist/last30days.skill | awk '{print $NF}' | grep -v "^$" | sort -u | grep "skills/" || truereturns nothing (confirms internal skill dirs excluded) -
Gate: a contributor must run
bash scripts/build-skill.shon their branch and attach the produced file to their PR before merging any change that touches.gitattributesor exclude-sensitive paths -
Unit 5: Document the upload path in README
Goal: Users know how to produce an upload .skill without reading the source.
Requirements: R3
Dependencies: Unit 4
Files:
- Modify:
README.md(add a short "Upload as a Claude Skill" subsection under the existing install section)
Approach:
- One paragraph plus a single command block:
bash scripts/build-skill.sh - Mention the 200-file cap as context so future changes do not bust it
- Point users at the claude.ai skill upload UI (note: link only if a stable URL exists at implementation time, otherwise describe the UI path)
Test scenarios:
- Test expectation: none - pure documentation change
Verification:
-
grep -n "build-skill" README.mdreturns a hit -
Instructions match actual script behavior
-
Unit 6: Trim SKILL.md description to =200 chars
Goal: Make root SKILL.md frontmatter pass Anthropic's documented description limit.
Requirements: R7
Dependencies: None (independent of other units)
Files:
- Modify:
SKILL.md(frontmatterdescription:field only)
Approach:
- Current description is 228 chars. Cut 28+ chars without losing signal.
- Suggested rewrite (196 chars):
"Multi-query social search with planned queries. Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web. Gemini/OpenAI fallback when needed." - Confirm the trimmed version still surfaces for the right prompts (smoke test: run
python scripts/last30days.py "test" --emit=compactand confirm behavior unchanged; description is metadata, not runtime input) - Update
skills/last30days/SKILL.mddescription too if it exceeds 200 chars (check during implementation)
Test scenarios:
- Happy path:
python3 -c "import re; d=open('SKILL.md').read(); m=re.search(r'^description:\s*\"(.+?)\"', d, re.M); assert len(m.group(1)) <= 200, len(m.group(1))"passes
Verification:
-
Description field is =200 chars in root SKILL.md
-
Skill still triggers on relevant prompts (manual smoke check)
-
Unit 7: Version bump and changelog
Goal: Ship as 3.0.1 so consumers see the packaging fix.
Requirements: R5
Dependencies: Units 1-6
Files:
- Modify:
.claude-plugin/plugin.json(3.0.0 - 3.0.1) - Modify:
SKILL.mdfrontmatter version - Modify:
skills/last30days/SKILL.mdfrontmatter version - Modify:
gemini-extension.jsonversion (note: #190 flags this as stale at 2.9.5; bumping here partially addresses that but full resolution is out of scope) - Modify:
CHANGELOG.md - Modify:
release-notes.md
Approach:
- Atomic version bump across all manifests
- Changelog entry: "Packaging:
scripts/build-skill.shproduces a compliant.skillfile; removed unused rootvendor/(215 files) and legacyplans/; repo file count fits under claude.ai's 200-file upload cap"
Test scenarios:
- Happy path:
grep -rn "3.0.1" SKILL.md skills/last30days/SKILL.md .claude-plugin/plugin.json gemini-extension.jsonreturns four consistent hits - Integration:
bash scripts/sync.shcompletes cleanly
Verification:
- All four version declarations read
3.0.1 - CHANGELOG and release-notes have dated entries
System-Wide Impact
- Interaction graph: Skill-runtime import graph is unchanged. Removed code (root
vendor/,plans/) has zero importers. - Error propagation:
build-skill.shis a new surface; failure mode is non-zero exit with clear stderr. No runtime error paths touched. - State lifecycle risks: None.
dist/is gitignored build output. - API surface parity: No change to any user-facing API, CLI flag, config key, or SKILL.md contract.
- Integration coverage:
tests/test_bird_x.pyexercises the real vendored client - if it regressed, the test fails. Run it after Unit 1. - Unchanged invariants:
scripts/lib/vendor/bird-search/stays.scripts/sync.shdeploy behavior unchanged. ClawHub publish flow (private repo) untouched. Claude Code plugin install via GitHub URL still works.
Risks and Dependencies
| Risk | Mitigation |
|---|---|
Deleting vendor/ silently breaks something we missed |
Run pytest tests/test_bird_x.py and bash scripts/sync.sh after the delete; grep for vendor/package before merging |
claude.ai rejects the .skill file for a reason other than file count (e.g., frontmatter character, hidden file) |
Test-upload the produced artifact against claude.ai once before merging; iterate on .gitattributes if needed |
.gitattributes over-excludes and breaks the runtime skill |
Unit 3 verification step explicitly checks runtime paths are present in the produced archive |
A future PR re-vendors something at /vendor/ and busts the 200 cap again |
/vendor/ in .gitignore plus the defensive =200 check in build-skill.sh catches it |
| Version bump collides with in-flight PRs that also bump versions | Coordinate with #229, #217 which touched version strings; check before merging |
skills/last30days/SKILL.md (internal spec) being shipped inside the zip confuses the claude.ai uploader |
Resolved by excluding skills/ from the zip (Unit 3). Internal spec remains in the repo for plugin consumers |
skills/last30days-nux/SKILL.md is a symlink to ../../SKILL.md; claude.ai may reject zips with symlink entries |
Resolved by excluding skills/ from the zip (Unit 3). Symlink never enters the archive |
Documentation and Operational Notes
- Update README only (Unit 5). No runbook, no migration, no flag.
- No deployment step - plugin consumers get the packaging fix automatically on next update.
- Release notes flag: manual uploaders should re-zip via
scripts/build-skill.sh. - Opportunistic future work (out of scope here): CI check that fails PRs that push the zip over 200 files.
Sources and References
- Trevin's 2026-04-14 chat screenshot: "Zip contains too many files (maximum 200)"
- anthropics/skills README - YAML frontmatter requirements
- anthropics/skills package_skill.py - canonical exclusions, arcname convention, validation gates
- claude.ai skill help center - upload failure modes (zip size, folder-name mismatch, missing SKILL.md)
- PR #48 (2026-02) - the merge that introduced
vendor/package/ - Open issues adjacent but out of scope: #239, #236, #231, #190, #184
- Related code:
scripts/lib/bird_x.py:5,tests/test_bird_x.py:133,.clawhubignore,scripts/sync.sh, rootSKILL.mdfrontmatter - Private-repo reference pattern:
scripts/clawhub-publish.sh+scripts/strip_for_openclaw.py- not copied here;git archiveis simpler for the public path