Skip to content

Commit af32950

Browse files
authored
Merge pull request #197 from Extra-Chill/claude-code-provider
Add Claude Code provider plugin
2 parents b55a5b3 + 8d1a9a6 commit af32950

20 files changed

Lines changed: 1439 additions & 13 deletions

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ SITE_DOMAIN=example.com ./setup.sh --dry-run
183183
| **[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` |
184184
| **[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 |
185185
| **[Data Machine Code](https://github.com/Extra-Chill/data-machine-code)** | Workspace management, GitHub integration, git operations | Installed with Data Machine |
186+
| **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 |
186187
| **[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` |
187188
| **[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` |
188189
| **SessionStart hook** | Syncs Data Machine agents into CLAUDE.md on every session (Claude Code and Studio Code) | Always installed |
@@ -247,6 +248,25 @@ Data Machine is the substrate wp-coding-agents composes on top of — memory, sc
247248

248249
## Optional Homeboy Layer
249250

251+
### Claude Code Provider
252+
253+
When the selected or detected runtime includes Claude Code, wp-coding-agents syncs and activates a carried plugin at:
254+
255+
```
256+
wp-content/plugins/ai-provider-for-claude-code
257+
```
258+
259+
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.
260+
261+
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.
262+
263+
Configuration:
264+
265+
- `AI_PROVIDER_CLAUDE_CODE_REFRESH_TOKEN` provides the Claude Code OAuth refresh token.
266+
- `AI_PROVIDER_CLAUDE_CODE_ACCESS_TOKEN` and `AI_PROVIDER_CLAUDE_CODE_EXPIRES_AT` optionally provide a cached access token.
267+
- The same names may be supplied as WordPress constants.
268+
- `ai_provider_claude_code_oauth_tokens` can filter token data at runtime.
269+
250270
`--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.
251271

252272
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.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# AI Provider for Claude Code
2+
3+
`ai-provider-for-claude-code` is a wp-coding-agents-carried WP AI Client provider backed by Claude Code OAuth credentials.
4+
5+
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.
6+
7+
## Provider
8+
9+
- Provider ID: `claude-code`
10+
- Models: `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-haiku-4-5`
11+
- Auth: Claude Code OAuth refresh/access token credentials
12+
13+
## Configuration
14+
15+
- `AI_PROVIDER_CLAUDE_CODE_REFRESH_TOKEN`: Claude Code OAuth refresh token.
16+
- `AI_PROVIDER_CLAUDE_CODE_ACCESS_TOKEN`: optional cached access token.
17+
- `AI_PROVIDER_CLAUDE_CODE_EXPIRES_AT`: optional Unix timestamp for the cached access token expiry.
18+
- `AI_PROVIDER_CLAUDE_CODE_USER_AGENT`: optional Claude CLI user agent override.
19+
- WordPress constants with the same names are also supported.
20+
- `ai_provider_claude_code_oauth_tokens` filter: override token data at runtime.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
/**
4+
* Plugin Name: AI Provider for Claude Code
5+
* Plugin URI: https://github.com/Extra-Chill/wp-coding-agents
6+
* Description: WP AI Client provider backed by Claude Code OAuth credentials.
7+
* Requires at least: 6.9
8+
* Requires PHP: 7.4
9+
* Version: 0.1.0
10+
* Author: Extra Chill
11+
* License: GPL-2.0-or-later
12+
* License URI: https://spdx.org/licenses/GPL-2.0-or-later.html
13+
* Text Domain: ai-provider-for-claude-code
14+
*
15+
* @package ExtraChill\ClaudeCodeAiProvider
16+
*/
17+
18+
declare(strict_types=1);
19+
20+
namespace ExtraChill\ClaudeCodeAiProvider;
21+
22+
use ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeProvider;
23+
use ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeOAuthClient;
24+
use ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeRequestAuthentication;
25+
use ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeTokenStore;
26+
use WordPress\AiClient\AiClient;
27+
28+
if (!defined('ABSPATH')) {
29+
return;
30+
}
31+
32+
require_once __DIR__ . '/src/autoload.php';
33+
34+
/**
35+
* Registers the Claude Code provider with the WP AI Client registry.
36+
*
37+
* @return void
38+
*/
39+
function register_provider(): void
40+
{
41+
if (!class_exists(AiClient::class)) {
42+
return;
43+
}
44+
45+
$registry = AiClient::defaultRegistry();
46+
47+
if (!$registry->hasProvider(ClaudeCodeProvider::class)) {
48+
$registry->registerProvider(ClaudeCodeProvider::class);
49+
}
50+
51+
$tokenStore = new ClaudeCodeTokenStore();
52+
$registry->setProviderRequestAuthentication(
53+
ClaudeCodeProvider::class,
54+
new ClaudeCodeRequestAuthentication($tokenStore, new ClaudeCodeOAuthClient($tokenStore))
55+
);
56+
}
57+
58+
add_action('init', __NAMESPACE__ . '\\register_provider', 5);
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ExtraChill\ClaudeCodeAiProvider\Provider;
6+
7+
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
8+
use WordPress\AiClient\Messages\Enums\ModalityEnum;
9+
use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
10+
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
11+
use WordPress\AiClient\Providers\Models\DTO\SupportedOption;
12+
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
13+
use WordPress\AiClient\Providers\Models\Enums\OptionEnum;
14+
15+
/**
16+
* Model metadata directory for Claude Code models.
17+
*/
18+
class ClaudeCodeModelMetadataDirectory implements ModelMetadataDirectoryInterface
19+
{
20+
/**
21+
* {@inheritDoc}
22+
*/
23+
public function listModelMetadata(): array
24+
{
25+
return array_values($this->getModelMap());
26+
}
27+
28+
/**
29+
* {@inheritDoc}
30+
*/
31+
public function hasModelMetadata(string $modelId): bool
32+
{
33+
return isset($this->getModelMap()[$modelId]);
34+
}
35+
36+
/**
37+
* {@inheritDoc}
38+
*/
39+
public function getModelMetadata(string $modelId): ModelMetadata
40+
{
41+
$models = $this->getModelMap();
42+
if (!isset($models[$modelId])) {
43+
throw new InvalidArgumentException('No Claude Code model with the requested ID was found.');
44+
}
45+
46+
return $models[$modelId];
47+
}
48+
49+
/**
50+
* Gets the supported Claude Code model map.
51+
*
52+
* @return array<string, ModelMetadata> Model metadata keyed by ID.
53+
*/
54+
private function getModelMap(): array
55+
{
56+
$options = [
57+
new SupportedOption(OptionEnum::systemInstruction()),
58+
new SupportedOption(OptionEnum::maxTokens()),
59+
new SupportedOption(OptionEnum::temperature()),
60+
new SupportedOption(OptionEnum::topP()),
61+
new SupportedOption(OptionEnum::outputMimeType(), ['text/plain', 'application/json']),
62+
new SupportedOption(OptionEnum::outputSchema()),
63+
new SupportedOption(OptionEnum::functionDeclarations()),
64+
new SupportedOption(OptionEnum::customOptions()),
65+
new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]),
66+
new SupportedOption(OptionEnum::outputModalities(), [[ModalityEnum::text()]]),
67+
];
68+
69+
$models = [];
70+
foreach (['claude-opus-4-8', 'claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5'] as $modelId) {
71+
$models[$modelId] = new ModelMetadata(
72+
$modelId,
73+
$modelId,
74+
[CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory()],
75+
$options
76+
);
77+
}
78+
79+
return $models;
80+
}
81+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ExtraChill\ClaudeCodeAiProvider\Provider;
6+
7+
use RuntimeException;
8+
9+
/**
10+
* Refreshes Claude Code OAuth access tokens.
11+
*/
12+
class ClaudeCodeOAuthClient
13+
{
14+
private const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
15+
private const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
16+
17+
/**
18+
* @var ClaudeCodeTokenStore Token store.
19+
*/
20+
private ClaudeCodeTokenStore $tokenStore;
21+
22+
public function __construct(ClaudeCodeTokenStore $tokenStore)
23+
{
24+
$this->tokenStore = $tokenStore;
25+
}
26+
27+
public function getAccessToken(): string
28+
{
29+
$accessToken = $this->tokenStore->getAccessToken();
30+
if ($accessToken !== null) {
31+
return $accessToken;
32+
}
33+
34+
$tokens = $this->tokenStore->getTokens();
35+
$refreshToken = $tokens['refresh_token'] ?? '';
36+
if ($refreshToken === '') {
37+
throw new RuntimeException('Claude Code OAuth refresh token is not configured.');
38+
}
39+
40+
$data = $this->refreshAccessToken($refreshToken);
41+
if (empty($data['access_token']) || !is_scalar($data['access_token'])) {
42+
throw new RuntimeException('Claude Code OAuth refresh returned an invalid response.');
43+
}
44+
45+
$updated = array_merge(
46+
$tokens,
47+
[
48+
'access_token' => (string) $data['access_token'],
49+
'expires_at' => time() + $this->getIntegerValue($data['expires_in'] ?? null, 3600),
50+
]
51+
);
52+
53+
if (!empty($data['refresh_token']) && is_scalar($data['refresh_token'])) {
54+
$updated['refresh_token'] = (string) $data['refresh_token'];
55+
}
56+
57+
$this->tokenStore->updateTokens($updated);
58+
return (string) $data['access_token'];
59+
}
60+
61+
/**
62+
* @return array<string, mixed>
63+
*/
64+
private function refreshAccessToken(string $refreshToken): array
65+
{
66+
$body = [
67+
'grant_type' => 'refresh_token',
68+
'client_id' => self::CLIENT_ID,
69+
'refresh_token' => $refreshToken,
70+
];
71+
72+
if (function_exists('wp_remote_post')) {
73+
return $this->refreshAccessTokenWithWordPress($body);
74+
}
75+
76+
$context = stream_context_create(
77+
[
78+
'http' => [
79+
'method' => 'POST',
80+
'header' => "Content-Type: application/json\r\n",
81+
'content' => json_encode($body),
82+
'ignore_errors' => true,
83+
'timeout' => 20,
84+
],
85+
]
86+
);
87+
$responseBody = file_get_contents(self::TOKEN_URL, false, $context);
88+
if ($responseBody === false) {
89+
throw new RuntimeException('Claude Code OAuth refresh failed.');
90+
}
91+
92+
$data = json_decode($responseBody, true);
93+
if (!is_array($data)) {
94+
throw new RuntimeException('Claude Code OAuth refresh returned an invalid response.');
95+
}
96+
97+
/** @var array<string, mixed> $data */
98+
return $data;
99+
}
100+
101+
/**
102+
* @param array<string, string> $body Request body.
103+
* @return array<string, mixed>
104+
*/
105+
private function refreshAccessTokenWithWordPress(array $body): array
106+
{
107+
$wpRemotePost = 'wp_remote_post';
108+
// @phpstan-ignore-next-line WordPress HTTP API is available at runtime when function_exists() passes.
109+
$response = $wpRemotePost(self::TOKEN_URL, [
110+
'body' => wp_json_encode($body),
111+
'headers' => ['Content-Type' => 'application/json'],
112+
'timeout' => 20,
113+
]);
114+
115+
$isWpError = 'is_wp_error';
116+
// @phpstan-ignore-next-line WordPress error helper is available at runtime when function_exists() passes.
117+
if (function_exists('is_wp_error') && $isWpError($response)) {
118+
throw new RuntimeException('Claude Code OAuth refresh failed.');
119+
}
120+
121+
$wpRemoteRetrieveResponseCode = 'wp_remote_retrieve_response_code';
122+
$rawStatusCode = 0;
123+
if (function_exists('wp_remote_retrieve_response_code')) {
124+
// @phpstan-ignore-next-line WordPress HTTP helper is available at runtime when function_exists() passes.
125+
$rawStatusCode = $wpRemoteRetrieveResponseCode($response);
126+
}
127+
$statusCode = is_numeric($rawStatusCode) ? (int) $rawStatusCode : 0;
128+
if ($statusCode < 200 || $statusCode >= 300) {
129+
throw new RuntimeException('Claude Code OAuth refresh failed.');
130+
}
131+
132+
$wpRemoteRetrieveBody = 'wp_remote_retrieve_body';
133+
$rawResponseBody = '';
134+
if (function_exists('wp_remote_retrieve_body')) {
135+
// @phpstan-ignore-next-line WordPress HTTP helper is available at runtime when function_exists() passes.
136+
$rawResponseBody = $wpRemoteRetrieveBody($response);
137+
}
138+
$responseBody = is_scalar($rawResponseBody) ? (string) $rawResponseBody : '';
139+
$data = json_decode($responseBody, true);
140+
if (!is_array($data)) {
141+
throw new RuntimeException('Claude Code OAuth refresh returned an invalid response.');
142+
}
143+
144+
/** @var array<string, mixed> $data */
145+
return $data;
146+
}
147+
148+
/**
149+
* @param mixed $value Raw value.
150+
*/
151+
private function getIntegerValue($value, int $fallback): int
152+
{
153+
return is_numeric($value) ? (int) $value : $fallback;
154+
}
155+
}

0 commit comments

Comments
 (0)