Skip to content

Commit 5cb4d07

Browse files
Skn0ttCopilot
andcommitted
fix(cli): rewrite CLI command in installed skills
Skills still document `playwright-cli`, which is fine for GitHub search, but when installed via `npx/yarn/pnpm playwright cli` the examples need the package-manager form. Rewrite at install time and leave skill name / allowed-tools / `.playwright-cli/` alone. Fixes: #42135 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f3c49995-a324-4711-b5b6-d4fe2f766e2d
1 parent d5b1217 commit 5cb4d07

9 files changed

Lines changed: 81 additions & 25 deletions

File tree

packages/playwright-core/src/cli/program.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,9 @@ export function decorateProgram(program: Command) {
242242
.allowExcessArguments(true)
243243
.allowUnknownOption(true)
244244
.helpOption(false)
245-
.action(async options => {
245+
.action(async () => {
246246
process.argv.splice(process.argv.indexOf('cli'), 1);
247-
cliProgram().catch(logErrorAndExit);
247+
cliProgram({ cliCommand: `${getPackageManagerExecCommand()} playwright cli` }).catch(logErrorAndExit);
248248
});
249249

250250
decorateMCPCommand(program

packages/playwright-core/src/tools/cli-client/program.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,10 @@ const booleanOptions: (keyof (GlobalOptions & OpenOptions & AttachOptions & { al
7474
'version',
7575
];
7676

77-
export async function program(options?: { embedderVersion?: string}) {
77+
export async function program(options?: { embedderVersion?: string, cliCommand?: string }) {
7878
const clientInfo = createClientInfo();
7979
const help = require(libPath('tools', 'cli-client', 'help.json'));
80+
const cliCommand = options?.cliCommand || 'playwright-cli';
8081

8182
const argv = process.argv.slice(2);
8283
const boolean = [...help.booleanOptions, ...booleanOptions];
@@ -204,7 +205,7 @@ export async function program(options?: { embedderVersion?: string}) {
204205
case 'install':
205206
if (args.global && !args.skills)
206207
output.errorInstallGlobalRequiresSkills();
207-
await runInitWorkspace(args, output);
208+
await runInitWorkspace(args, output, cliCommand);
208209
output.installed();
209210
return;
210211
case 'install-browser':
@@ -321,12 +322,13 @@ async function runInSessionOrStop(entry: SessionFile, clientInfo: ClientInfo, ar
321322
}
322323
}
323324

324-
async function runInitWorkspace(args: MinimistArgs, output: Output) {
325+
async function runInitWorkspace(args: MinimistArgs, output: Output, cliCommand: string) {
325326
const cliPath = libPath('entry', 'cliDaemon.js');
326327
const daemonArgs: string[] = [
327328
cliPath,
328329
'--init-workspace',
329330
...(args.skills ? [args.global ? '--init-skills-global' : '--init-skills', String(args.skills)] : []),
331+
...(args.skills ? ['--cli-command', cliCommand] : []),
330332
];
331333
await new Promise<void>((resolve, reject) => {
332334
const child = spawn(process.execPath, daemonArgs, {

packages/playwright-core/src/tools/cli-daemon/DEPS.list

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@
88
@utils/**
99
../../server/registry/index.ts
1010
@utils/**
11+
node_modules/commander
1112
node_modules/zod

packages/playwright-core/src/tools/cli-daemon/program.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import fs from 'fs';
2020
import os from 'os';
2121
import path from 'path';
2222

23+
import { Option } from 'commander';
2324
import { getAsBooleanFromENV, guessClientName } from '@utils/env';
2425
import { gracefullyProcessExitDoNotHang } from '@utils/processLauncher';
2526
import { startCliDaemonServer } from './daemon';
@@ -29,6 +30,7 @@ import * as configUtils from '../mcp/config';
2930
import { createClientInfo } from '../cli-client/registry';
3031
import { installSkills } from '../utils/installSkills';
3132
import { registry as browserRegistry } from '../../server/registry/index';
33+
3234
import type { Command } from 'commander';
3335

3436
export function decorateProgram(program: Command) {
@@ -46,10 +48,11 @@ export function decorateProgram(program: Command) {
4648
.option('--init-workspace', 'initialize workspace')
4749
.option('--init-skills <value>', 'install skills for the given agent type ("claude" or "agents")')
4850
.option('--init-skills-global <value>', 'install skills for the given agent type ("claude" or "agents") into the home directory')
51+
.addOption(new Option('--cli-command <command>', 'command prefix to embed into installed skills').hideHelp())
4952

5053
.action(async (sessionName: string, options: any) => {
5154
if (options.initWorkspace) {
52-
await initWorkspace(options.initSkills, options.initSkillsGlobal);
55+
await initWorkspace(options.initSkills, options.initSkillsGlobal, options.cliCommand);
5356
return;
5457
}
5558

@@ -86,7 +89,7 @@ function globalConfigFile(): string {
8689
return path.join(process.env['PWTEST_CLI_GLOBAL_CONFIG'] ?? os.homedir(), '.playwright', 'cli.config.json');
8790
}
8891

89-
export async function initWorkspace(initSkills: string | undefined, initSkillsGlobal?: string) {
92+
export async function initWorkspace(initSkills: string | undefined, initSkillsGlobal?: string, cliCommand?: string) {
9093
const globalSkills = !!initSkillsGlobal;
9194
if (!globalSkills) {
9295
const cwd = process.cwd();
@@ -99,7 +102,7 @@ export async function initWorkspace(initSkills: string | undefined, initSkillsGl
99102
if (skills) {
100103
const target = skills === 'agents' ? 'agents' : 'claude';
101104
try {
102-
await installSkills(['playwright-cli'], target, { global: globalSkills });
105+
await installSkills(['playwright-cli'], target, { global: globalSkills, cliCommand });
103106
} catch (error) {
104107
console.error('❌', error instanceof Error ? error.message : error);
105108
// eslint-disable-next-line no-restricted-properties

packages/playwright-core/src/tools/skills/playwright-cli/SKILL.md

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -340,20 +340,6 @@ playwright-cli close-all
340340
playwright-cli kill-all
341341
```
342342

343-
## Installation
344-
345-
If global `playwright-cli` command is not available, try a local version via `npx playwright cli`:
346-
347-
```bash
348-
npx --no-install playwright --version
349-
```
350-
351-
When local version is available, use `npx playwright cli` in all commands. Otherwise, install `playwright-cli` as a global command:
352-
353-
```bash
354-
npm install -g @playwright/cli@latest
355-
```
356-
357343
## Example: Form submission
358344

359345
```bash

packages/playwright-core/src/tools/utils/installSkills.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,49 @@ export const allSkills = ['playwright-cli', 'playwright-component-testing', 'pla
2727
export type SkillName = typeof allSkills[number];
2828
export type SkillTarget = 'claude' | 'agents';
2929

30-
export async function installSkills(skills: readonly SkillName[], target: SkillTarget = 'claude', options?: { global?: boolean }) {
30+
// Source skill docs use this as the command. Kept literal so GitHub/search still
31+
// reads as a real CLI. At install time it is rewritten when the invoker differs
32+
// (e.g. `npx`/`yarn`/`pnpm exec` playwright cli), while skill identity and
33+
// artifact paths stay put.
34+
const sourceCliCommand = 'playwright-cli';
35+
36+
export async function installSkills(skills: readonly SkillName[], target: SkillTarget = 'claude', options?: { global?: boolean, cliCommand?: string }) {
3137
const cwd = process.cwd();
3238
const baseDir = options?.global ? os.homedir() : cwd;
3339
for (const skill of skills) {
3440
const sourceDir = libPath('tools', 'skills', skill);
3541
if (!fs.existsSync(sourceDir))
3642
throw new Error(`Skill source directory not found: ${sourceDir}`);
3743
const destDir = path.join(baseDir, `.${target}`, 'skills', skill);
38-
await fs.promises.cp(sourceDir, destDir, { recursive: true });
44+
await copySkillDir(sourceDir, destDir, options?.cliCommand || sourceCliCommand);
3945
console.log(`✅ Skill installed to \`${options?.global ? destDir : path.relative(cwd, destDir)}\`.`);
4046
}
4147
}
48+
49+
async function copySkillDir(sourceDir: string, destDir: string, cliCommand: string) {
50+
await fs.promises.mkdir(destDir, { recursive: true });
51+
const entries = await fs.promises.readdir(sourceDir, { withFileTypes: true });
52+
for (const entry of entries) {
53+
const from = path.join(sourceDir, entry.name);
54+
const to = path.join(destDir, entry.name);
55+
if (entry.isDirectory()) {
56+
await copySkillDir(from, to, cliCommand);
57+
continue;
58+
}
59+
if (!entry.isFile())
60+
continue;
61+
const content = await fs.promises.readFile(from, 'utf8');
62+
await fs.promises.writeFile(to, rewriteCliCommand(content, cliCommand));
63+
}
64+
}
65+
66+
function rewriteCliCommand(content: string, cliCommand: string): string {
67+
// Must not contain `playwright-cli`, or the bulk replace below would rewrite it.
68+
const protectedToken = '\0PWCLI\0';
69+
return content
70+
.replaceAll(`name: ${sourceCliCommand}`, `name: ${protectedToken}`)
71+
.replaceAll(`Bash(${sourceCliCommand}:*)`, `Bash(${protectedToken}:*)`)
72+
.replaceAll(`.${sourceCliCommand}/`, `.${protectedToken}/`)
73+
.replaceAll(sourceCliCommand, cliCommand)
74+
.replaceAll(protectedToken, sourceCliCommand);
75+
}

packages/playwright/src/program.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import 'playwright-core/lib/bootstrap';
2020

2121
import { libCli, tools } from 'playwright-core/lib/coreBundle';
2222
import { program } from 'commander';
23+
import { getPackageManagerExecCommand } from '@utils/env';
2324
import { setBoxedStackPrefixes } from '@utils/stackTrace';
2425
import { gracefullyProcessExitDoNotHang } from '@utils/processLauncher';
2526
import { builtInReporters, config, configLoader } from './common';
@@ -193,7 +194,7 @@ function addInitSkillsCommand(program: Command) {
193194
command.addOption(option);
194195
command.action(async opts => {
195196
try {
196-
await tools.installSkills(tools.allSkills, opts.loop);
197+
await tools.installSkills(tools.allSkills, opts.loop, { cliCommand: `${getPackageManagerExecCommand()} playwright cli` });
197198
} catch (e) {
198199
console.error(e);
199200
gracefullyProcessExitDoNotHang(1);

tests/mcp/cli-misc.spec.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ test('install workspace w/skills', async ({ cli }, testInfo) => {
5050
const referencesDir = testInfo.outputPath('.claude', 'skills', 'playwright-cli', 'references');
5151
const references = await fs.promises.readdir(referencesDir);
5252
expect(references.length).toBeGreaterThan(0);
53+
54+
const skill = await fs.promises.readFile(skillFile, 'utf8');
55+
expect(skill).toContain('playwright-cli open');
56+
expect(skill).not.toContain('npx playwright cli open');
57+
expect(skill).toContain('name: playwright-cli');
58+
expect(skill).toContain('.playwright-cli/');
59+
expect(skill).toContain('Bash(playwright-cli:*)');
60+
61+
const sessionRef = await fs.promises.readFile(path.join(referencesDir, 'session-management.md'), 'utf8');
62+
expect(sessionRef).toContain('playwright-cli list');
5363
});
5464

5565
test('install workspace w/--skills=agents', async ({ cli }, testInfo) => {

tests/mcp/init-agents.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,25 @@ test('init-skills installs all skills', async ({ }) => {
130130
for (const skill of ['playwright-cli', 'playwright-component-testing', 'playwright-trace'])
131131
expect(fs.existsSync(path.join(baseDir, '.claude', 'skills', skill, 'SKILL.md'))).toBe(true);
132132
expect(fs.existsSync(path.join(baseDir, '.claude', 'skills', 'playwright-cli', 'references', 'tracing.md'))).toBe(true);
133+
134+
const skill = fs.readFileSync(path.join(baseDir, '.claude', 'skills', 'playwright-cli', 'SKILL.md'), 'utf-8');
135+
expect(skill).toContain('npx playwright cli open');
136+
expect(skill).toContain('name: playwright-cli');
137+
expect(skill).toContain('.playwright-cli/');
138+
expect(skill).not.toMatch(/(^|\n)playwright-cli open\b/);
139+
});
140+
141+
test('playwright cli install --skills templates npx playwright cli command', async ({ }) => {
142+
const baseDir = await writeFiles({});
143+
144+
await spawnAsync('npx', ['playwright', 'cli', 'install', '--skills'], { cwd: baseDir, shell: true });
145+
146+
const skill = fs.readFileSync(path.join(baseDir, '.claude', 'skills', 'playwright-cli', 'SKILL.md'), 'utf-8');
147+
expect(skill).toContain('npx playwright cli open');
148+
expect(skill).toContain('name: playwright-cli');
149+
expect(skill).toContain('.playwright-cli/');
150+
expect(skill).toContain('Bash(playwright-cli:*)');
151+
expect(skill).not.toMatch(/(^|\n)playwright-cli open\b/);
133152
});
134153

135154
test('init-skills installs into .agents with --loop agents', async ({ }) => {

0 commit comments

Comments
 (0)