Skip to content

feat: install a base skill set by default - #135

Merged
andrepimenta merged 5 commits into
mainfrom
feat/base-skills-mobile
Sep 3, 2026
Merged

andrepimenta merged 5 commits into
mainfrom
feat/base-skills-mobile

Conversation

@andrepimenta

@andrepimenta andrepimenta commented Aug 25, 2026 •

Copy link
Copy Markdown
Member

What

Turns on a base skill set that installs for every engineer on a fresh clone, with no flags and nothing to read first. Mobile gets 10 skills; everything else stays opt-in.

The mandatory frontmatter flag is renamed to base and actually used. It was already implemented end to end — tools/install, tools/sync, the schema, the CLI, even a ★ marker in the interactive picker — but no skill had ever set it, and the default domain filter was permissive enough that the bypass had nothing to bypass.

Why

Three things had to be true together for "base skills by default" to work. Only the flag existed.

Problem Fix
Empty SKILLS_DOMAINS meant all domains, so base never applied none/all sentinels
SKILLS_AUTO_UPDATE was off by default, so postinstall refreshed the cache and installed nothing Defaults on. The cache refresh moved behind the gate, so opted-out engineers no longer pay a network fetch per install
Six base descriptions were too thin to ever trigger Rewritten. coding-guidelines was literally "General coding guidelines" — 25 characters

That last one is the subtle one: a skill's description is its entire trigger surface. A 25-character description never fires on the work it governs, while appearing to be covered.

Automatic vs explicit

yarn skills reads as "give me the skills", so having it install 10 instead of 36 would be a surprising regression for a command someone typed on purpose. The two paths differ:

Path Domains Installed
yarn skills, no config all 36 — unchanged from today
yarn install → postinstall, no config none 10
yarn install with SKILLS_DOMAINS=perps perps 12 = 10 base + 2 perps
yarn skills --domain none none 10

postinstall exports SKILLS_DEFAULT_SCOPE=base and tools/sync branches its fallback on that. Everything above the fallback in the resolution order is untouched — a --domain flag, SKILLS_DOMAINS, the picker, and a saved selection all still win.

That last property is the point of doing it this way. Passing --domain none from postinstall would have outranked a saved selection, so an engineer who opted into perps would have silently lost it on their next yarn install.

The base set (10)

mobile-testing · coding-guidelines · controller-integration · ui-development · component-scaffold · performance · pr-guidelines · create-pr · pr-description · pr-codeowners

pr-description and pr-codeowners are included because create-pr delegates its main output to them with "if available" phrasing — without them it silently runs in a reduced mode.

Always-on cost: ~1,130 tokens per session. The set is deliberately small: when the agent's skill listing overflows its budget, descriptions get truncated starting with the least-invoked skills, so a bloated base set makes every skill harder to trigger.

Safety

Enabling auto-update puts git operations on every engineer's install path, so this also:

  • suppresses git credential prompts (GIT_TERMINAL_PROMPT=0, empty GIT_ASKPASS) in both run() and the delegated environment — tools/sync pulls the private Consensys overlay, which could otherwise block on stdin during yarn install with no output
  • bounds every spawn: 300s for network git, 10s for local bash --version probes

Guardrails added

  • base: true + maturity: experimental is now a lint error — contradictory intent; installing unfinished guidance for everyone is the bad outcome
  • warns when a base skill's description is under 120 characters

Verified

Check Result
Automatic path (SKILLS_DEFAULT_SCOPE=base) exactly 10, in .claude/skills and .agents/skills
Explicit yarn skills 36
Automatic + SKILLS_DOMAINS=perps 12 — saved selection respected
SKILLS_AUTO_UPDATE=0 / CI=1 silent, exit 0
Full postinstall → sync → install exit 0
Overlay merge intact coding-guidelines 71-byte source stub → 3,990 bytes installed
node --test 65/65
lint-skill-entry.mjs 52 skills, 0 errors

Migration note

Existing engineers are not dropped to 10. Their current installs stay, because pruning is off by default and their next yarn install only adds the base set. yarn skills --prune-stale once gives a clean state.

This does reach external contributors: the skills package is a devDependency of public repos, so a drive-by contributor's yarn install now clones this repo into .skills-cache/ and writes 10 gitignored skill files. Public content, gitignored output, one clone — flagging it rather than letting it be discovered later.

Related

Known gaps documented separately: no standardized PR-review skill, platform APIs at 2 of ~11, and five deprecated testing skills that still compete with mobile-testing.

🤖 Generated with Claude Code

Rename the `mandatory` frontmatter flag to `base` and turn it on for the
ten skills every metamask-mobile engineer needs, so a fresh clone lands
exactly those from `yarn install` with no flags and nothing to read first.
The flag was already implemented end to end but no skill had ever set it,
and the default domain filter was permissive enough that it never applied.

