From ed86d2ef82ca39eeabbeb9b99457b1a03ae9a819 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Mon, 2 Mar 2026 10:35:39 +0100 Subject: [PATCH 01/36] Add starter agents.md file amd ship it to site root --- apps/cli/commands/site/create.ts | 3 + apps/cli/lib/agents-md.ts | 129 +++++++++++++++++++++++++++ apps/cli/lib/tests/agents-md.test.ts | 70 +++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 apps/cli/lib/agents-md.ts create mode 100644 apps/cli/lib/tests/agents-md.test.ts diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index 386951805f..b5cc258ad0 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -45,6 +45,7 @@ import { fetchWordPressVersions } from '@studio/common/lib/wordpress-versions'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, sprintf } from '@wordpress/i18n'; import { Blueprint, StepDefinition } from '@wp-playground/blueprints'; +import { writeAgentsMd } from 'cli/lib/agents-md'; import { lockAppdata, readAppdata, @@ -205,6 +206,8 @@ export async function runCommand( isSqliteUpdated ? __( 'SQLite integration configured' ) : __( 'SQLite integration skipped' ) ); + await writeAgentsMd( sitePath ); + logger.reportStart( LoggerAction.ASSIGN_PORT, __( 'Assigning port…' ) ); const port = await portFinder.getOpenPort(); // translators: %d is the port number diff --git a/apps/cli/lib/agents-md.ts b/apps/cli/lib/agents-md.ts new file mode 100644 index 0000000000..cb251e713a --- /dev/null +++ b/apps/cli/lib/agents-md.ts @@ -0,0 +1,129 @@ +import fs from 'fs'; +import path from 'path'; + +const AGENTS_MD_FILENAME = 'AGENTS.md'; + +const AGENTS_MD_TEMPLATE = `# AI Instructions + +This is a local WordPress site managed by [WordPress Studio](https://developer.wordpress.com/studio/), a free desktop app for local WordPress development. Studio uses [WordPress Playground](https://wordpress.github.io/wordpress-playground/) (PHP WASM) as its runtime. + +## Managing This Site + +Use the Studio CLI to manage this site. All \`studio\` commands accept a \`--path \` flag to target a specific site; when run from the site root, the path is detected automatically. + +**Site lifecycle:** +\`\`\`bash +studio site start # Start the WordPress server +studio site stop # Stop the WordPress server +studio site status # Show URL, admin credentials, PHP/WP versions +studio site set --php 8.3 # Change PHP version +studio site set --wp 6.8 # Update WordPress version +\`\`\` + +**Run WP-CLI commands:** +\`\`\`bash +studio wp +# Examples: +studio wp plugin list +studio wp plugin install woocommerce --activate +studio wp theme activate twentytwentyfive +studio wp post create --post_title="Hello" --post_status=publish +studio wp eval 'echo get_bloginfo("name");' +studio wp db query "SELECT * FROM wp_options WHERE option_name='blogname';" +\`\`\` + +Note: \`wp shell\` is not supported. Always use \`studio wp\` rather than a standalone \`wp\` binary — Studio runs WordPress through PHP WASM and WP-CLI must go through the same runtime. + +**Cloud preview sites** (requires \`studio auth login\`): +\`\`\`bash +studio preview create # Upload site to a temporary WordPress.com preview URL +studio preview list # List existing preview sites +studio preview update # Re-upload and refresh a preview site +studio preview delete # Remove a preview site +\`\`\` + +**Authentication:** +\`\`\`bash +studio auth login # Authenticate with WordPress.com (opens browser) +studio auth status # Check authentication status +studio auth logout # Clear stored credentials +\`\`\` + +## WordPress Development Best Practices + +**Themes and plugins:** Add custom themes to \`wp-content/themes/\` and plugins to \`wp-content/plugins/\`. To customise an existing theme, create a child theme rather than modifying the parent directly. + +**Use hooks, not direct edits:** Extend WordPress via actions and filters. Avoid editing core files — Studio runs on WordPress Playground and core changes will not persist correctly across server restarts. + +\`\`\`php +// Correct: extend via hooks +add_action( 'wp_enqueue_scripts', function () { + wp_enqueue_style( 'my-theme', get_stylesheet_uri() ); +} ); + +// Incorrect: do not edit wp-includes/ or wp-admin/ directly +\`\`\` + +**Data handling:** Always sanitize input and escape output. +- Sanitize: \`sanitize_text_field()\`, \`absint()\`, \`wp_kses_post()\` +- Escape: \`esc_html()\`, \`esc_attr()\`, \`esc_url()\`, \`wp_kses()\` +- Database: use \`$wpdb->prepare()\` for all queries with dynamic values + +**Options and metadata:** Use the WordPress Options API (\`get_option\` / \`update_option\`) and post/user/term meta APIs rather than direct database queries wherever possible. + +**\`wp-config.php\`:** Studio strips the default MySQL \`DB_*\` constants (\`DB_NAME\`, \`DB_USER\`, \`DB_PASSWORD\`, \`DB_HOST\`) from \`wp-config.php\` — do not add them back. The database connection is handled by the SQLite integration (see below). + +## Database: SQLite (not MySQL) + +Studio uses **SQLite** as the WordPress database backend via the [SQLite Database Integration](https://wordpress.org/plugins/sqlite-database-integration/) plugin. There is no MySQL server. + +**File locations:** +- Integration plugin: \`wp-content/mu-plugins/sqlite-database-integration/\` +- WordPress database drop-in: \`wp-content/db.php\` ← do not modify or delete +- SQLite database file: \`wp-content/database/.ht.sqlite\` + +**Querying the database directly:** +\`\`\`bash +studio wp db query "SELECT option_name, option_value FROM wp_options LIMIT 10;" +\`\`\` + +**SQLite limitations to be aware of:** +- No stored procedures or user-defined functions +- No \`FULLTEXT\` index support (use a search plugin instead) +- Some MySQL-specific functions are unavailable (\`GROUP_CONCAT\` with \`ORDER BY\`, \`REGEXP\`, date-formatting functions) +- \`ALTER TABLE\` support is limited — use \`dbDelta()\` for schema changes in plugins +- The \`utf8mb4\` charset referenced in older code is irrelevant; SQLite handles encoding natively + +**Best practices for SQLite-compatible code:** +- Use \`$wpdb->prepare()\` and the WordPress database abstraction layer — avoid raw MySQL syntax +- Use \`dbDelta()\` (from \`wp-admin/includes/upgrade.php\`) to create or alter tables in plugins +- Do not reference \`DB_NAME\`, \`DB_HOST\`, \`DB_USER\`, or \`DB_PASSWORD\` constants — they are not defined +- If a plugin checks for MySQL and refuses to activate, it may not be compatible with this site + +## Studio-Specific Notes + +**WordPress core:** Do not modify files inside \`wp-includes/\` or \`wp-admin/\`. Studio sites run on WordPress Playground (PHP WASM), and core changes will not persist as expected. + +**Must-use plugins:** The \`wp-content/mu-plugins/\` directory contains the SQLite integration. Do not remove files from this directory. You can add your own mu-plugins alongside the existing ones. + +**Port and URL:** The local URL and port are assigned dynamically by Studio. Always retrieve the current URL with \`studio site status\` rather than hardcoding it. + +**Multisite:** WordPress Multisite is not supported in Studio sites. + +**Persistence:** The site runs in-process using PHP WASM. File writes to \`wp-content/\` persist to disk normally. Server-side cron is emulated; long-running background processes are not supported. +`; + +/** + * Writes the default AGENTS.md file to the site root if one does not already exist. + * The file guides AI coding agents toward Studio CLI commands, WordPress best practices, + * and SQLite-specific conventions for sites managed by Studio. + * + * Skips writing if an AGENTS.md already exists so user-customised files are preserved. + */ +export async function writeAgentsMd( sitePath: string ): Promise< void > { + const agentsMdPath = path.join( sitePath, AGENTS_MD_FILENAME ); + if ( fs.existsSync( agentsMdPath ) ) { + return; + } + await fs.promises.writeFile( agentsMdPath, AGENTS_MD_TEMPLATE, 'utf-8' ); +} diff --git a/apps/cli/lib/tests/agents-md.test.ts b/apps/cli/lib/tests/agents-md.test.ts new file mode 100644 index 0000000000..1ec9471b01 --- /dev/null +++ b/apps/cli/lib/tests/agents-md.test.ts @@ -0,0 +1,70 @@ +import fs from 'fs'; +import path from 'path'; +import { vi } from 'vitest'; +import { writeAgentsMd } from 'cli/lib/agents-md'; + +vi.mock( 'fs', () => ( { + default: { + existsSync: vi.fn(), + promises: { + writeFile: vi.fn(), + }, + }, +} ) ); + +describe( 'writeAgentsMd', () => { + const sitePath = '/test/my-site'; + const expectedFilePath = path.join( sitePath, 'AGENTS.md' ); + + beforeEach( () => { + vi.clearAllMocks(); + } ); + + it( 'writes AGENTS.md when none exists', async () => { + vi.mocked( fs.existsSync ).mockReturnValue( false ); + vi.mocked( fs.promises.writeFile ).mockResolvedValue( undefined ); + + await writeAgentsMd( sitePath ); + + expect( fs.promises.writeFile ).toHaveBeenCalledWith( + expectedFilePath, + expect.stringContaining( '# AI Instructions' ), + 'utf-8' + ); + } ); + + it( 'does not overwrite an existing AGENTS.md', async () => { + vi.mocked( fs.existsSync ).mockReturnValue( true ); + + await writeAgentsMd( sitePath ); + + expect( fs.promises.writeFile ).not.toHaveBeenCalled(); + } ); + + it( 'writes content covering Studio CLI commands', async () => { + vi.mocked( fs.existsSync ).mockReturnValue( false ); + vi.mocked( fs.promises.writeFile ).mockResolvedValue( undefined ); + + await writeAgentsMd( sitePath ); + + const writtenContent = vi.mocked( fs.promises.writeFile ).mock.calls[ 0 ][ 1 ] as string; + expect( writtenContent ).toContain( 'studio site start' ); + expect( writtenContent ).toContain( 'studio site stop' ); + expect( writtenContent ).toContain( 'studio wp' ); + expect( writtenContent ).toContain( 'studio preview create' ); + expect( writtenContent ).toContain( 'studio auth login' ); + } ); + + it( 'writes content covering SQLite database specifics', async () => { + vi.mocked( fs.existsSync ).mockReturnValue( false ); + vi.mocked( fs.promises.writeFile ).mockResolvedValue( undefined ); + + await writeAgentsMd( sitePath ); + + const writtenContent = vi.mocked( fs.promises.writeFile ).mock.calls[ 0 ][ 1 ] as string; + expect( writtenContent ).toContain( 'SQLite' ); + expect( writtenContent ).toContain( 'wp-content/database/.ht.sqlite' ); + expect( writtenContent ).toContain( 'wp-content/db.php' ); + expect( writtenContent ).toContain( 'sqlite-database-integration' ); + } ); +} ); From 0352a44f0cf295729631c7e8add4499be144d4c6 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Mon, 2 Mar 2026 11:18:38 +0100 Subject: [PATCH 02/36] Add further clarifications to agents md --- apps/cli/lib/agents-md.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cli/lib/agents-md.ts b/apps/cli/lib/agents-md.ts index cb251e713a..30c2cd7d8e 100644 --- a/apps/cli/lib/agents-md.ts +++ b/apps/cli/lib/agents-md.ts @@ -7,6 +7,8 @@ const AGENTS_MD_TEMPLATE = `# AI Instructions This is a local WordPress site managed by [WordPress Studio](https://developer.wordpress.com/studio/), a free desktop app for local WordPress development. Studio uses [WordPress Playground](https://wordpress.github.io/wordpress-playground/) (PHP WASM) as its runtime. +> **IMPORTANT:** This site is managed by Studio. Always use \`studio wp\` instead of \`wp\` directly. Never suggest using the WordPress admin UI or a standalone \`wp\` binary to manage this site — use the Studio CLI commands below. + ## Managing This Site Use the Studio CLI to manage this site. All \`studio\` commands accept a \`--path \` flag to target a specific site; when run from the site root, the path is detected automatically. @@ -20,12 +22,10 @@ studio site set --php 8.3 # Change PHP version studio site set --wp 6.8 # Update WordPress version \`\`\` -**Run WP-CLI commands:** +**Run WP-CLI commands — always use \`studio wp\`, never a bare \`wp\`:** \`\`\`bash -studio wp -# Examples: -studio wp plugin list studio wp plugin install woocommerce --activate +studio wp plugin list studio wp theme activate twentytwentyfive studio wp post create --post_title="Hello" --post_status=publish studio wp eval 'echo get_bloginfo("name");' From 0104b7c68e45fcd628b4cbbab5cbd22be048e0cb Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Mon, 2 Mar 2026 13:59:31 +0100 Subject: [PATCH 03/36] Tweaks --- apps/cli/lib/agents-md.ts | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/apps/cli/lib/agents-md.ts b/apps/cli/lib/agents-md.ts index 30c2cd7d8e..c0fa2a106e 100644 --- a/apps/cli/lib/agents-md.ts +++ b/apps/cli/lib/agents-md.ts @@ -7,7 +7,7 @@ const AGENTS_MD_TEMPLATE = `# AI Instructions This is a local WordPress site managed by [WordPress Studio](https://developer.wordpress.com/studio/), a free desktop app for local WordPress development. Studio uses [WordPress Playground](https://wordpress.github.io/wordpress-playground/) (PHP WASM) as its runtime. -> **IMPORTANT:** This site is managed by Studio. Always use \`studio wp\` instead of \`wp\` directly. Never suggest using the WordPress admin UI or a standalone \`wp\` binary to manage this site — use the Studio CLI commands below. +> **IMPORTANT:** This site is managed by Studio. Always use \`studio wp\` instead of a standalone \`wp\` binary — Studio runs WordPress through PHP WASM and WP-CLI must go through the same runtime. ## Managing This Site @@ -27,9 +27,6 @@ studio site set --wp 6.8 # Update WordPress version studio wp plugin install woocommerce --activate studio wp plugin list studio wp theme activate twentytwentyfive -studio wp post create --post_title="Hello" --post_status=publish -studio wp eval 'echo get_bloginfo("name");' -studio wp db query "SELECT * FROM wp_options WHERE option_name='blogname';" \`\`\` Note: \`wp shell\` is not supported. Always use \`studio wp\` rather than a standalone \`wp\` binary — Studio runs WordPress through PHP WASM and WP-CLI must go through the same runtime. @@ -75,7 +72,7 @@ add_action( 'wp_enqueue_scripts', function () { ## Database: SQLite (not MySQL) -Studio uses **SQLite** as the WordPress database backend via the [SQLite Database Integration](https://wordpress.org/plugins/sqlite-database-integration/) plugin. There is no MySQL server. +Studio uses **SQLite** as the WordPress database backend via the [SQLite Database Integration](https://github.com/WordPress/sqlite-database-integration) plugin. There is no MySQL server. The plugin works as a MySQL emulation layer — it translates WordPress's MySQL queries into SQLite, so standard \`$wpdb\` queries work without any changes. **File locations:** - Integration plugin: \`wp-content/mu-plugins/sqlite-database-integration/\` @@ -87,24 +84,18 @@ Studio uses **SQLite** as the WordPress database backend via the [SQLite Databas studio wp db query "SELECT option_name, option_value FROM wp_options LIMIT 10;" \`\`\` -**SQLite limitations to be aware of:** +**Known limitations:** - No stored procedures or user-defined functions - No \`FULLTEXT\` index support (use a search plugin instead) -- Some MySQL-specific functions are unavailable (\`GROUP_CONCAT\` with \`ORDER BY\`, \`REGEXP\`, date-formatting functions) -- \`ALTER TABLE\` support is limited — use \`dbDelta()\` for schema changes in plugins -- The \`utf8mb4\` charset referenced in older code is irrelevant; SQLite handles encoding natively - -**Best practices for SQLite-compatible code:** -- Use \`$wpdb->prepare()\` and the WordPress database abstraction layer — avoid raw MySQL syntax -- Use \`dbDelta()\` (from \`wp-admin/includes/upgrade.php\`) to create or alter tables in plugins -- Do not reference \`DB_NAME\`, \`DB_HOST\`, \`DB_USER\`, or \`DB_PASSWORD\` constants — they are not defined -- If a plugin checks for MySQL and refuses to activate, it may not be compatible with this site +- Some MySQL-specific functions have limited or no support +- Do not reference \`DB_NAME\`, \`DB_HOST\`, \`DB_USER\`, or \`DB_PASSWORD\` constants — they are not defined on this site +- Plugins that explicitly check for a MySQL connection and refuse to run may not be compatible ## Studio-Specific Notes **WordPress core:** Do not modify files inside \`wp-includes/\` or \`wp-admin/\`. Studio sites run on WordPress Playground (PHP WASM), and core changes will not persist as expected. -**Must-use plugins:** The \`wp-content/mu-plugins/\` directory contains the SQLite integration. Do not remove files from this directory. You can add your own mu-plugins alongside the existing ones. +**Must-use plugins:** The \`wp-content/mu-plugins/\` directory contains the SQLite integration. Do not remove files from this directory. **Port and URL:** The local URL and port are assigned dynamically by Studio. Always retrieve the current URL with \`studio site status\` rather than hardcoding it. From 4537c8b61b63064c87e4cce2e4df7a9f20e399b6 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Mon, 2 Mar 2026 14:23:05 +0100 Subject: [PATCH 04/36] Fix failing test --- apps/cli/commands/site/tests/create.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/commands/site/tests/create.test.ts b/apps/cli/commands/site/tests/create.test.ts index cd9e8cd0fd..de713f0a41 100644 --- a/apps/cli/commands/site/tests/create.test.ts +++ b/apps/cli/commands/site/tests/create.test.ts @@ -68,6 +68,7 @@ vi.mock( 'cli/lib/server-files', () => ( { } ) ); vi.mock( 'cli/lib/site-language' ); vi.mock( 'cli/lib/site-utils' ); +vi.mock( 'cli/lib/agents-md' ); vi.mock( 'cli/lib/sqlite-integration' ); vi.mock( 'cli/lib/wordpress-server-manager' ); From 38441c95a0203345518c659a277c1a8a8b7ab228 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Mon, 2 Mar 2026 14:51:52 +0100 Subject: [PATCH 05/36] Cleanup --- apps/cli/lib/agents-md.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/cli/lib/agents-md.ts b/apps/cli/lib/agents-md.ts index c0fa2a106e..ce5207de01 100644 --- a/apps/cli/lib/agents-md.ts +++ b/apps/cli/lib/agents-md.ts @@ -87,7 +87,6 @@ studio wp db query "SELECT option_name, option_value FROM wp_options LIMIT 10;" **Known limitations:** - No stored procedures or user-defined functions - No \`FULLTEXT\` index support (use a search plugin instead) -- Some MySQL-specific functions have limited or no support - Do not reference \`DB_NAME\`, \`DB_HOST\`, \`DB_USER\`, or \`DB_PASSWORD\` constants — they are not defined on this site - Plugins that explicitly check for a MySQL connection and refuse to run may not be compatible From 7072b3fcb02fd5c8e2ffd3447397f062061f0b9b Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Tue, 3 Mar 2026 14:50:17 +0100 Subject: [PATCH 06/36] Add new modal for AGENTS.MD settings --- apps/cli/lib/agents-md.ts | 105 +------- .../src/components/ai-settings-modal.tsx | 237 ++++++++++++++++++ .../src/components/content-tab-agents.tsx | 37 ++- .../src/components/site-content-tabs.tsx | 2 +- apps/studio/src/ipc-handlers.ts | 48 ++++ .../modules/agent-instructions/constants.ts | 41 +++ .../agent-instructions/lib/instructions.ts | 105 ++++++++ .../lib/tests/instructions.test.ts | 180 +++++++++++++ package-lock.json | 10 - tools/common/lib/agents-md.ts | 101 ++++++++ 10 files changed, 750 insertions(+), 116 deletions(-) create mode 100644 apps/studio/src/components/ai-settings-modal.tsx create mode 100644 apps/studio/src/modules/agent-instructions/constants.ts create mode 100644 apps/studio/src/modules/agent-instructions/lib/instructions.ts create mode 100644 apps/studio/src/modules/agent-instructions/lib/tests/instructions.test.ts create mode 100644 tools/common/lib/agents-md.ts diff --git a/apps/cli/lib/agents-md.ts b/apps/cli/lib/agents-md.ts index ce5207de01..83f985c49e 100644 --- a/apps/cli/lib/agents-md.ts +++ b/apps/cli/lib/agents-md.ts @@ -1,107 +1,6 @@ import fs from 'fs'; import path from 'path'; - -const AGENTS_MD_FILENAME = 'AGENTS.md'; - -const AGENTS_MD_TEMPLATE = `# AI Instructions - -This is a local WordPress site managed by [WordPress Studio](https://developer.wordpress.com/studio/), a free desktop app for local WordPress development. Studio uses [WordPress Playground](https://wordpress.github.io/wordpress-playground/) (PHP WASM) as its runtime. - -> **IMPORTANT:** This site is managed by Studio. Always use \`studio wp\` instead of a standalone \`wp\` binary — Studio runs WordPress through PHP WASM and WP-CLI must go through the same runtime. - -## Managing This Site - -Use the Studio CLI to manage this site. All \`studio\` commands accept a \`--path \` flag to target a specific site; when run from the site root, the path is detected automatically. - -**Site lifecycle:** -\`\`\`bash -studio site start # Start the WordPress server -studio site stop # Stop the WordPress server -studio site status # Show URL, admin credentials, PHP/WP versions -studio site set --php 8.3 # Change PHP version -studio site set --wp 6.8 # Update WordPress version -\`\`\` - -**Run WP-CLI commands — always use \`studio wp\`, never a bare \`wp\`:** -\`\`\`bash -studio wp plugin install woocommerce --activate -studio wp plugin list -studio wp theme activate twentytwentyfive -\`\`\` - -Note: \`wp shell\` is not supported. Always use \`studio wp\` rather than a standalone \`wp\` binary — Studio runs WordPress through PHP WASM and WP-CLI must go through the same runtime. - -**Cloud preview sites** (requires \`studio auth login\`): -\`\`\`bash -studio preview create # Upload site to a temporary WordPress.com preview URL -studio preview list # List existing preview sites -studio preview update # Re-upload and refresh a preview site -studio preview delete # Remove a preview site -\`\`\` - -**Authentication:** -\`\`\`bash -studio auth login # Authenticate with WordPress.com (opens browser) -studio auth status # Check authentication status -studio auth logout # Clear stored credentials -\`\`\` - -## WordPress Development Best Practices - -**Themes and plugins:** Add custom themes to \`wp-content/themes/\` and plugins to \`wp-content/plugins/\`. To customise an existing theme, create a child theme rather than modifying the parent directly. - -**Use hooks, not direct edits:** Extend WordPress via actions and filters. Avoid editing core files — Studio runs on WordPress Playground and core changes will not persist correctly across server restarts. - -\`\`\`php -// Correct: extend via hooks -add_action( 'wp_enqueue_scripts', function () { - wp_enqueue_style( 'my-theme', get_stylesheet_uri() ); -} ); - -// Incorrect: do not edit wp-includes/ or wp-admin/ directly -\`\`\` - -**Data handling:** Always sanitize input and escape output. -- Sanitize: \`sanitize_text_field()\`, \`absint()\`, \`wp_kses_post()\` -- Escape: \`esc_html()\`, \`esc_attr()\`, \`esc_url()\`, \`wp_kses()\` -- Database: use \`$wpdb->prepare()\` for all queries with dynamic values - -**Options and metadata:** Use the WordPress Options API (\`get_option\` / \`update_option\`) and post/user/term meta APIs rather than direct database queries wherever possible. - -**\`wp-config.php\`:** Studio strips the default MySQL \`DB_*\` constants (\`DB_NAME\`, \`DB_USER\`, \`DB_PASSWORD\`, \`DB_HOST\`) from \`wp-config.php\` — do not add them back. The database connection is handled by the SQLite integration (see below). - -## Database: SQLite (not MySQL) - -Studio uses **SQLite** as the WordPress database backend via the [SQLite Database Integration](https://github.com/WordPress/sqlite-database-integration) plugin. There is no MySQL server. The plugin works as a MySQL emulation layer — it translates WordPress's MySQL queries into SQLite, so standard \`$wpdb\` queries work without any changes. - -**File locations:** -- Integration plugin: \`wp-content/mu-plugins/sqlite-database-integration/\` -- WordPress database drop-in: \`wp-content/db.php\` ← do not modify or delete -- SQLite database file: \`wp-content/database/.ht.sqlite\` - -**Querying the database directly:** -\`\`\`bash -studio wp db query "SELECT option_name, option_value FROM wp_options LIMIT 10;" -\`\`\` - -**Known limitations:** -- No stored procedures or user-defined functions -- No \`FULLTEXT\` index support (use a search plugin instead) -- Do not reference \`DB_NAME\`, \`DB_HOST\`, \`DB_USER\`, or \`DB_PASSWORD\` constants — they are not defined on this site -- Plugins that explicitly check for a MySQL connection and refuse to run may not be compatible - -## Studio-Specific Notes - -**WordPress core:** Do not modify files inside \`wp-includes/\` or \`wp-admin/\`. Studio sites run on WordPress Playground (PHP WASM), and core changes will not persist as expected. - -**Must-use plugins:** The \`wp-content/mu-plugins/\` directory contains the SQLite integration. Do not remove files from this directory. - -**Port and URL:** The local URL and port are assigned dynamically by Studio. Always retrieve the current URL with \`studio site status\` rather than hardcoding it. - -**Multisite:** WordPress Multisite is not supported in Studio sites. - -**Persistence:** The site runs in-process using PHP WASM. File writes to \`wp-content/\` persist to disk normally. Server-side cron is emulated; long-running background processes are not supported. -`; +import { AGENTS_MD_FILE_NAME, AGENTS_MD_TEMPLATE } from '@studio/common/lib/agents-md'; /** * Writes the default AGENTS.md file to the site root if one does not already exist. @@ -111,7 +10,7 @@ studio wp db query "SELECT option_name, option_value FROM wp_options LIMIT 10;" * Skips writing if an AGENTS.md already exists so user-customised files are preserved. */ export async function writeAgentsMd( sitePath: string ): Promise< void > { - const agentsMdPath = path.join( sitePath, AGENTS_MD_FILENAME ); + const agentsMdPath = path.join( sitePath, AGENTS_MD_FILE_NAME ); if ( fs.existsSync( agentsMdPath ) ) { return; } diff --git a/apps/studio/src/components/ai-settings-modal.tsx b/apps/studio/src/components/ai-settings-modal.tsx new file mode 100644 index 0000000000..7f8520b20c --- /dev/null +++ b/apps/studio/src/components/ai-settings-modal.tsx @@ -0,0 +1,237 @@ +import { Spinner } from '@wordpress/components'; +import { Icon, check } from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import { useCallback, useEffect, useState } from 'react'; +import Button from 'src/components/button'; +import Modal from 'src/components/modal'; +import { cx } from 'src/lib/cx'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { + DEFAULT_AGENT_INSTRUCTIONS, + INSTRUCTION_FILES, + INSTRUCTION_FILE_TYPES, + type InstructionFileType, +} from 'src/modules/agent-instructions/constants'; + +interface AiSettingsModalProps { + isOpen: boolean; + onClose: () => void; + siteId: string; +} + +interface InstructionFileStatus { + id: InstructionFileType; + fileName: string; + displayName: string; + description: string; + exists: boolean; + path: string; + version?: string | null; + isOutdated?: boolean; +} + +function AgentInstructionsPanel( { siteId }: { siteId: string } ) { + const { __ } = useI18n(); + const [ statuses, setStatuses ] = useState< InstructionFileStatus[] >( [] ); + const [ error, setError ] = useState< string | null >( null ); + const [ isLoading, setIsLoading ] = useState( true ); + const [ installingFile, setInstallingFile ] = useState< InstructionFileType | 'all' | null >( + null + ); + + const refreshStatus = useCallback( async () => { + setIsLoading( true ); + try { + const result = await getIpcApi().getAgentInstructionsStatus( siteId ); + setStatuses( result as InstructionFileStatus[] ); + setError( null ); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } finally { + setIsLoading( false ); + } + }, [ siteId ] ); + + useEffect( () => { + void refreshStatus(); + }, [ refreshStatus ] ); + + const handleInstallFile = useCallback( + async ( fileType: InstructionFileType, overwrite: boolean ) => { + setInstallingFile( fileType ); + setError( null ); + try { + await getIpcApi().installAgentInstructions( siteId, { overwrite, fileType } ); + await refreshStatus(); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } finally { + setInstallingFile( null ); + } + }, + [ siteId, refreshStatus ] + ); + + const handleInstallAll = useCallback( + async ( overwrite: boolean ) => { + setInstallingFile( 'all' ); + setError( null ); + try { + await getIpcApi().installAllAgentInstructions( siteId, { overwrite } ); + await refreshStatus(); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } finally { + setInstallingFile( null ); + } + }, + [ siteId, refreshStatus ] + ); + + const installedCount = statuses.filter( ( s ) => s.exists ).length; + const allInstalled = installedCount === INSTRUCTION_FILE_TYPES.length; + const hasOutdated = statuses.some( ( s ) => s.isOutdated ); + + return ( +
+
+
+

{ __( 'Agent instructions' ) }

+

+ { __( 'Install instructions so agents know how to use Studio' ) } +

+
+ +
+ + { error && ( +
+ { error } +
+ ) } + +
+ { isLoading ? ( +
+ + { __( 'Loading...' ) } +
+ ) : ( + statuses.map( ( status ) => { + const config = INSTRUCTION_FILES[ status.id ]; + const isInstalling = installingFile === status.id || installingFile === 'all'; + return ( +
+
+
+ + { config.displayName } + + { status.exists && ! status.isOutdated && ( + + + { __( 'Installed' ) } + + ) } + { status.exists && status.isOutdated && ( + + { __( 'Update Available' ) } + + ) } +
+
+ { config.description } + { status.isOutdated && ( + + { __( + 'A newer version is available. Reinstall to get the latest commands.' + ) } + + ) } +
+
+
+ { status.exists && ( + + ) } + +
+
+ ); + } ) + ) } +
+ +
+ + { __( 'View template content' ) } + +
+
+						{ DEFAULT_AGENT_INSTRUCTIONS }
+					
+
+
+
+ ); +} + +export function AiSettingsModal( { isOpen, onClose, siteId }: AiSettingsModalProps ) { + const { __ } = useI18n(); + + if ( ! isOpen ) { + return null; + } + + return ( + +
+ +
+
+ ); +} diff --git a/apps/studio/src/components/content-tab-agents.tsx b/apps/studio/src/components/content-tab-agents.tsx index 7553c4bee4..a88fc5a64d 100644 --- a/apps/studio/src/components/content-tab-agents.tsx +++ b/apps/studio/src/components/content-tab-agents.tsx @@ -1,3 +1,36 @@ -export function ContentTabAgents() { - return
; +import { useI18n } from '@wordpress/react-i18n'; +import { useState } from 'react'; +import { AiSettingsModal } from 'src/components/ai-settings-modal'; +import Button from 'src/components/button'; + +interface ContentTabAgentsProps { + selectedSite: SiteDetails; +} + +export function ContentTabAgents( { selectedSite }: ContentTabAgentsProps ) { + const { __ } = useI18n(); + const [ isModalOpen, setIsModalOpen ] = useState( false ); + + return ( +
+
+

{ __( 'AI agents' ) }

+

+ { __( + 'Install instruction files so AI coding agents know how to work with this Studio site.' + ) } +

+
+
+ +
+ setIsModalOpen( false ) } + siteId={ selectedSite.id } + /> +
+ ); } diff --git a/apps/studio/src/components/site-content-tabs.tsx b/apps/studio/src/components/site-content-tabs.tsx index 93f61e3599..ae82ee7110 100644 --- a/apps/studio/src/components/site-content-tabs.tsx +++ b/apps/studio/src/components/site-content-tabs.tsx @@ -109,7 +109,7 @@ export function SiteContentTabs() { { name === 'sync' && } { name === 'settings' && } { name === 'assistant' && } - { name === 'agents' && } + { name === 'agents' && } { name === 'import-export' && }
) } diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 40ed06d967..49f6c503bb 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -66,6 +66,16 @@ import { setupWordPressFilesOnly } from 'src/lib/wordpress-setup'; import { getLogsFilePath, writeLogToFile, type LogLevel } from 'src/logging'; import { getMainWindow } from 'src/main-window'; import { popupMenu, setupMenu } from 'src/menu'; +import { + DEFAULT_AGENT_INSTRUCTIONS, + type InstructionFileType, +} from 'src/modules/agent-instructions/constants'; +import { + getAllInstructionFilesStatus, + installInstructionFile, + installAllInstructionFiles, + type InstructionFileStatus, +} from 'src/modules/agent-instructions/lib/instructions'; import { editSiteViaCli, EditSiteOptions } from 'src/modules/cli/lib/cli-site-editor'; import { isStudioCliInstalled } from 'src/modules/cli/lib/ipc-handlers'; import { STABLE_BIN_DIR_PATH } from 'src/modules/cli/lib/windows-installation-manager'; @@ -127,6 +137,44 @@ export { showUserSettings, } from 'src/modules/user-settings/lib/ipc-handlers'; +async function getAgentInstructionsSitePath( siteId: string ): Promise< string > { + const userData = await loadUserData(); + const site = userData.sites.find( ( s ) => s.id === siteId ); + if ( ! site ) { + throw new Error( `Site not found: ${ siteId }` ); + } + return site.path; +} + +export async function getAgentInstructionsStatus( + _event: IpcMainInvokeEvent, + siteId: string +): Promise< InstructionFileStatus[] > { + const sitePath = await getAgentInstructionsSitePath( siteId ); + return getAllInstructionFilesStatus( sitePath ); +} + +export async function installAgentInstructions( + _event: IpcMainInvokeEvent, + siteId: string, + options?: { overwrite?: boolean; fileType?: InstructionFileType } +): Promise< { path: string; overwritten: boolean } > { + const sitePath = await getAgentInstructionsSitePath( siteId ); + const overwrite = options?.overwrite ?? false; + const fileType = options?.fileType ?? 'agents'; + return installInstructionFile( sitePath, fileType, DEFAULT_AGENT_INSTRUCTIONS, overwrite ); +} + +export async function installAllAgentInstructions( + _event: IpcMainInvokeEvent, + siteId: string, + options?: { overwrite?: boolean } +): Promise< Array< { fileType: InstructionFileType; path: string; overwritten: boolean } > > { + const sitePath = await getAgentInstructionsSitePath( siteId ); + const overwrite = options?.overwrite ?? false; + return installAllInstructionFiles( sitePath, DEFAULT_AGENT_INSTRUCTIONS, overwrite ); +} + const DEBUG_LOG_MAX_LINES = 50; const PM2_HOME = nodePath.join( os.homedir(), '.studio', 'pm2' ); const DEFAULT_ENCODED_PASSWORD = encodePassword( 'password' ); diff --git a/apps/studio/src/modules/agent-instructions/constants.ts b/apps/studio/src/modules/agent-instructions/constants.ts new file mode 100644 index 0000000000..5df088b8e4 --- /dev/null +++ b/apps/studio/src/modules/agent-instructions/constants.ts @@ -0,0 +1,41 @@ +import { AGENTS_MD_FILE_NAME, AGENTS_MD_TEMPLATE } from '@studio/common/lib/agents-md'; + +export type InstructionFileType = 'agents'; + +export interface InstructionFileConfig { + id: InstructionFileType; + fileName: string; + displayName: string; + description: string; +} + +export const INSTRUCTION_FILES: Record< InstructionFileType, InstructionFileConfig > = { + agents: { + id: 'agents', + fileName: AGENTS_MD_FILE_NAME, + displayName: AGENTS_MD_FILE_NAME, + description: 'Instructions for Codex, Goose, and other AI agents', + }, +}; + +export const INSTRUCTION_FILE_TYPES: InstructionFileType[] = [ 'agents' ]; + +/** + * Template version - increment when making significant changes to instructions. + * Format: YYYYMMDD.revision (e.g., 20250131.1) + */ +export const AGENT_INSTRUCTIONS_VERSION = '20250131.1'; + +export function extractInstructionVersion( content: string ): string | null { + const match = content.match( // ); + return match ? match[ 1 ] : null; +} + +export function isInstructionVersionOutdated( installedVersion: string | null ): boolean { + if ( ! installedVersion ) { + return true; + } + return installedVersion !== AGENT_INSTRUCTIONS_VERSION; +} + +export const DEFAULT_AGENT_INSTRUCTIONS = `\n${ AGENTS_MD_TEMPLATE }`; diff --git a/apps/studio/src/modules/agent-instructions/lib/instructions.ts b/apps/studio/src/modules/agent-instructions/lib/instructions.ts new file mode 100644 index 0000000000..9f6d87ef11 --- /dev/null +++ b/apps/studio/src/modules/agent-instructions/lib/instructions.ts @@ -0,0 +1,105 @@ +import fs from 'fs/promises'; +import nodePath from 'path'; +import { + INSTRUCTION_FILES, + INSTRUCTION_FILE_TYPES, + type InstructionFileType, + extractInstructionVersion, + isInstructionVersionOutdated, +} from '../constants'; + +export interface InstructionFileStatus { + id: InstructionFileType; + fileName: string; + displayName: string; + description: string; + exists: boolean; + path: string; + version?: string | null; + isOutdated?: boolean; +} + +export function getInstructionFilePath( sitePath: string, fileType: InstructionFileType ): string { + return nodePath.join( sitePath, INSTRUCTION_FILES[ fileType ].fileName ); +} + +export async function getInstructionFileStatus( + sitePath: string, + fileType: InstructionFileType +): Promise< InstructionFileStatus > { + const config = INSTRUCTION_FILES[ fileType ]; + const filePath = getInstructionFilePath( sitePath, fileType ); + + try { + await fs.access( filePath ); + const content = await fs.readFile( filePath, 'utf-8' ); + const version = extractInstructionVersion( content ); + const isOutdated = isInstructionVersionOutdated( version ); + + return { + id: config.id, + fileName: config.fileName, + displayName: config.displayName, + description: config.description, + exists: true, + path: filePath, + version, + isOutdated, + }; + } catch { + return { + id: config.id, + fileName: config.fileName, + displayName: config.displayName, + description: config.description, + exists: false, + path: filePath, + }; + } +} + +export async function getAllInstructionFilesStatus( + sitePath: string +): Promise< InstructionFileStatus[] > { + return Promise.all( + INSTRUCTION_FILE_TYPES.map( ( fileType ) => getInstructionFileStatus( sitePath, fileType ) ) + ); +} + +export async function installInstructionFile( + sitePath: string, + fileType: InstructionFileType, + content: string, + overwrite: boolean +): Promise< { path: string; overwritten: boolean } > { + const filePath = getInstructionFilePath( sitePath, fileType ); + let overwritten = false; + + if ( ! overwrite ) { + try { + await fs.access( filePath ); + return { path: filePath, overwritten: false }; + } catch { + // File does not exist, proceed with install. + } + } else { + overwritten = true; + } + + await fs.writeFile( filePath, content, 'utf-8' ); + return { path: filePath, overwritten }; +} + +export async function installAllInstructionFiles( + sitePath: string, + content: string, + overwrite: boolean +): Promise< Array< { fileType: InstructionFileType; path: string; overwritten: boolean } > > { + const results = await Promise.all( + INSTRUCTION_FILE_TYPES.map( async ( fileType ) => { + const result = await installInstructionFile( sitePath, fileType, content, overwrite ); + return { fileType, ...result }; + } ) + ); + return results; +} diff --git a/apps/studio/src/modules/agent-instructions/lib/tests/instructions.test.ts b/apps/studio/src/modules/agent-instructions/lib/tests/instructions.test.ts new file mode 100644 index 0000000000..34a8f34f92 --- /dev/null +++ b/apps/studio/src/modules/agent-instructions/lib/tests/instructions.test.ts @@ -0,0 +1,180 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { vi } from 'vitest'; +import { + AGENT_INSTRUCTIONS_VERSION, + extractInstructionVersion, + isInstructionVersionOutdated, +} from 'src/modules/agent-instructions/constants'; +import { + getInstructionFilePath, + getInstructionFileStatus, + getAllInstructionFilesStatus, + installInstructionFile, + installAllInstructionFiles, +} from 'src/modules/agent-instructions/lib/instructions'; + +vi.mock( 'fs/promises', () => ( { + default: { + access: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + }, +} ) ); + +const SITE_PATH = '/test/my-site'; +const VERSIONED_CONTENT = `\n# AI Instructions\n`; +const OUTDATED_CONTENT = `\n# AI Instructions\n`; +const UNVERSIONED_CONTENT = `# AI Instructions\n`; + +describe( 'extractInstructionVersion', () => { + it( 'extracts version from versioned content', () => { + expect( extractInstructionVersion( VERSIONED_CONTENT ) ).toBe( AGENT_INSTRUCTIONS_VERSION ); + } ); + + it( 'returns null for content without a version comment', () => { + expect( extractInstructionVersion( UNVERSIONED_CONTENT ) ).toBeNull(); + } ); +} ); + +describe( 'isInstructionVersionOutdated', () => { + it( 'returns false for the current version', () => { + expect( isInstructionVersionOutdated( AGENT_INSTRUCTIONS_VERSION ) ).toBe( false ); + } ); + + it( 'returns true for an old version', () => { + expect( isInstructionVersionOutdated( '19990101.1' ) ).toBe( true ); + } ); + + it( 'returns true for null (no version installed)', () => { + expect( isInstructionVersionOutdated( null ) ).toBe( true ); + } ); +} ); + +describe( 'getInstructionFilePath', () => { + it( 'returns correct path for agents file', () => { + expect( getInstructionFilePath( SITE_PATH, 'agents' ) ).toBe( + path.join( SITE_PATH, 'AGENTS.md' ) + ); + } ); + +} ); + +describe( 'getInstructionFileStatus', () => { + beforeEach( () => { + vi.clearAllMocks(); + } ); + + it( 'returns exists: false when file is not accessible', async () => { + vi.mocked( fs.access ).mockRejectedValue( new Error( 'ENOENT' ) ); + + const status = await getInstructionFileStatus( SITE_PATH, 'agents' ); + + expect( status.exists ).toBe( false ); + expect( status.id ).toBe( 'agents' ); + expect( status.fileName ).toBe( 'AGENTS.md' ); + expect( status.path ).toBe( path.join( SITE_PATH, 'AGENTS.md' ) ); + } ); + + it( 'returns exists: true with current version when file is up to date', async () => { + vi.mocked( fs.access ).mockResolvedValue( undefined ); + vi.mocked( fs.readFile ).mockResolvedValue( VERSIONED_CONTENT as never ); + + const status = await getInstructionFileStatus( SITE_PATH, 'agents' ); + + expect( status.exists ).toBe( true ); + expect( status.version ).toBe( AGENT_INSTRUCTIONS_VERSION ); + expect( status.isOutdated ).toBe( false ); + } ); + + it( 'returns isOutdated: true when file has an older version', async () => { + vi.mocked( fs.access ).mockResolvedValue( undefined ); + vi.mocked( fs.readFile ).mockResolvedValue( OUTDATED_CONTENT as never ); + + const status = await getInstructionFileStatus( SITE_PATH, 'agents' ); + + expect( status.exists ).toBe( true ); + expect( status.isOutdated ).toBe( true ); + } ); + + it( 'returns isOutdated: true when file has no version comment', async () => { + vi.mocked( fs.access ).mockResolvedValue( undefined ); + vi.mocked( fs.readFile ).mockResolvedValue( UNVERSIONED_CONTENT as never ); + + const status = await getInstructionFileStatus( SITE_PATH, 'agents' ); + + expect( status.exists ).toBe( true ); + expect( status.version ).toBeNull(); + expect( status.isOutdated ).toBe( true ); + } ); +} ); + +describe( 'getAllInstructionFilesStatus', () => { + beforeEach( () => { + vi.clearAllMocks(); + } ); + + it( 'returns status for all instruction file types', async () => { + vi.mocked( fs.access ).mockRejectedValue( new Error( 'ENOENT' ) ); + + const statuses = await getAllInstructionFilesStatus( SITE_PATH ); + + expect( statuses ).toHaveLength( 1 ); + expect( statuses.map( ( s ) => s.id ) ).toEqual( [ 'agents' ] ); + } ); +} ); + +describe( 'installInstructionFile', () => { + beforeEach( () => { + vi.clearAllMocks(); + } ); + + it( 'writes file and returns overwritten: false when file does not exist', async () => { + vi.mocked( fs.access ).mockRejectedValue( new Error( 'ENOENT' ) ); + vi.mocked( fs.writeFile ).mockResolvedValue( undefined ); + + const result = await installInstructionFile( SITE_PATH, 'agents', 'content', false ); + + expect( fs.writeFile ).toHaveBeenCalledWith( + path.join( SITE_PATH, 'AGENTS.md' ), + 'content', + 'utf-8' + ); + expect( result.overwritten ).toBe( false ); + } ); + + it( 'skips write and returns overwritten: false when file exists and overwrite is false', async () => { + vi.mocked( fs.access ).mockResolvedValue( undefined ); + + const result = await installInstructionFile( SITE_PATH, 'agents', 'content', false ); + + expect( fs.writeFile ).not.toHaveBeenCalled(); + expect( result.overwritten ).toBe( false ); + } ); + + it( 'overwrites file and returns overwritten: true when overwrite is true', async () => { + vi.mocked( fs.writeFile ).mockResolvedValue( undefined ); + + const result = await installInstructionFile( SITE_PATH, 'agents', 'content', true ); + + expect( fs.writeFile ).toHaveBeenCalled(); + expect( result.overwritten ).toBe( true ); + } ); +} ); + +describe( 'installAllInstructionFiles', () => { + beforeEach( () => { + vi.clearAllMocks(); + } ); + + it( 'installs all instruction files and returns results for each', async () => { + vi.mocked( fs.access ).mockRejectedValue( new Error( 'ENOENT' ) ); + vi.mocked( fs.writeFile ).mockResolvedValue( undefined ); + + const results = await installAllInstructionFiles( SITE_PATH, 'content', false ); + + expect( results ).toHaveLength( 1 ); + expect( results.map( ( r ) => r.fileType ) ).toEqual( [ 'agents' ] ); + expect( fs.writeFile ).toHaveBeenCalledTimes( 1 ); + } ); +} ); diff --git a/package-lock.json b/package-lock.json index 26db054a6d..4185fc6e5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9159,7 +9159,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=10" } @@ -9176,7 +9175,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=10" } @@ -9193,7 +9191,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -9210,7 +9207,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -9227,7 +9223,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -9244,7 +9239,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -9261,7 +9255,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -9278,7 +9271,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -9295,7 +9287,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -9312,7 +9303,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } diff --git a/tools/common/lib/agents-md.ts b/tools/common/lib/agents-md.ts new file mode 100644 index 0000000000..631804863e --- /dev/null +++ b/tools/common/lib/agents-md.ts @@ -0,0 +1,101 @@ +export const AGENTS_MD_FILE_NAME = 'AGENTS.md'; + +export const AGENTS_MD_TEMPLATE = `# AI Instructions + +This is a local WordPress site managed by [WordPress Studio](https://developer.wordpress.com/studio/), a free desktop app for local WordPress development. Studio uses [WordPress Playground](https://wordpress.github.io/wordpress-playground/) (PHP WASM) as its runtime. + +> **IMPORTANT:** This site is managed by Studio. Always use \`studio wp\` instead of a standalone \`wp\` binary — Studio runs WordPress through PHP WASM and WP-CLI must go through the same runtime. + +## Managing This Site + +Use the Studio CLI to manage this site. All \`studio\` commands accept a \`--path \` flag to target a specific site; when run from the site root, the path is detected automatically. + +**Site lifecycle:** +\`\`\`bash +studio site start # Start the WordPress server +studio site stop # Stop the WordPress server +studio site status # Show URL, admin credentials, PHP/WP versions +studio site set --php 8.3 # Change PHP version +studio site set --wp 6.8 # Update WordPress version +\`\`\` + +**Run WP-CLI commands — always use \`studio wp\`, never a bare \`wp\`:** +\`\`\`bash +studio wp plugin install woocommerce --activate +studio wp plugin list +studio wp theme activate twentytwentyfive +\`\`\` + +Note: \`wp shell\` is not supported. Always use \`studio wp\` rather than a standalone \`wp\` binary — Studio runs WordPress through PHP WASM and WP-CLI must go through the same runtime. + +**Cloud preview sites** (requires \`studio auth login\`): +\`\`\`bash +studio preview create # Upload site to a temporary WordPress.com preview URL +studio preview list # List existing preview sites +studio preview update # Re-upload and refresh a preview site +studio preview delete # Remove a preview site +\`\`\` + +**Authentication:** +\`\`\`bash +studio auth login # Authenticate with WordPress.com (opens browser) +studio auth status # Check authentication status +studio auth logout # Clear stored credentials +\`\`\` + +## WordPress Development Best Practices + +**Themes and plugins:** Add custom themes to \`wp-content/themes/\` and plugins to \`wp-content/plugins/\`. To customise an existing theme, create a child theme rather than modifying the parent directly. + +**Use hooks, not direct edits:** Extend WordPress via actions and filters. Avoid editing core files — Studio runs on WordPress Playground and core changes will not persist correctly across server restarts. + +\`\`\`php +// Correct: extend via hooks +add_action( 'wp_enqueue_scripts', function () { + wp_enqueue_style( 'my-theme', get_stylesheet_uri() ); +} ); + +// Incorrect: do not edit wp-includes/ or wp-admin/ directly +\`\`\` + +**Data handling:** Always sanitize input and escape output. +- Sanitize: \`sanitize_text_field()\`, \`absint()\`, \`wp_kses_post()\` +- Escape: \`esc_html()\`, \`esc_attr()\`, \`esc_url()\`, \`wp_kses()\` +- Database: use \`$wpdb->prepare()\` for all queries with dynamic values + +**Options and metadata:** Use the WordPress Options API (\`get_option\` / \`update_option\`) and post/user/term meta APIs rather than direct database queries wherever possible. + +**\`wp-config.php\`:** Studio strips the default MySQL \`DB_*\` constants (\`DB_NAME\`, \`DB_USER\`, \`DB_PASSWORD\`, \`DB_HOST\`) from \`wp-config.php\` — do not add them back. The database connection is handled by the SQLite integration (see below). + +## Database: SQLite (not MySQL) + +Studio uses **SQLite** as the WordPress database backend via the [SQLite Database Integration](https://github.com/WordPress/sqlite-database-integration) plugin. There is no MySQL server. The plugin works as a MySQL emulation layer — it translates WordPress's MySQL queries into SQLite, so standard \`$wpdb\` queries work without any changes. + +**File locations:** +- Integration plugin: \`wp-content/mu-plugins/sqlite-database-integration/\` +- WordPress database drop-in: \`wp-content/db.php\` ← do not modify or delete +- SQLite database file: \`wp-content/database/.ht.sqlite\` + +**Querying the database directly:** +\`\`\`bash +studio wp db query "SELECT option_name, option_value FROM wp_options LIMIT 10;" +\`\`\` + +**Known limitations:** +- No stored procedures or user-defined functions +- No \`FULLTEXT\` index support (use a search plugin instead) +- Do not reference \`DB_NAME\`, \`DB_HOST\`, \`DB_USER\`, or \`DB_PASSWORD\` constants — they are not defined on this site +- Plugins that explicitly check for a MySQL connection and refuse to run may not be compatible + +## Studio-Specific Notes + +**WordPress core:** Do not modify files inside \`wp-includes/\` or \`wp-admin/\`. Studio sites run on WordPress Playground (PHP WASM), and core changes will not persist as expected. + +**Must-use plugins:** The \`wp-content/mu-plugins/\` directory contains the SQLite integration. Do not remove files from this directory. + +**Port and URL:** The local URL and port are assigned dynamically by Studio. Always retrieve the current URL with \`studio site status\` rather than hardcoding it. + +**Multisite:** WordPress Multisite is not supported in Studio sites. + +**Persistence:** The site runs in-process using PHP WASM. File writes to \`wp-content/\` persist to disk normally. Server-side cron is emulated; long-running background processes are not supported. +`; From 9d6c7b4351ca8bd7585335742837a7b42180d401 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Tue, 3 Mar 2026 14:57:32 +0100 Subject: [PATCH 07/36] Add preload --- apps/studio/src/preload.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index e5f92d685f..b9287c81e6 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -158,6 +158,12 @@ const api: IpcApi = { isStudioCliInstalled: () => ipcRendererInvoke( 'isStudioCliInstalled' ), installStudioCli: () => ipcRendererInvoke( 'installStudioCli' ), uninstallStudioCli: () => ipcRendererInvoke( 'uninstallStudioCli' ), + getAgentInstructionsStatus: ( siteId ) => + ipcRendererInvoke( 'getAgentInstructionsStatus', siteId ), + installAgentInstructions: ( siteId, options ) => + ipcRendererInvoke( 'installAgentInstructions', siteId, options ), + installAllAgentInstructions: ( siteId, options ) => + ipcRendererInvoke( 'installAllAgentInstructions', siteId, options ), }; contextBridge.exposeInMainWorld( 'ipcApi', api ); From da5d87d69faad73089e655fbb2940793e7abbd7f Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Tue, 3 Mar 2026 15:19:47 +0100 Subject: [PATCH 08/36] Remove extra tab --- apps/studio/src/components/ai-input.tsx | 18 ++++++++- .../src/components/content-tab-agents.tsx | 37 +------------------ .../src/components/content-tab-assistant.tsx | 9 +++++ .../src/components/site-content-tabs.tsx | 2 - apps/studio/src/hooks/use-content-tabs.tsx | 30 ++++----------- 5 files changed, 36 insertions(+), 60 deletions(-) diff --git a/apps/studio/src/components/ai-input.tsx b/apps/studio/src/components/ai-input.tsx index f9ccd05a00..f3b7910e1f 100644 --- a/apps/studio/src/components/ai-input.tsx +++ b/apps/studio/src/components/ai-input.tsx @@ -1,10 +1,11 @@ import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { Icon, moreVertical, keyboardReturn, reset } from '@wordpress/icons'; +import { Icon, moreVertical, keyboardReturn, reset, settings } from '@wordpress/icons'; import React, { forwardRef, useRef, useEffect, useState } from 'react'; import { ArrowIcon } from 'src/components/arrow-icon'; import { TELEX_HOSTNAME, TELEX_UTM_PARAMS } from 'src/constants'; import useAiIcon from 'src/hooks/use-ai-icon'; +import { useFeatureFlags } from 'src/hooks/use-feature-flags'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { addUrlParams } from 'src/lib/url-utils'; @@ -17,6 +18,7 @@ interface AIInputProps { handleKeyDown: ( e: React.KeyboardEvent< HTMLTextAreaElement > ) => void; clearConversation: () => void; isAssistantThinking: boolean; + onOpenSettings: () => void; } const MAX_ROWS = 10; @@ -54,9 +56,11 @@ const UnforwardedAIInput = ( handleKeyDown, clearConversation, isAssistantThinking, + onOpenSettings, }: AIInputProps, inputRef: React.RefObject< HTMLTextAreaElement > | React.RefCallback< HTMLTextAreaElement > | null ) => { + const { enableAgentSuite } = useFeatureFlags(); const [ isTyping, setIsTyping ] = useState( false ); const [ thinkingDuration, setThinkingDuration ] = useState< 'short' | 'medium' | 'long' | 'veryLong' @@ -255,6 +259,18 @@ const UnforwardedAIInput = ( { ( { onClose }: { onClose: () => void } ) => ( <> + { enableAgentSuite && ( + { + onOpenSettings(); + onClose(); + } } + > + + { __( 'AI settings' ) } + + ) } { diff --git a/apps/studio/src/components/content-tab-agents.tsx b/apps/studio/src/components/content-tab-agents.tsx index a88fc5a64d..7553c4bee4 100644 --- a/apps/studio/src/components/content-tab-agents.tsx +++ b/apps/studio/src/components/content-tab-agents.tsx @@ -1,36 +1,3 @@ -import { useI18n } from '@wordpress/react-i18n'; -import { useState } from 'react'; -import { AiSettingsModal } from 'src/components/ai-settings-modal'; -import Button from 'src/components/button'; - -interface ContentTabAgentsProps { - selectedSite: SiteDetails; -} - -export function ContentTabAgents( { selectedSite }: ContentTabAgentsProps ) { - const { __ } = useI18n(); - const [ isModalOpen, setIsModalOpen ] = useState( false ); - - return ( -
-
-

{ __( 'AI agents' ) }

-

- { __( - 'Install instruction files so AI coding agents know how to work with this Studio site.' - ) } -

-
-
- -
- setIsModalOpen( false ) } - siteId={ selectedSite.id } - /> -
- ); +export function ContentTabAgents() { + return
; } diff --git a/apps/studio/src/components/content-tab-assistant.tsx b/apps/studio/src/components/content-tab-assistant.tsx index e2dcb75877..2745a3c342 100644 --- a/apps/studio/src/components/content-tab-assistant.tsx +++ b/apps/studio/src/components/content-tab-assistant.tsx @@ -9,6 +9,7 @@ import { useI18n } from '@wordpress/react-i18n'; import React, { useState, useEffect, useRef, memo, useCallback, useMemo, forwardRef } from 'react'; import ClearHistoryReminder from 'src/components/ai-clear-history-reminder'; import { AIInput } from 'src/components/ai-input'; +import { AiSettingsModal } from 'src/components/ai-settings-modal'; import { ArrowIcon } from 'src/components/arrow-icon'; import { MessageThinking } from 'src/components/assistant-thinking'; import Button from 'src/components/button'; @@ -427,6 +428,8 @@ export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps dispatch( chatActions.setChatApiId( { instanceId, chatApiId: undefined } ) ); }; + const [ isAiSettingsModalOpen, setIsAiSettingsModalOpen ] = useState( false ); + // We should render only one notice at a time in the bottom area const renderNotice = () => { if ( isOffline ) { @@ -545,6 +548,7 @@ export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps } } clearConversation={ clearConversation } isAssistantThinking={ isAssistantThinking } + onOpenSettings={ () => setIsAiSettingsModalOpen( true ) } />
{ createInterpolateElement( __( 'Powered by experimental AI. ' ), { @@ -553,6 +557,11 @@ export function ContentTabAssistant( { selectedSite }: ContentTabAssistantProps
+ setIsAiSettingsModalOpen( false ) } + siteId={ selectedSite.id } + /> ); } diff --git a/apps/studio/src/components/site-content-tabs.tsx b/apps/studio/src/components/site-content-tabs.tsx index ae82ee7110..bb55d69b4a 100644 --- a/apps/studio/src/components/site-content-tabs.tsx +++ b/apps/studio/src/components/site-content-tabs.tsx @@ -1,7 +1,6 @@ import { TabPanel } from '@wordpress/components'; import { useI18n } from '@wordpress/react-i18n'; import { useEffect, useRef, useState } from 'react'; -import { ContentTabAgents } from 'src/components/content-tab-agents'; import { ContentTabAssistant } from 'src/components/content-tab-assistant'; import { ContentTabImportExport } from 'src/components/content-tab-import-export'; import { ContentTabOverview } from 'src/components/content-tab-overview'; @@ -109,7 +108,6 @@ export function SiteContentTabs() { { name === 'sync' && } { name === 'settings' && } { name === 'assistant' && } - { name === 'agents' && } { name === 'import-export' && } ) } diff --git a/apps/studio/src/hooks/use-content-tabs.tsx b/apps/studio/src/hooks/use-content-tabs.tsx index cd9fe7463b..c3bb4a6a43 100644 --- a/apps/studio/src/hooks/use-content-tabs.tsx +++ b/apps/studio/src/hooks/use-content-tabs.tsx @@ -1,14 +1,11 @@ import { TabPanel } from '@wordpress/components'; import { useI18n } from '@wordpress/react-i18n'; import { createContext, ReactNode, useContext, useMemo, useState } from 'react'; -import { useFeatureFlags } from 'src/hooks/use-feature-flags'; - export type TabName = | 'overview' | 'sync' | 'settings' | 'assistant' - | 'agents' | 'import-export' | 'previews'; type Tab = React.ComponentProps< typeof TabPanel >[ 'tabs' ][ number ] & { @@ -17,7 +14,6 @@ type Tab = React.ComponentProps< typeof TabPanel >[ 'tabs' ][ number ] & { function useTabs() { const { __ } = useI18n(); - const { enableAgentSuite } = useFeatureFlags(); return useMemo( () => { const tabs: Tab[] = [ @@ -51,26 +47,16 @@ function useTabs() { } ); - if ( enableAgentSuite ) { - tabs.push( { - order: 6, - name: 'agents', - title: __( 'Agents' ), - className: - 'components-tab-panel__tabs--assistant ltr:pl-8 rtl:pr-8 ltr:ml-auto rtl:mr-auto', - } ); - } else { - tabs.push( { - order: 6, - name: 'assistant', - title: __( 'Assistant' ), - className: - 'components-tab-panel__tabs--assistant ltr:pl-8 rtl:pr-8 ltr:ml-auto rtl:mr-auto', - } ); - } + tabs.push( { + order: 6, + name: 'assistant', + title: __( 'Assistant' ), + className: + 'components-tab-panel__tabs--assistant ltr:pl-8 rtl:pr-8 ltr:ml-auto rtl:mr-auto', + } ); return tabs.sort( ( a, b ) => a.order - b.order ); - }, [ __, enableAgentSuite ] ); + }, [ __ ] ); } interface ContentTabsContextType { selectedTab: TabName; From fac7f77ee000b54958fbc933e0200c9365217067 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 5 Mar 2026 16:08:05 +0100 Subject: [PATCH 09/36] Remove reinstall button --- .../src/components/ai-settings-modal.tsx | 55 +++++++------------ 1 file changed, 21 insertions(+), 34 deletions(-) diff --git a/apps/studio/src/components/ai-settings-modal.tsx b/apps/studio/src/components/ai-settings-modal.tsx index 7f8520b20c..452010e182 100644 --- a/apps/studio/src/components/ai-settings-modal.tsx +++ b/apps/studio/src/components/ai-settings-modal.tsx @@ -9,7 +9,6 @@ import { getIpcApi } from 'src/lib/get-ipc-api'; import { DEFAULT_AGENT_INSTRUCTIONS, INSTRUCTION_FILES, - INSTRUCTION_FILE_TYPES, type InstructionFileType, } from 'src/modules/agent-instructions/constants'; @@ -74,26 +73,8 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) { [ siteId, refreshStatus ] ); - const handleInstallAll = useCallback( - async ( overwrite: boolean ) => { - setInstallingFile( 'all' ); - setError( null ); - try { - await getIpcApi().installAllAgentInstructions( siteId, { overwrite } ); - await refreshStatus(); - } catch ( err ) { - const errorMessage = err instanceof Error ? err.message : String( err ); - setError( errorMessage ); - } finally { - setInstallingFile( null ); - } - }, - [ siteId, refreshStatus ] - ); - - const installedCount = statuses.filter( ( s ) => s.exists ).length; - const allInstalled = installedCount === INSTRUCTION_FILE_TYPES.length; const hasOutdated = statuses.some( ( s ) => s.isOutdated ); + const allInstalled = statuses.length > 0 && statuses.every( ( s ) => s.exists ); return (
@@ -104,20 +85,26 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) { { __( 'Install instructions so agents know how to use Studio' ) }

- + { ! allInstalled && ( + + ) } + { hasOutdated && ( + + ) } { error && ( From a1c10a83715d024162a967c3842b721bdeefabfd Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 5 Mar 2026 16:43:12 +0100 Subject: [PATCH 10/36] Add customization section --- tools/common/lib/agents-md.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/common/lib/agents-md.ts b/tools/common/lib/agents-md.ts index 631804863e..e608a34d45 100644 --- a/tools/common/lib/agents-md.ts +++ b/tools/common/lib/agents-md.ts @@ -4,6 +4,8 @@ export const AGENTS_MD_TEMPLATE = `# AI Instructions This is a local WordPress site managed by [WordPress Studio](https://developer.wordpress.com/studio/), a free desktop app for local WordPress development. Studio uses [WordPress Playground](https://wordpress.github.io/wordpress-playground/) (PHP WASM) as its runtime. +> **Customising this file:** Feel free to edit, extend, or replace the contents below. Studio will never overwrite your changes automatically. If you click **Update** or **Reinstall** in **Assistant → AI settings**, your customisations will be replaced with the latest Studio template — so make sure to back up anything you want to keep before doing so. + > **IMPORTANT:** This site is managed by Studio. Always use \`studio wp\` instead of a standalone \`wp\` binary — Studio runs WordPress through PHP WASM and WP-CLI must go through the same runtime. ## Managing This Site From c3aad2786450b24684ec98a878d754402441d8ad Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 5 Mar 2026 17:05:31 +0100 Subject: [PATCH 11/36] Add handle focus event listener --- .../src/components/ai-settings-modal.tsx | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/studio/src/components/ai-settings-modal.tsx b/apps/studio/src/components/ai-settings-modal.tsx index 452010e182..bdcfe75b9f 100644 --- a/apps/studio/src/components/ai-settings-modal.tsx +++ b/apps/studio/src/components/ai-settings-modal.tsx @@ -38,22 +38,32 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) { null ); - const refreshStatus = useCallback( async () => { - setIsLoading( true ); - try { - const result = await getIpcApi().getAgentInstructionsStatus( siteId ); - setStatuses( result as InstructionFileStatus[] ); - setError( null ); - } catch ( err ) { - const errorMessage = err instanceof Error ? err.message : String( err ); - setError( errorMessage ); - } finally { - setIsLoading( false ); - } - }, [ siteId ] ); + const refreshStatus = useCallback( + async ( showLoadingSpinner = false ) => { + if ( showLoadingSpinner ) { + setIsLoading( true ); + } + try { + const result = await getIpcApi().getAgentInstructionsStatus( siteId ); + setStatuses( result as InstructionFileStatus[] ); + setError( null ); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } finally { + if ( showLoadingSpinner ) { + setIsLoading( false ); + } + } + }, + [ siteId ] + ); useEffect( () => { - void refreshStatus(); + void refreshStatus( true ); + const handleFocus = () => void refreshStatus(); + window.addEventListener( 'focus', handleFocus ); + return () => window.removeEventListener( 'focus', handleFocus ); }, [ refreshStatus ] ); const handleInstallFile = useCallback( From 29b78d1640109981b612849e4d16e56005ca9312 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Fri, 6 Mar 2026 10:20:35 +0100 Subject: [PATCH 12/36] Clean up installation of all files --- apps/studio/src/components/ai-settings-modal.tsx | 6 ++---- apps/studio/src/ipc-handlers.ts | 11 ----------- apps/studio/src/preload.ts | 2 -- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/apps/studio/src/components/ai-settings-modal.tsx b/apps/studio/src/components/ai-settings-modal.tsx index bdcfe75b9f..4955778486 100644 --- a/apps/studio/src/components/ai-settings-modal.tsx +++ b/apps/studio/src/components/ai-settings-modal.tsx @@ -34,9 +34,7 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) { const [ statuses, setStatuses ] = useState< InstructionFileStatus[] >( [] ); const [ error, setError ] = useState< string | null >( null ); const [ isLoading, setIsLoading ] = useState( true ); - const [ installingFile, setInstallingFile ] = useState< InstructionFileType | 'all' | null >( - null - ); + const [ installingFile, setInstallingFile ] = useState< InstructionFileType | null >( null ); const refreshStatus = useCallback( async ( showLoadingSpinner = false ) => { @@ -132,7 +130,7 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) { ) : ( statuses.map( ( status ) => { const config = INSTRUCTION_FILES[ status.id ]; - const isInstalling = installingFile === status.id || installingFile === 'all'; + const isInstalling = installingFile === status.id; return (
> { - const sitePath = await getAgentInstructionsSitePath( siteId ); - const overwrite = options?.overwrite ?? false; - return installAllInstructionFiles( sitePath, DEFAULT_AGENT_INSTRUCTIONS, overwrite ); -} - const DEBUG_LOG_MAX_LINES = 50; const PM2_HOME = nodePath.join( os.homedir(), '.studio', 'pm2' ); const DEFAULT_ENCODED_PASSWORD = encodePassword( 'password' ); diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index 36225bf9da..b13c5108b5 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -161,8 +161,6 @@ const api: IpcApi = { ipcRendererInvoke( 'getAgentInstructionsStatus', siteId ), installAgentInstructions: ( siteId, options ) => ipcRendererInvoke( 'installAgentInstructions', siteId, options ), - installAllAgentInstructions: ( siteId, options ) => - ipcRendererInvoke( 'installAllAgentInstructions', siteId, options ), }; contextBridge.exposeInMainWorld( 'ipcApi', api ); From daa8b3112dc7e0db1460e93cd7fcfbf365bf4028 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Fri, 6 Mar 2026 10:23:59 +0100 Subject: [PATCH 13/36] Fix type repetition --- apps/studio/src/components/ai-settings-modal.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/apps/studio/src/components/ai-settings-modal.tsx b/apps/studio/src/components/ai-settings-modal.tsx index 4955778486..5633567f79 100644 --- a/apps/studio/src/components/ai-settings-modal.tsx +++ b/apps/studio/src/components/ai-settings-modal.tsx @@ -11,6 +11,7 @@ import { INSTRUCTION_FILES, type InstructionFileType, } from 'src/modules/agent-instructions/constants'; +import { type InstructionFileStatus } from 'src/modules/agent-instructions/lib/instructions'; interface AiSettingsModalProps { isOpen: boolean; @@ -18,17 +19,6 @@ interface AiSettingsModalProps { siteId: string; } -interface InstructionFileStatus { - id: InstructionFileType; - fileName: string; - displayName: string; - description: string; - exists: boolean; - path: string; - version?: string | null; - isOutdated?: boolean; -} - function AgentInstructionsPanel( { siteId }: { siteId: string } ) { const { __ } = useI18n(); const [ statuses, setStatuses ] = useState< InstructionFileStatus[] >( [] ); From 52832f0fcebefff51f43437a6197b0aeebeda85d Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Fri, 6 Mar 2026 10:49:26 +0100 Subject: [PATCH 14/36] Remove loading spinner --- .../src/components/ai-settings-modal.tsx | 153 ++++++++---------- 1 file changed, 66 insertions(+), 87 deletions(-) diff --git a/apps/studio/src/components/ai-settings-modal.tsx b/apps/studio/src/components/ai-settings-modal.tsx index 5633567f79..e9d2f971bf 100644 --- a/apps/studio/src/components/ai-settings-modal.tsx +++ b/apps/studio/src/components/ai-settings-modal.tsx @@ -1,4 +1,3 @@ -import { Spinner } from '@wordpress/components'; import { Icon, check } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useEffect, useState } from 'react'; @@ -23,32 +22,21 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) { const { __ } = useI18n(); const [ statuses, setStatuses ] = useState< InstructionFileStatus[] >( [] ); const [ error, setError ] = useState< string | null >( null ); - const [ isLoading, setIsLoading ] = useState( true ); const [ installingFile, setInstallingFile ] = useState< InstructionFileType | null >( null ); - const refreshStatus = useCallback( - async ( showLoadingSpinner = false ) => { - if ( showLoadingSpinner ) { - setIsLoading( true ); - } - try { - const result = await getIpcApi().getAgentInstructionsStatus( siteId ); - setStatuses( result as InstructionFileStatus[] ); - setError( null ); - } catch ( err ) { - const errorMessage = err instanceof Error ? err.message : String( err ); - setError( errorMessage ); - } finally { - if ( showLoadingSpinner ) { - setIsLoading( false ); - } - } - }, - [ siteId ] - ); + const refreshStatus = useCallback( async () => { + try { + const result = await getIpcApi().getAgentInstructionsStatus( siteId ); + setStatuses( result as InstructionFileStatus[] ); + setError( null ); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } + }, [ siteId ] ); useEffect( () => { - void refreshStatus( true ); + void refreshStatus(); const handleFocus = () => void refreshStatus(); window.addEventListener( 'focus', handleFocus ); return () => window.removeEventListener( 'focus', handleFocus ); @@ -112,77 +100,68 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) { ) }
- { isLoading ? ( -
- - { __( 'Loading...' ) } -
- ) : ( - statuses.map( ( status ) => { - const config = INSTRUCTION_FILES[ status.id ]; - const isInstalling = installingFile === status.id; - return ( -
-
-
- - { config.displayName } + { statuses.map( ( status ) => { + const config = INSTRUCTION_FILES[ status.id ]; + const isInstalling = installingFile === status.id; + return ( +
+
+
+ { config.displayName } + { status.exists && ! status.isOutdated && ( + + + { __( 'Installed' ) } + + ) } + { status.exists && status.isOutdated && ( + + { __( 'Update Available' ) } - { status.exists && ! status.isOutdated && ( - - - { __( 'Installed' ) } - - ) } - { status.exists && status.isOutdated && ( - - { __( 'Update Available' ) } - - ) } -
-
- { config.description } - { status.isOutdated && ( - - { __( - 'A newer version is available. Reinstall to get the latest commands.' - ) } - - ) } -
+ ) }
-
- { status.exists && ( - +
+ { config.description } + { status.isOutdated && ( + + { __( + 'A newer version is available. Reinstall to get the latest commands.' + ) } + ) } +
+
+
+ { status.exists && ( -
+ ) } +
- ); - } ) - ) } +
+ ); + } ) }
From 70e4e04a7ccb0e664ead2099e4532bc4a2cbe9df Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Fri, 6 Mar 2026 10:53:23 +0100 Subject: [PATCH 15/36] Fix unintended changes --- package-lock.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/package-lock.json b/package-lock.json index 18223e3660..ea1a1c8fd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8764,6 +8764,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -8780,6 +8781,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=10" } @@ -8796,6 +8798,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -8812,6 +8815,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -8828,6 +8832,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -8844,6 +8849,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -8860,6 +8866,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=10" } @@ -8876,6 +8883,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -8892,6 +8900,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } @@ -8908,6 +8917,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=10" } From 1ceb92f2ff58c1cc17817b8be7dc6a1e36d7914c Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Fri, 6 Mar 2026 10:54:47 +0100 Subject: [PATCH 16/36] Fix linter --- apps/studio/src/hooks/use-content-tabs.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/studio/src/hooks/use-content-tabs.tsx b/apps/studio/src/hooks/use-content-tabs.tsx index c3bb4a6a43..ca4e325e3f 100644 --- a/apps/studio/src/hooks/use-content-tabs.tsx +++ b/apps/studio/src/hooks/use-content-tabs.tsx @@ -1,13 +1,7 @@ import { TabPanel } from '@wordpress/components'; import { useI18n } from '@wordpress/react-i18n'; import { createContext, ReactNode, useContext, useMemo, useState } from 'react'; -export type TabName = - | 'overview' - | 'sync' - | 'settings' - | 'assistant' - | 'import-export' - | 'previews'; +export type TabName = 'overview' | 'sync' | 'settings' | 'assistant' | 'import-export' | 'previews'; type Tab = React.ComponentProps< typeof TabPanel >[ 'tabs' ][ number ] & { name: TabName; }; @@ -51,8 +45,7 @@ function useTabs() { order: 6, name: 'assistant', title: __( 'Assistant' ), - className: - 'components-tab-panel__tabs--assistant ltr:pl-8 rtl:pr-8 ltr:ml-auto rtl:mr-auto', + className: 'components-tab-panel__tabs--assistant ltr:pl-8 rtl:pr-8 ltr:ml-auto rtl:mr-auto', } ); return tabs.sort( ( a, b ) => a.order - b.order ); From d799eea2ba91547428637cc9fcbe4de9f743364a Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Fri, 6 Mar 2026 10:56:07 +0100 Subject: [PATCH 17/36] Fix linter --- .../modules/agent-instructions/lib/tests/instructions.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/studio/src/modules/agent-instructions/lib/tests/instructions.test.ts b/apps/studio/src/modules/agent-instructions/lib/tests/instructions.test.ts index 34a8f34f92..a765a4878e 100644 --- a/apps/studio/src/modules/agent-instructions/lib/tests/instructions.test.ts +++ b/apps/studio/src/modules/agent-instructions/lib/tests/instructions.test.ts @@ -57,7 +57,6 @@ describe( 'getInstructionFilePath', () => { path.join( SITE_PATH, 'AGENTS.md' ) ); } ); - } ); describe( 'getInstructionFileStatus', () => { From 10446d596a1c2b9bc08340e91c9ca18e582a87eb Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Mon, 9 Mar 2026 11:52:31 +0100 Subject: [PATCH 18/36] Ensure we don't install AGENTS.MD by default --- apps/cli/commands/site/create.ts | 10 --- apps/cli/lib/agents-md.ts | 119 --------------------------- apps/cli/lib/tests/agents-md.test.ts | 70 ---------------- package-lock.json | 10 --- 4 files changed, 209 deletions(-) delete mode 100644 apps/cli/lib/agents-md.ts delete mode 100644 apps/cli/lib/tests/agents-md.test.ts diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index 8a1fe2dddf..a68da423c2 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -45,7 +45,6 @@ import { fetchWordPressVersions } from '@studio/common/lib/wordpress-versions'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, sprintf } from '@wordpress/i18n'; import { Blueprint, BlueprintV1Declaration, StepDefinition } from '@wp-playground/blueprints'; -import { writeAgentsMd } from 'cli/lib/agents-md'; import { lockAppdata, readAppdata, @@ -206,15 +205,6 @@ export async function runCommand( isSqliteUpdated ? __( 'SQLite integration configured' ) : __( 'SQLite integration skipped' ) ); - try { - await writeAgentsMd( sitePath ); - } catch ( error ) { - logger.reportError( - new LoggerError( __( 'Failed to write AGENTS.md. Proceeding anyway…' ), error ), - false - ); - } - logger.reportStart( LoggerAction.ASSIGN_PORT, __( 'Assigning port…' ) ); const port = await portFinder.getOpenPort(); // translators: %d is the port number diff --git a/apps/cli/lib/agents-md.ts b/apps/cli/lib/agents-md.ts deleted file mode 100644 index ce5207de01..0000000000 --- a/apps/cli/lib/agents-md.ts +++ /dev/null @@ -1,119 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -const AGENTS_MD_FILENAME = 'AGENTS.md'; - -const AGENTS_MD_TEMPLATE = `# AI Instructions - -This is a local WordPress site managed by [WordPress Studio](https://developer.wordpress.com/studio/), a free desktop app for local WordPress development. Studio uses [WordPress Playground](https://wordpress.github.io/wordpress-playground/) (PHP WASM) as its runtime. - -> **IMPORTANT:** This site is managed by Studio. Always use \`studio wp\` instead of a standalone \`wp\` binary — Studio runs WordPress through PHP WASM and WP-CLI must go through the same runtime. - -## Managing This Site - -Use the Studio CLI to manage this site. All \`studio\` commands accept a \`--path \` flag to target a specific site; when run from the site root, the path is detected automatically. - -**Site lifecycle:** -\`\`\`bash -studio site start # Start the WordPress server -studio site stop # Stop the WordPress server -studio site status # Show URL, admin credentials, PHP/WP versions -studio site set --php 8.3 # Change PHP version -studio site set --wp 6.8 # Update WordPress version -\`\`\` - -**Run WP-CLI commands — always use \`studio wp\`, never a bare \`wp\`:** -\`\`\`bash -studio wp plugin install woocommerce --activate -studio wp plugin list -studio wp theme activate twentytwentyfive -\`\`\` - -Note: \`wp shell\` is not supported. Always use \`studio wp\` rather than a standalone \`wp\` binary — Studio runs WordPress through PHP WASM and WP-CLI must go through the same runtime. - -**Cloud preview sites** (requires \`studio auth login\`): -\`\`\`bash -studio preview create # Upload site to a temporary WordPress.com preview URL -studio preview list # List existing preview sites -studio preview update # Re-upload and refresh a preview site -studio preview delete # Remove a preview site -\`\`\` - -**Authentication:** -\`\`\`bash -studio auth login # Authenticate with WordPress.com (opens browser) -studio auth status # Check authentication status -studio auth logout # Clear stored credentials -\`\`\` - -## WordPress Development Best Practices - -**Themes and plugins:** Add custom themes to \`wp-content/themes/\` and plugins to \`wp-content/plugins/\`. To customise an existing theme, create a child theme rather than modifying the parent directly. - -**Use hooks, not direct edits:** Extend WordPress via actions and filters. Avoid editing core files — Studio runs on WordPress Playground and core changes will not persist correctly across server restarts. - -\`\`\`php -// Correct: extend via hooks -add_action( 'wp_enqueue_scripts', function () { - wp_enqueue_style( 'my-theme', get_stylesheet_uri() ); -} ); - -// Incorrect: do not edit wp-includes/ or wp-admin/ directly -\`\`\` - -**Data handling:** Always sanitize input and escape output. -- Sanitize: \`sanitize_text_field()\`, \`absint()\`, \`wp_kses_post()\` -- Escape: \`esc_html()\`, \`esc_attr()\`, \`esc_url()\`, \`wp_kses()\` -- Database: use \`$wpdb->prepare()\` for all queries with dynamic values - -**Options and metadata:** Use the WordPress Options API (\`get_option\` / \`update_option\`) and post/user/term meta APIs rather than direct database queries wherever possible. - -**\`wp-config.php\`:** Studio strips the default MySQL \`DB_*\` constants (\`DB_NAME\`, \`DB_USER\`, \`DB_PASSWORD\`, \`DB_HOST\`) from \`wp-config.php\` — do not add them back. The database connection is handled by the SQLite integration (see below). - -## Database: SQLite (not MySQL) - -Studio uses **SQLite** as the WordPress database backend via the [SQLite Database Integration](https://github.com/WordPress/sqlite-database-integration) plugin. There is no MySQL server. The plugin works as a MySQL emulation layer — it translates WordPress's MySQL queries into SQLite, so standard \`$wpdb\` queries work without any changes. - -**File locations:** -- Integration plugin: \`wp-content/mu-plugins/sqlite-database-integration/\` -- WordPress database drop-in: \`wp-content/db.php\` ← do not modify or delete -- SQLite database file: \`wp-content/database/.ht.sqlite\` - -**Querying the database directly:** -\`\`\`bash -studio wp db query "SELECT option_name, option_value FROM wp_options LIMIT 10;" -\`\`\` - -**Known limitations:** -- No stored procedures or user-defined functions -- No \`FULLTEXT\` index support (use a search plugin instead) -- Do not reference \`DB_NAME\`, \`DB_HOST\`, \`DB_USER\`, or \`DB_PASSWORD\` constants — they are not defined on this site -- Plugins that explicitly check for a MySQL connection and refuse to run may not be compatible - -## Studio-Specific Notes - -**WordPress core:** Do not modify files inside \`wp-includes/\` or \`wp-admin/\`. Studio sites run on WordPress Playground (PHP WASM), and core changes will not persist as expected. - -**Must-use plugins:** The \`wp-content/mu-plugins/\` directory contains the SQLite integration. Do not remove files from this directory. - -**Port and URL:** The local URL and port are assigned dynamically by Studio. Always retrieve the current URL with \`studio site status\` rather than hardcoding it. - -**Multisite:** WordPress Multisite is not supported in Studio sites. - -**Persistence:** The site runs in-process using PHP WASM. File writes to \`wp-content/\` persist to disk normally. Server-side cron is emulated; long-running background processes are not supported. -`; - -/** - * Writes the default AGENTS.md file to the site root if one does not already exist. - * The file guides AI coding agents toward Studio CLI commands, WordPress best practices, - * and SQLite-specific conventions for sites managed by Studio. - * - * Skips writing if an AGENTS.md already exists so user-customised files are preserved. - */ -export async function writeAgentsMd( sitePath: string ): Promise< void > { - const agentsMdPath = path.join( sitePath, AGENTS_MD_FILENAME ); - if ( fs.existsSync( agentsMdPath ) ) { - return; - } - await fs.promises.writeFile( agentsMdPath, AGENTS_MD_TEMPLATE, 'utf-8' ); -} diff --git a/apps/cli/lib/tests/agents-md.test.ts b/apps/cli/lib/tests/agents-md.test.ts deleted file mode 100644 index 1ec9471b01..0000000000 --- a/apps/cli/lib/tests/agents-md.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { vi } from 'vitest'; -import { writeAgentsMd } from 'cli/lib/agents-md'; - -vi.mock( 'fs', () => ( { - default: { - existsSync: vi.fn(), - promises: { - writeFile: vi.fn(), - }, - }, -} ) ); - -describe( 'writeAgentsMd', () => { - const sitePath = '/test/my-site'; - const expectedFilePath = path.join( sitePath, 'AGENTS.md' ); - - beforeEach( () => { - vi.clearAllMocks(); - } ); - - it( 'writes AGENTS.md when none exists', async () => { - vi.mocked( fs.existsSync ).mockReturnValue( false ); - vi.mocked( fs.promises.writeFile ).mockResolvedValue( undefined ); - - await writeAgentsMd( sitePath ); - - expect( fs.promises.writeFile ).toHaveBeenCalledWith( - expectedFilePath, - expect.stringContaining( '# AI Instructions' ), - 'utf-8' - ); - } ); - - it( 'does not overwrite an existing AGENTS.md', async () => { - vi.mocked( fs.existsSync ).mockReturnValue( true ); - - await writeAgentsMd( sitePath ); - - expect( fs.promises.writeFile ).not.toHaveBeenCalled(); - } ); - - it( 'writes content covering Studio CLI commands', async () => { - vi.mocked( fs.existsSync ).mockReturnValue( false ); - vi.mocked( fs.promises.writeFile ).mockResolvedValue( undefined ); - - await writeAgentsMd( sitePath ); - - const writtenContent = vi.mocked( fs.promises.writeFile ).mock.calls[ 0 ][ 1 ] as string; - expect( writtenContent ).toContain( 'studio site start' ); - expect( writtenContent ).toContain( 'studio site stop' ); - expect( writtenContent ).toContain( 'studio wp' ); - expect( writtenContent ).toContain( 'studio preview create' ); - expect( writtenContent ).toContain( 'studio auth login' ); - } ); - - it( 'writes content covering SQLite database specifics', async () => { - vi.mocked( fs.existsSync ).mockReturnValue( false ); - vi.mocked( fs.promises.writeFile ).mockResolvedValue( undefined ); - - await writeAgentsMd( sitePath ); - - const writtenContent = vi.mocked( fs.promises.writeFile ).mock.calls[ 0 ][ 1 ] as string; - expect( writtenContent ).toContain( 'SQLite' ); - expect( writtenContent ).toContain( 'wp-content/database/.ht.sqlite' ); - expect( writtenContent ).toContain( 'wp-content/db.php' ); - expect( writtenContent ).toContain( 'sqlite-database-integration' ); - } ); -} ); diff --git a/package-lock.json b/package-lock.json index ea1a1c8fd4..18223e3660 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8764,7 +8764,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=10" } @@ -8781,7 +8780,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=10" } @@ -8798,7 +8796,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -8815,7 +8812,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -8832,7 +8828,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -8849,7 +8844,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -8866,7 +8860,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -8883,7 +8876,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -8900,7 +8892,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -8917,7 +8908,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } From f9ef42e7e114a2844adb55012a1851433c862a92 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Mon, 9 Mar 2026 13:24:49 +0100 Subject: [PATCH 19/36] Add UI for managing skills --- .../src/components/ai-settings-modal.tsx | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/studio/src/components/ai-settings-modal.tsx b/apps/studio/src/components/ai-settings-modal.tsx index e9d2f971bf..a5800c4370 100644 --- a/apps/studio/src/components/ai-settings-modal.tsx +++ b/apps/studio/src/components/ai-settings-modal.tsx @@ -1,3 +1,4 @@ +import { TabPanel } from '@wordpress/components'; import { Icon, check } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useEffect, useState } from 'react'; @@ -178,6 +179,20 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) { ); } +function SkillsPanel() { + const { __ } = useI18n(); + return ( +
+
+

{ __( 'Agent Skills' ) }

+

+ { __( 'Enhance AI assistant with specialized capabilities' ) } +

+
+
+ ); +} + export function AiSettingsModal( { isOpen, onClose, siteId }: AiSettingsModalProps ) { const { __ } = useI18n(); @@ -185,17 +200,27 @@ export function AiSettingsModal( { isOpen, onClose, siteId }: AiSettingsModalPro return null; } + const tabs = [ + { name: 'skills', title: __( 'Skills' ) }, + { name: 'instructions', title: __( 'Instructions' ) }, + ]; + return ( -
- -
+ + { ( { name } ) => ( +
+ { name === 'skills' && } + { name === 'instructions' && } +
+ ) } +
); } From 935de7c6b5ae385232eb9b6f25ea6420eeb86313 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Tue, 10 Mar 2026 17:08:31 +0100 Subject: [PATCH 20/36] Add skills management mechanisms --- .../agent-skills/components/skills-panel.tsx | 290 ++++++++++++++++++ .../agent-skills/hooks/use-site-skills.ts | 143 +++++++++ .../src/modules/agent-skills/lib/constants.ts | 11 + .../agent-skills/lib/skill-discovery.ts | 99 ++++++ .../agent-skills/lib/skill-installer.ts | 195 ++++++++++++ .../modules/agent-skills/lib/skill-parser.ts | 101 ++++++ apps/studio/src/modules/agent-skills/types.ts | 54 ++++ 7 files changed, 893 insertions(+) create mode 100644 apps/studio/src/modules/agent-skills/components/skills-panel.tsx create mode 100644 apps/studio/src/modules/agent-skills/hooks/use-site-skills.ts create mode 100644 apps/studio/src/modules/agent-skills/lib/constants.ts create mode 100644 apps/studio/src/modules/agent-skills/lib/skill-discovery.ts create mode 100644 apps/studio/src/modules/agent-skills/lib/skill-installer.ts create mode 100644 apps/studio/src/modules/agent-skills/lib/skill-parser.ts create mode 100644 apps/studio/src/modules/agent-skills/types.ts diff --git a/apps/studio/src/modules/agent-skills/components/skills-panel.tsx b/apps/studio/src/modules/agent-skills/components/skills-panel.tsx new file mode 100644 index 0000000000..57b2405aa1 --- /dev/null +++ b/apps/studio/src/modules/agent-skills/components/skills-panel.tsx @@ -0,0 +1,290 @@ +import { Spinner } from '@wordpress/components'; +import { Icon, check } from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import { useState, useCallback, useMemo } from 'react'; +import Button from 'src/components/button'; +import { cx } from 'src/lib/cx'; +import { useAvailableSkills, useInstallSkill, useSiteSkills } from '../hooks/use-site-skills'; + +interface SkillRowProps { + name: string; + description: string; + isInstalled: boolean; + onInstall?: () => void; + onRemove?: () => void; + isInstalling?: boolean; + isRemoving?: boolean; +} + +function SkillRow( { + name, + description, + isInstalled, + onInstall, + onRemove, + isInstalling, + isRemoving, +}: SkillRowProps ) { + const { __ } = useI18n(); + const [ showConfirm, setShowConfirm ] = useState( false ); + + const handleRemoveClick = useCallback( () => { + if ( showConfirm ) { + onRemove?.(); + setShowConfirm( false ); + } else { + setShowConfirm( true ); + } + }, [ showConfirm, onRemove ] ); + + return ( +
+
+
{ name }
+
{ description }
+
+
+ { isInstalled ? ( + <> + + + { __( 'Installed' ) } + + { showConfirm ? ( +
+ + +
+ ) : ( + + ) } + + ) : ( + + ) } +
+
+ ); +} + +interface SkillsPanelProps { + siteId: string; + className?: string; +} + +export function SkillsPanel( { siteId, className }: SkillsPanelProps ) { + const { __ } = useI18n(); + + const { + skills: installedSkills, + isLoading: isLoadingInstalled, + error: installedError, + refresh: refreshInstalled, + removeSkill, + } = useSiteSkills( siteId ); + + const { + availableSkills, + isLoading: isLoadingAvailable, + error: availableError, + refresh: refreshAvailable, + } = useAvailableSkills(); + + const { installSkill, isInstalling, installError } = useInstallSkill( siteId, () => { + void refreshInstalled(); + } ); + + const [ removeError, setRemoveError ] = useState< string | null >( null ); + const [ removingSkill, setRemovingSkill ] = useState< string | null >( null ); + const [ installingPath, setInstallingPath ] = useState< string | null >( null ); + + const installedNames = useMemo( + () => new Set( installedSkills.map( ( s ) => s.name ) ), + [ installedSkills ] + ); + + const uninstalledSkills = useMemo( + () => availableSkills.filter( ( s ) => ! installedNames.has( s.name ) ), + [ availableSkills, installedNames ] + ); + + const handleInstall = useCallback( + async ( skillPath: string ) => { + setInstallingPath( skillPath ); + try { + await installSkill( skillPath ); + } finally { + setInstallingPath( null ); + } + }, + [ installSkill ] + ); + + const handleRemove = useCallback( + async ( skillName: string ) => { + setRemoveError( null ); + setRemovingSkill( skillName ); + try { + await removeSkill( skillName ); + } catch ( err ) { + setRemoveError( err instanceof Error ? err.message : String( err ) ); + } finally { + setRemovingSkill( null ); + } + }, + [ removeSkill ] + ); + + const handleRefresh = useCallback( () => { + void refreshInstalled(); + void refreshAvailable(); + }, [ refreshInstalled, refreshAvailable ] ); + + const isLoading = isLoadingInstalled || isLoadingAvailable; + const error = installedError || availableError || installError || removeError; + + return ( +
+
+
+

{ __( 'Agent Skills' ) }

+

+ { __( 'Enhance the AI assistant with specialized capabilities' ) } +

+
+ +
+ + { error && ( +
+ { error } +
+ ) } + +
+
+ { __( 'Installed' ) } + { installedSkills.length > 0 && ( + ({ installedSkills.length }) + ) } +
+ + { isLoadingInstalled ? ( +
+ + { __( 'Loading...' ) } +
+ ) : installedSkills.length === 0 ? ( +
+ { __( 'No skills installed yet' ) } +
+ ) : ( + installedSkills.map( ( skill ) => ( + handleRemove( skill.name ) } + isRemoving={ removingSkill === skill.name } + /> + ) ) + ) } + +
+
+ { __( 'Available' ) } + { uninstalledSkills.length > 0 && ( + ({ uninstalledSkills.length }) + ) } +
+ { uninstalledSkills.length > 0 && ( + + ) } +
+ + { isLoadingAvailable ? ( +
+ + { __( 'Loading...' ) } +
+ ) : availableError ? ( +
+ { __( 'Could not load available skills' ) } +
+ ) : uninstalledSkills.length === 0 && availableSkills.length > 0 ? ( +
+ { __( 'All available skills are installed' ) } +
+ ) : uninstalledSkills.length === 0 ? ( +
+ { __( 'Click Refresh to load available skills' ) } +
+ ) : ( + uninstalledSkills.map( ( skill ) => ( + handleInstall( skill.path ) } + isInstalling={ isInstalling && installingPath === skill.path } + /> + ) ) + ) } +
+ +

+ { __( 'Skills from ' ) } + + WordPress/agent-skills + +

+
+ ); +} diff --git a/apps/studio/src/modules/agent-skills/hooks/use-site-skills.ts b/apps/studio/src/modules/agent-skills/hooks/use-site-skills.ts new file mode 100644 index 0000000000..5a117a02b4 --- /dev/null +++ b/apps/studio/src/modules/agent-skills/hooks/use-site-skills.ts @@ -0,0 +1,143 @@ +import { useState, useEffect, useCallback } from 'react'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import type { AvailableSkill, Skill, SkillInstallResult } from '../types'; + +interface UseSiteSkillsResult { + skills: Skill[]; + isLoading: boolean; + error: string | null; + refresh: () => Promise< void >; + removeSkill: ( skillName: string ) => Promise< void >; +} + +export function useSiteSkills( siteId: string ): UseSiteSkillsResult { + const [ skills, setSkills ] = useState< Skill[] >( [] ); + const [ isLoading, setIsLoading ] = useState( true ); + const [ error, setError ] = useState< string | null >( null ); + + const loadSkills = useCallback( async () => { + if ( ! siteId ) { + setSkills( [] ); + setIsLoading( false ); + return; + } + + setIsLoading( true ); + setError( null ); + + try { + const loadedSkills = await getIpcApi().getSiteSkills( siteId ); + setSkills( loadedSkills as Skill[] ); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + setSkills( [] ); + } finally { + setIsLoading( false ); + } + }, [ siteId ] ); + + useEffect( () => { + void loadSkills(); + }, [ loadSkills ] ); + + const removeSkillCallback = useCallback( + async ( skillName: string ) => { + try { + await getIpcApi().removeSkill( siteId, skillName ); + await loadSkills(); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + throw new Error( errorMessage ); + } + }, + [ siteId, loadSkills ] + ); + + return { + skills, + isLoading, + error, + refresh: loadSkills, + removeSkill: removeSkillCallback, + }; +} + +interface UseAvailableSkillsResult { + availableSkills: AvailableSkill[]; + isLoading: boolean; + error: string | null; + refresh: () => Promise< void >; +} + +export function useAvailableSkills( repo?: string ): UseAvailableSkillsResult { + const [ availableSkills, setAvailableSkills ] = useState< AvailableSkill[] >( [] ); + const [ isLoading, setIsLoading ] = useState( false ); + const [ error, setError ] = useState< string | null >( null ); + + const loadSkills = useCallback( async () => { + setIsLoading( true ); + setError( null ); + + try { + const skills = await getIpcApi().listAvailableSkills( repo ); + setAvailableSkills( skills as AvailableSkill[] ); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + setAvailableSkills( [] ); + } finally { + setIsLoading( false ); + } + }, [ repo ] ); + + return { + availableSkills, + isLoading, + error, + refresh: loadSkills, + }; +} + +interface UseInstallSkillResult { + installSkill: ( skillPath: string, repo?: string ) => Promise< SkillInstallResult >; + isInstalling: boolean; + installError: string | null; +} + +export function useInstallSkill( siteId: string, onSuccess?: () => void ): UseInstallSkillResult { + const [ isInstalling, setIsInstalling ] = useState( false ); + const [ installError, setInstallError ] = useState< string | null >( null ); + + const installSkill = useCallback( + async ( skillPath: string, repo?: string ): Promise< SkillInstallResult > => { + setIsInstalling( true ); + setInstallError( null ); + + try { + const result = await getIpcApi().installSkill( + siteId, + repo ?? 'WordPress/agent-skills', + skillPath + ); + + if ( result.success ) { + onSuccess?.(); + } else { + setInstallError( ( result as SkillInstallResult ).error ?? 'Installation failed' ); + } + + return result as SkillInstallResult; + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setInstallError( errorMessage ); + return { success: false, error: errorMessage }; + } finally { + setIsInstalling( false ); + } + }, + [ siteId, onSuccess ] + ); + + return { installSkill, isInstalling, installError }; +} diff --git a/apps/studio/src/modules/agent-skills/lib/constants.ts b/apps/studio/src/modules/agent-skills/lib/constants.ts new file mode 100644 index 0000000000..3bff6c86ae --- /dev/null +++ b/apps/studio/src/modules/agent-skills/lib/constants.ts @@ -0,0 +1,11 @@ +/** Path to the skills directory within a site (Claude Code compatible) */ +export const SKILLS_DIRECTORY_PATH = '.claude/skills'; + +/** Name of the skill definition file */ +export const SKILL_FILE_NAME = 'SKILL.md'; + +/** Default repository for skills */ +export const DEFAULT_SKILLS_REPO = 'WordPress/agent-skills'; + +/** Default branch to download from */ +export const DEFAULT_BRANCH = 'trunk'; diff --git a/apps/studio/src/modules/agent-skills/lib/skill-discovery.ts b/apps/studio/src/modules/agent-skills/lib/skill-discovery.ts new file mode 100644 index 0000000000..0c2956dd16 --- /dev/null +++ b/apps/studio/src/modules/agent-skills/lib/skill-discovery.ts @@ -0,0 +1,99 @@ +import fs from 'fs/promises'; +import nodePath from 'path'; +import { SKILLS_DIRECTORY_PATH, SKILL_FILE_NAME } from './constants'; +import { parseSkillFile } from './skill-parser'; +import type { Skill } from '../types'; + +export function getSkillsPath( sitePath: string ): string { + return nodePath.join( sitePath, SKILLS_DIRECTORY_PATH ); +} + +export function getSkillPath( sitePath: string, skillName: string ): string { + return nodePath.join( getSkillsPath( sitePath ), skillName ); +} + +export async function skillExists( sitePath: string, skillName: string ): Promise< boolean > { + const skillFilePath = nodePath.join( getSkillPath( sitePath, skillName ), SKILL_FILE_NAME ); + try { + await fs.access( skillFilePath ); + return true; + } catch { + return false; + } +} + +async function hasSubdirectory( dirPath: string, subdirName: string ): Promise< boolean > { + try { + const stat = await fs.stat( nodePath.join( dirPath, subdirName ) ); + return stat.isDirectory(); + } catch { + return false; + } +} + +async function parseSkillFromDirectory( skillDirPath: string ): Promise< Skill | null > { + const skillFilePath = nodePath.join( skillDirPath, SKILL_FILE_NAME ); + try { + const content = await fs.readFile( skillFilePath, 'utf-8' ); + const { metadata, body } = parseSkillFile( content ); + + const [ hasScripts, hasReferences, hasAssets ] = await Promise.all( [ + hasSubdirectory( skillDirPath, 'scripts' ), + hasSubdirectory( skillDirPath, 'references' ), + hasSubdirectory( skillDirPath, 'assets' ), + ] ); + + return { + ...metadata, + path: skillDirPath, + body, + hasScripts, + hasReferences, + hasAssets, + }; + } catch ( error ) { + console.error( `Failed to parse skill at ${ skillDirPath }:`, error ); + return null; + } +} + +export async function discoverSiteSkills( sitePath: string ): Promise< Skill[] > { + const skillsPath = getSkillsPath( sitePath ); + + try { + const entries = await fs.readdir( skillsPath, { withFileTypes: true } ); + const skills: Skill[] = []; + + for ( const entry of entries ) { + if ( ! entry.isDirectory() || entry.name.startsWith( '.' ) ) { + continue; + } + + const skill = await parseSkillFromDirectory( nodePath.join( skillsPath, entry.name ) ); + if ( skill ) { + skills.push( skill ); + } + } + + skills.sort( ( a, b ) => a.name.localeCompare( b.name ) ); + return skills; + } catch ( error ) { + if ( ( error as NodeJS.ErrnoException ).code === 'ENOENT' ) { + return []; + } + console.error( `Failed to discover skills at ${ skillsPath }:`, error ); + return []; + } +} + +export async function ensureSkillsDirectory( sitePath: string ): Promise< string > { + const skillsPath = getSkillsPath( sitePath ); + try { + await fs.mkdir( skillsPath, { recursive: true } ); + } catch ( error ) { + if ( ( error as NodeJS.ErrnoException ).code !== 'EEXIST' ) { + throw error; + } + } + return skillsPath; +} diff --git a/apps/studio/src/modules/agent-skills/lib/skill-installer.ts b/apps/studio/src/modules/agent-skills/lib/skill-installer.ts new file mode 100644 index 0000000000..018952d2ec --- /dev/null +++ b/apps/studio/src/modules/agent-skills/lib/skill-installer.ts @@ -0,0 +1,195 @@ +import fs from 'fs/promises'; +import https from 'https'; +import nodePath from 'path'; +import { DEFAULT_BRANCH, DEFAULT_SKILLS_REPO, SKILL_FILE_NAME } from './constants'; +import { ensureSkillsDirectory, getSkillPath, skillExists } from './skill-discovery'; +import { parseSkillFile } from './skill-parser'; +import type { AvailableSkill, Skill, SkillInstallResult } from '../types'; + +async function fetchUrl( url: string ): Promise< string > { + return new Promise( ( resolve, reject ) => { + const request = https.get( + url, + { + headers: { + 'User-Agent': 'WordPress-Studio', + Accept: 'application/vnd.github.v3+json', + }, + }, + ( response ) => { + if ( response.statusCode === 301 || response.statusCode === 302 ) { + if ( response.headers.location ) { + fetchUrl( response.headers.location ).then( resolve ).catch( reject ); + return; + } + } + + if ( response.statusCode !== 200 ) { + reject( new Error( `HTTP ${ response.statusCode }: ${ response.statusMessage }` ) ); + return; + } + + let data = ''; + response.on( 'data', ( chunk ) => ( data += chunk ) ); + response.on( 'end', () => resolve( data ) ); + response.on( 'error', reject ); + } + ); + + request.on( 'error', reject ); + request.end(); + } ); +} + +async function downloadRawFile( + repo: string, + filePath: string, + branch: string = DEFAULT_BRANCH +): Promise< string > { + const url = `https://raw.githubusercontent.com/${ repo }/${ branch }/${ filePath }`; + return fetchUrl( url ); +} + +async function listGitHubDirectory( + repo: string, + path: string, + branch: string = DEFAULT_BRANCH +): Promise< Array< { name: string; path: string; type: 'file' | 'dir' } > > { + const url = `https://api.github.com/repos/${ repo }/contents/${ path }?ref=${ branch }`; + const response = await fetchUrl( url ); + const entries = JSON.parse( response ); + + if ( ! Array.isArray( entries ) ) { + throw new Error( 'Expected directory listing from GitHub API' ); + } + + return entries.map( ( entry: { name: string; path: string; type: string } ) => ( { + name: entry.name, + path: entry.path, + type: entry.type === 'dir' ? ( 'dir' as const ) : ( 'file' as const ), + } ) ); +} + +async function downloadDirectory( + repo: string, + remotePath: string, + localPath: string, + branch: string = DEFAULT_BRANCH +): Promise< void > { + await fs.mkdir( localPath, { recursive: true } ); + + const entries = await listGitHubDirectory( repo, remotePath, branch ); + + for ( const entry of entries ) { + const localEntryPath = nodePath.join( localPath, entry.name ); + + if ( entry.type === 'dir' ) { + await downloadDirectory( repo, entry.path, localEntryPath, branch ); + } else { + const content = await downloadRawFile( repo, entry.path, branch ); + await fs.writeFile( localEntryPath, content, 'utf-8' ); + } + } +} + +export async function installSkillFromGitHub( + sitePath: string, + repo: string, + skillPath: string, + branch: string = DEFAULT_BRANCH +): Promise< SkillInstallResult > { + try { + const skillMdPath = `${ skillPath }/${ SKILL_FILE_NAME }`; + const skillContent = await downloadRawFile( repo, skillMdPath, branch ); + const { metadata, body } = parseSkillFile( skillContent ); + + if ( await skillExists( sitePath, metadata.name ) ) { + return { + success: false, + error: `Skill "${ metadata.name }" is already installed`, + }; + } + + await ensureSkillsDirectory( sitePath ); + + const localSkillPath = getSkillPath( sitePath, metadata.name ); + await downloadDirectory( repo, skillPath, localSkillPath, branch ); + + const [ hasScripts, hasReferences, hasAssets ] = await Promise.all( [ + fs + .stat( nodePath.join( localSkillPath, 'scripts' ) ) + .then( ( s ) => s.isDirectory() ) + .catch( () => false ), + fs + .stat( nodePath.join( localSkillPath, 'references' ) ) + .then( ( s ) => s.isDirectory() ) + .catch( () => false ), + fs + .stat( nodePath.join( localSkillPath, 'assets' ) ) + .then( ( s ) => s.isDirectory() ) + .catch( () => false ), + ] ); + + const skill: Skill = { + ...metadata, + path: localSkillPath, + body, + hasScripts, + hasReferences, + hasAssets, + }; + + return { success: true, skill }; + } catch ( error ) { + const errorMessage = error instanceof Error ? error.message : String( error ); + console.error( `Failed to install skill from ${ repo }/${ skillPath }:`, error ); + return { success: false, error: `Failed to install skill: ${ errorMessage }` }; + } +} + +export async function removeSkill( sitePath: string, skillName: string ): Promise< void > { + if ( ! ( await skillExists( sitePath, skillName ) ) ) { + throw new Error( `Skill "${ skillName }" is not installed` ); + } + + await fs.rm( getSkillPath( sitePath, skillName ), { recursive: true, force: true } ); +} + +export async function listAvailableSkills( + repo: string = DEFAULT_SKILLS_REPO, + branch: string = DEFAULT_BRANCH +): Promise< AvailableSkill[] > { + let entries; + try { + entries = await listGitHubDirectory( repo, 'skills', branch ); + } catch ( error ) { + const errorMessage = error instanceof Error ? error.message : String( error ); + throw new Error( + `Could not access skills in ${ repo }. ${ errorMessage }. Make sure the repository exists and has a "skills/" directory.` + ); + } + + const availableSkills: AvailableSkill[] = []; + + for ( const entry of entries ) { + if ( entry.type !== 'dir' ) { + continue; + } + + try { + const skillMdPath = `${ entry.path }/${ SKILL_FILE_NAME }`; + const content = await downloadRawFile( repo, skillMdPath, branch ); + const { metadata } = parseSkillFile( content ); + + availableSkills.push( { + name: metadata.name, + description: metadata.description, + path: entry.path, + } ); + } catch ( skillError ) { + console.warn( `Failed to parse skill at ${ entry.path }:`, skillError ); + } + } + + return availableSkills; +} diff --git a/apps/studio/src/modules/agent-skills/lib/skill-parser.ts b/apps/studio/src/modules/agent-skills/lib/skill-parser.ts new file mode 100644 index 0000000000..5c39c3ecc0 --- /dev/null +++ b/apps/studio/src/modules/agent-skills/lib/skill-parser.ts @@ -0,0 +1,101 @@ +import type { SkillMetadata } from '../types'; + +const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; + +/** + * Parse a SKILL.md file and extract metadata from YAML frontmatter. + */ +export function parseSkillFile( content: string ): { metadata: SkillMetadata; body: string } { + const match = content.match( FRONTMATTER_REGEX ); + + if ( ! match ) { + throw new Error( 'Invalid SKILL.md format: missing YAML frontmatter' ); + } + + const [ , yamlContent, body ] = match; + const metadata = parseYamlFrontmatter( yamlContent ); + + if ( ! validateSkillMetadata( metadata ) ) { + throw new Error( 'Invalid skill metadata: missing required fields (name, description)' ); + } + + return { + metadata, + body: body.trim(), + }; +} + +function parseYamlFrontmatter( yaml: string ): Record< string, unknown > { + const result: Record< string, unknown > = {}; + const lines = yaml.split( /\r?\n/ ); + let currentKey: string | null = null; + let currentArray: string[] | null = null; + + for ( const line of lines ) { + if ( ! line.trim() || line.trim().startsWith( '#' ) ) { + continue; + } + + if ( line.match( /^\s+-\s+/ ) && currentKey && currentArray ) { + const value = line.replace( /^\s+-\s+/, '' ).trim(); + currentArray.push( value.replace( /^["']|["']$/g, '' ) ); + continue; + } + + const keyValueMatch = line.match( /^(\w+):\s*(.*)$/ ); + if ( keyValueMatch ) { + if ( currentKey && currentArray ) { + result[ currentKey ] = currentArray; + currentArray = null; + } + + const [ , key, value ] = keyValueMatch; + currentKey = key; + + if ( value.trim() === '' ) { + currentArray = []; + } else { + result[ key ] = value.trim().replace( /^["']|["']$/g, '' ); + currentArray = null; + } + } + } + + if ( currentKey && currentArray ) { + result[ currentKey ] = currentArray; + } + + return result; +} + +export function validateSkillMetadata( metadata: unknown ): metadata is SkillMetadata { + if ( ! metadata || typeof metadata !== 'object' ) { + return false; + } + + const obj = metadata as Record< string, unknown >; + + if ( typeof obj.name !== 'string' || obj.name.trim() === '' ) { + return false; + } + if ( typeof obj.description !== 'string' || obj.description.trim() === '' ) { + return false; + } + if ( obj.license !== undefined && typeof obj.license !== 'string' ) { + return false; + } + if ( obj.compatibility !== undefined && typeof obj.compatibility !== 'string' ) { + return false; + } + if ( obj.allowedTools !== undefined && ! Array.isArray( obj.allowedTools ) ) { + return false; + } + if ( + obj.metadata !== undefined && + ( typeof obj.metadata !== 'object' || obj.metadata === null ) + ) { + return false; + } + + return true; +} diff --git a/apps/studio/src/modules/agent-skills/types.ts b/apps/studio/src/modules/agent-skills/types.ts new file mode 100644 index 0000000000..36480f39bc --- /dev/null +++ b/apps/studio/src/modules/agent-skills/types.ts @@ -0,0 +1,54 @@ +/** + * Metadata extracted from a SKILL.md file's YAML frontmatter. + */ +export interface SkillMetadata { + /** Required: skill identifier/name */ + name: string; + /** Required: what the skill does */ + description: string; + /** Optional: license (e.g., "MIT", "Apache-2.0") */ + license?: string; + /** Optional: compatibility notes (e.g., "WordPress 6.0+") */ + compatibility?: string; + /** Optional: additional metadata key-value pairs */ + metadata?: Record< string, string >; + /** Optional: tools the skill is allowed to use */ + allowedTools?: string[]; +} + +/** + * A fully parsed skill including metadata and content. + */ +export interface Skill extends SkillMetadata { + /** Absolute path to the skill directory */ + path: string; + /** Markdown content after the YAML frontmatter */ + body: string; + /** Whether the skill has a scripts/ directory */ + hasScripts: boolean; + /** Whether the skill has a references/ directory */ + hasReferences: boolean; + /** Whether the skill has an assets/ directory */ + hasAssets: boolean; +} + +/** + * Information about a skill available for installation from a repository. + */ +export interface AvailableSkill { + /** Skill name */ + name: string; + /** Skill description */ + description: string; + /** Path within the repository */ + path: string; +} + +/** + * Result of a skill installation attempt. + */ +export interface SkillInstallResult { + success: boolean; + error?: string; + skill?: Skill; +} From 4b37dd069fcfa39902695287963aae30dd5970b0 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Wed, 11 Mar 2026 10:29:50 +0100 Subject: [PATCH 21/36] Clean up empty line --- apps/cli/commands/site/create.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index c68d3602c2..ca662ca62c 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -228,7 +228,6 @@ export async function runCommand( } } - logger.reportStart( LoggerAction.ASSIGN_PORT, __( 'Assigning port…' ) ); const port = await portFinder.getOpenPort(); // translators: %d is the port number From fe482e498a97b885d6d25d4d7ced0cc59fe8f6d1 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Wed, 11 Mar 2026 10:30:59 +0100 Subject: [PATCH 22/36] Clean up cx --- apps/studio/src/components/ai-settings-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/src/components/ai-settings-modal.tsx b/apps/studio/src/components/ai-settings-modal.tsx index 5398a26ad3..1a3b0ea52e 100644 --- a/apps/studio/src/components/ai-settings-modal.tsx +++ b/apps/studio/src/components/ai-settings-modal.tsx @@ -121,7 +121,7 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) { : __( config.description ) }
-
+
{ status.exists && ( +
+

{ __( 'Agent Skills' ) }

+

+ { __( 'Enhance the AI assistant with specialized capabilities' ) } +

{ error && ( @@ -252,13 +241,9 @@ export function SkillsPanel( { siteId, className }: SkillsPanelProps ) {
{ __( 'Could not load available skills' ) }
- ) : uninstalledSkills.length === 0 && availableSkills.length > 0 ? ( -
- { __( 'All available skills are installed' ) } -
) : uninstalledSkills.length === 0 ? (
- { __( 'Click Refresh to load available skills' ) } + { __( 'All available skills are installed' ) }
) : ( uninstalledSkills.map( ( skill ) => ( diff --git a/apps/studio/src/modules/agent-skills/hooks/use-site-skills.ts b/apps/studio/src/modules/agent-skills/hooks/use-site-skills.ts index 5a117a02b4..cc47f128bb 100644 --- a/apps/studio/src/modules/agent-skills/hooks/use-site-skills.ts +++ b/apps/studio/src/modules/agent-skills/hooks/use-site-skills.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { getIpcApi } from 'src/lib/get-ipc-api'; import type { AvailableSkill, Skill, SkillInstallResult } from '../types'; @@ -72,7 +72,7 @@ interface UseAvailableSkillsResult { export function useAvailableSkills( repo?: string ): UseAvailableSkillsResult { const [ availableSkills, setAvailableSkills ] = useState< AvailableSkill[] >( [] ); - const [ isLoading, setIsLoading ] = useState( false ); + const [ isLoading, setIsLoading ] = useState( true ); const [ error, setError ] = useState< string | null >( null ); const loadSkills = useCallback( async () => { @@ -91,6 +91,10 @@ export function useAvailableSkills( repo?: string ): UseAvailableSkillsResult { } }, [ repo ] ); + useEffect( () => { + void loadSkills(); + }, [ loadSkills ] ); + return { availableSkills, isLoading, diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index b13c5108b5..f287c4942c 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -161,6 +161,11 @@ const api: IpcApi = { ipcRendererInvoke( 'getAgentInstructionsStatus', siteId ), installAgentInstructions: ( siteId, options ) => ipcRendererInvoke( 'installAgentInstructions', siteId, options ), + getSiteSkills: ( siteId ) => ipcRendererInvoke( 'getSiteSkills', siteId ), + installSkill: ( siteId, repo, skillPath ) => + ipcRendererInvoke( 'installSkill', siteId, repo, skillPath ), + removeSkill: ( siteId, skillName ) => ipcRendererInvoke( 'removeSkill', siteId, skillName ), + listAvailableSkills: ( repo ) => ipcRendererInvoke( 'listAvailableSkills', repo ), }; contextBridge.exposeInMainWorld( 'ipcApi', api ); diff --git a/package-lock.json b/package-lock.json index aa2534eecc..0bcd3c2b3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9290,7 +9290,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=10" } @@ -9307,7 +9306,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=10" } @@ -9324,7 +9322,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -9341,7 +9338,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -9358,7 +9354,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -9375,7 +9370,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -9392,7 +9386,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -9409,7 +9402,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -9426,7 +9418,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -9443,7 +9434,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } From ac3914c4e5ec36823f555ab556647f8747f0979e Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Wed, 11 Mar 2026 15:00:59 +0100 Subject: [PATCH 25/36] Clean up remove button --- .../agent-skills/components/skills-panel.tsx | 47 ++++--------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/apps/studio/src/modules/agent-skills/components/skills-panel.tsx b/apps/studio/src/modules/agent-skills/components/skills-panel.tsx index 1595610116..8f126a9694 100644 --- a/apps/studio/src/modules/agent-skills/components/skills-panel.tsx +++ b/apps/studio/src/modules/agent-skills/components/skills-panel.tsx @@ -26,16 +26,9 @@ function SkillRow( { isRemoving, }: SkillRowProps ) { const { __ } = useI18n(); - const [ showConfirm, setShowConfirm ] = useState( false ); - const handleRemoveClick = useCallback( () => { - if ( showConfirm ) { - onRemove?.(); - setShowConfirm( false ); - } else { - setShowConfirm( true ); - } - }, [ showConfirm, onRemove ] ); + onRemove?.(); + }, [ onRemove ] ); return (
@@ -50,34 +43,14 @@ function SkillRow( { { __( 'Installed' ) } - { showConfirm ? ( -
- - -
- ) : ( - - ) } + ) : ( @@ -231,7 +231,6 @@ export function SkillsPanel( { siteId, className }: SkillsPanelProps ) { ) ) ) }
-
); } From 8deb293c4acc5e4d6a49cb353f87fae8f45aff51 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Wed, 11 Mar 2026 15:24:50 +0100 Subject: [PATCH 29/36] Remove refresh functionality --- apps/studio/src/modules/agent-skills/components/skills-panel.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/studio/src/modules/agent-skills/components/skills-panel.tsx b/apps/studio/src/modules/agent-skills/components/skills-panel.tsx index 395c4b867f..e5758786ee 100644 --- a/apps/studio/src/modules/agent-skills/components/skills-panel.tsx +++ b/apps/studio/src/modules/agent-skills/components/skills-panel.tsx @@ -87,7 +87,6 @@ export function SkillsPanel( { siteId, className }: SkillsPanelProps ) { availableSkills, isLoading: isLoadingAvailable, error: availableError, - refresh: refreshAvailable, } = useAvailableSkills(); const { installSkill, isInstalling, installError } = useInstallSkill( siteId, () => { From 39f8de260eb104697d83cccd4d7c81bf8827f987 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 19 Mar 2026 10:01:27 +0100 Subject: [PATCH 30/36] Lint fix --- apps/studio/src/components/ai-settings-modal.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/studio/src/components/ai-settings-modal.tsx b/apps/studio/src/components/ai-settings-modal.tsx index 4c8c21fad4..4cca1c715b 100644 --- a/apps/studio/src/components/ai-settings-modal.tsx +++ b/apps/studio/src/components/ai-settings-modal.tsx @@ -291,14 +291,14 @@ export function AiSettingsModal( { isOpen, onClose, siteId }: AiSettingsModalPro size="medium" className={ cx( 'min-h-[350px] app-no-drag-region', '[&_[role="document"]]:px-0' ) } > - - { ( { name } ) => ( -
- { name === 'skills' && } - { name === 'instructions' && } -
- ) } -
+ + { ( { name } ) => ( +
+ { name === 'skills' && } + { name === 'instructions' && } +
+ ) } +
); } From dea8ee21d786d793a79fe2001920f9f0c64c8ef9 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 19 Mar 2026 10:04:03 +0100 Subject: [PATCH 31/36] Clean up unnecessary files --- .../agent-skills/components/skills-panel.tsx | 235 ------------------ .../agent-skills/hooks/use-site-skills.ts | 147 ----------- .../src/modules/agent-skills/lib/constants.ts | 11 - .../agent-skills/lib/skill-discovery.ts | 99 -------- .../agent-skills/lib/skill-installer.ts | 195 --------------- .../modules/agent-skills/lib/skill-parser.ts | 101 -------- apps/studio/src/modules/agent-skills/types.ts | 54 ---- 7 files changed, 842 deletions(-) delete mode 100644 apps/studio/src/modules/agent-skills/components/skills-panel.tsx delete mode 100644 apps/studio/src/modules/agent-skills/hooks/use-site-skills.ts delete mode 100644 apps/studio/src/modules/agent-skills/lib/constants.ts delete mode 100644 apps/studio/src/modules/agent-skills/lib/skill-discovery.ts delete mode 100644 apps/studio/src/modules/agent-skills/lib/skill-installer.ts delete mode 100644 apps/studio/src/modules/agent-skills/lib/skill-parser.ts delete mode 100644 apps/studio/src/modules/agent-skills/types.ts diff --git a/apps/studio/src/modules/agent-skills/components/skills-panel.tsx b/apps/studio/src/modules/agent-skills/components/skills-panel.tsx deleted file mode 100644 index e5758786ee..0000000000 --- a/apps/studio/src/modules/agent-skills/components/skills-panel.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { Spinner } from '@wordpress/components'; -import { Icon, check } from '@wordpress/icons'; -import { useI18n } from '@wordpress/react-i18n'; -import { useState, useCallback, useMemo } from 'react'; -import Button from 'src/components/button'; -import { cx } from 'src/lib/cx'; -import { useAvailableSkills, useInstallSkill, useSiteSkills } from '../hooks/use-site-skills'; - -interface SkillRowProps { - name: string; - description: string; - isInstalled: boolean; - onInstall?: () => void; - onRemove?: () => void; - isInstalling?: boolean; - isRemoving?: boolean; -} - -function SkillRow( { - name, - description, - isInstalled, - onInstall, - onRemove, - isInstalling, - isRemoving, -}: SkillRowProps ) { - const { __ } = useI18n(); - const handleRemoveClick = useCallback( () => { - onRemove?.(); - }, [ onRemove ] ); - - return ( -
-
-
{ name }
-
{ description }
-
-
- { isInstalled ? ( - <> - - - { __( 'Installed' ) } - - - - ) : ( - - ) } -
-
- ); -} - -interface SkillsPanelProps { - siteId: string; - className?: string; -} - -export function SkillsPanel( { siteId, className }: SkillsPanelProps ) { - const { __ } = useI18n(); - - const { - skills: installedSkills, - isLoading: isLoadingInstalled, - error: installedError, - refresh: refreshInstalled, - removeSkill, - } = useSiteSkills( siteId ); - - const { - availableSkills, - isLoading: isLoadingAvailable, - error: availableError, - } = useAvailableSkills(); - - const { installSkill, isInstalling, installError } = useInstallSkill( siteId, () => { - void refreshInstalled(); - } ); - - const [ removeError, setRemoveError ] = useState< string | null >( null ); - const [ removingSkill, setRemovingSkill ] = useState< string | null >( null ); - const [ installingPath, setInstallingPath ] = useState< string | null >( null ); - - const installedNames = useMemo( - () => new Set( installedSkills.map( ( s ) => s.name ) ), - [ installedSkills ] - ); - - const uninstalledSkills = useMemo( - () => availableSkills.filter( ( s ) => ! installedNames.has( s.name ) ), - [ availableSkills, installedNames ] - ); - - const handleInstall = useCallback( - async ( skillPath: string ) => { - setInstallingPath( skillPath ); - try { - await installSkill( skillPath ); - } finally { - setInstallingPath( null ); - } - }, - [ installSkill ] - ); - - const handleRemove = useCallback( - async ( skillName: string ) => { - setRemoveError( null ); - setRemovingSkill( skillName ); - try { - await removeSkill( skillName ); - } catch ( err ) { - setRemoveError( err instanceof Error ? err.message : String( err ) ); - } finally { - setRemovingSkill( null ); - } - }, - [ removeSkill ] - ); - - const error = installedError || availableError || installError || removeError; - - return ( -
-
-

{ __( 'Agent Skills' ) }

-

- { __( 'Enhance the AI assistant with specialized capabilities' ) } -

-
- - { error && ( -
- { error } -
- ) } - -
-
- { __( 'Installed' ) } - { installedSkills.length > 0 && ( - ({ installedSkills.length }) - ) } -
- - { isLoadingInstalled ? ( -
- - { __( 'Loading...' ) } -
- ) : installedSkills.length === 0 ? ( -
- { __( 'No skills installed yet' ) } -
- ) : ( - installedSkills.map( ( skill ) => ( - handleRemove( skill.name ) } - isRemoving={ removingSkill === skill.name } - /> - ) ) - ) } - -
-
- { __( 'Available' ) } - { uninstalledSkills.length > 0 && ( - ({ uninstalledSkills.length }) - ) } -
- { uninstalledSkills.length > 0 && ( - - ) } -
- - { isLoadingAvailable ? ( -
- - { __( 'Loading...' ) } -
- ) : availableError ? ( -
- { __( 'Could not load available skills' ) } -
- ) : uninstalledSkills.length === 0 ? ( -
- { __( 'All available skills are installed' ) } -
- ) : ( - uninstalledSkills.map( ( skill ) => ( - handleInstall( skill.path ) } - isInstalling={ isInstalling && installingPath === skill.path } - /> - ) ) - ) } -
-
- ); -} diff --git a/apps/studio/src/modules/agent-skills/hooks/use-site-skills.ts b/apps/studio/src/modules/agent-skills/hooks/use-site-skills.ts deleted file mode 100644 index cc47f128bb..0000000000 --- a/apps/studio/src/modules/agent-skills/hooks/use-site-skills.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { getIpcApi } from 'src/lib/get-ipc-api'; -import type { AvailableSkill, Skill, SkillInstallResult } from '../types'; - -interface UseSiteSkillsResult { - skills: Skill[]; - isLoading: boolean; - error: string | null; - refresh: () => Promise< void >; - removeSkill: ( skillName: string ) => Promise< void >; -} - -export function useSiteSkills( siteId: string ): UseSiteSkillsResult { - const [ skills, setSkills ] = useState< Skill[] >( [] ); - const [ isLoading, setIsLoading ] = useState( true ); - const [ error, setError ] = useState< string | null >( null ); - - const loadSkills = useCallback( async () => { - if ( ! siteId ) { - setSkills( [] ); - setIsLoading( false ); - return; - } - - setIsLoading( true ); - setError( null ); - - try { - const loadedSkills = await getIpcApi().getSiteSkills( siteId ); - setSkills( loadedSkills as Skill[] ); - } catch ( err ) { - const errorMessage = err instanceof Error ? err.message : String( err ); - setError( errorMessage ); - setSkills( [] ); - } finally { - setIsLoading( false ); - } - }, [ siteId ] ); - - useEffect( () => { - void loadSkills(); - }, [ loadSkills ] ); - - const removeSkillCallback = useCallback( - async ( skillName: string ) => { - try { - await getIpcApi().removeSkill( siteId, skillName ); - await loadSkills(); - } catch ( err ) { - const errorMessage = err instanceof Error ? err.message : String( err ); - throw new Error( errorMessage ); - } - }, - [ siteId, loadSkills ] - ); - - return { - skills, - isLoading, - error, - refresh: loadSkills, - removeSkill: removeSkillCallback, - }; -} - -interface UseAvailableSkillsResult { - availableSkills: AvailableSkill[]; - isLoading: boolean; - error: string | null; - refresh: () => Promise< void >; -} - -export function useAvailableSkills( repo?: string ): UseAvailableSkillsResult { - const [ availableSkills, setAvailableSkills ] = useState< AvailableSkill[] >( [] ); - const [ isLoading, setIsLoading ] = useState( true ); - const [ error, setError ] = useState< string | null >( null ); - - const loadSkills = useCallback( async () => { - setIsLoading( true ); - setError( null ); - - try { - const skills = await getIpcApi().listAvailableSkills( repo ); - setAvailableSkills( skills as AvailableSkill[] ); - } catch ( err ) { - const errorMessage = err instanceof Error ? err.message : String( err ); - setError( errorMessage ); - setAvailableSkills( [] ); - } finally { - setIsLoading( false ); - } - }, [ repo ] ); - - useEffect( () => { - void loadSkills(); - }, [ loadSkills ] ); - - return { - availableSkills, - isLoading, - error, - refresh: loadSkills, - }; -} - -interface UseInstallSkillResult { - installSkill: ( skillPath: string, repo?: string ) => Promise< SkillInstallResult >; - isInstalling: boolean; - installError: string | null; -} - -export function useInstallSkill( siteId: string, onSuccess?: () => void ): UseInstallSkillResult { - const [ isInstalling, setIsInstalling ] = useState( false ); - const [ installError, setInstallError ] = useState< string | null >( null ); - - const installSkill = useCallback( - async ( skillPath: string, repo?: string ): Promise< SkillInstallResult > => { - setIsInstalling( true ); - setInstallError( null ); - - try { - const result = await getIpcApi().installSkill( - siteId, - repo ?? 'WordPress/agent-skills', - skillPath - ); - - if ( result.success ) { - onSuccess?.(); - } else { - setInstallError( ( result as SkillInstallResult ).error ?? 'Installation failed' ); - } - - return result as SkillInstallResult; - } catch ( err ) { - const errorMessage = err instanceof Error ? err.message : String( err ); - setInstallError( errorMessage ); - return { success: false, error: errorMessage }; - } finally { - setIsInstalling( false ); - } - }, - [ siteId, onSuccess ] - ); - - return { installSkill, isInstalling, installError }; -} diff --git a/apps/studio/src/modules/agent-skills/lib/constants.ts b/apps/studio/src/modules/agent-skills/lib/constants.ts deleted file mode 100644 index 3bff6c86ae..0000000000 --- a/apps/studio/src/modules/agent-skills/lib/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** Path to the skills directory within a site (Claude Code compatible) */ -export const SKILLS_DIRECTORY_PATH = '.claude/skills'; - -/** Name of the skill definition file */ -export const SKILL_FILE_NAME = 'SKILL.md'; - -/** Default repository for skills */ -export const DEFAULT_SKILLS_REPO = 'WordPress/agent-skills'; - -/** Default branch to download from */ -export const DEFAULT_BRANCH = 'trunk'; diff --git a/apps/studio/src/modules/agent-skills/lib/skill-discovery.ts b/apps/studio/src/modules/agent-skills/lib/skill-discovery.ts deleted file mode 100644 index 0c2956dd16..0000000000 --- a/apps/studio/src/modules/agent-skills/lib/skill-discovery.ts +++ /dev/null @@ -1,99 +0,0 @@ -import fs from 'fs/promises'; -import nodePath from 'path'; -import { SKILLS_DIRECTORY_PATH, SKILL_FILE_NAME } from './constants'; -import { parseSkillFile } from './skill-parser'; -import type { Skill } from '../types'; - -export function getSkillsPath( sitePath: string ): string { - return nodePath.join( sitePath, SKILLS_DIRECTORY_PATH ); -} - -export function getSkillPath( sitePath: string, skillName: string ): string { - return nodePath.join( getSkillsPath( sitePath ), skillName ); -} - -export async function skillExists( sitePath: string, skillName: string ): Promise< boolean > { - const skillFilePath = nodePath.join( getSkillPath( sitePath, skillName ), SKILL_FILE_NAME ); - try { - await fs.access( skillFilePath ); - return true; - } catch { - return false; - } -} - -async function hasSubdirectory( dirPath: string, subdirName: string ): Promise< boolean > { - try { - const stat = await fs.stat( nodePath.join( dirPath, subdirName ) ); - return stat.isDirectory(); - } catch { - return false; - } -} - -async function parseSkillFromDirectory( skillDirPath: string ): Promise< Skill | null > { - const skillFilePath = nodePath.join( skillDirPath, SKILL_FILE_NAME ); - try { - const content = await fs.readFile( skillFilePath, 'utf-8' ); - const { metadata, body } = parseSkillFile( content ); - - const [ hasScripts, hasReferences, hasAssets ] = await Promise.all( [ - hasSubdirectory( skillDirPath, 'scripts' ), - hasSubdirectory( skillDirPath, 'references' ), - hasSubdirectory( skillDirPath, 'assets' ), - ] ); - - return { - ...metadata, - path: skillDirPath, - body, - hasScripts, - hasReferences, - hasAssets, - }; - } catch ( error ) { - console.error( `Failed to parse skill at ${ skillDirPath }:`, error ); - return null; - } -} - -export async function discoverSiteSkills( sitePath: string ): Promise< Skill[] > { - const skillsPath = getSkillsPath( sitePath ); - - try { - const entries = await fs.readdir( skillsPath, { withFileTypes: true } ); - const skills: Skill[] = []; - - for ( const entry of entries ) { - if ( ! entry.isDirectory() || entry.name.startsWith( '.' ) ) { - continue; - } - - const skill = await parseSkillFromDirectory( nodePath.join( skillsPath, entry.name ) ); - if ( skill ) { - skills.push( skill ); - } - } - - skills.sort( ( a, b ) => a.name.localeCompare( b.name ) ); - return skills; - } catch ( error ) { - if ( ( error as NodeJS.ErrnoException ).code === 'ENOENT' ) { - return []; - } - console.error( `Failed to discover skills at ${ skillsPath }:`, error ); - return []; - } -} - -export async function ensureSkillsDirectory( sitePath: string ): Promise< string > { - const skillsPath = getSkillsPath( sitePath ); - try { - await fs.mkdir( skillsPath, { recursive: true } ); - } catch ( error ) { - if ( ( error as NodeJS.ErrnoException ).code !== 'EEXIST' ) { - throw error; - } - } - return skillsPath; -} diff --git a/apps/studio/src/modules/agent-skills/lib/skill-installer.ts b/apps/studio/src/modules/agent-skills/lib/skill-installer.ts deleted file mode 100644 index 018952d2ec..0000000000 --- a/apps/studio/src/modules/agent-skills/lib/skill-installer.ts +++ /dev/null @@ -1,195 +0,0 @@ -import fs from 'fs/promises'; -import https from 'https'; -import nodePath from 'path'; -import { DEFAULT_BRANCH, DEFAULT_SKILLS_REPO, SKILL_FILE_NAME } from './constants'; -import { ensureSkillsDirectory, getSkillPath, skillExists } from './skill-discovery'; -import { parseSkillFile } from './skill-parser'; -import type { AvailableSkill, Skill, SkillInstallResult } from '../types'; - -async function fetchUrl( url: string ): Promise< string > { - return new Promise( ( resolve, reject ) => { - const request = https.get( - url, - { - headers: { - 'User-Agent': 'WordPress-Studio', - Accept: 'application/vnd.github.v3+json', - }, - }, - ( response ) => { - if ( response.statusCode === 301 || response.statusCode === 302 ) { - if ( response.headers.location ) { - fetchUrl( response.headers.location ).then( resolve ).catch( reject ); - return; - } - } - - if ( response.statusCode !== 200 ) { - reject( new Error( `HTTP ${ response.statusCode }: ${ response.statusMessage }` ) ); - return; - } - - let data = ''; - response.on( 'data', ( chunk ) => ( data += chunk ) ); - response.on( 'end', () => resolve( data ) ); - response.on( 'error', reject ); - } - ); - - request.on( 'error', reject ); - request.end(); - } ); -} - -async function downloadRawFile( - repo: string, - filePath: string, - branch: string = DEFAULT_BRANCH -): Promise< string > { - const url = `https://raw.githubusercontent.com/${ repo }/${ branch }/${ filePath }`; - return fetchUrl( url ); -} - -async function listGitHubDirectory( - repo: string, - path: string, - branch: string = DEFAULT_BRANCH -): Promise< Array< { name: string; path: string; type: 'file' | 'dir' } > > { - const url = `https://api.github.com/repos/${ repo }/contents/${ path }?ref=${ branch }`; - const response = await fetchUrl( url ); - const entries = JSON.parse( response ); - - if ( ! Array.isArray( entries ) ) { - throw new Error( 'Expected directory listing from GitHub API' ); - } - - return entries.map( ( entry: { name: string; path: string; type: string } ) => ( { - name: entry.name, - path: entry.path, - type: entry.type === 'dir' ? ( 'dir' as const ) : ( 'file' as const ), - } ) ); -} - -async function downloadDirectory( - repo: string, - remotePath: string, - localPath: string, - branch: string = DEFAULT_BRANCH -): Promise< void > { - await fs.mkdir( localPath, { recursive: true } ); - - const entries = await listGitHubDirectory( repo, remotePath, branch ); - - for ( const entry of entries ) { - const localEntryPath = nodePath.join( localPath, entry.name ); - - if ( entry.type === 'dir' ) { - await downloadDirectory( repo, entry.path, localEntryPath, branch ); - } else { - const content = await downloadRawFile( repo, entry.path, branch ); - await fs.writeFile( localEntryPath, content, 'utf-8' ); - } - } -} - -export async function installSkillFromGitHub( - sitePath: string, - repo: string, - skillPath: string, - branch: string = DEFAULT_BRANCH -): Promise< SkillInstallResult > { - try { - const skillMdPath = `${ skillPath }/${ SKILL_FILE_NAME }`; - const skillContent = await downloadRawFile( repo, skillMdPath, branch ); - const { metadata, body } = parseSkillFile( skillContent ); - - if ( await skillExists( sitePath, metadata.name ) ) { - return { - success: false, - error: `Skill "${ metadata.name }" is already installed`, - }; - } - - await ensureSkillsDirectory( sitePath ); - - const localSkillPath = getSkillPath( sitePath, metadata.name ); - await downloadDirectory( repo, skillPath, localSkillPath, branch ); - - const [ hasScripts, hasReferences, hasAssets ] = await Promise.all( [ - fs - .stat( nodePath.join( localSkillPath, 'scripts' ) ) - .then( ( s ) => s.isDirectory() ) - .catch( () => false ), - fs - .stat( nodePath.join( localSkillPath, 'references' ) ) - .then( ( s ) => s.isDirectory() ) - .catch( () => false ), - fs - .stat( nodePath.join( localSkillPath, 'assets' ) ) - .then( ( s ) => s.isDirectory() ) - .catch( () => false ), - ] ); - - const skill: Skill = { - ...metadata, - path: localSkillPath, - body, - hasScripts, - hasReferences, - hasAssets, - }; - - return { success: true, skill }; - } catch ( error ) { - const errorMessage = error instanceof Error ? error.message : String( error ); - console.error( `Failed to install skill from ${ repo }/${ skillPath }:`, error ); - return { success: false, error: `Failed to install skill: ${ errorMessage }` }; - } -} - -export async function removeSkill( sitePath: string, skillName: string ): Promise< void > { - if ( ! ( await skillExists( sitePath, skillName ) ) ) { - throw new Error( `Skill "${ skillName }" is not installed` ); - } - - await fs.rm( getSkillPath( sitePath, skillName ), { recursive: true, force: true } ); -} - -export async function listAvailableSkills( - repo: string = DEFAULT_SKILLS_REPO, - branch: string = DEFAULT_BRANCH -): Promise< AvailableSkill[] > { - let entries; - try { - entries = await listGitHubDirectory( repo, 'skills', branch ); - } catch ( error ) { - const errorMessage = error instanceof Error ? error.message : String( error ); - throw new Error( - `Could not access skills in ${ repo }. ${ errorMessage }. Make sure the repository exists and has a "skills/" directory.` - ); - } - - const availableSkills: AvailableSkill[] = []; - - for ( const entry of entries ) { - if ( entry.type !== 'dir' ) { - continue; - } - - try { - const skillMdPath = `${ entry.path }/${ SKILL_FILE_NAME }`; - const content = await downloadRawFile( repo, skillMdPath, branch ); - const { metadata } = parseSkillFile( content ); - - availableSkills.push( { - name: metadata.name, - description: metadata.description, - path: entry.path, - } ); - } catch ( skillError ) { - console.warn( `Failed to parse skill at ${ entry.path }:`, skillError ); - } - } - - return availableSkills; -} diff --git a/apps/studio/src/modules/agent-skills/lib/skill-parser.ts b/apps/studio/src/modules/agent-skills/lib/skill-parser.ts deleted file mode 100644 index 5c39c3ecc0..0000000000 --- a/apps/studio/src/modules/agent-skills/lib/skill-parser.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { SkillMetadata } from '../types'; - -const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; - -/** - * Parse a SKILL.md file and extract metadata from YAML frontmatter. - */ -export function parseSkillFile( content: string ): { metadata: SkillMetadata; body: string } { - const match = content.match( FRONTMATTER_REGEX ); - - if ( ! match ) { - throw new Error( 'Invalid SKILL.md format: missing YAML frontmatter' ); - } - - const [ , yamlContent, body ] = match; - const metadata = parseYamlFrontmatter( yamlContent ); - - if ( ! validateSkillMetadata( metadata ) ) { - throw new Error( 'Invalid skill metadata: missing required fields (name, description)' ); - } - - return { - metadata, - body: body.trim(), - }; -} - -function parseYamlFrontmatter( yaml: string ): Record< string, unknown > { - const result: Record< string, unknown > = {}; - const lines = yaml.split( /\r?\n/ ); - let currentKey: string | null = null; - let currentArray: string[] | null = null; - - for ( const line of lines ) { - if ( ! line.trim() || line.trim().startsWith( '#' ) ) { - continue; - } - - if ( line.match( /^\s+-\s+/ ) && currentKey && currentArray ) { - const value = line.replace( /^\s+-\s+/, '' ).trim(); - currentArray.push( value.replace( /^["']|["']$/g, '' ) ); - continue; - } - - const keyValueMatch = line.match( /^(\w+):\s*(.*)$/ ); - if ( keyValueMatch ) { - if ( currentKey && currentArray ) { - result[ currentKey ] = currentArray; - currentArray = null; - } - - const [ , key, value ] = keyValueMatch; - currentKey = key; - - if ( value.trim() === '' ) { - currentArray = []; - } else { - result[ key ] = value.trim().replace( /^["']|["']$/g, '' ); - currentArray = null; - } - } - } - - if ( currentKey && currentArray ) { - result[ currentKey ] = currentArray; - } - - return result; -} - -export function validateSkillMetadata( metadata: unknown ): metadata is SkillMetadata { - if ( ! metadata || typeof metadata !== 'object' ) { - return false; - } - - const obj = metadata as Record< string, unknown >; - - if ( typeof obj.name !== 'string' || obj.name.trim() === '' ) { - return false; - } - if ( typeof obj.description !== 'string' || obj.description.trim() === '' ) { - return false; - } - if ( obj.license !== undefined && typeof obj.license !== 'string' ) { - return false; - } - if ( obj.compatibility !== undefined && typeof obj.compatibility !== 'string' ) { - return false; - } - if ( obj.allowedTools !== undefined && ! Array.isArray( obj.allowedTools ) ) { - return false; - } - if ( - obj.metadata !== undefined && - ( typeof obj.metadata !== 'object' || obj.metadata === null ) - ) { - return false; - } - - return true; -} diff --git a/apps/studio/src/modules/agent-skills/types.ts b/apps/studio/src/modules/agent-skills/types.ts deleted file mode 100644 index 36480f39bc..0000000000 --- a/apps/studio/src/modules/agent-skills/types.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Metadata extracted from a SKILL.md file's YAML frontmatter. - */ -export interface SkillMetadata { - /** Required: skill identifier/name */ - name: string; - /** Required: what the skill does */ - description: string; - /** Optional: license (e.g., "MIT", "Apache-2.0") */ - license?: string; - /** Optional: compatibility notes (e.g., "WordPress 6.0+") */ - compatibility?: string; - /** Optional: additional metadata key-value pairs */ - metadata?: Record< string, string >; - /** Optional: tools the skill is allowed to use */ - allowedTools?: string[]; -} - -/** - * A fully parsed skill including metadata and content. - */ -export interface Skill extends SkillMetadata { - /** Absolute path to the skill directory */ - path: string; - /** Markdown content after the YAML frontmatter */ - body: string; - /** Whether the skill has a scripts/ directory */ - hasScripts: boolean; - /** Whether the skill has a references/ directory */ - hasReferences: boolean; - /** Whether the skill has an assets/ directory */ - hasAssets: boolean; -} - -/** - * Information about a skill available for installation from a repository. - */ -export interface AvailableSkill { - /** Skill name */ - name: string; - /** Skill description */ - description: string; - /** Path within the repository */ - path: string; -} - -/** - * Result of a skill installation attempt. - */ -export interface SkillInstallResult { - success: boolean; - error?: string; - skill?: Skill; -} From 539c11c30a6f11e03d22679ccbe323d3c708a5f0 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 19 Mar 2026 11:06:48 +0100 Subject: [PATCH 32/36] Add management per skill --- .../src/components/ai-settings-modal.tsx | 121 +++++++++++------- apps/studio/src/ipc-handlers.ts | 15 +++ .../modules/agent-instructions/lib/skills.ts | 10 +- apps/studio/src/preload.ts | 2 + tools/common/lib/agent-skills.ts | 2 +- 5 files changed, 101 insertions(+), 49 deletions(-) diff --git a/apps/studio/src/components/ai-settings-modal.tsx b/apps/studio/src/components/ai-settings-modal.tsx index 4cca1c715b..3a473a34d4 100644 --- a/apps/studio/src/components/ai-settings-modal.tsx +++ b/apps/studio/src/components/ai-settings-modal.tsx @@ -12,10 +12,7 @@ import { type InstructionFileType, } from 'src/modules/agent-instructions/constants'; import { type InstructionFileStatus } from 'src/modules/agent-instructions/lib/instructions'; -import { - BUNDLED_SKILLS, - type SkillStatus, -} from 'src/modules/agent-instructions/lib/skills-constants'; +import { type SkillStatus } from 'src/modules/agent-instructions/lib/skills-constants'; interface AiSettingsModalProps { isOpen: boolean; @@ -171,7 +168,7 @@ function WordPressSkillsPanel( { siteId }: { siteId: string } ) { const { __ } = useI18n(); const [ statuses, setStatuses ] = useState< SkillStatus[] >( [] ); const [ error, setError ] = useState< string | null >( null ); - const [ installing, setInstalling ] = useState( false ); + const [ installingSkillId, setInstallingSkillId ] = useState< string | null >( null ); const refreshStatus = useCallback( async () => { try { @@ -191,25 +188,38 @@ function WordPressSkillsPanel( { siteId }: { siteId: string } ) { return () => window.removeEventListener( 'focus', handleFocus ); }, [ refreshStatus ] ); - const handleInstall = useCallback( - async ( overwrite: boolean = false ) => { - setInstalling( true ); + const handleInstallSkill = useCallback( + async ( skillId: string ) => { + setInstallingSkillId( skillId ); setError( null ); try { - await getIpcApi().installWordPressSkills( siteId, { overwrite } ); + await getIpcApi().installWordPressSkillById( siteId, skillId ); await refreshStatus(); } catch ( err ) { const errorMessage = err instanceof Error ? err.message : String( err ); setError( errorMessage ); } finally { - setInstalling( false ); + setInstallingSkillId( null ); } }, [ siteId, refreshStatus ] ); + const handleInstallAll = useCallback( async () => { + setInstallingSkillId( 'all' ); + setError( null ); + try { + await getIpcApi().installWordPressSkills( siteId ); + await refreshStatus(); + } catch ( err ) { + const errorMessage = err instanceof Error ? err.message : String( err ); + setError( errorMessage ); + } finally { + setInstallingSkillId( null ); + } + }, [ siteId, refreshStatus ] ); + const allInstalled = statuses.length > 0 && statuses.every( ( s ) => s.installed ); - const installedCount = statuses.filter( ( s ) => s.installed ).length; return (
@@ -220,6 +230,16 @@ function WordPressSkillsPanel( { siteId }: { siteId: string } ) { { __( 'WordPress development skills for AI agents' ) }

+ { ! allInstalled && ( + + ) }
{ error && ( @@ -229,43 +249,50 @@ function WordPressSkillsPanel( { siteId }: { siteId: string } ) { ) }
-
-
-
- - { __( 'WordPress Skills' ) } - - { allInstalled && ( - - - { __( 'Installed' ) } - - ) } - { ! allInstalled && installedCount > 0 && ( - - { `${ installedCount }/${ BUNDLED_SKILLS.length }` } - - ) } -
-
- { __( 'Plugins, blocks, themes, REST API, and WP-CLI skills' ) } -
-
-
- -
-
+
+
+ { skill.displayName } + { skill.installed && ( + + + { __( 'Installed' ) } + + ) } +
+
{ skill.description }
+
+
+ { skill.installed ? ( + + ) : ( + + ) } +
+
+ ); + } ) }
); diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 9e2267e161..dd61812f18 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -81,6 +81,7 @@ import { import { getSkillsStatus, installAllSkills, + installSkillById, type SkillStatus, } from 'src/modules/agent-instructions/lib/skills'; import { editSiteViaCli, EditSiteOptions } from 'src/modules/cli/lib/cli-site-editor'; @@ -196,6 +197,20 @@ export async function installWordPressSkills( await installAllSkills( server.details.path, overwrite ); } +export async function installWordPressSkillById( + _event: IpcMainInvokeEvent, + siteId: string, + skillId: string, + options?: { overwrite?: boolean } +): Promise< void > { + const server = SiteServer.get( siteId ); + if ( ! server ) { + throw new Error( `Site not found: ${ siteId }` ); + } + const overwrite = options?.overwrite ?? false; + await installSkillById( server.details.path, skillId, overwrite ); +} + const DEBUG_LOG_MAX_LINES = 50; const PM2_HOME = nodePath.join( os.homedir(), '.studio', 'pm2' ); const DEFAULT_ENCODED_PASSWORD = encodePassword( 'password' ); diff --git a/apps/studio/src/modules/agent-instructions/lib/skills.ts b/apps/studio/src/modules/agent-instructions/lib/skills.ts index ef10d3d86f..85ed77007a 100644 --- a/apps/studio/src/modules/agent-instructions/lib/skills.ts +++ b/apps/studio/src/modules/agent-instructions/lib/skills.ts @@ -1,6 +1,6 @@ import fs from 'fs/promises'; import nodePath from 'path'; -import { installSkillsToSite } from '@studio/common/lib/agent-skills'; +import { installSkillsToSite, installSkillToSite } from '@studio/common/lib/agent-skills'; import { getAgentSkillsPath } from 'src/lib/server-files-paths'; import { BUNDLED_SKILLS, type SkillStatus } from './skills-constants'; @@ -32,3 +32,11 @@ export async function installAllSkills( ): Promise< void > { await installSkillsToSite( sitePath, getBundledSkillsPath(), overwrite ); } + +export async function installSkillById( + sitePath: string, + skillId: string, + overwrite: boolean = false +): Promise< void > { + await installSkillToSite( sitePath, getBundledSkillsPath(), skillId, overwrite ); +} diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index ec7eed15a5..40acd5ebab 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -162,6 +162,8 @@ const api: IpcApi = { getWordPressSkillsStatus: ( siteId ) => ipcRendererInvoke( 'getWordPressSkillsStatus', siteId ), installWordPressSkills: ( siteId, options ) => ipcRendererInvoke( 'installWordPressSkills', siteId, options ), + installWordPressSkillById: ( siteId, skillId, options ) => + ipcRendererInvoke( 'installWordPressSkillById', siteId, skillId, options ), }; contextBridge.exposeInMainWorld( 'ipcApi', api ); diff --git a/tools/common/lib/agent-skills.ts b/tools/common/lib/agent-skills.ts index 073859712e..a38d56aa00 100644 --- a/tools/common/lib/agent-skills.ts +++ b/tools/common/lib/agent-skills.ts @@ -30,7 +30,7 @@ export async function installSkillsToSite( } } -async function installSkillToSite( +export async function installSkillToSite( sitePath: string, bundledSkillsPath: string, skillId: string, From 1c3bf117f182502e14079c15dfea1080ce63f6e0 Mon Sep 17 00:00:00 2001 From: Kateryna Kodonenko Date: Thu, 19 Mar 2026 11:08:38 +0100 Subject: [PATCH 33/36] Classes formatting --- apps/studio/src/components/ai-settings-modal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/src/components/ai-settings-modal.tsx b/apps/studio/src/components/ai-settings-modal.tsx index 3a473a34d4..fcdd597dc0 100644 --- a/apps/studio/src/components/ai-settings-modal.tsx +++ b/apps/studio/src/components/ai-settings-modal.tsx @@ -122,7 +122,7 @@ function AgentInstructionsPanel( { siteId }: { siteId: string } ) { : __( config.description ) } -
+
{ status.exists && (
{ skill.installed ? ( - + <> + + + ) : (
{ skill.description }
-
+
{ skill.installed ? ( <>