From 59f78417a19b0e1d167aaa9009de518966652816 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 2 Jun 2026 10:16:57 +0800 Subject: [PATCH 01/11] feat(skills): add agent skills integration --- .gitignore | 14 +- .skills.local.example | 27 +++ README.md | 19 +++ package.json | 6 +- scripts/skills-postinstall.ts | 134 +++++++++++++++ scripts/skills-sync.ts | 299 ++++++++++++++++++++++++++++++++++ 6 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 .skills.local.example create mode 100644 scripts/skills-postinstall.ts create mode 100644 scripts/skills-sync.ts diff --git a/.gitignore b/.gitignore index 2f1de08239..587c73925b 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,16 @@ scripts/coverage packages/*/*.tsbuildinfo # AI -.sisyphus/ \ No newline at end of file +.sisyphus/ + +# Agent skills +# Copy `.skills.local.example` to `.skills.local` and edit `SKILLS_DOMAINS=`. +.skills.local + +# Public MetaMask/skills cache maintained by `yarn skills` / `yarn setup`. +.skills-cache/ + +# Generated by MetaMask/skills tools/install. Run `yarn skills` to refresh. +.claude/skills/ +.agents/skills/ +.cursor/rules/ diff --git a/.skills.local.example b/.skills.local.example new file mode 100644 index 0000000000..76b62e872e --- /dev/null +++ b/.skills.local.example @@ -0,0 +1,27 @@ +# Template for per-engineer skills config used by `yarn skills`. +# Copy this file to `.skills.local` (gitignored). +# +# Zero-config default: `yarn setup` and `yarn skills` clone MetaMask/skills into +# `.skills-cache/metamask-skills`. `yarn skills` auto-detects that cache when no +# env var is set — nothing to do. +# +# Optional persistent skills config belongs in this file. Environment variables +# with the same names are only for one-off shell or CI overrides and take +# precedence over this file. +# METAMASK_SKILLS_DIR path to MetaMask/skills checkout (public, no auth) +# CONSENSYS_SKILLS_DIR path to Consensys/skills checkout (private overlay) +# +# Example local setup (only if you want to override the cache): +# METAMASK_SKILLS_DIR=~/dev/metamask/skills +# CONSENSYS_SKILLS_DIR=~/dev/Consensys/skills # optional +# +# Default behavior installs ALL stable domains available for Core. Set +# SKILLS_DOMAINS to opt out of some: +# SKILLS_DOMAINS= # all (default) +# SKILLS_DOMAINS=perps # single domain +# SKILLS_DOMAINS=perps,coding,pr-workflow # multiple domains +# +# Override per-run with `SKILLS_DOMAINS=... yarn skills` or `--domain `. +# Pick interactively with `yarn skills --select`. +# Use `yarn skills --reset` to wipe. +SKILLS_DOMAINS= diff --git a/README.md b/README.md index 5a6cd3bd82..7887518f97 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,25 @@ See the [Contributor Documentation](./docs) for help on: Each package in this repository has its own README where you can find installation and usage instructions. See `packages/` for more. +## Agent skills + +This repo can install MetaMask agent skills for Claude, Cursor, and Codex/OpenAI. +`yarn setup` best-effort refreshes the public [`MetaMask/skills`](https://github.com/MetaMask/skills) +cache and runs `yarn skills`; `yarn skills` can also be run any time to refresh the +gitignored generated skills under `.claude/skills/`, `.cursor/rules/`, and +`.agents/skills/`. + +By default, all stable skills that support Core are installed. To persist a local +selection, copy `.skills.local.example` to `.skills.local` and set values such as +`SKILLS_DOMAINS=perps`. + +```bash +yarn skills # refresh default stable Core skills +yarn skills --domain perps # install only the perps domain +yarn skills --select # interactively choose domains +yarn skills --reset # clear saved local selection +``` + ## Packages diff --git a/package.json b/package.json index a01894190c..89a22afc0a 100644 --- a/package.json +++ b/package.json @@ -36,13 +36,15 @@ "prepare-preview-builds": "./scripts/prepare-preview-builds.sh", "readme-content:check": "tsx scripts/update-readme-content.ts --check", "readme-content:update": "tsx scripts/update-readme-content.ts", - "setup": "yarn install", + "setup": "yarn install && yarn skills:postinstall && (yarn skills || echo 'skills setup skipped; run `yarn skills` for details')", "test": "yarn test:scripts --silent --collectCoverage=false --reporters=jest-silent-reporter && yarn test:packages", "test:clean": "yarn workspaces foreach --all --parallel --verbose run test:clean && yarn test", "test:packages": "yarn test:verbose --silent --collectCoverage=false --reporters=jest-silent-reporter", "test:scripts": "NODE_OPTIONS=--experimental-vm-modules yarn jest --config ./jest.config.scripts.js --silent", "test:verbose": "yarn workspaces foreach --all --parallel --verbose run test:verbose", - "workspaces:list-versions": "./scripts/list-workspace-versions.sh" + "workspaces:list-versions": "./scripts/list-workspace-versions.sh", + "skills": "tsx scripts/skills-sync.ts", + "skills:postinstall": "tsx scripts/skills-postinstall.ts" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", diff --git a/scripts/skills-postinstall.ts b/scripts/skills-postinstall.ts new file mode 100644 index 0000000000..7407a989b6 --- /dev/null +++ b/scripts/skills-postinstall.ts @@ -0,0 +1,134 @@ +/* eslint-disable n/no-process-env, n/no-process-exit */ +// Auto-update the public MetaMask skills cache. Best-effort: never fails setup. +// +// Core disables package lifecycle scripts (`enableScripts: false`), so this is +// invoked explicitly by `yarn setup` and can also be run as +// `yarn skills:postinstall`. +// +// - Skipped on CI, or when SKILLS_SKIP_POSTINSTALL=1. +// - Override CI skip with SKILLS_FORCE_POSTINSTALL=1 (for CI jobs that +// actually need skills installed, e.g. agent-driven review bots). +// - Clones https://github.com/MetaMask/skills (public, no auth) into +// .skills-cache/metamask-skills if absent. +// - `git fetch + reset` to origin/main if present. +// - Leaves installation/domain selection to `yarn skills`, which reads +// .skills.local and SKILLS_DOMAINS. +// - All errors are swallowed with a one-line warning. Engineers can run +// `yarn skills` manually for interactive feedback. + +import { spawnSync } from 'node:child_process'; +import type { SpawnSyncReturns } from 'node:child_process'; +import { mkdirSync, statSync } from 'node:fs'; +import path from 'node:path'; + +export const CACHE_DIR = '.skills-cache/metamask-skills'; +export const PUBLIC_REPO = 'https://github.com/MetaMask/skills.git'; + +type SpawnSync = typeof spawnSync; +type StatSync = typeof statSync; +type MkdirSync = typeof mkdirSync; +type Stderr = Pick; + +type CacheDeps = { + mkdir?: MkdirSync; + spawn?: SpawnSync; + stat?: StatSync; + stderr?: Stderr; +}; + +type PostinstallDeps = CacheDeps & { + env?: NodeJS.ProcessEnv; +}; + +export function warn(message: string, stderr?: Stderr): void { + const writer = stderr ?? process.stderr; + writer.write(`skills cache: ${message} (run \`yarn skills\` for details)\n`); +} + +export function run( + cmd: string, + args: string[], + spawn?: SpawnSync, +): SpawnSyncReturns { + const spawnFn = spawn ?? spawnSync; + return spawnFn(cmd, args, { stdio: 'ignore' }); +} + +export function isGitDir(dir: string, stat?: StatSync): boolean { + const statFn = stat ?? statSync; + try { + return statFn(path.join(dir, '.git')).isDirectory(); + } catch { + return false; + } +} + +export function shouldSkipPostinstall(env: NodeJS.ProcessEnv): boolean { + return Boolean( + env.SKILLS_SKIP_POSTINSTALL ?? (env.CI && !env.SKILLS_FORCE_POSTINSTALL), + ); +} + +export function ensurePublicSkillsCache(deps?: CacheDeps): boolean { + const mkdir = deps?.mkdir ?? mkdirSync; + const spawn = deps?.spawn ?? spawnSync; + const stat = deps?.stat ?? statSync; + const stderr = deps?.stderr ?? process.stderr; + + try { + const hasCache = isGitDir(CACHE_DIR, stat); + if (hasCache) { + const fetchResult = run( + 'git', + ['-C', CACHE_DIR, 'fetch', '--depth', '1', 'origin', 'main'], + spawn, + ); + if (fetchResult.status !== 0) { + warn('fetch failed (offline?)', stderr); + return false; + } + const resetResult = run( + 'git', + ['-C', CACHE_DIR, 'reset', '--hard', 'origin/main'], + spawn, + ); + if (resetResult.status !== 0) { + warn('reset failed', stderr); + return false; + } + } else { + mkdir(path.dirname(CACHE_DIR), { recursive: true }); + const cloneResult = run( + 'git', + ['clone', '--depth', '1', '--branch', 'main', PUBLIC_REPO, CACHE_DIR], + spawn, + ); + if (cloneResult.status !== 0) { + warn('clone failed (offline?)', stderr); + return false; + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warn(`unexpected error: ${message}`, stderr); + return false; + } + + return true; +} + +export function postinstall(deps?: PostinstallDeps): number { + const env = deps?.env ?? process.env; + + if (shouldSkipPostinstall(env)) { + return 0; + } + + ensurePublicSkillsCache(deps); + return 0; +} + +/* istanbul ignore next */ +if (process.argv[1]?.endsWith(`${path.sep}skills-postinstall.ts`)) { + process.exit(postinstall()); +} diff --git a/scripts/skills-sync.ts b/scripts/skills-sync.ts new file mode 100644 index 0000000000..c5c1889ec4 --- /dev/null +++ b/scripts/skills-sync.ts @@ -0,0 +1,299 @@ +/* eslint-disable n/no-process-env, n/no-process-exit */ +// Wrapper for `yarn skills`. Picks a multi-source-aware tools/sync from +// whichever skill repo is configured and delegates. +// +// Source configuration comes from env vars first, then .skills.local. +// Prefer the public MetaMask/skills sync CLI whenever it is available: +// 1. METAMASK_SKILLS_DIR/tools/sync +// 2. .skills-cache/metamask-skills/tools/sync (zero-config default) +// 3. CONSENSYS_SKILLS_DIR/tools/sync (private fallback when no public source exists) +// The public sync still walks every configured source. Cache fallback means +// `yarn skills` works out of the box from a fresh checkout; if the cache is +// missing, this wrapper clones it before delegating. + +import { spawnSync } from 'node:child_process'; +import { readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; + +import { CACHE_DIR, ensurePublicSkillsCache } from './skills-postinstall'; + +const REPO = 'core'; +const SOURCE_ENV_KEYS = [ + 'METAMASK_SKILLS_DIR', + 'CONSENSYS_SKILLS_DIR', +] as const; +const NO_SOURCE_MESSAGE = [ + 'No skills source available.', + '', + '`yarn skills` normally clones the public skills repo into', + '.skills-cache/metamask-skills automatically. If that did not happen', + '(for example, you are offline), point at a clone manually in .skills.local:', + '', + ' git clone https://github.com/MetaMask/skills ~/dev/metamask/skills', + ' echo METAMASK_SKILLS_DIR=~/dev/metamask/skills >> .skills.local', + '', + 'Optional private overlay (Consensys internal, SSH required):', + ' git clone git@github.com:Consensys/skills.git ~/dev/Consensys/skills', + ' echo CONSENSYS_SKILLS_DIR=~/dev/Consensys/skills >> .skills.local', + '', + 'Then re-run `yarn skills`.', + '', +].join('\n'); +const NO_BASH_MESSAGE = [ + 'No supported Bash found.', + '', + '`yarn skills` requires Bash 4+ because the shared skills installer uses', + 'modern Bash features. macOS /bin/bash is 3.2.', + '', + 'Install a current Bash, then re-run `yarn skills`:', + ' brew install bash', + '', +].join('\n'); + +type StatSync = typeof statSync; +type ReadFileSync = typeof readFileSync; +type SpawnSync = typeof spawnSync; + +type SkillSourceEnv = Record< + (typeof SOURCE_ENV_KEYS)[number], + string | undefined +>; +type SyncScriptPick = { sync: string }; + +export function cacheDir(cwd: string): string { + return path.join(cwd, CACHE_DIR); +} + +export function syncIn(dir: string, stat?: StatSync): string | null { + const statFn = stat ?? statSync; + const candidate = path.join(dir, 'tools', 'sync'); + try { + if (statFn(candidate).isFile()) { + return candidate; + } + } catch { + // ignored + } + return null; +} + +export function bashMajorVersion( + candidate: string, + spawn?: SpawnSync, +): number | null { + const spawnFn = spawn ?? spawnSync; + const result = spawnFn(candidate, ['--version'], { encoding: 'utf8' }); + if (result.status !== 0) { + return null; + } + + const match = `${result.stdout}${result.stderr}`.match( + /GNU bash, version (\d+)\./u, + ); + return match ? Number(match[1]) : null; +} + +export function pickBash( + env?: NodeJS.ProcessEnv, + spawn?: SpawnSync, +): string | null { + const resolvedEnv = env ?? process.env; + const candidates = [ + resolvedEnv.BASH, + 'bash', + '/opt/homebrew/bin/bash', + '/usr/local/bin/bash', + '/bin/bash', + ].filter((candidate): candidate is string => Boolean(candidate)); + + for (const candidate of new Set(candidates)) { + const major = bashMajorVersion(candidate, spawn); + if (major && major >= 4) { + return candidate; + } + } + + return null; +} + +export function expandLeadingTilde( + value: string | undefined, + env?: NodeJS.ProcessEnv, +): string | undefined { + if (!value?.startsWith('~')) { + return value; + } + + const home = (env ?? process.env).HOME; + if (!home) { + return value; + } + + if (value === '~') { + return home; + } + + if (value.startsWith(`~${path.sep}`) || value.startsWith('~/')) { + return path.join(home, value.slice(2)); + } + + return value; +} + +export function parseLocalConfig(contents: string): Record { + const parsed: Record = {}; + + for (const rawLine of contents.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + continue; + } + + const match = /^(?[A-Za-z_][A-Za-z0-9_]*)=(?.*)$/u.exec(line); + if (!match?.groups) { + continue; + } + + let value = match.groups.value.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + parsed[match.groups.key] = value; + } + + return parsed; +} + +export function loadSkillSourceEnv( + cwd?: string, + processEnv?: NodeJS.ProcessEnv, + readFile?: ReadFileSync, +): SkillSourceEnv { + const resolvedCwd = cwd ?? process.cwd(); + const resolvedEnv = processEnv ?? process.env; + const readFileFn = readFile ?? readFileSync; + const env: SkillSourceEnv = { + METAMASK_SKILLS_DIR: resolvedEnv.METAMASK_SKILLS_DIR, + CONSENSYS_SKILLS_DIR: resolvedEnv.CONSENSYS_SKILLS_DIR, + }; + + try { + const localConfig = parseLocalConfig( + readFileFn(path.join(resolvedCwd, '.skills.local'), 'utf8'), + ); + for (const key of SOURCE_ENV_KEYS) { + env[key] ??= expandLeadingTilde(localConfig[key], resolvedEnv); + } + } catch { + // ignored: .skills.local is optional + } + + return env; +} + +export function pickSyncScript( + cwd: string, + sourceEnv: SkillSourceEnv, + stat?: StatSync, +): SyncScriptPick | null { + const publicSync = sourceEnv.METAMASK_SKILLS_DIR + ? syncIn(sourceEnv.METAMASK_SKILLS_DIR, stat) + : null; + if (publicSync) { + return { sync: publicSync }; + } + + const cacheSync = syncIn(cacheDir(cwd), stat); + if (cacheSync) { + return { sync: cacheSync }; + } + + if (sourceEnv.CONSENSYS_SKILLS_DIR) { + const privateSync = syncIn(sourceEnv.CONSENSYS_SKILLS_DIR, stat); + if (privateSync) { + return { sync: privateSync }; + } + } + + return null; +} + +export function buildDelegatedEnv( + cwd: string, + sourceEnv: SkillSourceEnv, + processEnv?: NodeJS.ProcessEnv, + stat?: StatSync, +): NodeJS.ProcessEnv { + const env = { ...(processEnv ?? process.env) }; + for (const key of SOURCE_ENV_KEYS) { + env[key] ??= sourceEnv[key]; + } + if (!env.METAMASK_SKILLS_DIR && syncIn(cacheDir(cwd), stat)) { + env.METAMASK_SKILLS_DIR = cacheDir(cwd); + } + return env; +} + +export function prependBashToPath( + env: NodeJS.ProcessEnv, + bash: string, +): NodeJS.ProcessEnv { + if (!bash.includes(path.sep)) { + return env; + } + return { + ...env, + PATH: `${path.dirname(bash)}${path.delimiter}${env.PATH ?? ''}`, + }; +} + +export function main( + argv?: string[], + cwd?: string, + processEnv?: NodeJS.ProcessEnv, + spawn?: SpawnSync, + stat?: StatSync, + readFile?: ReadFileSync, +): number { + const resolvedArgv = argv ?? process.argv.slice(2); + const resolvedCwd = cwd ?? process.cwd(); + const resolvedEnv = processEnv ?? process.env; + const spawnFn = spawn ?? spawnSync; + const sourceEnv = loadSkillSourceEnv(resolvedCwd, resolvedEnv, readFile); + + if (!sourceEnv.METAMASK_SKILLS_DIR && !syncIn(cacheDir(resolvedCwd), stat)) { + ensurePublicSkillsCache({ spawn: spawnFn, stat }); + } + + const picked = pickSyncScript(resolvedCwd, sourceEnv, stat); + if (!picked) { + process.stderr.write(NO_SOURCE_MESSAGE); + return 1; + } + + const bash = pickBash(resolvedEnv, spawnFn); + if (!bash) { + process.stderr.write(NO_BASH_MESSAGE); + return 1; + } + + const env = prependBashToPath( + buildDelegatedEnv(resolvedCwd, sourceEnv, resolvedEnv, stat), + bash, + ); + + const result = spawnFn( + bash, + [picked.sync, '--repo', REPO, '--target', resolvedCwd, ...resolvedArgv], + { stdio: 'inherit', env }, + ); + return result.status ?? 1; +} + +/* istanbul ignore next */ +if (process.argv[1]?.endsWith(`${path.sep}skills-sync.ts`)) { + process.exit(main()); +} From 6521767c61a065cd43ac7881e81547da4c30f538 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 2 Jun 2026 10:41:24 +0800 Subject: [PATCH 02/11] fix(skills): align core setup with skills sync flow --- README.md | 8 ++++---- package.json | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7887518f97..8828e5df6f 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,10 @@ Each package in this repository has its own README where you can find installati ## Agent skills This repo can install MetaMask agent skills for Claude, Cursor, and Codex/OpenAI. -`yarn setup` best-effort refreshes the public [`MetaMask/skills`](https://github.com/MetaMask/skills) -cache and runs `yarn skills`; `yarn skills` can also be run any time to refresh the -gitignored generated skills under `.claude/skills/`, `.cursor/rules/`, and -`.agents/skills/`. +`yarn install` keeps the public [`MetaMask/skills`](https://github.com/MetaMask/skills) +cache available through the postinstall hook. Run `yarn skills` any time to +install or refresh the gitignored generated skills under `.claude/skills/`, +`.cursor/rules/`, and `.agents/skills/`. By default, all stable skills that support Core are installed. To persist a local selection, copy `.skills.local.example` to `.skills.local` and set values such as diff --git a/package.json b/package.json index 89a22afc0a..16b24bf1e3 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "prepare-preview-builds": "./scripts/prepare-preview-builds.sh", "readme-content:check": "tsx scripts/update-readme-content.ts --check", "readme-content:update": "tsx scripts/update-readme-content.ts", - "setup": "yarn install && yarn skills:postinstall && (yarn skills || echo 'skills setup skipped; run `yarn skills` for details')", + "setup": "yarn install", "test": "yarn test:scripts --silent --collectCoverage=false --reporters=jest-silent-reporter && yarn test:packages", "test:clean": "yarn workspaces foreach --all --parallel --verbose run test:clean && yarn test", "test:packages": "yarn test:verbose --silent --collectCoverage=false --reporters=jest-silent-reporter", @@ -44,7 +44,7 @@ "test:verbose": "yarn workspaces foreach --all --parallel --verbose run test:verbose", "workspaces:list-versions": "./scripts/list-workspace-versions.sh", "skills": "tsx scripts/skills-sync.ts", - "skills:postinstall": "tsx scripts/skills-postinstall.ts" + "postinstall": "tsx scripts/skills-postinstall.ts" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", From aa2b929f07a049d1e504f9d90775aa06aa5b068f Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 2 Jun 2026 17:44:44 +0800 Subject: [PATCH 03/11] chore(skills): add opt-in setup auto-update --- .skills.local.example | 4 + README.md | 7 +- scripts/skills-postinstall.test.ts | 137 +++++++++++++++++++++++++++++ scripts/skills-postinstall.ts | 34 ++++++- 4 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 scripts/skills-postinstall.test.ts diff --git a/.skills.local.example b/.skills.local.example index 76b62e872e..253d69c4ad 100644 --- a/.skills.local.example +++ b/.skills.local.example @@ -21,6 +21,10 @@ # SKILLS_DOMAINS=perps # single domain # SKILLS_DOMAINS=perps,coding,pr-workflow # multiple domains # +# Optional: regenerate gitignored installed skills during yarn install/setup after +# the public cache refreshes. Off by default for backward compatibility. +# SKILLS_AUTO_UPDATE=1 +# # Override per-run with `SKILLS_DOMAINS=... yarn skills` or `--domain `. # Pick interactively with `yarn skills --select`. # Use `yarn skills --reset` to wipe. diff --git a/README.md b/README.md index 8828e5df6f..067d1ff024 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,10 @@ cache available through the postinstall hook. Run `yarn skills` any time to install or refresh the gitignored generated skills under `.claude/skills/`, `.cursor/rules/`, and `.agents/skills/`. -By default, all stable skills that support Core are installed. To persist a local -selection, copy `.skills.local.example` to `.skills.local` and set values such as -`SKILLS_DOMAINS=perps`. +By default, all stable skills that support Core are installed when you run `yarn skills`. +Set `SKILLS_AUTO_UPDATE=1` to opt into best-effort regeneration during setup. +To persist a local selection, copy `.skills.local.example` to `.skills.local` and +set values such as `SKILLS_DOMAINS=perps`. ```bash yarn skills # refresh default stable Core skills diff --git a/scripts/skills-postinstall.test.ts b/scripts/skills-postinstall.test.ts new file mode 100644 index 0000000000..a91c764c0a --- /dev/null +++ b/scripts/skills-postinstall.test.ts @@ -0,0 +1,137 @@ +import type { Stats } from 'fs'; + +import { + autoUpdateSkills, + CACHE_DIR, + ensurePublicSkillsCache, + isGitDir, + postinstall, + PUBLIC_REPO, + shouldAutoUpdateSkills, + shouldSkipPostinstall, + warn, +} from './skills-postinstall'; + +function statGitDir(existing: boolean): typeof import('fs').statSync { + return jest.fn(() => { + if (!existing) { + throw new Error('missing'); + } + return { isDirectory: () => true } as Stats; + }) as unknown as typeof import('fs').statSync; +} + +function spawnWithStatuses( + statuses: number[], +): typeof import('child_process').spawnSync { + let index = 0; + return jest.fn(() => { + const status = statuses[index] ?? 0; + index += 1; + return { + status, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + pid: 1, + output: [], + signal: null, + }; + }) as unknown as typeof import('child_process').spawnSync; +} + +describe('skills-postinstall', () => { + it('skips when explicitly disabled or running in CI without force', () => { + expect(shouldSkipPostinstall({ SKILLS_SKIP_POSTINSTALL: '1' })).toBe(true); + expect(shouldSkipPostinstall({ CI: 'true' })).toBe(true); + expect( + shouldSkipPostinstall({ CI: 'true', SKILLS_FORCE_POSTINSTALL: '1' }), + ).toBe(false); + }); + + it('only auto-updates generated skills when explicitly opted in', () => { + expect(shouldAutoUpdateSkills({})).toBe(false); + expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: '0' })).toBe(false); + expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: '1' })).toBe(true); + expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: 'true' })).toBe(true); + expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: 'YES' })).toBe(true); + }); + + it('detects whether the public cache is a git checkout', () => { + expect(isGitDir(CACHE_DIR, statGitDir(true))).toBe(true); + expect(isGitDir(CACHE_DIR, statGitDir(false))).toBe(false); + }); + + it('clones the public skills cache when cache is absent', () => { + const mkdir = jest.fn(); + const spawn = spawnWithStatuses([0]); + + expect( + ensurePublicSkillsCache({ + mkdir, + spawn, + stat: statGitDir(false), + }), + ).toBe(true); + expect(mkdir).toHaveBeenCalledWith('.skills-cache', { recursive: true }); + expect(spawn).toHaveBeenNthCalledWith( + 1, + 'git', + ['clone', '--depth', '1', '--branch', 'main', PUBLIC_REPO, CACHE_DIR], + { stdio: 'ignore' }, + ); + }); + + it('does not run yarn skills by default', () => { + const spawn = spawnWithStatuses([0, 0]); + + expect(postinstall({ env: {}, spawn, stat: statGitDir(true) })).toBe(0); + + expect(spawn).toHaveBeenCalledTimes(2); + }); + + it('runs yarn skills after cache refresh when auto-update is opted in', () => { + const spawn = spawnWithStatuses([0, 0, 0]); + + expect( + postinstall({ + env: { SKILLS_AUTO_UPDATE: '1' }, + spawn, + stat: statGitDir(true), + }), + ).toBe(0); + + expect(spawn).toHaveBeenNthCalledWith(3, 'yarn', ['skills'], { + stdio: 'ignore', + }); + }); + + it('warns but does not fail when auto-update sync fails', () => { + const stderr = { write: jest.fn() }; + + expect(autoUpdateSkills({ spawn: spawnWithStatuses([1]), stderr })).toBe( + false, + ); + expect(stderr.write).toHaveBeenCalledWith( + expect.stringContaining('skills sync failed'), + ); + }); + + it('returns without side effects when postinstall is skipped', () => { + const spawn = spawnWithStatuses([]); + + expect(postinstall({ env: { SKILLS_SKIP_POSTINSTALL: '1' }, spawn })).toBe( + 0, + ); + expect(spawn).not.toHaveBeenCalled(); + }); + + it('formats warnings consistently', () => { + const stderr = { write: jest.fn() }; + + warn('install failed', stderr); + + expect(stderr.write).toHaveBeenCalledWith( + 'skills cache: install failed (run `yarn skills` for details)\n', + ); + }); +}); diff --git a/scripts/skills-postinstall.ts b/scripts/skills-postinstall.ts index 7407a989b6..55fcdb2583 100644 --- a/scripts/skills-postinstall.ts +++ b/scripts/skills-postinstall.ts @@ -1,5 +1,5 @@ /* eslint-disable n/no-process-env, n/no-process-exit */ -// Auto-update the public MetaMask skills cache. Best-effort: never fails setup. +// Refresh the public MetaMask skills cache. Best-effort: never fails setup. // // Core disables package lifecycle scripts (`enableScripts: false`), so this is // invoked explicitly by `yarn setup` and can also be run as @@ -11,9 +11,12 @@ // - Clones https://github.com/MetaMask/skills (public, no auth) into // .skills-cache/metamask-skills if absent. // - `git fetch + reset` to origin/main if present. +// - When SKILLS_AUTO_UPDATE=1, also runs `yarn skills` after the cache +// refresh so generated skills stay current. Off by default for backward +// compatibility. // - Leaves installation/domain selection to `yarn skills`, which reads // .skills.local and SKILLS_DOMAINS. -// - All errors are swallowed with a one-line warning. Engineers can run +// - Failures are reported with a one-line warning. Engineers can run // `yarn skills` manually for interactive feedback. import { spawnSync } from 'node:child_process'; @@ -69,6 +72,10 @@ export function shouldSkipPostinstall(env: NodeJS.ProcessEnv): boolean { ); } +export function shouldAutoUpdateSkills(env: NodeJS.ProcessEnv): boolean { + return /^(1|true|yes)$/iu.test(env.SKILLS_AUTO_UPDATE ?? ''); +} + export function ensurePublicSkillsCache(deps?: CacheDeps): boolean { const mkdir = deps?.mkdir ?? mkdirSync; const spawn = deps?.spawn ?? spawnSync; @@ -117,6 +124,17 @@ export function ensurePublicSkillsCache(deps?: CacheDeps): boolean { return true; } +export function autoUpdateSkills(deps?: PostinstallDeps): boolean { + const spawn = deps?.spawn ?? spawnSync; + const stderr = deps?.stderr ?? process.stderr; + const result = run('yarn', ['skills'], spawn); + if (result.status !== 0) { + warn('skills sync failed', stderr); + return false; + } + return true; +} + export function postinstall(deps?: PostinstallDeps): number { const env = deps?.env ?? process.env; @@ -124,7 +142,17 @@ export function postinstall(deps?: PostinstallDeps): number { return 0; } - ensurePublicSkillsCache(deps); + const cacheReady = ensurePublicSkillsCache(deps); + if (shouldAutoUpdateSkills(env)) { + if (cacheReady || env.METAMASK_SKILLS_DIR || env.CONSENSYS_SKILLS_DIR) { + autoUpdateSkills(deps); + } else { + warn( + 'auto-update skipped because skills cache is unavailable', + deps?.stderr, + ); + } + } return 0; } From 2af8b88d927a40414b70995cc6a4c7896262e6ae Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 2 Jun 2026 18:02:29 +0800 Subject: [PATCH 04/11] fix(skills): honor local auto-update opt-in --- .skills.local.example | 2 +- scripts/skills-postinstall.test.ts | 23 +++++++++- scripts/skills-postinstall.ts | 70 +++++++++++++++++++++++++++--- 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/.skills.local.example b/.skills.local.example index 253d69c4ad..6427de0ea5 100644 --- a/.skills.local.example +++ b/.skills.local.example @@ -23,7 +23,7 @@ # # Optional: regenerate gitignored installed skills during yarn install/setup after # the public cache refreshes. Off by default for backward compatibility. -# SKILLS_AUTO_UPDATE=1 +# SKILLS_AUTO_UPDATE=1 # also accepts true/yes # # Override per-run with `SKILLS_DOMAINS=... yarn skills` or `--domain `. # Pick interactively with `yarn skills --select`. diff --git a/scripts/skills-postinstall.test.ts b/scripts/skills-postinstall.test.ts index a91c764c0a..4a6d2c8952 100644 --- a/scripts/skills-postinstall.test.ts +++ b/scripts/skills-postinstall.test.ts @@ -7,6 +7,7 @@ import { isGitDir, postinstall, PUBLIC_REPO, + parseSkillsLocal, shouldAutoUpdateSkills, shouldSkipPostinstall, warn, @@ -54,6 +55,26 @@ describe('skills-postinstall', () => { expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: '1' })).toBe(true); expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: 'true' })).toBe(true); expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: 'YES' })).toBe(true); + expect(shouldAutoUpdateSkills({}, () => 'SKILLS_AUTO_UPDATE=1\n')).toBe( + true, + ); + expect( + shouldAutoUpdateSkills( + { SKILLS_AUTO_UPDATE: '0' }, + () => 'SKILLS_AUTO_UPDATE=1\n', + ), + ).toBe(false); + }); + + it('parses .skills.local shell-style assignments', () => { + expect( + parseSkillsLocal( + '# comment\nexport SKILLS_AUTO_UPDATE="yes"\nSKILLS_DOMAINS=perps\n', + ), + ).toStrictEqual({ + SKILLS_AUTO_UPDATE: 'yes', + SKILLS_DOMAINS: 'perps', + }); }); it('detects whether the public cache is a git checkout', () => { @@ -101,7 +122,7 @@ describe('skills-postinstall', () => { ).toBe(0); expect(spawn).toHaveBeenNthCalledWith(3, 'yarn', ['skills'], { - stdio: 'ignore', + stdio: 'inherit', }); }); diff --git a/scripts/skills-postinstall.ts b/scripts/skills-postinstall.ts index 55fcdb2583..567e7863bc 100644 --- a/scripts/skills-postinstall.ts +++ b/scripts/skills-postinstall.ts @@ -21,7 +21,7 @@ import { spawnSync } from 'node:child_process'; import type { SpawnSyncReturns } from 'node:child_process'; -import { mkdirSync, statSync } from 'node:fs'; +import { mkdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; export const CACHE_DIR = '.skills-cache/metamask-skills'; @@ -31,12 +31,14 @@ type SpawnSync = typeof spawnSync; type StatSync = typeof statSync; type MkdirSync = typeof mkdirSync; type Stderr = Pick; +type ReadFileSync = typeof readFileSync; type CacheDeps = { mkdir?: MkdirSync; spawn?: SpawnSync; stat?: StatSync; stderr?: Stderr; + readFile?: ReadFileSync; }; type PostinstallDeps = CacheDeps & { @@ -52,9 +54,10 @@ export function run( cmd: string, args: string[], spawn?: SpawnSync, + stdio: 'ignore' | 'inherit' = 'ignore', ): SpawnSyncReturns { const spawnFn = spawn ?? spawnSync; - return spawnFn(cmd, args, { stdio: 'ignore' }); + return spawnFn(cmd, args, { stdio }); } export function isGitDir(dir: string, stat?: StatSync): boolean { @@ -72,8 +75,63 @@ export function shouldSkipPostinstall(env: NodeJS.ProcessEnv): boolean { ); } -export function shouldAutoUpdateSkills(env: NodeJS.ProcessEnv): boolean { - return /^(1|true|yes)$/iu.test(env.SKILLS_AUTO_UPDATE ?? ''); +export function parseSkillsLocal(content: string): Record { + const config: Record = {}; + + for (const rawLine of content.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + continue; + } + + const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(line); + if (!match) { + continue; + } + + const [, key, rawValue] = match; + let value = rawValue.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + config[key] = value; + } + + return config; +} + +export function readSkillsLocal( + readFile?: ReadFileSync, +): Record { + const read = readFile ?? readFileSync; + try { + return parseSkillsLocal(read('.skills.local', 'utf8')); + } catch { + return {}; + } +} + +export function getConfigValue( + env: NodeJS.ProcessEnv, + key: string, + readFile?: ReadFileSync, +): string | undefined { + if (Object.prototype.hasOwnProperty.call(env, key)) { + return env[key]; + } + return readSkillsLocal(readFile)[key]; +} + +export function shouldAutoUpdateSkills( + env: NodeJS.ProcessEnv, + readFile?: ReadFileSync, +): boolean { + return /^(1|true|yes)$/iu.test( + getConfigValue(env, 'SKILLS_AUTO_UPDATE', readFile) ?? '', + ); } export function ensurePublicSkillsCache(deps?: CacheDeps): boolean { @@ -127,7 +185,7 @@ export function ensurePublicSkillsCache(deps?: CacheDeps): boolean { export function autoUpdateSkills(deps?: PostinstallDeps): boolean { const spawn = deps?.spawn ?? spawnSync; const stderr = deps?.stderr ?? process.stderr; - const result = run('yarn', ['skills'], spawn); + const result = run('yarn', ['skills'], spawn, 'inherit'); if (result.status !== 0) { warn('skills sync failed', stderr); return false; @@ -143,7 +201,7 @@ export function postinstall(deps?: PostinstallDeps): number { } const cacheReady = ensurePublicSkillsCache(deps); - if (shouldAutoUpdateSkills(env)) { + if (shouldAutoUpdateSkills(env, deps?.readFile)) { if (cacheReady || env.METAMASK_SKILLS_DIR || env.CONSENSYS_SKILLS_DIR) { autoUpdateSkills(deps); } else { From 6911d3bf2066b88ffaa084ee06f7df5d571c8ffc Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 2 Jun 2026 18:20:41 +0800 Subject: [PATCH 05/11] test(skills): cover local auto-update behavior --- scripts/skills-postinstall.test.ts | 18 ++++++++++++++++++ scripts/skills-postinstall.ts | 10 +++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/scripts/skills-postinstall.test.ts b/scripts/skills-postinstall.test.ts index 4a6d2c8952..956f98cfb0 100644 --- a/scripts/skills-postinstall.test.ts +++ b/scripts/skills-postinstall.test.ts @@ -126,6 +126,24 @@ describe('skills-postinstall', () => { }); }); + it('runs yarn skills when cache refresh fails but .skills.local has a source checkout', () => { + const spawn = spawnWithStatuses([1, 0]); + + expect( + postinstall({ + env: {}, + readFile: () => + 'SKILLS_AUTO_UPDATE=1\nMETAMASK_SKILLS_DIR=/tmp/metamask-skills\n', + spawn, + stat: statGitDir(false), + }), + ).toBe(0); + + expect(spawn).toHaveBeenNthCalledWith(2, 'yarn', ['skills'], { + stdio: 'inherit', + }); + }); + it('warns but does not fail when auto-update sync fails', () => { const stderr = { write: jest.fn() }; diff --git a/scripts/skills-postinstall.ts b/scripts/skills-postinstall.ts index 567e7863bc..7eaa7657d9 100644 --- a/scripts/skills-postinstall.ts +++ b/scripts/skills-postinstall.ts @@ -96,6 +96,8 @@ export function parseSkillsLocal(content: string): Record { (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); + } else { + value = value.replace(/\s+#.*$/u, '').trim(); } config[key] = value; } @@ -119,6 +121,8 @@ export function getConfigValue( key: string, readFile?: ReadFileSync, ): string | undefined { + // A shell/CI value intentionally wins even when empty, so developers can + // override a persistent .skills.local opt-in for one install. if (Object.prototype.hasOwnProperty.call(env, key)) { return env[key]; } @@ -202,7 +206,11 @@ export function postinstall(deps?: PostinstallDeps): number { const cacheReady = ensurePublicSkillsCache(deps); if (shouldAutoUpdateSkills(env, deps?.readFile)) { - if (cacheReady || env.METAMASK_SKILLS_DIR || env.CONSENSYS_SKILLS_DIR) { + if ( + cacheReady || + getConfigValue(env, 'METAMASK_SKILLS_DIR', deps?.readFile) || + getConfigValue(env, 'CONSENSYS_SKILLS_DIR', deps?.readFile) + ) { autoUpdateSkills(deps); } else { warn( From 4c517af5059ade267331b51a92b5d73848c19905 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 2 Jun 2026 18:40:10 +0800 Subject: [PATCH 06/11] test(skills): isolate postinstall local config reads --- scripts/skills-postinstall.test.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/scripts/skills-postinstall.test.ts b/scripts/skills-postinstall.test.ts index 956f98cfb0..67a91779cb 100644 --- a/scripts/skills-postinstall.test.ts +++ b/scripts/skills-postinstall.test.ts @@ -22,6 +22,10 @@ function statGitDir(existing: boolean): typeof import('fs').statSync { }) as unknown as typeof import('fs').statSync; } +function readSkillsLocal(content: string): typeof import('fs').readFileSync { + return (() => content) as unknown as typeof import('fs').readFileSync; +} + function spawnWithStatuses( statuses: number[], ): typeof import('child_process').spawnSync { @@ -50,14 +54,14 @@ describe('skills-postinstall', () => { }); it('only auto-updates generated skills when explicitly opted in', () => { - expect(shouldAutoUpdateSkills({})).toBe(false); + expect(shouldAutoUpdateSkills({}, readSkillsLocal(''))).toBe(false); expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: '0' })).toBe(false); expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: '1' })).toBe(true); expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: 'true' })).toBe(true); expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: 'YES' })).toBe(true); - expect(shouldAutoUpdateSkills({}, () => 'SKILLS_AUTO_UPDATE=1\n')).toBe( - true, - ); + expect( + shouldAutoUpdateSkills({}, readSkillsLocal('SKILLS_AUTO_UPDATE=1\n')), + ).toBe(true); expect( shouldAutoUpdateSkills( { SKILLS_AUTO_UPDATE: '0' }, @@ -105,7 +109,14 @@ describe('skills-postinstall', () => { it('does not run yarn skills by default', () => { const spawn = spawnWithStatuses([0, 0]); - expect(postinstall({ env: {}, spawn, stat: statGitDir(true) })).toBe(0); + expect( + postinstall({ + env: {}, + readFile: readSkillsLocal(''), + spawn, + stat: statGitDir(true), + }), + ).toBe(0); expect(spawn).toHaveBeenCalledTimes(2); }); @@ -132,8 +143,9 @@ describe('skills-postinstall', () => { expect( postinstall({ env: {}, - readFile: () => + readFile: readSkillsLocal( 'SKILLS_AUTO_UPDATE=1\nMETAMASK_SKILLS_DIR=/tmp/metamask-skills\n', + ), spawn, stat: statGitDir(false), }), From a0c82a4696d7e8994f7f0cd5dfd38521bc02ead5 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Tue, 2 Jun 2026 18:45:12 +0800 Subject: [PATCH 07/11] fix(skills): preserve postinstall env semantics --- scripts/skills-postinstall.test.ts | 20 ++++++++++++++ scripts/skills-postinstall.ts | 44 ++++++++++++++++++------------ 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/scripts/skills-postinstall.test.ts b/scripts/skills-postinstall.test.ts index 67a91779cb..cd4b6a1b79 100644 --- a/scripts/skills-postinstall.test.ts +++ b/scripts/skills-postinstall.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable n/no-sync -- Tests mock the synchronous postinstall spawn API. */ import type { Stats } from 'fs'; import { @@ -156,6 +157,25 @@ describe('skills-postinstall', () => { }); }); + it('warns without failing when auto-update throws unexpectedly', () => { + const stderr = { write: jest.fn() }; + + expect( + postinstall({ + env: { SKILLS_AUTO_UPDATE: '1' }, + readFile: readSkillsLocal(''), + spawn: (() => { + throw new Error('spawn unavailable'); + }) as unknown as typeof import('child_process').spawnSync, + stat: statGitDir(true), + stderr, + }), + ).toBe(0); + expect(stderr.write).toHaveBeenCalledWith( + expect.stringContaining('unexpected error: spawn unavailable'), + ); + }); + it('warns but does not fail when auto-update sync fails', () => { const stderr = { write: jest.fn() }; diff --git a/scripts/skills-postinstall.ts b/scripts/skills-postinstall.ts index 7eaa7657d9..a6de251e86 100644 --- a/scripts/skills-postinstall.ts +++ b/scripts/skills-postinstall.ts @@ -69,9 +69,14 @@ export function isGitDir(dir: string, stat?: StatSync): boolean { } } +export function isTruthy(value: string | undefined): boolean { + return /^(1|true|yes)$/iu.test(value ?? ''); +} + export function shouldSkipPostinstall(env: NodeJS.ProcessEnv): boolean { - return Boolean( - env.SKILLS_SKIP_POSTINSTALL ?? (env.CI && !env.SKILLS_FORCE_POSTINSTALL), + return ( + isTruthy(env.SKILLS_SKIP_POSTINSTALL) || + (isTruthy(env.CI) && !isTruthy(env.SKILLS_FORCE_POSTINSTALL)) ); } @@ -133,9 +138,7 @@ export function shouldAutoUpdateSkills( env: NodeJS.ProcessEnv, readFile?: ReadFileSync, ): boolean { - return /^(1|true|yes)$/iu.test( - getConfigValue(env, 'SKILLS_AUTO_UPDATE', readFile) ?? '', - ); + return isTruthy(getConfigValue(env, 'SKILLS_AUTO_UPDATE', readFile)); } export function ensurePublicSkillsCache(deps?: CacheDeps): boolean { @@ -204,20 +207,25 @@ export function postinstall(deps?: PostinstallDeps): number { return 0; } - const cacheReady = ensurePublicSkillsCache(deps); - if (shouldAutoUpdateSkills(env, deps?.readFile)) { - if ( - cacheReady || - getConfigValue(env, 'METAMASK_SKILLS_DIR', deps?.readFile) || - getConfigValue(env, 'CONSENSYS_SKILLS_DIR', deps?.readFile) - ) { - autoUpdateSkills(deps); - } else { - warn( - 'auto-update skipped because skills cache is unavailable', - deps?.stderr, - ); + try { + const cacheReady = ensurePublicSkillsCache(deps); + if (shouldAutoUpdateSkills(env, deps?.readFile)) { + if ( + cacheReady || + getConfigValue(env, 'METAMASK_SKILLS_DIR', deps?.readFile) || + getConfigValue(env, 'CONSENSYS_SKILLS_DIR', deps?.readFile) + ) { + autoUpdateSkills(deps); + } else { + warn( + 'auto-update skipped because skills cache is unavailable', + deps?.stderr, + ); + } } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warn(`unexpected error: ${message}`, deps?.stderr); } return 0; } From 9969f81a4fb080d9a6345983e4b29dbccf21b6c6 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 3 Jun 2026 10:57:53 +0800 Subject: [PATCH 08/11] chore(skills): use shared skills package --- .skills.local.example | 13 +- README.md | 8 +- package.json | 7 +- scripts/skills-postinstall.test.ts | 208 -------------------- scripts/skills-postinstall.ts | 236 ----------------------- scripts/skills-sync.ts | 299 ----------------------------- 6 files changed, 16 insertions(+), 755 deletions(-) delete mode 100644 scripts/skills-postinstall.test.ts delete mode 100644 scripts/skills-postinstall.ts delete mode 100644 scripts/skills-sync.ts diff --git a/.skills.local.example b/.skills.local.example index 6427de0ea5..8254a792a1 100644 --- a/.skills.local.example +++ b/.skills.local.example @@ -1,19 +1,22 @@ # Template for per-engineer skills config used by `yarn skills`. # Copy this file to `.skills.local` (gitignored). # -# Zero-config default: `yarn setup` and `yarn skills` clone MetaMask/skills into -# `.skills-cache/metamask-skills`. `yarn skills` auto-detects that cache when no -# env var is set — nothing to do. +# Zero-config default: the shared @metamask/skills CLI refreshes +# `.skills-cache/metamask-skills` during setup. `yarn skills` auto-detects that +# cache when no env var is set, and falls back to the bundled package snapshot +# if the cache is unavailable — nothing to do. # # Optional persistent skills config belongs in this file. Environment variables # with the same names are only for one-off shell or CI overrides and take # precedence over this file. -# METAMASK_SKILLS_DIR path to MetaMask/skills checkout (public, no auth) -# CONSENSYS_SKILLS_DIR path to Consensys/skills checkout (private overlay) +# METAMASK_SKILLS_DIR path to MetaMask/skills source checkout (optional override) +# CONSENSYS_SKILLS_DIR path to Consensys/skills checkout (private overlay) +# METAMASK_SKILLS_TARGET_REPO canonical repo overlay for forks/unusual remotes # # Example local setup (only if you want to override the cache): # METAMASK_SKILLS_DIR=~/dev/metamask/skills # CONSENSYS_SKILLS_DIR=~/dev/Consensys/skills # optional +# METAMASK_SKILLS_TARGET_REPO=metamask-mobile # optional fork override # # Default behavior installs ALL stable domains available for Core. Set # SKILLS_DOMAINS to opt out of some: diff --git a/README.md b/README.md index 067d1ff024..3351f08931 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,13 @@ Each package in this repository has its own README where you can find installati ## Agent skills This repo can install MetaMask agent skills for Claude, Cursor, and Codex/OpenAI. -`yarn install` keeps the public [`MetaMask/skills`](https://github.com/MetaMask/skills) -cache available through the postinstall hook. Run `yarn skills` any time to -install or refresh the gitignored generated skills under `.claude/skills/`, +`yarn setup` keeps the public [`MetaMask/skills`](https://github.com/MetaMask/skills) +cache available through the shared `@metamask/skills` CLI. Run `yarn skills` any +time to install or refresh the gitignored generated skills under `.claude/skills/`, `.cursor/rules/`, and `.agents/skills/`. By default, all stable skills that support Core are installed when you run `yarn skills`. -Set `SKILLS_AUTO_UPDATE=1` to opt into best-effort regeneration during setup. +Set `SKILLS_AUTO_UPDATE=1` to opt into best-effort regeneration during setup. The shared package keeps sync/cache behavior uniform with Mobile and Extension. To persist a local selection, copy `.skills.local.example` to `.skills.local` and set values such as `SKILLS_DOMAINS=perps`. diff --git a/package.json b/package.json index 16b24bf1e3..6a168bb4f0 100644 --- a/package.json +++ b/package.json @@ -36,15 +36,15 @@ "prepare-preview-builds": "./scripts/prepare-preview-builds.sh", "readme-content:check": "tsx scripts/update-readme-content.ts --check", "readme-content:update": "tsx scripts/update-readme-content.ts", - "setup": "yarn install", + "setup": "yarn install && yarn skills:postinstall", "test": "yarn test:scripts --silent --collectCoverage=false --reporters=jest-silent-reporter && yarn test:packages", "test:clean": "yarn workspaces foreach --all --parallel --verbose run test:clean && yarn test", "test:packages": "yarn test:verbose --silent --collectCoverage=false --reporters=jest-silent-reporter", "test:scripts": "NODE_OPTIONS=--experimental-vm-modules yarn jest --config ./jest.config.scripts.js --silent", "test:verbose": "yarn workspaces foreach --all --parallel --verbose run test:verbose", "workspaces:list-versions": "./scripts/list-workspace-versions.sh", - "skills": "tsx scripts/skills-sync.ts", - "postinstall": "tsx scripts/skills-postinstall.ts" + "skills": "metamask-skills sync", + "skills:postinstall": "metamask-skills postinstall" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", @@ -58,6 +58,7 @@ "@metamask/eth-json-rpc-provider": "^6.0.1", "@metamask/json-rpc-engine": "^10.5.0", "@metamask/network-controller": "^32.0.0", + "@metamask/skills": "^0.1.0", "@metamask/utils": "^11.9.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", diff --git a/scripts/skills-postinstall.test.ts b/scripts/skills-postinstall.test.ts deleted file mode 100644 index cd4b6a1b79..0000000000 --- a/scripts/skills-postinstall.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -/* eslint-disable n/no-sync -- Tests mock the synchronous postinstall spawn API. */ -import type { Stats } from 'fs'; - -import { - autoUpdateSkills, - CACHE_DIR, - ensurePublicSkillsCache, - isGitDir, - postinstall, - PUBLIC_REPO, - parseSkillsLocal, - shouldAutoUpdateSkills, - shouldSkipPostinstall, - warn, -} from './skills-postinstall'; - -function statGitDir(existing: boolean): typeof import('fs').statSync { - return jest.fn(() => { - if (!existing) { - throw new Error('missing'); - } - return { isDirectory: () => true } as Stats; - }) as unknown as typeof import('fs').statSync; -} - -function readSkillsLocal(content: string): typeof import('fs').readFileSync { - return (() => content) as unknown as typeof import('fs').readFileSync; -} - -function spawnWithStatuses( - statuses: number[], -): typeof import('child_process').spawnSync { - let index = 0; - return jest.fn(() => { - const status = statuses[index] ?? 0; - index += 1; - return { - status, - stdout: Buffer.from(''), - stderr: Buffer.from(''), - pid: 1, - output: [], - signal: null, - }; - }) as unknown as typeof import('child_process').spawnSync; -} - -describe('skills-postinstall', () => { - it('skips when explicitly disabled or running in CI without force', () => { - expect(shouldSkipPostinstall({ SKILLS_SKIP_POSTINSTALL: '1' })).toBe(true); - expect(shouldSkipPostinstall({ CI: 'true' })).toBe(true); - expect( - shouldSkipPostinstall({ CI: 'true', SKILLS_FORCE_POSTINSTALL: '1' }), - ).toBe(false); - }); - - it('only auto-updates generated skills when explicitly opted in', () => { - expect(shouldAutoUpdateSkills({}, readSkillsLocal(''))).toBe(false); - expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: '0' })).toBe(false); - expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: '1' })).toBe(true); - expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: 'true' })).toBe(true); - expect(shouldAutoUpdateSkills({ SKILLS_AUTO_UPDATE: 'YES' })).toBe(true); - expect( - shouldAutoUpdateSkills({}, readSkillsLocal('SKILLS_AUTO_UPDATE=1\n')), - ).toBe(true); - expect( - shouldAutoUpdateSkills( - { SKILLS_AUTO_UPDATE: '0' }, - () => 'SKILLS_AUTO_UPDATE=1\n', - ), - ).toBe(false); - }); - - it('parses .skills.local shell-style assignments', () => { - expect( - parseSkillsLocal( - '# comment\nexport SKILLS_AUTO_UPDATE="yes"\nSKILLS_DOMAINS=perps\n', - ), - ).toStrictEqual({ - SKILLS_AUTO_UPDATE: 'yes', - SKILLS_DOMAINS: 'perps', - }); - }); - - it('detects whether the public cache is a git checkout', () => { - expect(isGitDir(CACHE_DIR, statGitDir(true))).toBe(true); - expect(isGitDir(CACHE_DIR, statGitDir(false))).toBe(false); - }); - - it('clones the public skills cache when cache is absent', () => { - const mkdir = jest.fn(); - const spawn = spawnWithStatuses([0]); - - expect( - ensurePublicSkillsCache({ - mkdir, - spawn, - stat: statGitDir(false), - }), - ).toBe(true); - expect(mkdir).toHaveBeenCalledWith('.skills-cache', { recursive: true }); - expect(spawn).toHaveBeenNthCalledWith( - 1, - 'git', - ['clone', '--depth', '1', '--branch', 'main', PUBLIC_REPO, CACHE_DIR], - { stdio: 'ignore' }, - ); - }); - - it('does not run yarn skills by default', () => { - const spawn = spawnWithStatuses([0, 0]); - - expect( - postinstall({ - env: {}, - readFile: readSkillsLocal(''), - spawn, - stat: statGitDir(true), - }), - ).toBe(0); - - expect(spawn).toHaveBeenCalledTimes(2); - }); - - it('runs yarn skills after cache refresh when auto-update is opted in', () => { - const spawn = spawnWithStatuses([0, 0, 0]); - - expect( - postinstall({ - env: { SKILLS_AUTO_UPDATE: '1' }, - spawn, - stat: statGitDir(true), - }), - ).toBe(0); - - expect(spawn).toHaveBeenNthCalledWith(3, 'yarn', ['skills'], { - stdio: 'inherit', - }); - }); - - it('runs yarn skills when cache refresh fails but .skills.local has a source checkout', () => { - const spawn = spawnWithStatuses([1, 0]); - - expect( - postinstall({ - env: {}, - readFile: readSkillsLocal( - 'SKILLS_AUTO_UPDATE=1\nMETAMASK_SKILLS_DIR=/tmp/metamask-skills\n', - ), - spawn, - stat: statGitDir(false), - }), - ).toBe(0); - - expect(spawn).toHaveBeenNthCalledWith(2, 'yarn', ['skills'], { - stdio: 'inherit', - }); - }); - - it('warns without failing when auto-update throws unexpectedly', () => { - const stderr = { write: jest.fn() }; - - expect( - postinstall({ - env: { SKILLS_AUTO_UPDATE: '1' }, - readFile: readSkillsLocal(''), - spawn: (() => { - throw new Error('spawn unavailable'); - }) as unknown as typeof import('child_process').spawnSync, - stat: statGitDir(true), - stderr, - }), - ).toBe(0); - expect(stderr.write).toHaveBeenCalledWith( - expect.stringContaining('unexpected error: spawn unavailable'), - ); - }); - - it('warns but does not fail when auto-update sync fails', () => { - const stderr = { write: jest.fn() }; - - expect(autoUpdateSkills({ spawn: spawnWithStatuses([1]), stderr })).toBe( - false, - ); - expect(stderr.write).toHaveBeenCalledWith( - expect.stringContaining('skills sync failed'), - ); - }); - - it('returns without side effects when postinstall is skipped', () => { - const spawn = spawnWithStatuses([]); - - expect(postinstall({ env: { SKILLS_SKIP_POSTINSTALL: '1' }, spawn })).toBe( - 0, - ); - expect(spawn).not.toHaveBeenCalled(); - }); - - it('formats warnings consistently', () => { - const stderr = { write: jest.fn() }; - - warn('install failed', stderr); - - expect(stderr.write).toHaveBeenCalledWith( - 'skills cache: install failed (run `yarn skills` for details)\n', - ); - }); -}); diff --git a/scripts/skills-postinstall.ts b/scripts/skills-postinstall.ts deleted file mode 100644 index a6de251e86..0000000000 --- a/scripts/skills-postinstall.ts +++ /dev/null @@ -1,236 +0,0 @@ -/* eslint-disable n/no-process-env, n/no-process-exit */ -// Refresh the public MetaMask skills cache. Best-effort: never fails setup. -// -// Core disables package lifecycle scripts (`enableScripts: false`), so this is -// invoked explicitly by `yarn setup` and can also be run as -// `yarn skills:postinstall`. -// -// - Skipped on CI, or when SKILLS_SKIP_POSTINSTALL=1. -// - Override CI skip with SKILLS_FORCE_POSTINSTALL=1 (for CI jobs that -// actually need skills installed, e.g. agent-driven review bots). -// - Clones https://github.com/MetaMask/skills (public, no auth) into -// .skills-cache/metamask-skills if absent. -// - `git fetch + reset` to origin/main if present. -// - When SKILLS_AUTO_UPDATE=1, also runs `yarn skills` after the cache -// refresh so generated skills stay current. Off by default for backward -// compatibility. -// - Leaves installation/domain selection to `yarn skills`, which reads -// .skills.local and SKILLS_DOMAINS. -// - Failures are reported with a one-line warning. Engineers can run -// `yarn skills` manually for interactive feedback. - -import { spawnSync } from 'node:child_process'; -import type { SpawnSyncReturns } from 'node:child_process'; -import { mkdirSync, readFileSync, statSync } from 'node:fs'; -import path from 'node:path'; - -export const CACHE_DIR = '.skills-cache/metamask-skills'; -export const PUBLIC_REPO = 'https://github.com/MetaMask/skills.git'; - -type SpawnSync = typeof spawnSync; -type StatSync = typeof statSync; -type MkdirSync = typeof mkdirSync; -type Stderr = Pick; -type ReadFileSync = typeof readFileSync; - -type CacheDeps = { - mkdir?: MkdirSync; - spawn?: SpawnSync; - stat?: StatSync; - stderr?: Stderr; - readFile?: ReadFileSync; -}; - -type PostinstallDeps = CacheDeps & { - env?: NodeJS.ProcessEnv; -}; - -export function warn(message: string, stderr?: Stderr): void { - const writer = stderr ?? process.stderr; - writer.write(`skills cache: ${message} (run \`yarn skills\` for details)\n`); -} - -export function run( - cmd: string, - args: string[], - spawn?: SpawnSync, - stdio: 'ignore' | 'inherit' = 'ignore', -): SpawnSyncReturns { - const spawnFn = spawn ?? spawnSync; - return spawnFn(cmd, args, { stdio }); -} - -export function isGitDir(dir: string, stat?: StatSync): boolean { - const statFn = stat ?? statSync; - try { - return statFn(path.join(dir, '.git')).isDirectory(); - } catch { - return false; - } -} - -export function isTruthy(value: string | undefined): boolean { - return /^(1|true|yes)$/iu.test(value ?? ''); -} - -export function shouldSkipPostinstall(env: NodeJS.ProcessEnv): boolean { - return ( - isTruthy(env.SKILLS_SKIP_POSTINSTALL) || - (isTruthy(env.CI) && !isTruthy(env.SKILLS_FORCE_POSTINSTALL)) - ); -} - -export function parseSkillsLocal(content: string): Record { - const config: Record = {}; - - for (const rawLine of content.split(/\r?\n/u)) { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) { - continue; - } - - const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(line); - if (!match) { - continue; - } - - const [, key, rawValue] = match; - let value = rawValue.trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } else { - value = value.replace(/\s+#.*$/u, '').trim(); - } - config[key] = value; - } - - return config; -} - -export function readSkillsLocal( - readFile?: ReadFileSync, -): Record { - const read = readFile ?? readFileSync; - try { - return parseSkillsLocal(read('.skills.local', 'utf8')); - } catch { - return {}; - } -} - -export function getConfigValue( - env: NodeJS.ProcessEnv, - key: string, - readFile?: ReadFileSync, -): string | undefined { - // A shell/CI value intentionally wins even when empty, so developers can - // override a persistent .skills.local opt-in for one install. - if (Object.prototype.hasOwnProperty.call(env, key)) { - return env[key]; - } - return readSkillsLocal(readFile)[key]; -} - -export function shouldAutoUpdateSkills( - env: NodeJS.ProcessEnv, - readFile?: ReadFileSync, -): boolean { - return isTruthy(getConfigValue(env, 'SKILLS_AUTO_UPDATE', readFile)); -} - -export function ensurePublicSkillsCache(deps?: CacheDeps): boolean { - const mkdir = deps?.mkdir ?? mkdirSync; - const spawn = deps?.spawn ?? spawnSync; - const stat = deps?.stat ?? statSync; - const stderr = deps?.stderr ?? process.stderr; - - try { - const hasCache = isGitDir(CACHE_DIR, stat); - if (hasCache) { - const fetchResult = run( - 'git', - ['-C', CACHE_DIR, 'fetch', '--depth', '1', 'origin', 'main'], - spawn, - ); - if (fetchResult.status !== 0) { - warn('fetch failed (offline?)', stderr); - return false; - } - const resetResult = run( - 'git', - ['-C', CACHE_DIR, 'reset', '--hard', 'origin/main'], - spawn, - ); - if (resetResult.status !== 0) { - warn('reset failed', stderr); - return false; - } - } else { - mkdir(path.dirname(CACHE_DIR), { recursive: true }); - const cloneResult = run( - 'git', - ['clone', '--depth', '1', '--branch', 'main', PUBLIC_REPO, CACHE_DIR], - spawn, - ); - if (cloneResult.status !== 0) { - warn('clone failed (offline?)', stderr); - return false; - } - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - warn(`unexpected error: ${message}`, stderr); - return false; - } - - return true; -} - -export function autoUpdateSkills(deps?: PostinstallDeps): boolean { - const spawn = deps?.spawn ?? spawnSync; - const stderr = deps?.stderr ?? process.stderr; - const result = run('yarn', ['skills'], spawn, 'inherit'); - if (result.status !== 0) { - warn('skills sync failed', stderr); - return false; - } - return true; -} - -export function postinstall(deps?: PostinstallDeps): number { - const env = deps?.env ?? process.env; - - if (shouldSkipPostinstall(env)) { - return 0; - } - - try { - const cacheReady = ensurePublicSkillsCache(deps); - if (shouldAutoUpdateSkills(env, deps?.readFile)) { - if ( - cacheReady || - getConfigValue(env, 'METAMASK_SKILLS_DIR', deps?.readFile) || - getConfigValue(env, 'CONSENSYS_SKILLS_DIR', deps?.readFile) - ) { - autoUpdateSkills(deps); - } else { - warn( - 'auto-update skipped because skills cache is unavailable', - deps?.stderr, - ); - } - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - warn(`unexpected error: ${message}`, deps?.stderr); - } - return 0; -} - -/* istanbul ignore next */ -if (process.argv[1]?.endsWith(`${path.sep}skills-postinstall.ts`)) { - process.exit(postinstall()); -} diff --git a/scripts/skills-sync.ts b/scripts/skills-sync.ts deleted file mode 100644 index c5c1889ec4..0000000000 --- a/scripts/skills-sync.ts +++ /dev/null @@ -1,299 +0,0 @@ -/* eslint-disable n/no-process-env, n/no-process-exit */ -// Wrapper for `yarn skills`. Picks a multi-source-aware tools/sync from -// whichever skill repo is configured and delegates. -// -// Source configuration comes from env vars first, then .skills.local. -// Prefer the public MetaMask/skills sync CLI whenever it is available: -// 1. METAMASK_SKILLS_DIR/tools/sync -// 2. .skills-cache/metamask-skills/tools/sync (zero-config default) -// 3. CONSENSYS_SKILLS_DIR/tools/sync (private fallback when no public source exists) -// The public sync still walks every configured source. Cache fallback means -// `yarn skills` works out of the box from a fresh checkout; if the cache is -// missing, this wrapper clones it before delegating. - -import { spawnSync } from 'node:child_process'; -import { readFileSync, statSync } from 'node:fs'; -import path from 'node:path'; - -import { CACHE_DIR, ensurePublicSkillsCache } from './skills-postinstall'; - -const REPO = 'core'; -const SOURCE_ENV_KEYS = [ - 'METAMASK_SKILLS_DIR', - 'CONSENSYS_SKILLS_DIR', -] as const; -const NO_SOURCE_MESSAGE = [ - 'No skills source available.', - '', - '`yarn skills` normally clones the public skills repo into', - '.skills-cache/metamask-skills automatically. If that did not happen', - '(for example, you are offline), point at a clone manually in .skills.local:', - '', - ' git clone https://github.com/MetaMask/skills ~/dev/metamask/skills', - ' echo METAMASK_SKILLS_DIR=~/dev/metamask/skills >> .skills.local', - '', - 'Optional private overlay (Consensys internal, SSH required):', - ' git clone git@github.com:Consensys/skills.git ~/dev/Consensys/skills', - ' echo CONSENSYS_SKILLS_DIR=~/dev/Consensys/skills >> .skills.local', - '', - 'Then re-run `yarn skills`.', - '', -].join('\n'); -const NO_BASH_MESSAGE = [ - 'No supported Bash found.', - '', - '`yarn skills` requires Bash 4+ because the shared skills installer uses', - 'modern Bash features. macOS /bin/bash is 3.2.', - '', - 'Install a current Bash, then re-run `yarn skills`:', - ' brew install bash', - '', -].join('\n'); - -type StatSync = typeof statSync; -type ReadFileSync = typeof readFileSync; -type SpawnSync = typeof spawnSync; - -type SkillSourceEnv = Record< - (typeof SOURCE_ENV_KEYS)[number], - string | undefined ->; -type SyncScriptPick = { sync: string }; - -export function cacheDir(cwd: string): string { - return path.join(cwd, CACHE_DIR); -} - -export function syncIn(dir: string, stat?: StatSync): string | null { - const statFn = stat ?? statSync; - const candidate = path.join(dir, 'tools', 'sync'); - try { - if (statFn(candidate).isFile()) { - return candidate; - } - } catch { - // ignored - } - return null; -} - -export function bashMajorVersion( - candidate: string, - spawn?: SpawnSync, -): number | null { - const spawnFn = spawn ?? spawnSync; - const result = spawnFn(candidate, ['--version'], { encoding: 'utf8' }); - if (result.status !== 0) { - return null; - } - - const match = `${result.stdout}${result.stderr}`.match( - /GNU bash, version (\d+)\./u, - ); - return match ? Number(match[1]) : null; -} - -export function pickBash( - env?: NodeJS.ProcessEnv, - spawn?: SpawnSync, -): string | null { - const resolvedEnv = env ?? process.env; - const candidates = [ - resolvedEnv.BASH, - 'bash', - '/opt/homebrew/bin/bash', - '/usr/local/bin/bash', - '/bin/bash', - ].filter((candidate): candidate is string => Boolean(candidate)); - - for (const candidate of new Set(candidates)) { - const major = bashMajorVersion(candidate, spawn); - if (major && major >= 4) { - return candidate; - } - } - - return null; -} - -export function expandLeadingTilde( - value: string | undefined, - env?: NodeJS.ProcessEnv, -): string | undefined { - if (!value?.startsWith('~')) { - return value; - } - - const home = (env ?? process.env).HOME; - if (!home) { - return value; - } - - if (value === '~') { - return home; - } - - if (value.startsWith(`~${path.sep}`) || value.startsWith('~/')) { - return path.join(home, value.slice(2)); - } - - return value; -} - -export function parseLocalConfig(contents: string): Record { - const parsed: Record = {}; - - for (const rawLine of contents.split(/\r?\n/u)) { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) { - continue; - } - - const match = /^(?[A-Za-z_][A-Za-z0-9_]*)=(?.*)$/u.exec(line); - if (!match?.groups) { - continue; - } - - let value = match.groups.value.trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - parsed[match.groups.key] = value; - } - - return parsed; -} - -export function loadSkillSourceEnv( - cwd?: string, - processEnv?: NodeJS.ProcessEnv, - readFile?: ReadFileSync, -): SkillSourceEnv { - const resolvedCwd = cwd ?? process.cwd(); - const resolvedEnv = processEnv ?? process.env; - const readFileFn = readFile ?? readFileSync; - const env: SkillSourceEnv = { - METAMASK_SKILLS_DIR: resolvedEnv.METAMASK_SKILLS_DIR, - CONSENSYS_SKILLS_DIR: resolvedEnv.CONSENSYS_SKILLS_DIR, - }; - - try { - const localConfig = parseLocalConfig( - readFileFn(path.join(resolvedCwd, '.skills.local'), 'utf8'), - ); - for (const key of SOURCE_ENV_KEYS) { - env[key] ??= expandLeadingTilde(localConfig[key], resolvedEnv); - } - } catch { - // ignored: .skills.local is optional - } - - return env; -} - -export function pickSyncScript( - cwd: string, - sourceEnv: SkillSourceEnv, - stat?: StatSync, -): SyncScriptPick | null { - const publicSync = sourceEnv.METAMASK_SKILLS_DIR - ? syncIn(sourceEnv.METAMASK_SKILLS_DIR, stat) - : null; - if (publicSync) { - return { sync: publicSync }; - } - - const cacheSync = syncIn(cacheDir(cwd), stat); - if (cacheSync) { - return { sync: cacheSync }; - } - - if (sourceEnv.CONSENSYS_SKILLS_DIR) { - const privateSync = syncIn(sourceEnv.CONSENSYS_SKILLS_DIR, stat); - if (privateSync) { - return { sync: privateSync }; - } - } - - return null; -} - -export function buildDelegatedEnv( - cwd: string, - sourceEnv: SkillSourceEnv, - processEnv?: NodeJS.ProcessEnv, - stat?: StatSync, -): NodeJS.ProcessEnv { - const env = { ...(processEnv ?? process.env) }; - for (const key of SOURCE_ENV_KEYS) { - env[key] ??= sourceEnv[key]; - } - if (!env.METAMASK_SKILLS_DIR && syncIn(cacheDir(cwd), stat)) { - env.METAMASK_SKILLS_DIR = cacheDir(cwd); - } - return env; -} - -export function prependBashToPath( - env: NodeJS.ProcessEnv, - bash: string, -): NodeJS.ProcessEnv { - if (!bash.includes(path.sep)) { - return env; - } - return { - ...env, - PATH: `${path.dirname(bash)}${path.delimiter}${env.PATH ?? ''}`, - }; -} - -export function main( - argv?: string[], - cwd?: string, - processEnv?: NodeJS.ProcessEnv, - spawn?: SpawnSync, - stat?: StatSync, - readFile?: ReadFileSync, -): number { - const resolvedArgv = argv ?? process.argv.slice(2); - const resolvedCwd = cwd ?? process.cwd(); - const resolvedEnv = processEnv ?? process.env; - const spawnFn = spawn ?? spawnSync; - const sourceEnv = loadSkillSourceEnv(resolvedCwd, resolvedEnv, readFile); - - if (!sourceEnv.METAMASK_SKILLS_DIR && !syncIn(cacheDir(resolvedCwd), stat)) { - ensurePublicSkillsCache({ spawn: spawnFn, stat }); - } - - const picked = pickSyncScript(resolvedCwd, sourceEnv, stat); - if (!picked) { - process.stderr.write(NO_SOURCE_MESSAGE); - return 1; - } - - const bash = pickBash(resolvedEnv, spawnFn); - if (!bash) { - process.stderr.write(NO_BASH_MESSAGE); - return 1; - } - - const env = prependBashToPath( - buildDelegatedEnv(resolvedCwd, sourceEnv, resolvedEnv, stat), - bash, - ); - - const result = spawnFn( - bash, - [picked.sync, '--repo', REPO, '--target', resolvedCwd, ...resolvedArgv], - { stdio: 'inherit', env }, - ); - return result.status ?? 1; -} - -/* istanbul ignore next */ -if (process.argv[1]?.endsWith(`${path.sep}skills-sync.ts`)) { - process.exit(main()); -} From f4e6eb9f4858eadbfe45c1ae78b00ea0419032b4 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 3 Jun 2026 11:12:46 +0800 Subject: [PATCH 09/11] fix(skills): allow core install cache refresh --- package.json | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 6a168bb4f0..5e3673075f 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "prepare-preview-builds": "./scripts/prepare-preview-builds.sh", "readme-content:check": "tsx scripts/update-readme-content.ts --check", "readme-content:update": "tsx scripts/update-readme-content.ts", - "setup": "yarn install && yarn skills:postinstall", + "setup": "yarn install", "test": "yarn test:scripts --silent --collectCoverage=false --reporters=jest-silent-reporter && yarn test:packages", "test:clean": "yarn workspaces foreach --all --parallel --verbose run test:clean && yarn test", "test:packages": "yarn test:verbose --silent --collectCoverage=false --reporters=jest-silent-reporter", @@ -44,7 +44,8 @@ "test:verbose": "yarn workspaces foreach --all --parallel --verbose run test:verbose", "workspaces:list-versions": "./scripts/list-workspace-versions.sh", "skills": "metamask-skills sync", - "skills:postinstall": "metamask-skills postinstall" + "skills:postinstall": "metamask-skills postinstall", + "postinstall": "metamask-skills postinstall" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", @@ -111,12 +112,13 @@ "packageManager": "yarn@4.14.1", "lavamoat": { "allowScripts": { - "@lavamoat/preinstall-always-fail": false, + "$root$": true, "@keystonehq/bc-ur-registry-eth>hdkey>secp256k1": true, + "@lavamoat/preinstall-always-fail": false, "babel-runtime>core-js": false, + "eslint-plugin-import-x>unrs-resolver": false, "simple-git-hooks": false, - "tsx>esbuild": false, - "eslint-plugin-import-x>unrs-resolver": false + "tsx>esbuild": false } } } From e1c8832d81e0685ac5696ee9cb2c9475035cfa3f Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 3 Jun 2026 11:14:54 +0800 Subject: [PATCH 10/11] fix(skills): run core postinstall through yarn --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5e3673075f..6d97b11d9b 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "workspaces:list-versions": "./scripts/list-workspace-versions.sh", "skills": "metamask-skills sync", "skills:postinstall": "metamask-skills postinstall", - "postinstall": "metamask-skills postinstall" + "postinstall": "yarn skills:postinstall" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", From 147301e81e86f7007d9924ab317ce37caa2ff2f2 Mon Sep 17 00:00:00 2001 From: Arthur Breton Date: Wed, 3 Jun 2026 11:18:49 +0800 Subject: [PATCH 11/11] fix(skills): skip core cache refresh before package install --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6d97b11d9b..41ebe3b6a9 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "workspaces:list-versions": "./scripts/list-workspace-versions.sh", "skills": "metamask-skills sync", "skills:postinstall": "metamask-skills postinstall", - "postinstall": "yarn skills:postinstall" + "postinstall": "if [ -x ./node_modules/.bin/metamask-skills ]; then ./node_modules/.bin/metamask-skills postinstall; else echo \"metamask-skills not installed; skipping skills cache refresh\"; fi" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4",