- `SKILLS_DOMAINS` gains `none`/`all` sentinels; the zero-config default is
  now base-only rather than every domain
- `SKILLS_AUTO_UPDATE` defaults on, and the cache refresh moved behind that
  gate so opted-out engineers no longer pay a network fetch per install
- suppress git credential prompts and bound every spawn, so enabling the
  above cannot hang `yarn install` on a private-source fetch
- reject `base: true` alongside `maturity: experimental`, and warn when a
  base description is too thin to be selected
- rewrite six base descriptions that could never have triggered
  ("General coding guidelines" was 25 characters)

Base set: mobile-testing, coding-guidelines, controller-integration,
ui-development, component-scaffold, performance, pr-guidelines, create-pr,
pr-description, pr-codeowners.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`yarn skills` reads as "give me the skills", so having it install 10 instead
of 36 was a surprising regression for a command an engineer typed on purpose.
Split the two paths: an automatic install nobody asked for gets the minimum
that works, an explicit run still gets every domain.

`postinstall` now exports SKILLS_DEFAULT_SCOPE=base when it delegates to sync,
and sync branches its *fallback* on that. Everything above the fallback in the
resolution order is untouched — a --domain flag, SKILLS_DOMAINS, the picker and
a saved selection all still win.

That last part is the point: passing `--domain none` from postinstall would
have outranked a saved selection, so an engineer who opted into perps would
have silently lost it on their next `yarn install`.

| path | domains | installed |
| --- | --- | --- |
| `yarn skills`, no config | all | 36 |
| `yarn install`, no config | none | 10 |
| `yarn install`, SKILLS_DOMAINS=perps | perps | 12 |
| `yarn skills --domain none` | none | 10 |

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@NicolasMassart NicolasMassart left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking on the delegated postinstall path still being able to wait indefinitely.

Comment thread bin/metamask-skills.mjs
run() bounds its own spawns, but delegate() called spawnSync directly with no
timeout. That is the subprocess launching tools/sync — including its git work —
from the automatic postinstall path, so a stalled child could still hang
`yarn install` indefinitely despite the "every spawn is capped" guarantee.

Three details beyond adding the flag:

- DELEGATE_TIMEOUT_MS is double SPAWN_TIMEOUT_MS, not equal to it. delegate()
  wraps a script that itself makes several already-capped calls; with the same
  budget the outer timer would fire first, after the wrapper's own startup cost,
  and kill a legitimately slow clone from the outside. That drops the engineer to
  the stale bundled snapshot — the exact outcome the existing cap is written to
  avoid.

- killSignal is SIGKILL rather than the default SIGTERM. The child is Bash, which
  does not reliably forward a term signal to an in-flight git, so SIGTERM can
  leave an orphan holding the index lock and break the next install too.

- spawnSync reports status === null on timeout, so `result.status ?? 1` returned
  a bare 1, indistinguishable from an ordinary failure. It now detects
  ETIMEDOUT explicitly and says what happened and what state the engineer is left
  in, which is what makes the failure reportable rather than merely bounded.

`...options` is spread after `timeout`, matching run(), so an explicit override
still wins.

Verified: node --check passes, yarn lint clean, 65/65 tests pass. Confirmed
empirically that spawnSync with timeout + SIGKILL yields error.code ETIMEDOUT
and status null, so the new branch is the one that fires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor
cursor Bot requested review from NicolasMassart and abretonc7s August 28, 2026 14:59
NicolasMassart
NicolasMassart previously approved these changes Aug 28, 2026

@NicolasMassart NicolasMassart left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me.

@Qbandev

Qbandev commented Aug 28, 2026

Copy link
Copy Markdown

Reviewed in depth — the diagnosis in the PR body is genuinely good (three gaps, one problem), and every number in it checks out against a clone at 637242a (65/65 tests, 0 lint errors, 10/36/12/4/0 domain counts verified). One blocker on top of @NicolasMassart's delegate() timeout comment, plus a few smaller items.

Blocker: legacy saved "all" selections are silently dropped to base-only.
The old picker returned empty output for the blank/'a' = "all [recommended]" choice, and the persist block wrote that as a literal SKILLS_DOMAINS= in .skills.local. After this PR, load_saved can't distinguish that from "no saved selection", so the [[ -n "$saved" ]] test fails and those engineers fall through to the new base-only fallback — reproduced end to end:

# legacy .skills.local with SKILLS_DOMAINS=
Domains: <base skills only>  (source: base only (automatic install …))   # 10 skills
# same file with SKILLS_DOMAINS=all
Domains: <all>  (source: saved (.skills.local))                          # 36 skills

This breaks the PR's own "a saved selection still wins" guarantee — and since pruning is off by default, the other 26 skills stay on disk and silently go stale rather than visibly disappearing. Small fix in load_saved (tools/sync:214): treat key-presence as "saved" and map an empty value to all:

