Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ SITE_DOMAIN=example.com ./setup.sh --dry-run
| **[OpenCode](https://opencode.ai)**, **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)**, or **[Studio Code](https://developer.wordpress.com/studio/)** | AI coding agent runtime | Selected via `--runtime` |
| **[Data Machine](https://github.com/Extra-Chill/data-machine)** | Memory (SOUL/USER/MEMORY.md), self-scheduling, AI tools, Agent Ping | No — wp-coding-agents composes on top of DM |
| **[Data Machine Code](https://github.com/Extra-Chill/data-machine-code)** | Workspace management, GitHub integration, git operations | Installed with Data Machine |
| **AI Provider for Claude Code** | wp-coding-agents-carried WP AI Client provider backed by Claude Code OAuth credentials | Installed when Claude Code is selected or detected |
| **[Homeboy](https://github.com/Extra-Chill/homeboy)** | Optional developer power layer for project status, component-aware checks, review loops, and WordPress extension verification | `--with-homeboy` |
| **[Kimaki](https://kimaki.xyz)**, **[cc-connect](https://github.com/nichochar/cc-connect)**, or **[opencode-telegram](https://github.com/grinev/opencode-telegram-bot)** | Chat bridge (Discord, multi-platform, or Telegram) | `--no-chat` |
| **SessionStart hook** | Syncs Data Machine agents into CLAUDE.md on every session (Claude Code and Studio Code) | Always installed |
Expand Down Expand Up @@ -247,6 +248,25 @@ Data Machine is the substrate wp-coding-agents composes on top of — memory, sc

## Optional Homeboy Layer

### Claude Code Provider

When the selected or detected runtime includes Claude Code, wp-coding-agents syncs and activates a carried plugin at:

```
wp-content/plugins/ai-provider-for-claude-code
```

This plugin registers WP AI Client provider id `claude-code`. It is backed by Claude Code OAuth credentials and sends Anthropic Messages API requests with Claude Code subscription headers. It is not an official Anthropic API-key provider and does not require WP AI Gateway for Homeboy Codebox cooking.

Use WP AI Gateway only when an external client needs an OpenAI-compatible WordPress endpoint. Homeboy Codebox tasks can select the provider directly with `--provider claude-code` and pass the carried provider plugin path through the provider config.

Configuration:

- `AI_PROVIDER_CLAUDE_CODE_REFRESH_TOKEN` provides the Claude Code OAuth refresh token.
- `AI_PROVIDER_CLAUDE_CODE_ACCESS_TOKEN` and `AI_PROVIDER_CLAUDE_CODE_EXPIRES_AT` optionally provide a cached access token.
- The same names may be supplied as WordPress constants.
- `ai_provider_claude_code_oauth_tokens` can filter token data at runtime.

`--with-homeboy` adds Homeboy as an optional, recommended developer power layer. Homeboy is not bundled or vendorized by wp-coding-agents; setup verifies or installs the external Homeboy CLI, verifies the WordPress extension, then exposes its availability to Data Machine Code so composed agent instructions can include Homeboy workflows.

When Homeboy is available, the composed `AGENTS.md` Homeboy Codebox section is the canonical guidance for repo-aware coding fan-out: use `homeboy agent-task` plans/runs with WP Codebox sandboxes for isolated tasks. Without Homeboy, agents should continue using Data Machine Code worktrees, normal git review, and the selected chat or terminal runtime; do not assume Homeboy commands exist.
Expand Down
20 changes: 20 additions & 0 deletions carried-plugins/ai-provider-for-claude-code/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# AI Provider for Claude Code

`ai-provider-for-claude-code` is a wp-coding-agents-carried WP AI Client provider backed by Claude Code OAuth credentials.

It is not an official Anthropic API-key provider. It sends Anthropic Messages API requests with Claude Code OAuth headers and does not execute the `claude` binary.

## Provider

- Provider ID: `claude-code`
- Models: `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-haiku-4-5`
- Auth: Claude Code OAuth refresh/access token credentials

## Configuration

- `AI_PROVIDER_CLAUDE_CODE_REFRESH_TOKEN`: Claude Code OAuth refresh token.
- `AI_PROVIDER_CLAUDE_CODE_ACCESS_TOKEN`: optional cached access token.
- `AI_PROVIDER_CLAUDE_CODE_EXPIRES_AT`: optional Unix timestamp for the cached access token expiry.
- `AI_PROVIDER_CLAUDE_CODE_USER_AGENT`: optional Claude CLI user agent override.
- WordPress constants with the same names are also supported.
- `ai_provider_claude_code_oauth_tokens` filter: override token data at runtime.
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

/**
* Plugin Name: AI Provider for Claude Code
* Plugin URI: https://github.com/Extra-Chill/wp-coding-agents
* Description: WP AI Client provider backed by Claude Code OAuth credentials.
* Requires at least: 6.9
* Requires PHP: 7.4
* Version: 0.1.0
* Author: Extra Chill
* License: GPL-2.0-or-later
* License URI: https://spdx.org/licenses/GPL-2.0-or-later.html
* Text Domain: ai-provider-for-claude-code
*
* @package ExtraChill\ClaudeCodeAiProvider
*/

declare(strict_types=1);

namespace ExtraChill\ClaudeCodeAiProvider;

use ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeProvider;
use ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeOAuthClient;
use ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeRequestAuthentication;
use ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeTokenStore;
use WordPress\AiClient\AiClient;

if (!defined('ABSPATH')) {
return;
}

require_once __DIR__ . '/src/autoload.php';

/**
* Registers the Claude Code provider with the WP AI Client registry.
*
* @return void
*/
function register_provider(): void
{
if (!class_exists(AiClient::class)) {
return;
}

$registry = AiClient::defaultRegistry();

if (!$registry->hasProvider(ClaudeCodeProvider::class)) {
$registry->registerProvider(ClaudeCodeProvider::class);
}

$tokenStore = new ClaudeCodeTokenStore();
$registry->setProviderRequestAuthentication(
ClaudeCodeProvider::class,
new ClaudeCodeRequestAuthentication($tokenStore, new ClaudeCodeOAuthClient($tokenStore))
);
}

add_action('init', __NAMESPACE__ . '\\register_provider', 5);
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace ExtraChill\ClaudeCodeAiProvider\Provider;

use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Messages\Enums\ModalityEnum;
use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
use WordPress\AiClient\Providers\Models\DTO\SupportedOption;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
use WordPress\AiClient\Providers\Models\Enums\OptionEnum;

/**
* Model metadata directory for Claude Code models.
*/
class ClaudeCodeModelMetadataDirectory implements ModelMetadataDirectoryInterface
{
/**
* {@inheritDoc}
*/
public function listModelMetadata(): array
{
return array_values($this->getModelMap());
}

/**
* {@inheritDoc}
*/
public function hasModelMetadata(string $modelId): bool
{
return isset($this->getModelMap()[$modelId]);
}

/**
* {@inheritDoc}
*/
public function getModelMetadata(string $modelId): ModelMetadata
{
$models = $this->getModelMap();
if (!isset($models[$modelId])) {
throw new InvalidArgumentException('No Claude Code model with the requested ID was found.');
}

return $models[$modelId];
}

/**
* Gets the supported Claude Code model map.
*
* @return array<string, ModelMetadata> Model metadata keyed by ID.
*/
private function getModelMap(): array
{
$options = [
new SupportedOption(OptionEnum::systemInstruction()),
new SupportedOption(OptionEnum::maxTokens()),
new SupportedOption(OptionEnum::temperature()),
new SupportedOption(OptionEnum::topP()),
new SupportedOption(OptionEnum::outputMimeType(), ['text/plain', 'application/json']),
new SupportedOption(OptionEnum::outputSchema()),
new SupportedOption(OptionEnum::functionDeclarations()),
new SupportedOption(OptionEnum::customOptions()),
new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]),
new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]),
];

$models = [];
foreach (['claude-opus-4-8', 'claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5'] as $modelId) {
$models[$modelId] = new ModelMetadata(
$modelId,
$modelId,
[CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory()],
$options
);
}

return $models;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php

declare(strict_types=1);

namespace ExtraChill\ClaudeCodeAiProvider\Provider;

use RuntimeException;

/**
* Refreshes Claude Code OAuth access tokens.
*/
class ClaudeCodeOAuthClient
{
private const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
private const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';

/**
* @var ClaudeCodeTokenStore Token store.
*/
private ClaudeCodeTokenStore $tokenStore;

public function __construct(ClaudeCodeTokenStore $tokenStore)
{
$this->tokenStore = $tokenStore;
}

public function getAccessToken(): string
{
$accessToken = $this->tokenStore->getAccessToken();
if ($accessToken !== null) {
return $accessToken;
}

$tokens = $this->tokenStore->getTokens();
$refreshToken = $tokens['refresh_token'] ?? '';
if ($refreshToken === '') {
throw new RuntimeException('Claude Code OAuth refresh token is not configured.');
}

$data = $this->refreshAccessToken($refreshToken);
if (empty($data['access_token']) || !is_scalar($data['access_token'])) {
throw new RuntimeException('Claude Code OAuth refresh returned an invalid response.');
}

$updated = array_merge(
$tokens,
[
'access_token' => (string) $data['access_token'],
'expires_at' => time() + $this->getIntegerValue($data['expires_in'] ?? null, 3600),
]
);

if (!empty($data['refresh_token']) && is_scalar($data['refresh_token'])) {
$updated['refresh_token'] = (string) $data['refresh_token'];
}

$this->tokenStore->updateTokens($updated);
return (string) $data['access_token'];
}

/**
* @return array<string, mixed>
*/
private function refreshAccessToken(string $refreshToken): array
{
$body = [
'grant_type' => 'refresh_token',
'client_id' => self::CLIENT_ID,
'refresh_token' => $refreshToken,
];

if (function_exists('wp_remote_post')) {
return $this->refreshAccessTokenWithWordPress($body);
}

$context = stream_context_create(
[
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n",
'content' => json_encode($body),
'ignore_errors' => true,
'timeout' => 20,
],
]
);
$responseBody = file_get_contents(self::TOKEN_URL, false, $context);
if ($responseBody === false) {
throw new RuntimeException('Claude Code OAuth refresh failed.');
}

$data = json_decode($responseBody, true);
if (!is_array($data)) {
throw new RuntimeException('Claude Code OAuth refresh returned an invalid response.');
}

/** @var array<string, mixed> $data */
return $data;
}

/**
* @param array<string, string> $body Request body.
* @return array<string, mixed>
*/
private function refreshAccessTokenWithWordPress(array $body): array
{
$wpRemotePost = 'wp_remote_post';
// @phpstan-ignore-next-line WordPress HTTP API is available at runtime when function_exists() passes.
$response = $wpRemotePost(self::TOKEN_URL, [
'body' => wp_json_encode($body),
'headers' => ['Content-Type' => 'application/json'],
'timeout' => 20,
]);

$isWpError = 'is_wp_error';
// @phpstan-ignore-next-line WordPress error helper is available at runtime when function_exists() passes.
if (function_exists('is_wp_error') && $isWpError($response)) {
throw new RuntimeException('Claude Code OAuth refresh failed.');
}

$wpRemoteRetrieveResponseCode = 'wp_remote_retrieve_response_code';
$rawStatusCode = 0;
if (function_exists('wp_remote_retrieve_response_code')) {
// @phpstan-ignore-next-line WordPress HTTP helper is available at runtime when function_exists() passes.
$rawStatusCode = $wpRemoteRetrieveResponseCode($response);
}
$statusCode = is_numeric($rawStatusCode) ? (int) $rawStatusCode : 0;
if ($statusCode < 200 || $statusCode >= 300) {
throw new RuntimeException('Claude Code OAuth refresh failed.');
}

$wpRemoteRetrieveBody = 'wp_remote_retrieve_body';
$rawResponseBody = '';
if (function_exists('wp_remote_retrieve_body')) {
// @phpstan-ignore-next-line WordPress HTTP helper is available at runtime when function_exists() passes.
$rawResponseBody = $wpRemoteRetrieveBody($response);
}
$responseBody = is_scalar($rawResponseBody) ? (string) $rawResponseBody : '';
$data = json_decode($responseBody, true);
if (!is_array($data)) {
throw new RuntimeException('Claude Code OAuth refresh returned an invalid response.');
}

/** @var array<string, mixed> $data */
return $data;
}

/**
* @param mixed $value Raw value.
*/
private function getIntegerValue($value, int $fallback): int
{
return is_numeric($value) ? (int) $value : $fallback;
}
}
Loading
Loading