Initial commit

This commit is contained in:
Pascal Birchler 2025-02-03 17:30:58 +01:00
commit 374e6ce910
No known key found for this signature in database
GPG key ID: 0DECE73DD74E8B2F
15 changed files with 855 additions and 0 deletions

18
.distignore Normal file
View file

@ -0,0 +1,18 @@
.DS_Store
.git
.gitignore
.gitlab-ci.yml
.editorconfig
.travis.yml
behat.yml
circle.yml
phpcs.xml.dist
phpunit.xml.dist
bin/
features/
utils/
*.zip
*.tar.gz
*.swp
*.txt
*.log

26
.editorconfig Normal file
View file

@ -0,0 +1,26 @@
# This file is for unifying the coding style for different editors and IDEs
# editorconfig.org

# WordPress Coding Standards
# https://make.wordpress.org/core/handbook/coding-standards/

# From https://github.com/WordPress/wordpress-develop/blob/trunk/.editorconfig with a couple of additions.

root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = tab

[{*.yml,*.feature,.jshintrc,*.json}]
indent_style = space
indent_size = 2

[*.md]
trim_trailing_whitespace = false

[{*.txt,wp-config-sample.php}]
end_of_line = crlf

11
.github/ISSUE_TEMPLATE vendored Normal file
View file

@ -0,0 +1,11 @@
<!--

Thanks for creating a new issue!

Found a bug or want to suggest an enhancement? Before completing your issue, please review our best practices: https://make.wordpress.org/cli/handbook/bug-reports/

Need help with something? GitHub issues aren't for general support questions, but there are other venues you can try: https://wp-cli.org/#support

You can safely delete this comment.

-->

16
.github/PULL_REQUEST_TEMPLATE vendored Normal file
View file

@ -0,0 +1,16 @@
<!--

Thanks for submitting a pull request!

Please review our contributing guidelines if you haven't recently: https://make.wordpress.org/cli/handbook/contributing/#creating-a-pull-request

Here's an overview to our process:

1. One of the project committers will soon provide a code review: https://make.wordpress.org/cli/handbook/code-review/
2. You are expected to address the code review comments in a timely manner (if we don't hear from you in two weeks, we'll consider your pull request abandoned).
3. Please make sure to include functional tests for your changes.
4. The reviewing committer will merge your pull request as soon as it passes code review (and provided it fits within the scope of the project).

You can safely delete this comment.

-->

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
.DS_Store
wp-cli.local.yml
node_modules/
vendor/
*.zip
*.tar.gz
*.swp
*.txt
*.log
composer.lock
phpunit.xml
phpcs.xml
.phpcs.xml

83
.travis.yml Normal file
View file

@ -0,0 +1,83 @@
os: linux
dist: xenial

language: php
php: 7.4

services:
- mysql

notifications:
email:
on_success: never
on_failure: change

branches:
only:
- master

cache:
directories:
- $HOME/.composer/cache

env:
global:
- PATH="$TRAVIS_BUILD_DIR/vendor/bin:$PATH"
- WP_CLI_BIN_DIR="$TRAVIS_BUILD_DIR/vendor/bin"

before_install:
- |
# Remove Xdebug for a huge performance increase:
if [ -f ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/xdebug.ini ]; then
phpenv config-rm xdebug.ini
else
echo "xdebug.ini does not exist"
fi
- |
# Raise PHP memory limit to 2048MB
echo 'memory_limit = 2048M' >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini
- composer validate

install:
- composer install
- composer prepare-tests

script:
- composer phpunit
- composer behat || composer behat-rerun

jobs:
include:
- stage: test
php: nightly
env: WP_VERSION=trunk
- stage: test
php: 7.4
env: WP_VERSION=latest
- stage: test
php: 7.3
env: WP_VERSION=latest
- stage: test
php: 7.2
env: WP_VERSION=latest
- stage: test
php: 7.1
env: WP_VERSION=latest
- stage: test
php: 7.0
env: WP_VERSION=latest
- stage: test
php: 5.6
env: WP_VERSION=latest
- stage: test
php: 5.6
env: WP_VERSION=3.7.11
dist: trusty
- stage: test
php: 5.6
env: WP_VERSION=trunk