load_saved() {
  [[ -f "$CONFIG_FILE" ]] || return 0
  grep -q '^SKILLS_DOMAINS=' "$CONFIG_FILE" || return 0
  local v; v=$(awk -F= '/^SKILLS_DOMAINS=/{sub(/^SKILLS_DOMAINS=/,""); print; exit}' "$CONFIG_FILE")
  echo "${v:-all}"
}

SSH prompts can still hang yarn install.
GIT_TERMINAL_PROMPT=0 + empty GIT_ASKPASS (bin/metamask-skills.mjs:272,411) don't affect ssh, which reads /dev/tty directly for passphrase/host-key prompts. The private Consensys overlay is documented as an SSH clone (tools/sync:166) and gets git pull --ff-only on every automatic sync — exactly the repo the Safety section names. Add GIT_SSH_COMMAND: 'ssh -oBatchMode=yes' alongside the existing two vars in both places. (Complementary to the delegate() timeout: the timeout bounds the hang, BatchMode prevents it.)

Smaller items:

  • base: truthiness diverges across 4 implementations: the linter's TRUTHY accepts on (lint-skill-entry.mjs:36), but tools/install's is_truthy, tools/sync's domain_has_base regex, and the CLI's isTruthy don't. Verified: base: on passes lint as a base skill but the installer silently skips it — the exact failure mode the new lint rules exist to prevent. Simplest fix: drop on/off from the linter's sets.
  • README:57 now states the opposite of the new behavior ("run sync only when SKILLS_AUTO_UPDATE=1") — both halves are false after this PR.
  • No CHANGELOG entry, despite a breaking frontmatter rename (mandatory → base) and a default-behavior flip. Warrants 0.3.0.
  • No tests for the new behavior — test/lint-skill-entry.test.mjs is a ready-made harness for the two new lint rules, and a test on the saved-selection resolution would have caught the blocker above.
  • A typo'd domain (--domain nome) silently installs base-only with exit 0 — worth validating against list_domains() now that SKILLS_DOMAINS is the documented opt-in on a default-on path.

Review pass over the base-skill work. Each item is a path where the new
default-on install either failed silently or failed harder than it should.

- .skills.local readers disagreed on inline comments. The JS side strips a
  ` # comment` tail via stripInlineComment(); Bash kept it, so the
  `SKILLS_DOMAINS=perps # my choice` shape that .skills.local.example teaches
  reached the new domain validation as `perps#mychoice` and aborted the run.
  read_config_value() now strips consistently, for every saved key.

- `--select` with no valid entries returned "", which resolves to `all`. An
  engineer who ran the picker specifically to narrow their set, then mistyped
  the numbers, installed every domain instead. Blank already means `none`; an
  all-invalid selection now means the same thing.

- An explicitly empty `SKILLS_AUTO_UPDATE=` is a choice, not an absence. Under
  0.2.0 isTruthy('') was false, so engineers wrote exactly that to turn syncing
  off, and reading it as unset would silently re-enable it. Only a genuinely
  absent key defaults to on — the same rule load_saved() applies to an empty
  SKILLS_DOMAINS.

- GIT_SSH_COMMAND is appended to rather than replaced. Overwriting breaks a
  custom key, an agent or a ProxyCommand on the private overlay clone these
  guards exist to protect, and buildDelegatedEnv() is shared, so it would hit
  the explicit `yarn skills` path too.

- killSignal is back to the default SIGTERM. spawnSync's timeout signals only
  the direct child, so neither signal reaches an in-flight git grandchild;
  SIGKILL bought nothing and removed Bash's ability to run a trap. The orphan
  holding .git/index.lock is a real gap, now documented as one instead of
  claimed as fixed.

- tools/install accepts the pre-rename `mandatory` key. Executable code ships
  in the pinned package while domains/ content can come from the origin/main
  cache or a private overlay on its own cadence, so the two can legitimately be
  out of step; without the alias that skew installs zero base skills and exits
  0. Recognised as DEPRECATED_FRONTMATTER so the linter warns rather than
  reporting an unknown key.

- Linter truthiness matches the other three implementations. It alone accepted
  `on`/`off`, so `base: on` linted clean while the installer skipped the skill.
  The base description floor is an error, not a warning: only errors set a
  non-zero exit, so as a warning it never failed a job.

- Restored the `sync --save` changelog entry, dropped when the new Fixed block
  replaced it.

Verified: yarn lint:changelog passes, 69/69 tests pass, skill linter reports 0
errors across 52 skills. Confirmed empirically that a saved
`SKILLS_DOMAINS=perps # my choice` previously exited 1 with
"unknown domain(s): perps#mychoice".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@andrepimenta

Copy link
Copy Markdown
Member Author

All of this is addressed in c30fa37. Going through it in order — and two of your items led to finding a further bug each, which is noted below.

Blocker — legacy saved "all" dropped to base-only. Fixed, and your reproduction was exactly right. I took your load_saved shape but factored the key-presence + read into a shared read_config_value(), because SKILLS_MATURITY, SKILLS_INCLUDE and SKILLS_EXCLUDE all had the same awk-per-key duplication.

That refactor surfaced a second bug in the same readers: the JS side strips a trailing # comment via stripInlineComment(), the Bash side never did. Harmless while an unmatched domain just meant "install nothing extra" — but with the new validation you asked for below, it became a hard failure on the exact shape .skills.local.example teaches:

$ printf 'SKILLS_DOMAINS=perps # my choice\n' > .skills.local && yarn skills
Error: unknown domain(s): perps#mychoice  (source: saved (.skills.local))

So the validation and the comment-stripping had to land together; separately, the first would have broken configs the docs recommend.

SSH prompts. Fixed in both places — with one deviation: appended, not replaced.

return /(^|\s)-o\s*BatchMode=/u.test(base) ? base : `${base} -oBatchMode=yes`;

Hard-setting GIT_SSH_COMMAND would clobber a custom key, a 1Password/Secretive agent, or a ProxyCommand — on the private-overlay clone these guards exist to protect. And since buildDelegatedEnv() is shared, that would have hit the explicit yarn skills path too, not just postinstall.

base: truthiness across 4 implementations. Fixed — on/off dropped from the linter, which is now the same 1|true|yes as the other three. Related: tools/install now also accepts the pre-rename mandatory key, because executable code ships in the pinned package while domains/ content can come from the origin/main cache or a private overlay on its own cadence. Without the alias, that skew installs zero base skills and exits 0. It's registered as DEPRECATED_FRONTMATTER so the linter warns instead of reporting an unknown key.

README:57. Fixed.

CHANGELOG / 0.3.0. Added, including two **Breaking:** entries for the rename and the default flip. Agreed on 0.3.0 — the bump itself is left to create-release-pr.yml off ## [Unreleased] rather than hand-edited. Worth flagging for whoever cuts it: metamask-mobile pins ^0.2.0, which resolves >=0.2.0 <0.3.0 and therefore cannot reach 0.3.0. Publishing as 0.2.x instead would push both breaking changes to every existing consumer silently, so the pin has to move in lockstep.

Tests. Added to test/lint-skill-entry.test.mjs — 65 → 69, covering the description floor as an error rather than a warning (only errors set a non-zero exit, so as a warning it never actually failed a job), the boundary at exactly BASE_DESCRIPTION_MIN, short descriptions on non-base skills still passing, and base: on not being truthy.

Typo'd domain silently installing base-only. Fixed — validated against list_domains(), all/none exempted as sentinels.

This one also surfaced a sibling bug in --select: when every entered number was invalid, interactive_select returned "", which resolves to all. So an engineer who opened the picker specifically to narrow their set and then fat-fingered the numbers installed every domain. Blank already meant none; an all-invalid selection now means the same.

Supply chain (raised on the mobile PR, but the fix belongs here). tools/sync now execs $SCRIPT_DIR/install — the sibling of the pinned tools/sync that's already running — instead of preferring $METAMASK_SKILLS_DIR/tools/install. The cache still supplies domains/ content; it no longer supplies executable code. As you asked, this lands before the mobile dependency bump.

Verified on c30fa37: yarn lint:changelog passes, 69/69 tests, skill linter 0 errors across 52 skills.

Thanks — the two blockers were both real, and chasing them turned up two more.

@NicolasMassart NicolasMassart left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. The previous blocking issues have been addressed, and I found no new blockers in the latest changes.

The domain-resolution logic would benefit from additional regression tests, but I don't consider that blocking.

@andrepimenta
andrepimenta enabled auto-merge (squash) September 3, 2026 12:44
@andrepimenta
andrepimenta merged commit 48c90e8 into main Sep 3, 2026
28 checks passed
@andrepimenta
andrepimenta deleted the feat/base-skills-mobile branch September 3, 2026 12:53
NicolasMassart pushed a commit that referenced this pull request Sep 3, 2026
Turns on a **base skill set** that installs for every engineer on a
fresh clone, with no flags and nothing to read first. Mobile gets 10
skills; everything else stays opt-in.

The `mandatory` frontmatter flag is renamed to `base` and actually used.
It was already implemented end to end — `tools/install`, `tools/sync`,
the schema, the CLI, even a `★` marker in the interactive picker — but
**no skill had ever set it**, and the default domain filter was
permissive enough that the bypass had nothing to bypass.

Three things had to be true together for "base skills by default" to
work. Only the flag existed.