allow_failures:
- stage: test
php: nightly
env: WP_VERSION=trunk

8
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,8 @@
Contributing
============

We appreciate you taking the initiative to contribute to this project.

Contributing isnt limited to just code. We encourage you to contribute in the way that best fits your abilities, by writing tutorials, giving a demo at your local meetup, helping other users with their support questions, or revising our documentation.

For a more thorough introduction, [check out WP-CLI's guide to contributing](https://make.wordpress.org/cli/handbook/contributing/). This package follows those policy and guidelines.

59
README.md Normal file
View file

@ -0,0 +1,59 @@
swissspidy/ai-command
=====================



[![Build Status](https://travis-ci.org/swissspidy/ai-command.svg?branch=master)](https://travis-ci.org/swissspidy/ai-command)

Quick links: [Using](#using) | [Installing](#installing) | [Contributing](#contributing) | [Support](#support)

## Using

~~~
wp hello-world
~~~

## Installing

Installing this package requires WP-CLI v2.5 or greater. Update to the latest stable release with `wp cli update`.

Once you've done so, you can install the latest stable version of this package with:

```bash
wp package install swissspidy/ai-command:@stable
```

To install the latest development version of this package, use the following command instead:

```bash
wp package install swissspidy/ai-command:dev-master
```

## Contributing

We appreciate you taking the initiative to contribute to this project.

Contributing isnt limited to just code. We encourage you to contribute in the way that best fits your abilities, by writing tutorials, giving a demo at your local meetup, helping other users with their support questions, or revising our documentation.

For a more thorough introduction, [check out WP-CLI's guide to contributing](https://make.wordpress.org/cli/handbook/contributing/). This package follows those policy and guidelines.

### Reporting a bug

Think youve found a bug? Wed love for you to help us get it fixed.

Before you create a new issue, you should [search existing issues](https://github.com/swissspidy/ai-command/issues?q=label%3Abug%20) to see if theres an existing resolution to it, or if its already been fixed in a newer version.

Once youve done a bit of searching and discovered there isnt an open or fixed issue for your bug, please [create a new issue](https://github.com/swissspidy/ai-command/issues/new). Include as much detail as you can, and clear steps to reproduce if possible. For more guidance, [review our bug report documentation](https://make.wordpress.org/cli/handbook/bug-reports/).

### Creating a pull request

Want to contribute a new feature? Please first [open a new issue](https://github.com/swissspidy/ai-command/issues/new) to discuss whether the feature is a good fit for the project.

Once you've decided to commit the time to seeing your pull request through, [please follow our guidelines for creating a pull request](https://make.wordpress.org/cli/handbook/pull-requests/) to make sure it's a pleasant experience. See "[Setting up](https://make.wordpress.org/cli/handbook/pull-requests/#setting-up)" for details specific to working on this package locally.

## Support

GitHub issues aren't for general support questions, but there are other venues you can try: https://wp-cli.org/#support


*This README.md is generated dynamically from the project's codebase using `wp scaffold package-readme` ([doc](https://github.com/wp-cli/scaffold-package-command#wp-scaffold-package-readme)). To suggest changes, please submit a pull request against the corresponding part of the codebase.*

17
ai-command.php Normal file
View file

@ -0,0 +1,17 @@
<?php

namespace WP_CLI\AiCommand;

use WP_CLI;

if ( ! class_exists( '\WP_CLI' ) ) {
return;
}

$ai_command_autoloader = __DIR__ . '/vendor/autoload.php';

if ( file_exists( $ai_command_autoloader ) ) {
require_once $ai_command_autoloader;
}

WP_CLI::add_command( 'ai', AiCommand::class );

63
composer.json Normal file
View file

@ -0,0 +1,63 @@
{
"name": "swissspidy/ai-command",
"type": "wp-cli-package",
"description": "",
"homepage": "https://github.com/swissspidy/ai-command",
"license": "MIT",
"authors": [],
"require": {
"gemini-api-php/client": "^1.7",
"logiscape/mcp-sdk-php": "^1.0",
"symfony/http-client": "^7.2",
"wp-cli/wp-cli": "^2.12"
},
"require-dev": {
"wp-cli/wp-cli-tests": "^v4.3.6"
},
"config": {
"process-timeout": 7200,
"sort-packages": true,
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true,
"php-http/discovery": true
}
},
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
},
"bundled": false,
"commands": [
"ai",
"ai prompt"
]
},
"autoload": {
"psr-4": {
"WP_CLI\\AiCommand\\": "src/",
"WP_CLI\\AiCommand\\MCP\\": "src/MCP"
},
"files": [
"ai-command.php"
]
},
"minimum-stability": "dev",
"prefer-stable": true,
"scripts": {
"behat": "run-behat-tests",
"behat-rerun": "rerun-behat-tests",
"lint": "run-linter-tests",
"phpcs": "run-phpcs-tests",
"phpunit": "run-php-unit-tests",
"prepare-tests": "install-package-tests",
"test": [
"@lint",
"@phpcs",
"@phpunit",
"@behat"
]
},
"support": {
"issues": "https://github.com/swissspidy/ai-command/issues"
}
}

View file

@ -0,0 +1,10 @@
Feature: Test that WP-CLI loads.

Scenario: WP-CLI loads for your tests
Given a WP install

When I run `wp eval 'echo "Hello world.";'`
Then STDOUT should contain:
"""
Hello world.
"""

112
src/AiCommand.php Normal file
View file

@ -0,0 +1,112 @@
<?php

namespace WP_CLI\AiCommand;

use Exception;
use WP_CLI;
use WP_CLI_Command;

/**
*
* Resources: File-like data that can be read by clients (like API responses or file contents)
* Tools: Functions that can be called by the LLM (with user approval)
* Prompts: Pre-written templates that help users accomplish specific tasks
*
* MCP follows a client-server architecture where:
*
* Hosts are LLM applications (like Claude Desktop or IDEs) that initiate connections
* Clients maintain 1:1 connections with servers, inside the host application
* Servers provide context, tools, and prompts to clients
*/
class AiCommand extends WP_CLI_Command {
/**
* Greets the world.
*
* ## OPTIONS
*
* <prompt>
* : AI prompt.
*
* ## EXAMPLES
*
* # Greet the world.
* $ wp ai "What are the titles of my last three posts?"
* Success: Hello World!
*
* # Greet the world.
* $ wp ai "create 10 test posts about swiss recipes and include generated featured images"
* Success: Hello World!
*
* @when before_wp_load
*
* @param array $args Indexed array of positional arguments.
* @param array $assoc_args Associative array of associative arguments.
*/
public function __invoke( $args, $assoc_args ) {
$server = new MCP\Server();

$server->registerTool( [
'name' => 'calculate_total',
'description' => 'Calculates the total price.',
'inputSchema' => [
'type' => 'object',
'properties' => [
'price' => [ 'type' => 'integer', 'description' => 'The price of the item.' ],
'quantity' => [ 'type' => 'integer', 'description' => 'The quantity of items.' ],
],
'required' => [ 'price', 'quantity' ],
],
'callable' => function ( $params ) {
$price = $params['price'] ?? 0;
$quantity = $params['quantity'] ?? 1;

return $price * $quantity;
},
] );

$server->registerTool( [
'name' => 'greet',
'description' => 'Greets the user.',
'inputSchema' => [
'type' => 'object',
'properties' => [
'name' => [ 'type' => 'string', 'description' => 'The name of the user.' ],
],
'required' => [ 'name' ],
],
'callable' => function ( $params ) {
return "Hello, " . $params['name'] . "!";
},
] );

// Register resources:
$server->registerResource( [
'name' => 'users',
'uri' => 'data://users',
'description' => 'List of users',
'mimeType' => 'application/json',
'dataKey' => 'users', // This tells getResourceData() to look in the $data array
] );

$server->registerResource( [
'name' => 'product_catalog',
'uri' => 'file://./products.json',
'description' => 'Product catalog',
'mimeType' => 'application/json',
'filePath' => './products.json', // This tells getResourceData() to read from a file
] );

$client = new MCP\Client( $server );

$result = $client->callGemini( [
[
"role" => "user",
"parts" => [
"text" => $args[0]
]
]
] );

WP_CLI::success( $result );
}
}

140
src/MCP/Client.php Normal file
View file

@ -0,0 +1,140 @@
<?php

namespace WP_CLI\AiCommand\MCP;

use Exception;

class Client {

private $server; // Instance of MCPServer

public function __construct( Server $server ) {
$this->server = $server;
}

public function sendRequest( $method, $params = [] ) {
$request = [
'jsonrpc' => '2.0',
'method' => $method,
'params' => $params,
'id' => uniqid(), // Generate a unique ID for each request
];

$requestData = json_encode( $request );
$responseData = $this->server->processRequest( $requestData );
$response = json_decode( $responseData, true );

if ( json_last_error() !== JSON_ERROR_NONE ) {
throw new Exception( 'Invalid JSON response: ' . json_last_error_msg() );
}

if ( isset( $response['error'] ) ) {
throw new Exception( "JSON-RPC Error: " . $response['error']['message'], $response['error']['code'] );
}

return $response['result'];
}

public function __call( $name, $arguments ) { // Magic method for calling any method
return $this->sendRequest( $name, $arguments[0] ?? [] );
}

public function list_resources() {
return $this->sendRequest( 'resources/list' );
}

public function read_resource( $uri ) {
return $this->sendRequest( 'resources/read', [ 'uri' => $uri ] );
}

public function callGemini( $contents ) {
$capabilities = $this->get_capabilities();

$tools = [];

foreach ( $capabilities['methods'] ?? [] as $tool ) {
$tools[] = [
"name" => $tool['name'],
"description" => $tool['description'] ?? "", // Provide a description
"parameters" => $tool['inputSchema'] ?? [], // Provide the inputSchema
];
}

\WP_CLI::log( 'Calling Gemini...' . json_encode( [

'contents' => $contents,
'tools' => [
'function_declarations' => $tools,
],
] ) );

$GOOGLE_API_KEY = getenv( 'GEMINI_API_KEY' );

$response = \WP_CLI\Utils\http_request(
'POST',
// "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=$GOOGLE_API_KEY",
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=$GOOGLE_API_KEY",
json_encode( [
'contents' => $contents,
'tools' => [
'function_declarations' => $tools,
],
]
),
[
'Content-Type' => 'application/json'
]
);

$data = json_decode( $response->body );

\WP_CLI::log( 'Receiving response...' . json_encode( $data ) );

$new_contents = $contents;

foreach ( $data->candidates[0]->content->parts as $part ) {
// Check for tool calls in Gemini response
if ( isset( $part->functionCall ) ) {
$name = $part->functionCall->name;
$args = (array) $part->functionCall->args;

$functionResult = $this->$name( $args );

\WP_CLI::log( "Calling function $name... Result:" . print_r( $functionResult, true ) );

$new_contents[] = [
'role' => 'model',
'parts' => [
$part
]
];
$new_contents[] = [
'role' => 'user',
'parts' => [
[
'functionResponse' => [
'name' => $name,
'response' => [
'name' => $name,
'content' => $functionResult,
]
]
]
]
];
}
}

if ( $new_contents !== $contents ) {
return $this->callGemini( $new_contents );
}

foreach ( $data->candidates[0]->content->parts as $part ) {
if ( isset( $part->text ) ) {
return $part->text;
}
}

return 'Unknown!';
}
}

277
src/MCP/Server.php Normal file
View file

@ -0,0 +1,277 @@
<?php

namespace WP_CLI\AiCommand\MCP;

use Exception;
use InvalidArgumentException;

class Server {

private array $data = [];
private array $tools = [];
private array $resources = [];

public function __construct() {
// Sample data (replace with your actual data handling)
$this->data['users'] = [
[ 'id' => 1, 'name' => 'Alice', 'email' => 'alice@example.com' ],
[ 'id' => 2, 'name' => 'Bob', 'email' => 'bob@example.com' ],
];
$this->data['products'] = [
[ 'id' => 101, 'name' => 'Product A', 'price' => 20 ],
[ 'id' => 102, 'name' => 'Product B', 'price' => 30 ],
];
}

public function registerTool( $toolDefinition ): void {
if ( ! is_array( $toolDefinition ) || ! isset( $toolDefinition['name'] ) || ! is_callable( $toolDefinition['callable'] ) ) {
throw new InvalidArgumentException( "Invalid tool definition. Must be an array with 'name' and 'callable'." );
}

$name = $toolDefinition['name'];
$callable = $toolDefinition['callable'];
$description = $toolDefinition['description'] ?? null;
$inputSchema = $toolDefinition['inputSchema'] ?? null;

$this->tools[ $name ] = [
'name' => $name,
'callable' => $callable,
'description' => $description,
'inputSchema' => $inputSchema,
];
}

public function registerResource( $resourceDefinition ) {
// Validate the resource definition (similar to tool validation)
if ( ! is_array( $resourceDefinition ) || ! isset( $resourceDefinition['name'] ) || ! isset( $resourceDefinition['uri'] ) ) {
throw new InvalidArgumentException( "Invalid resource definition." );
}

$this->resources[ $resourceDefinition['name'] ] = $resourceDefinition;
}

public function getCapabilities(): array {
$capabilities = [
'version' => '1.0', // MCP version (adjust as needed)
'methods' => [],
'data_resources' => [],
];

foreach ( $this->tools as $tool ) { // Iterate through the tools array
$capabilities['methods'][] = [ // Add each tool as an element in the array
'name' => $tool['name'],
'description' => $tool['description'],
'inputSchema' => $tool['inputSchema'],
];
}

// Add data resources
// Add resources to capabilities
foreach ( $this->resources as $resource ) {
$capabilities['data_resources'] = [
'name' => $resource['name'],
// You can add more details about the resource here if needed
];
}

return $capabilities;
}

public function handleRequest( $requestData ): false|string {
$request = json_decode( $requestData, true );

if ( json_last_error() !== JSON_ERROR_NONE ) {
return $this->createErrorResponse( null, 'Invalid JSON', - 32700 ); // Parse error
}

if ( ! isset( $request['jsonrpc'] ) || $request['jsonrpc'] !== '2.0' ) {
return $this->createErrorResponse( $request['id'] ?? null, 'Invalid JSON-RPC version', - 32600 ); // Invalid Request
}

if ( ! isset( $request['method'] ) ) {
return $this->createErrorResponse( $request['id'] ?? null, 'Missing method', - 32600 ); // Invalid Request
}

$method = $request['method'];
$params = $request['params'] ?? [];
$id = $request['id'] ?? null;

if ( $method === 'get_capabilities' ) { // Handle capabilities request
$capabilities = $this->getCapabilities();

return $this->createSuccessResponse( $id, $capabilities );
}

try {
// Check if it's a data access request (starts with "get_")
if ( str_starts_with( $method, 'get_' ) ) {
$resource = substr( $method, 4 ); // Extract the resource name (e.g., "users" from "get_users")

if ( isset( $this->data[ $resource ] ) ) {
$result = $this->handleGetRequest( '/' . $resource, $params ); // Re-use handleGetRequest
} else if ( isset( $this->data["{$resource}s"] ) ) {
$result = $this->handleGetRequest( '/' . "{$resource}s", $params ); // Re-use handleGetRequest
} else {
return $this->createErrorResponse( $id, 'Resource not found', - 32601 ); // Method not found
}

} else if ( $method === 'resources/list' ) {
$result = $this->listResources();
} elseif ( $method === 'resources/read' ) {
$result = $this->readResource( $params['uri'] ?? null );
} else { // Treat as a tool call

$tool = $this->tools[ $method ] ?? null;
if ( ! $tool ) {
return $this->createErrorResponse( $id, 'Method not found', - 32601 );
}

// Validate input parameters against the schema
$inputSchema = $tool['inputSchema'] ?? null;
if ( $inputSchema ) {
$isValid = $this->validateInput( $params, $inputSchema );
if ( ! $isValid['valid'] ) {
return $this->createErrorResponse( $id, 'Invalid input parameters: ' . implode( ", ", $isValid['errors'] ), - 32602 ); // Invalid params
}
}


$result = call_user_func( $tool['callable'], $params ); // Call the 'callable' property

return $this->createSuccessResponse( $id, $result ); // Return success immediately

}

return $this->createSuccessResponse( $id, $result );

} catch ( Exception $e ) {
return $this->createErrorResponse( $id, $e->getMessage(), - 32000 ); // Application error
}
}

private function listResources() {
$result = [];
foreach ( $this->resources as $resource ) {
$result[] = [
'uri' => $resource['uri'],
'name' => $resource['name'],
'description' => $resource['description'] ?? null,
'mimeType' => $resource['mimeType'] ?? null,
];
}

return $result;
}

private function readResource( $uri ) {
// Find the resource by URI
$resource = null;
foreach ( $this->resources as $r ) {
if ( $r['uri'] === $uri ) {
$resource = $r;
break;
}
}

if ( ! $resource ) {
throw new Exception( "Resource not found." );
}

// Access the resource data (replace with your actual data access logic)
$data = $this->getResourceData( $resource );

// Determine if it's text or binary
$isBinary = isset( $resource['mimeType'] ) && ! str_starts_with( $resource['mimeType'], 'text/' );

return [
'uri' => $resource['uri'],
'mimeType' => $resource['mimeType'] ?? null,
( $isBinary ? 'blob' : 'text' ) => $data,
];
}

private function getResourceData( $resource ) {
// Replace this with your actual logic to access the resource data
// based on the resource definition.

// Example: If the resource is a file, read the file contents.
if ( isset( $resource['filePath'] ) ) {
return file_get_contents( $resource['filePath'] );
}

// Example: If the resource is in the $data array, return the data.
if ( isset( $resource['dataKey'] ) ) {
return $this->data[ $resource['dataKey'] ];
}

//... other data access logic...

throw new Exception( "Unable to access resource data." );
}

private function validateInput( $input, $schema ): array {
// Basic input validation (you might want to use a dedicated JSON schema validator library)
$errors = [];
foreach ( $schema['properties'] ?? [] as $paramName => $paramSchema ) {
if ( isset( $paramSchema['required'] ) && $paramSchema['required'] === true && ! isset( $input[ $paramName ] ) ) {
$errors[] = $paramName . " is required";
}
// Add more validation rules as needed (e.g., type checking)
if ( isset( $input[ $paramName ] ) && isset( $paramSchema['type'] ) ) {
$inputType = gettype( $input[ $paramName ] );
if ( $inputType !== $paramSchema['type'] ) {
$errors[] = $paramName . " must be of type " . $paramSchema['type'] . " but " . $inputType . " was given.";
}
}
}

return [ 'valid' => empty( $errors ), 'errors' => $errors ];
}

private function handleGetRequest( $path, $params ) {
$parts = explode( '/', ltrim( $path, '/' ) );
$resource = $parts[0];
$id = $params['id'] ?? null; // Simplified parameter handling

if ( isset( $this->data[ $resource ] ) ) {
$data = $this->data[ $resource ];

if ( $id !== null ) {
foreach ( $data as $item ) {
if ( $item['id'] == $id ) {
return $item;
}
}
throw new Exception( 'Resource not found' );
} else {
return $data;
}
} else {
throw new Exception( 'Resource not found' );
}
}

private function createSuccessResponse( $id, $result ): false|string {
return json_encode( [
'jsonrpc' => '2.0',
'result' => $result,
'id' => $id,
] );
}

private function createErrorResponse( $id, $message, $code ): false|string {
return json_encode( [
'jsonrpc' => '2.0',
'error' => [
'code' => $code,
'message' => $message,
],
'id' => $id,
] );
}

public function processRequest( $requestData ): false|string {
return $this->handleRequest( $requestData );
}
}

2
wp-cli.yml Normal file
View file

@ -0,0 +1,2 @@
require:
- ai-command.php