| Problem | Fix |
|---|---|
| Empty `SKILLS_DOMAINS` meant *all* domains, so `base` never applied |
`none`/`all` sentinels |
| `SKILLS_AUTO_UPDATE` was off by default, so `postinstall` refreshed
the cache and installed nothing | Defaults on. The cache refresh moved
*behind* the gate, so opted-out engineers no longer pay a network fetch
per install |
| Six base descriptions were too thin to ever trigger | Rewritten.
`coding-guidelines` was literally `"General coding guidelines"` — 25
characters |

That last one is the subtle one: a skill's description is its **entire
trigger surface**. A 25-character description never fires on the work it
governs, while appearing to be covered.

`yarn skills` reads as "give me the skills", so having it install 10
instead of 36 would be a surprising regression for a command someone
typed on purpose. The two paths differ:

| Path | Domains | Installed |
|---|---|---|
| `yarn skills`, no config | all | **36** — unchanged from today |
| `yarn install` → `postinstall`, no config | none | **10** |
| `yarn install` **with `SKILLS_DOMAINS=perps`** | perps | **12** = 10
base + 2 perps |
| `yarn skills --domain none` | none | 10 |

`postinstall` exports `SKILLS_DEFAULT_SCOPE=base` and `tools/sync`
branches its **fallback** on that. Everything above the fallback in the
resolution order is untouched — a `--domain` flag, `SKILLS_DOMAINS`, the
picker, and a saved selection all still win.

That last property is the point of doing it this way. Passing `--domain
none` from `postinstall` would have outranked a saved selection, so an
engineer who opted into `perps` would have silently lost it on their
next `yarn install`.

`mobile-testing` · `coding-guidelines` · `controller-integration` ·
`ui-development` · `component-scaffold` · `performance` ·
`pr-guidelines` · `create-pr` · `pr-description` · `pr-codeowners`

`pr-description` and `pr-codeowners` are included because `create-pr`
delegates its main output to them with "if available" phrasing — without
them it silently runs in a reduced mode.

Always-on cost: **~1,130 tokens** per session. The set is deliberately
small: when the agent's skill listing overflows its budget, descriptions
get truncated starting with the least-invoked skills, so a bloated base
set makes *every* skill harder to trigger.

Enabling auto-update puts git operations on every engineer's install
path, so this also:

- suppresses git credential prompts (`GIT_TERMINAL_PROMPT=0`, empty
`GIT_ASKPASS`) in both `run()` and the delegated environment —
`tools/sync` pulls the **private** Consensys overlay, which could
otherwise block on stdin during `yarn install` with no output
- bounds every spawn: 300s for network git, 10s for local `bash
--version` probes

- `base: true` + `maturity: experimental` is now a **lint error** —
contradictory intent; installing unfinished guidance for everyone is the
bad outcome
- warns when a base skill's description is under 120 characters

| Check | Result |
|---|---|
| Automatic path (`SKILLS_DEFAULT_SCOPE=base`) | exactly 10, in
`.claude/skills` and `.agents/skills` |
| Explicit `yarn skills` | 36 |
| Automatic + `SKILLS_DOMAINS=perps` | 12 — saved selection respected |
| `SKILLS_AUTO_UPDATE=0` / `CI=1` | silent, exit 0 |
| Full `postinstall` → `sync` → `install` | exit 0 |
| Overlay merge intact | `coding-guidelines` 71-byte source stub → 3,990
bytes installed |
| `node --test` | 65/65 |
| `lint-skill-entry.mjs` | 52 skills, 0 errors |

Existing engineers are **not** dropped to 10. Their current installs
stay, because pruning is off by default and their next `yarn install`
only adds the base set. `yarn skills --prune-stale` once gives a clean
state.

This does reach **external contributors**: the skills package is a
devDependency of public repos, so a drive-by contributor's `yarn
install` now clones this repo into `.skills-cache/` and writes 10
gitignored skill files. Public content, gitignored output, one clone —
flagging it rather than letting it be discovered later.

- Extension's base set: stacked PR on this branch
- Consumer side: MetaMask/metamask-mobile#35263 (draft — needs a
published release of this)

Known gaps documented separately: no standardized PR-review skill,
platform APIs at 2 of ~11, and five deprecated testing skills that still
compete with `mobile-testing`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
andrepimenta added a commit that referenced this pull request Sep 3, 2026
## ⚠️ Merge after #135

Stacked on `feat/base-skills-mobile`. The `base` flag this PR sets is
introduced there — **merge #135 first**, then this retargets to `main`
automatically.

## What

Marks the 10 skills every `metamask-extension` engineer needs as `base`,
so a fresh clone lands exactly those.

Four are already shared with Mobile and land in #135 —
`coding-guidelines`, `ui-development`, `controller-integration`,
`pr-guidelines`. This PR adds the six Extension-only ones:

| Skill | Why base |
|---|---|
| `extension-testing` | Testing entrypoint — unit, integration (stub),
Selenium E2E |
| `controller-guidelines` | Authoring a controller: BaseController
choice, state metadata, messenger typing |
| `perf-rendering` | Lists, virtualization, memoization, code splitting
|
| `perf-hooks-effects` | `useEffect` misuse, dependency arrays,
cascading effects |
| `perf-react-compiler` | What the compiler optimizes, where it bails
out, when manual memoization remains |
| `perf-state-management` | Redux reducer purity, selector identity,
normalization |

Always-on cost: **3,257 characters ≈ 814 tokens** per session across all
ten.

## Descriptions

Both caveats from the original version are addressed.

**Descriptions now state trigger conditions, not topics.** They were
34–39 characters — `"Rendering performance optimization"` says what a
skill is about but never when to reach for it, and nobody phrases the
problem that way; they say the list stutters when they scroll. Each now
leads with concrete coverage and ends with a `Use when …` clause in
symptom vocabulary.

The original mitigation here — "CI warns on each until rewritten, so the
TODO cannot rot" — **was false**, as @Qbandev demonstrated: the linter
only sets a non-zero exit on errors, so a warning sat invisibly inside a
green check. #135 promotes the base-description floor to an error, which
is what makes this PR's descriptions load-bearing rather than optional.

**The four `perf-*` skills now route to each other.** They were mutually
indistinguishable — "my component re-renders too much" matched all four
equally, so selection was arbitrary and the model could pull ~20KB
scattershot. Each now names its siblings, so whichever fires hands off
rather than competing.

This mitigates the four-slots-for-one-concern problem; it does not solve
it. Consolidating to a single entrypoint with on-demand references, as
Mobile's `performance` does, remains the structural fix and is still
worth doing separately.

Nothing here is measured. The argument is mechanical — the description
is the selection surface, symptoms were absent, siblings were
undifferentiated — not the result of a triggering experiment.

## Scope

Proposed from the catalogue rather than from working in
`metamask-extension`, so **it still needs owner review** on:

- whether `extension-testing` is genuinely the right single testing
entrypoint

Resolved since the original: @MajorLift confirmed
`controller-guidelines` and `controller-integration` should **both** be
base, with `controller-integration` the more valuable for client work
since integrating controllers is routine and authoring them is not. They
now cross-reference each other, and `controller-integration`'s Extension
reference gains a section pointing at the authoring guidance.

## Known Extension gaps (not addressed here)

| Gap | Notes |
|---|---|
| **Almost no PR automation** | 9 of 10 `pr-workflow` skills are
Mobile-only, including `create-pr`. Extension gets PR *standards* but no
PR *automation*. Overlay work on existing skills — likely the cheapest
high-value win. |
| No `component-scaffold` equivalent | Design-system-first intent has no
enforcement at generation time |
| No component-view test layer | The "steer agents toward component view
tests" goal is Mobile-only today |
| Platform APIs | 1 of ~11 areas covered |

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
andrepimenta added a commit that referenced this pull request Sep 8, 2026
## Summary

`yarn install` installs **zero base skills** for anyone with
`CONSENSYS_SKILLS_DIR` set, and reports `Install complete.` while doing
it. Found while testing 0.3.0 end to end from the published package in
metamask-mobile.

This is a regression from #135 — my own change.

## Root cause

`tools/sync` picked its installer with an executable test:

```bash
if [[ -x "$SCRIPT_DIR/install" ]]; then
  INSTALL_BIN="$SCRIPT_DIR/install"
else
  for src in "${SOURCES[@]}"; do ... done   # keeps the LAST match
fi
```

npm and Yarn do not preserve the executable bit when unpacking a
tarball, so every `tools/*` file arrives `0644` in a consumer install:

```
node_modules/@metamask/skills/tools/   -rw-r--r--   install
.skills-cache/metamask-skills/tools/   -rwxr-xr-x   install
```

So the guard is **always false in the published package** and always
true in a git checkout — which is the only place it was ever exercised.
The pinned installer was never selected. The fallback loop ran instead,
and since it keeps the last match while `SOURCES` appends
`CONSENSYS_SKILLS_DIR` after `METAMASK_SKILLS_DIR`, the private
overlay's `tools/install` won every install.

An overlay predating the `base:` key has no notion of base skills, so it
skipped all 16 as `excluded by --domain` and exited 0.

Deterministic on a real checkout — identical command either side:

```
chmod +x tools/install → base-included: 16, .claude/skills: 10
chmod -x tools/install → base-included:  0, .claude/skills:  0
```

## Why it survived review

Two conditions had to line up, and #135 supplied both:

- Preferring the overlay in the fallback was **already** wrong, but
harmless — the overlay's installer understood every flag in use, so
output was identical.
- `base:` is the first flag it doesn't implement, turning a silent
misroute into zero skills.

Before #135, sync preferred `$METAMASK_SKILLS_DIR/tools/install`, which
the CLI defaults to the `.skills-cache` clone — a git checkout, so the
bit survived and the first branch always won. #135 was right to stop
executing code from that unpinned clone, but swapped in a condition that
cannot hold in the artifact it was protecting.

It cannot reproduce from a source checkout, so neither CI nor local dev
would ever see it.

## Changes

| File | Change |
|---|---|
| `tools/sync` | test `-f`, `exec bash "$INSTALL_BIN"` |
| `tools/bootstrap`, `tools/deploy` | same treatment — both exec a
sibling installer directly |
| `test/cli.test.mjs` | regression test |

The test builds the fixture that reproduces it — package installer at
`0644` as npm ships it, overlay at `0755` as git checks it out — and
asserts the pinned one runs. Reverting only `tools/sync` fails it with
`STALE-OVERLAY`.

## Testing

- `yarn test` — 70/70 (69 + the new one)
- `yarn lint`, `yarn lint:changelog` — clean
- Verified against the published `0.3.0` in metamask-mobile: `chmod +x`
on the package tools restores all 16 base skills

## Follow-up

Needs a **0.3.1** — 0.3.0 cannot deliver base skills to overlay users.
metamask-mobile#35263 is pinned at `^0.3.0` and picks the patch up
automatically.

Also worth noting: the supply-chain property #135 claimed has never
actually held for a consumer. Until this lands, every install executes
`tools/install` from the git cache or the overlay rather than the
lockfile-pinned package.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
pull Bot pushed a commit to Reality2byte/metamask-mobile that referenced this pull request Sep 8, 2026
> [!NOTE]
> **Unblocked.** MetaMask/skills#135 and MetaMask/skills#154 are merged
and `@metamask/skills@0.3.1` is published. Ready to review.

## **Description**

Agent skills currently require running `yarn skills` and picking domains
by hand. This makes the base set arrive from a plain `yarn install`
instead, so a fresh clone lands 10 skills with no flags and nothing to
read first. Everything else stays opt-in via `yarn skills --select`.

| File | Change |
|---|---|
| `package.json` | `@metamask/skills` bumped to `^0.3.1`; `postinstall`
can no longer fail `yarn install` |
| `yarn.lock` | resolves `@metamask/skills@npm:0.3.1` |
| `scripts/setup.mjs` | `yarn setup` reports which skills are on disk |
| `.skills.local.example` | documents the base-only default and the
opt-outs |
| `README.md`, `AGENTS.md`, `.gitignore` | all three described `yarn
skills` as the only way skills arrive |

**Why 0.3.1 and not 0.3.0.** The base-skill default shipped in 0.3.0,
but 0.3.0 cannot deliver it to anyone with `CONSENSYS_SKILLS_DIR` set:
`tools/sync` chose its installer with an `-x` test, npm strips the
executable bit when unpacking, so it fell through to the overlay's copy
of `tools/install` — which predates `base:` and installed **zero**
skills while exiting 0. Found by testing this branch against the
published package; fixed in MetaMask/skills#154 and released as 0.3.1.

Verified against the published 0.3.1 with the exec bits exactly as npm
delivers them (`tools/install` at `0644`) and the overlay configured:

```
yarn skills:postinstall   claude=10 cursor=10 agents=10
yarn skills               claude=39 cursor=39 agents=39
```

Same setup returned 0/0/0 on 0.3.0.

**Why the postinstall guard is `|| true`.** Unguarded, an unresolvable
`@metamask/skills` (production-only install, partial `node_modules`)
exits 127 and **`yarn install` exits 1** — measured. That contradicts
`README.md`, which promises *"Skipping `yarn skills` is fine — it only
affects agent tooling, not the app build."*

It was `|| echo '...' >&2` first. Measured with yarn 4.14.1 in a scratch
workspace, failing postinstall either way:

| postinstall | install #1 | install #2 |
|---|---|---|
| bare | `YN0009` + `build.log` path | retries |
| `\|\| echo …` | `YN0007` only, message printed nowhere | **never
retried** |

Yarn surfaces build output only for builds it considers *failed*, so the
guard discarded its own message **and** marked the build successful,
which stopped Yarn retrying. `|| true` is the honest form; the README
now says plainly that nothing is printed either way.

**Why the setup report.** Yarn swallows all postinstall output into a
build log, so the base set arrived silently and nobody learned that team
skills are opt-in. `yarn setup` runs in the foreground and is run often.
The task only reads `.claude/skills/`; it never runs a sync, so `yarn
setup` does not install twice. Skipped in CI and `--node` mode, where
`postinstall` already skips installing.

Output:

```
....
     Found 10 agent skill(s) in .claude/skills.

     The base set installs automatically; yarn skills adds every domain:
      🔎 Pick specific domains:       yarn skills --select
      📖 Inspect one:                 yarn metamask-skills describe <domain>/<skill>
      🧹 Drop ones no longer managed: yarn skills --prune-stale
                                      (add --domain none for the base set only)
....
```

"Found", not "installed": `postinstall` returns 0 even when the sync
fails, so this is a directory listing rather than a report of what this
run did. It names only Claude Code because it only reads
`.claude/skills` — the three targets drift, and captioning it "and
Cursor" reported 10 on a checkout where Cursor was loading 39.

The prune hint cannot be one command. With a domain saved, plain
`--prune-stale` keeps it (12 → 12) and `--domain none` silently discards
it (12 → 10); with nothing saved the fallback is `all`, so plain
`--prune-stale` installs every domain first (10 → 37). Both measured.

**One oddity worth knowing.** The trailing blank line comes from a `​`
constant, not whitespace. Listr trims each captured `console.log` chunk,
so a trailing newline, space, tab, NBSP, or an extra `console.log('')`
is all removed and the next task's output butts against this one. A
zero-width space is not in JavaScript's whitespace set, so it survives
the trim while rendering as nothing. Written as an escape with a comment
rather than a literal, so no invisible byte sits in the file for a
formatter to strip.

**Behaviour change.** Engineers without `SKILLS_DOMAINS` set go from ~36
skills to 10 on their next dependency change. Nothing is pruned
automatically, so existing installs accumulate — `yarn skills
--prune-stale` cleans up once, and `SKILLS_DOMAINS=all` restores
previous behaviour. Opt out entirely with `SKILLS_AUTO_UPDATE=0` or
`SKILLS_SKIP_POSTINSTALL=1`.

This also reaches **external contributors**: `@metamask/skills` is a
devDependency of a public repo, so a drive-by contributor's `yarn
install` will clone the public skills repo into `.skills-cache/` and
write 10 gitignored skill files. Public content, gitignored output, one
clone — flagging it rather than letting it be discovered later.

## **Changelog**

CHANGELOG entry: null

Developer tooling only — no user-facing change.

## **Related issues**

Refs: AIENG-38
Depended on: MetaMask/skills#135 and MetaMask/skills#154 (both merged,
released as `@metamask/skills@0.3.1`)

## **Manual testing steps**

```gherkin
Feature: base agent skills install by default

  Scenario: engineer runs setup and sees which skills are available
    Given a checkout with @metamask/skills at a version that supports `base`
    When the engineer runs `yarn setup:expo`
    Then the "Report agent skills" task lists the number of available skills
    And it points at `yarn skills --select` for opt-in domains
    And the EXPO "Setup complete!" banner remains the final message

  Scenario: a broken skills CLI does not fail the install
    Given @metamask/skills is not resolvable
    When the engineer runs `yarn install`
    Then the install exits 0
    And `yarn skills:postinstall` run by hand still exits non-zero and reports why

  Scenario: CI does not run the report
    Given CI is set
    When setup runs
    Then the "Report agent skills" task is skipped
```

## **Screenshots/Recordings**

### **Before**

N/A — no UI change. Before this PR, `yarn setup` printed nothing about
agent skills.

### **After**

Terminal output shown in the Description above.

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

#### Performance checks (if applicable)

- [x] I've tested on Android
- [x] I've tested with a power user scenario
- [x] I've instrumented key operations with Sentry traces for production
performance metrics

N/A for all three — this changes a setup script and a package.json
script, with no app-runtime code path.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Developer-only install and setup scripting; no app runtime or
production dependency paths change beyond a resilient postinstall hook.
> 
> **Overview**
> **Fresh clones get a default agent skill set without running `yarn
skills` first.** The PR bumps `@metamask/skills` to `^0.3.1` so
`postinstall` syncs only **base** skills during `yarn install` / setup;
`yarn skills` still installs every domain. Docs (README, AGENTS.md,
`.skills.local.example`, `.gitignore`) are updated to match that split
and to document `SKILLS_DOMAINS`, opt-outs (`SKILLS_AUTO_UPDATE=0`), and
when Yarn actually re-runs postinstall.
> 
> **`yarn install` no longer fails if the skills hook breaks:**
`postinstall` is now `yarn skills:postinstall || true`, aligning with
the promise that agent tooling is optional for the app build.
> 
> **`yarn setup` surfaces what’s on disk:** a new **Report agent
skills** Listr task (local only, not CI/`--node`) lists `mms-*` folders
under `.claude/skills` and points people at `yarn install` for the base
set or `yarn skills` for all domains, without triggering a second sync.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
51dd8ea. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants