diff --git a/README.md b/README.md index 70129ca..f6f5bbc 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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. diff --git a/carried-plugins/ai-provider-for-claude-code/README.md b/carried-plugins/ai-provider-for-claude-code/README.md new file mode 100644 index 0000000..0572231 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/README.md @@ -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. diff --git a/carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php b/carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php new file mode 100644 index 0000000..a24875e --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/ai-provider-for-claude-code.php @@ -0,0 +1,58 @@ +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); diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php new file mode 100644 index 0000000..02c4637 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeModelMetadataDirectory.php @@ -0,0 +1,81 @@ +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 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; + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeOAuthClient.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeOAuthClient.php new file mode 100644 index 0000000..152fb84 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeOAuthClient.php @@ -0,0 +1,155 @@ +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 + */ + 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 $data */ + return $data; + } + + /** + * @param array $body Request body. + * @return array + */ + 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 $data */ + return $data; + } + + /** + * @param mixed $value Raw value. + */ + private function getIntegerValue($value, int $fallback): int + { + return is_numeric($value) ? (int) $value : $fallback; + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php new file mode 100644 index 0000000..15a68f4 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProvider.php @@ -0,0 +1,76 @@ +getSupportedCapabilities() as $capability) { + if ($capability->isTextGeneration()) { + return new ClaudeCodeTextGenerationModel($modelMetadata, $providerMetadata); + } + } + + throw new RuntimeException('Unsupported Claude Code model capabilities.'); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderMetadata(): ProviderMetadata + { + return new ProviderMetadata( + 'claude-code', + 'Claude Code', + ProviderTypeEnum::cloud(), + 'https://docs.anthropic.com/en/docs/claude-code', + RequestAuthenticationMethod::apiKey(), + 'Claude Code subscription-backed access using Claude OAuth credentials.' + ); + } + + /** + * {@inheritDoc} + */ + protected static function createProviderAvailability(): ProviderAvailabilityInterface + { + return new ClaudeCodeProviderAvailability(new ClaudeCodeTokenStore()); + } + + /** + * {@inheritDoc} + */ + protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface + { + return new ClaudeCodeModelMetadataDirectory(); + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProviderAvailability.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProviderAvailability.php new file mode 100644 index 0000000..6145c81 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeProviderAvailability.php @@ -0,0 +1,36 @@ +tokenStore = $tokenStore; + } + + /** + * {@inheritDoc} + */ + public function isConfigured(): bool + { + return $this->tokenStore->hasRefreshToken(); + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeRequestAuthentication.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeRequestAuthentication.php new file mode 100644 index 0000000..29a1af7 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeRequestAuthentication.php @@ -0,0 +1,67 @@ +tokenStore = $tokenStore; + $this->oauthClient = $oauthClient; + } + + /** + * {@inheritDoc} + */ + public function authenticateRequest(Request $request): Request + { + return $request + ->withHeader('Accept', 'application/json') + ->withHeader('Anthropic-Version', '2023-06-01') + ->withHeader('Anthropic-Beta', self::ANTHROPIC_BETA) + ->withHeader('Anthropic-Dangerous-Direct-Browser-Access', 'true') + ->withHeader('Authorization', 'Bearer ' . $this->oauthClient->getAccessToken()) + ->withHeader('User-Agent', $this->userAgent()) + ->withHeader('X-App', 'cli'); + } + + /** + * {@inheritDoc} + */ + public static function getJsonSchema(): array + { + return parent::getJsonSchema(); + } + + private function userAgent(): string + { + $userAgent = getenv('AI_PROVIDER_CLAUDE_CODE_USER_AGENT') ?: ''; + if (defined('AI_PROVIDER_CLAUDE_CODE_USER_AGENT')) { + $userAgent = (string) constant('AI_PROVIDER_CLAUDE_CODE_USER_AGENT'); + } + + return $userAgent !== '' ? $userAgent : 'claude-cli/' . self::CLAUDE_CODE_VERSION; + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php new file mode 100644 index 0000000..9620789 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTextGenerationModel.php @@ -0,0 +1,357 @@ + 'application/json'], + $this->prepareGenerateTextParams($prompt), + $this->getClaudeCodeRequestOptions() + ); + + $request = $this->getRequestAuthentication()->authenticateRequest($request); + $response = $this->getHttpTransporter()->send($request); + ResponseUtil::throwIfNotSuccessful($response); + + return $this->parseResponseToResult($response); + } + + private function getClaudeCodeRequestOptions(): RequestOptions + { + $current = $this->getRequestOptions(); + $options = new RequestOptions(); + + $timeout = $current?->getTimeout(); + $timeout = max($timeout ?? 0.0, self::REQUEST_TIMEOUT_FLOOR); + $options->setTimeout($timeout); + + $connectTimeout = $current?->getConnectTimeout(); + $connectTimeoutFloor = min(self::CONNECT_TIMEOUT_FLOOR, $timeout); + $options->setConnectTimeout(max($connectTimeout ?? 0.0, $connectTimeoutFloor)); + + $maxRedirects = $current?->getMaxRedirects(); + if ($maxRedirects !== null) { + $options->setMaxRedirects($maxRedirects); + } + + return $options; + } + + /** + * Prepares the Anthropic Messages request payload. + * + * @param list $prompt Prompt messages. + * @return array Request payload. + */ + private function prepareGenerateTextParams(array $prompt): array + { + $config = $this->getConfig(); + $system = [ + ['type' => 'text', 'text' => self::CLAUDE_CODE_IDENTITY], + ]; + + if ($config->getSystemInstruction()) { + $system[] = ['type' => 'text', 'text' => $config->getSystemInstruction()]; + } + + $params = [ + 'model' => $this->metadata()->getId(), + 'max_tokens' => $config->getMaxTokens() ?? 4096, + 'messages' => $this->prepareMessages($prompt), + 'system' => $system, + ]; + + $temperature = $config->getTemperature(); + if ($temperature !== null) { + $params['temperature'] = $temperature; + } + + $topP = $config->getTopP(); + if ($topP !== null) { + $params['top_p'] = $topP; + } + + $functionDeclarations = $config->getFunctionDeclarations(); + if (is_array($functionDeclarations) && $functionDeclarations !== []) { + // Declare the agent's tools to Anthropic so the model returns + // structured `tool_use` blocks instead of improvising tool calls + // as freeform text. + $params['tools'] = $this->prepareToolsParam($functionDeclarations); + } elseif ($config->getOutputMimeType() === 'application/json' && $config->getOutputSchema()) { + $params['tools'] = [ + [ + 'name' => 'response_schema', + 'description' => 'Return a response matching the requested JSON schema.', + 'input_schema' => $config->getOutputSchema(), + ], + ]; + $params['tool_choice'] = ['type' => 'tool', 'name' => 'response_schema']; + } + + foreach ($config->getCustomOptions() as $key => $value) { + if (isset($params[$key])) { + throw new InvalidArgumentException( + sprintf('The custom option "%s" conflicts with an existing Claude Code request parameter.', $key) + ); + } + $params[$key] = $value; + } + + return $params; + } + + /** + * Prepares the Anthropic `tools` parameter from function declarations. + * + * @param list<\WordPress\AiClient\Tools\DTO\FunctionDeclaration> $functionDeclarations Declarations. + * @return list> Anthropic tool definitions. + */ + private function prepareToolsParam(array $functionDeclarations): array + { + $tools = []; + foreach ($functionDeclarations as $functionDeclaration) { + $parameters = $functionDeclaration->getParameters(); + $tools[] = [ + 'name' => $functionDeclaration->getName(), + 'description' => $functionDeclaration->getDescription(), + 'input_schema' => is_array($parameters) && $parameters !== [] + ? $parameters + : ['type' => 'object', 'properties' => (object) []], + ]; + } + + return $tools; + } + + /** + * Converts prompt messages to Anthropic Messages format. + * + * @param list $messages Prompt messages. + * @return list> Messages. + */ + private function prepareMessages(array $messages): array + { + $output = []; + foreach ($messages as $message) { + $content = []; + foreach ($message->getParts() as $part) { + $content[] = $this->prepareMessagePartContent($part); + } + + $output[] = [ + 'role' => $this->roleToAnthropicRole($message->getRole()), + 'content' => $content, + ]; + } + + return $output; + } + + /** + * Converts a single message part to an Anthropic content block. + * + * @param MessagePart $part Message part. + * @return array Anthropic content block. + */ + private function prepareMessagePartContent(MessagePart $part): array + { + if ($part->getType()->isText()) { + return ['type' => 'text', 'text' => (string) $part->getText()]; + } + + if ($part->getType()->isFunctionCall() && $part->getFunctionCall()) { + $functionCall = $part->getFunctionCall(); + + return [ + 'type' => 'tool_use', + 'id' => (string) ($functionCall->getId() ?? $functionCall->getName() ?? ''), + 'name' => (string) ($functionCall->getName() ?? ''), + // Anthropic requires tool_use.input to be a JSON object. An empty + // or list-shaped arguments value must encode as `{}`, not `[]`. + 'input' => $this->toInputObject($functionCall->getArgs()), + ]; + } + + if ($part->getType()->isFunctionResponse() && $part->getFunctionResponse()) { + $functionResponse = $part->getFunctionResponse(); + $response = $functionResponse->getResponse(); + + return [ + 'type' => 'tool_result', + 'tool_use_id' => (string) ($functionResponse->getId() ?? $functionResponse->getName() ?? ''), + 'content' => is_string($response) ? $response : $this->encodeJson($response), + ]; + } + + // Fallback for unsupported part types: serialize as text so context is preserved. + $payload = ['type' => $part->getType()->value]; + if ($part->getType()->isFile() && $part->getFile()) { + $payload['file'] = $part->getFile()->toArray(); + } + + return ['type' => 'text', 'text' => $this->encodeJson($payload)]; + } + + private function roleToAnthropicRole(MessageRoleEnum $role): string + { + return $role->isModel() ? 'assistant' : 'user'; + } + + private function parseResponseToResult(Response $response): GenerativeAiResult + { + $data = $response->getData(); + if (!is_array($data)) { + $data = json_decode((string) $response->getBody(), true); + } + if (!is_array($data)) { + throw ResponseException::fromMissingData('Claude Code', 'content'); + } + + $parts = $this->parseResponseParts($data); + if ($parts === []) { + throw ResponseException::fromMissingData('Claude Code', 'content'); + } + + $usage = isset($data['usage']) && is_array($data['usage']) ? $data['usage'] : []; + $inputTokens = $this->getIntegerValue($usage['input_tokens'] ?? null); + $outputTokens = $this->getIntegerValue($usage['output_tokens'] ?? null); + + return new GenerativeAiResult( + isset($data['id']) && is_string($data['id']) ? $data['id'] : '', + [new Candidate(new ModelMessage($parts), $this->mapFinishReason($data['stop_reason'] ?? null))], + new TokenUsage($inputTokens, $outputTokens, $inputTokens + $outputTokens), + $this->providerMetadata(), + $this->metadata(), + $data + ); + } + + /** + * Converts Anthropic response content blocks into message parts. + * + * @param array $data Response data. + * @return list Message parts. + */ + private function parseResponseParts(array $data): array + { + $parts = []; + if (!isset($data['content']) || !is_array($data['content'])) { + return $parts; + } + + foreach ($data['content'] as $part) { + if (!is_array($part)) { + continue; + } + + $type = $part['type'] ?? ''; + if ($type === 'text' && isset($part['text']) && is_string($part['text']) && $part['text'] !== '') { + $parts[] = new MessagePart($part['text']); + } elseif ($type === 'tool_use') { + $name = isset($part['name']) && is_string($part['name']) ? $part['name'] : ''; + if ($name === '') { + continue; + } + $input = isset($part['input']) && is_array($part['input']) ? $part['input'] : []; + $id = isset($part['id']) && is_string($part['id']) ? $part['id'] : null; + $parts[] = new MessagePart(new FunctionCall($id, $name, $input)); + } + } + + return $parts; + } + + /** + * Maps an Anthropic stop reason to a finish reason. + * + * @param mixed $stopReason Anthropic stop reason. + */ + private function mapFinishReason($stopReason): FinishReasonEnum + { + if ($stopReason === 'tool_use') { + return FinishReasonEnum::toolCalls(); + } + if ($stopReason === 'max_tokens') { + return FinishReasonEnum::length(); + } + + return FinishReasonEnum::stop(); + } + + /** + * Normalizes function-call arguments into a JSON object for `tool_use.input`. + * + * Anthropic rejects a request when `tool_use.input` is not an object. An + * associative array encodes as an object, but an empty or list-shaped array + * encodes as `[]`, so those are coerced to an empty object. + * + * @param mixed $arguments Raw function-call arguments. + * @return object|array Object-encodable input. + */ + private function toInputObject($arguments) + { + if (is_array($arguments) && $arguments !== [] && array_keys($arguments) !== range(0, count($arguments) - 1)) { + return $arguments; + } + + if (is_object($arguments)) { + return $arguments; + } + + return (object) []; + } + + /** + * @param mixed $value Raw value. + */ + private function encodeJson($value): string + { + $encoded = function_exists('wp_json_encode') ? wp_json_encode($value) : json_encode($value); + return is_string($encoded) ? $encoded : ''; + } + + /** + * @param mixed $value Raw value. + */ + private function getIntegerValue($value): int + { + return is_numeric($value) ? (int) $value : 0; + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTokenStore.php b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTokenStore.php new file mode 100644 index 0000000..8e28ed5 --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/Provider/ClaudeCodeTokenStore.php @@ -0,0 +1,141 @@ + $tokenData */ + $tokenData = $tokens; + $tokens = $this->addEnvironmentTokens($tokenData); + $tokens = $this->addConstantTokens($tokens); + + if (function_exists('apply_filters')) { + $tokens = apply_filters('ai_provider_claude_code_oauth_tokens', $tokens); + } + + if (!is_array($tokens)) { + return []; + } + + /** @var array $tokens */ + return $this->sanitizeTokens($tokens); + } + + /** + * @param array $tokens Token data. + */ + public function updateTokens(array $tokens): void + { + if (!function_exists('update_option')) { + return; + } + + update_option(self::OPTION_NAME, $this->sanitizeTokens($tokens), false); + } + + public function hasRefreshToken(): bool + { + $tokens = $this->getTokens(); + return !empty($tokens['refresh_token']); + } + + public function getAccessToken(): ?string + { + $tokens = $this->getTokens(); + $accessToken = $tokens['access_token'] ?? ''; + $expiresAt = $tokens['expires_at'] ?? 0; + + if ($accessToken === '' || $expiresAt <= time() + 60) { + return null; + } + + return $accessToken; + } + + /** + * @param array $tokens Token data. + * @return array Token data. + */ + private function addEnvironmentTokens(array $tokens): array + { + foreach ($this->tokenSourceMap() as $name => $tokenKey) { + $value = getenv($name); + if ($value !== false) { + $tokens[$tokenKey] = $value; + } + } + + return $tokens; + } + + /** + * @param array $tokens Token data. + * @return array Token data. + */ + private function addConstantTokens(array $tokens): array + { + foreach ($this->tokenSourceMap() as $constantName => $tokenKey) { + if (defined($constantName)) { + $tokens[$tokenKey] = constant($constantName); + } + } + + return $tokens; + } + + /** + * @return array + */ + private function tokenSourceMap(): array + { + return [ + 'AI_PROVIDER_CLAUDE_CODE_ACCESS_TOKEN' => 'access_token', + 'AI_PROVIDER_CLAUDE_CODE_REFRESH_TOKEN' => 'refresh_token', + 'AI_PROVIDER_CLAUDE_CODE_EXPIRES_AT' => 'expires_at', + ]; + } + + /** + * @param array $tokens Raw token data. + * @return ClaudeCodeTokens Sanitized token data. + */ + private function sanitizeTokens(array $tokens): array + { + /** @var ClaudeCodeTokens $sanitized */ + $sanitized = []; + foreach (['access_token', 'refresh_token'] as $key) { + if (isset($tokens[$key]) && is_scalar($tokens[$key])) { + $sanitized[$key] = trim((string) $tokens[$key]); + } + } + + if (isset($tokens['expires_at']) && is_numeric($tokens['expires_at'])) { + $sanitized['expires_at'] = (int) $tokens['expires_at']; + } + + return $sanitized; + } +} diff --git a/carried-plugins/ai-provider-for-claude-code/src/autoload.php b/carried-plugins/ai-provider-for-claude-code/src/autoload.php new file mode 100644 index 0000000..e0aa4bb --- /dev/null +++ b/carried-plugins/ai-provider-for-claude-code/src/autoload.php @@ -0,0 +1,20 @@ +setHttpTransporter( + new class ($capturedRequests) implements HttpTransporterInterface { + /** @var array */ + private array $captured; + + /** + * @param array $captured + */ + public function __construct(array &$captured) + { + $this->captured = &$captured; + } + + public function send(Request $request, ?RequestOptions $options = null): Response + { + $this->captured[] = $request; + + $body = json_encode( + [ + 'id' => 'msg_test', + 'type' => 'message', + 'role' => 'assistant', + 'stop_reason' => 'tool_use', + 'content' => [ + ['type' => 'text', 'text' => 'Writing the file now.'], + [ + 'type' => 'tool_use', + 'id' => 'toolu_123', + 'name' => 'workspace_write', + 'input' => ['path' => 'X.md', 'content' => '# hi'], + ], + ], + 'usage' => ['input_tokens' => 5, 'output_tokens' => 7], + ] + ); + + return new Response(200, ['Content-Type' => 'application/json'], (string) $body); + } + } +); + +putenv('AI_PROVIDER_CLAUDE_CODE_ACCESS_TOKEN=test-access-token'); +putenv('AI_PROVIDER_CLAUDE_CODE_REFRESH_TOKEN=test-refresh-token'); +putenv('AI_PROVIDER_CLAUDE_CODE_EXPIRES_AT=' . (time() + 3600)); + +$registry->registerProvider(ClaudeCodeProvider::class); +$registry->setProviderRequestAuthentication( + ClaudeCodeProvider::class, + new \ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeRequestAuthentication( + new \ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeTokenStore(), + new \ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeOAuthClient( + new \ExtraChill\ClaudeCodeAiProvider\Provider\ClaudeCodeTokenStore() + ) + ) +); + +$check($registry->isProviderConfigured('claude-code') === true, 'provider is configured with OAuth tokens'); + +$model = $registry->getProviderModel('claude-code', 'claude-opus-4-8'); + +$config = ModelConfig::fromArray([]); +$config->setFunctionDeclarations([ + new FunctionDeclaration( + 'workspace_write', + 'Write a file to the workspace.', + [ + 'type' => 'object', + 'properties' => [ + 'path' => ['type' => 'string'], + 'content' => ['type' => 'string'], + ], + 'required' => ['path', 'content'], + ] + ), +]); +$model->setConfig($config); + +// Prompt includes a prior tool call + response to exercise tool_use/tool_result +// serialization, plus a no-argument tool call to verify empty input encodes as `{}`. +$prompt = [ + new UserMessage([new MessagePart('create X.md')]), + new ModelMessage([new MessagePart(new FunctionCall('toolu_prev', 'workspace_ls', ['path' => '.']))]), + new Message( + MessageRoleEnum::user(), + [new MessagePart(new FunctionResponse('toolu_prev', 'workspace_ls', ['files' => ['README.md']]))] + ), + new ModelMessage([new MessagePart(new FunctionCall('toolu_noargs', 'workspace_capabilities', []))]), + new Message( + MessageRoleEnum::user(), + [new MessagePart(new FunctionResponse('toolu_noargs', 'workspace_capabilities', ['ok' => true]))] + ), +]; + +$result = $model->generateTextResult($prompt); + +// --- Assert the outgoing request declared structured tools --- +$check(count($capturedRequests) === 1, 'exactly one request was dispatched'); +$requestData = $capturedRequests[0]->getData(); +$check(is_array($requestData), 'request body is an array'); + +$tools = $requestData['tools'] ?? null; +$check(is_array($tools) && count($tools) === 1, 'request declares one tool'); +$check(($tools[0]['name'] ?? null) === 'workspace_write', 'tool name is forwarded'); +$check(($tools[0]['description'] ?? null) === 'Write a file to the workspace.', 'tool description is forwarded'); +$check( + isset($tools[0]['input_schema']['properties']['path']), + 'tool parameters are forwarded as input_schema' +); + +// --- Assert FunctionCall/FunctionResponse parts became tool_use/tool_result blocks --- +$messages = $requestData['messages'] ?? []; +$blocks = []; +foreach ($messages as $message) { + foreach (($message['content'] ?? []) as $contentBlock) { + $blocks[] = $contentBlock['type'] ?? ''; + } +} +$check(in_array('tool_use', $blocks, true), 'prior FunctionCall serialized as tool_use block'); +$check(in_array('tool_result', $blocks, true), 'prior FunctionResponse serialized as tool_result block'); + +// Anthropic requires tool_use.input to be a JSON object; a no-argument call must +// encode as `{}`, never `[]`. +$noArgsInputJson = null; +foreach ($messages as $message) { + foreach (($message['content'] ?? []) as $contentBlock) { + if (($contentBlock['type'] ?? '') === 'tool_use' && ($contentBlock['name'] ?? '') === 'workspace_capabilities') { + $noArgsInputJson = json_encode($contentBlock['input']); + } + } +} +$check($noArgsInputJson === '{}', 'no-argument tool_use input encodes as an empty object'); + +// --- Assert the response tool_use parsed into a FunctionCall message part --- +$candidates = $result->getCandidates(); +$check(count($candidates) === 1, 'result has one candidate'); +$parts = $candidates[0]->getMessage()->getParts(); +$functionCallPart = null; +foreach ($parts as $part) { + if ($part->getType()->isFunctionCall()) { + $functionCallPart = $part; + break; + } +} +$check($functionCallPart !== null, 'response tool_use parsed into a FunctionCall part'); +if ($functionCallPart !== null) { + $functionCall = $functionCallPart->getFunctionCall(); + $check($functionCall->getName() === 'workspace_write', 'parsed FunctionCall has correct name'); + $check($functionCall->getId() === 'toolu_123', 'parsed FunctionCall preserves id'); + $args = $functionCall->getArgs(); + $check(is_array($args) && ($args['path'] ?? null) === 'X.md', 'parsed FunctionCall has decoded arguments'); +} + +if ($failures > 0) { + fwrite(STDERR, "\n{$failures} structured-tools assertion(s) failed.\n"); + exit(1); +} + +echo "\nOK: {$passes} structured-tools assertions passed.\n"; diff --git a/lib/agents-md-guidance.sh b/lib/agents-md-guidance.sh index 44e7fc5..42dc592 100644 --- a/lib/agents-md-guidance.sh +++ b/lib/agents-md-guidance.sh @@ -203,6 +203,8 @@ agents_md_guidance_homeboy_codebox_content() { **Codex provider:** Codebox Codex tasks need the OpenAI Codex provider plugin/runtime overlay and `AI_PROVIDER_OPENAI_CODEX_*` secrets passed via `secret_env`. Never print token values in logs or task instructions. +**Claude Code provider:** Codebox Claude Code tasks use the `ai-provider-for-claude-code` plugin carried by wp-coding-agents. It is backed by Claude Code OAuth credentials through WP AI Client / PHP AI Client, not the `claude` binary, not an Anthropic API key, and not WP AI Gateway. Provider id: `claude-code`. Provide credentials through `AI_PROVIDER_CLAUDE_CODE_REFRESH_TOKEN` and optional `AI_PROVIDER_CLAUDE_CODE_ACCESS_TOKEN` / `AI_PROVIDER_CLAUDE_CODE_EXPIRES_AT`; never pass Claude subscription/session material through task prompts or logs. + **Operator verbs:** `homeboy release`, `homeboy deploy`, `homeboy fleet`, and `homeboy ssh` are operator actions. Use them only when the user explicitly asks for that action. **Chat bridges:** Kimaki, Discord, and other chat bridges display task status and results while Homeboy remains the source of truth for task state and artifacts. diff --git a/lib/carried-plugins.sh b/lib/carried-plugins.sh new file mode 100644 index 0000000..7b7bedc --- /dev/null +++ b/lib/carried-plugins.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Carried plugins: wp-coding-agents-owned WordPress plugins copied into installs. + +carried_plugin_should_install() { + local slug="$1" + + case "$slug" in + ai-provider-for-claude-code) + for runtime in "${DETECTED_RUNTIMES[@]}"; do + if [ "$runtime" = "claude-code" ]; then + return 0 + fi + done + [ "${RUNTIME:-}" = "claude-code" ] && return 0 + return 1 + ;; + esac + + return 1 +} + +sync_carried_plugins() { + local source_root="$SCRIPT_DIR/carried-plugins" + [ -d "$source_root" ] || return 0 + + local source_dir slug target_dir + for source_dir in "$source_root"/*; do + [ -d "$source_dir" ] || continue + slug=$(basename "$source_dir") + + if ! carried_plugin_should_install "$slug"; then + continue + fi + + target_dir="$SITE_PATH/wp-content/plugins/$slug" + log "Syncing carried plugin: $slug" + + if [ "$DRY_RUN" = true ]; then + echo -e "${BLUE}[dry-run]${NC} mkdir -p $target_dir" + echo -e "${BLUE}[dry-run]${NC} rsync -a --delete $source_dir/ $target_dir/" + echo -e "${BLUE}[dry-run]${NC} $WP_CMD plugin activate $slug --path=$SITE_PATH $WP_ROOT_FLAG" + continue + fi + + if [ -d "$target_dir/.git" ]; then + warn "Carried plugin target $target_dir is a git checkout — skipping sync" + continue + fi + + mkdir -p "$target_dir" + rsync -a --delete "$source_dir/" "$target_dir/" + activate_plugin "$slug" + fix_ownership "$target_dir" + + if declare -p UPDATED_ITEMS >/dev/null 2>&1; then + UPDATED_ITEMS+=("carried plugin $slug") + fi + done +} diff --git a/lib/homeboy.sh b/lib/homeboy.sh index 7657373..fdafdf6 100644 --- a/lib/homeboy.sh +++ b/lib/homeboy.sh @@ -364,5 +364,5 @@ print_homeboy_verification_commands() { echo " homeboy extension show wordpress" echo " homeboy project show " echo " homeboy project components list " - echo " ./scripts/verify-homeboy-codebox-canary.sh --workspace --secret-env --agents-api --agent-runtime --agent-runtime-tools --provider-plugin-path # opt-in model-backed Codebox canary; add --run to execute" + echo " ./scripts/verify-homeboy-codebox-canary.sh --workspace --secret-env --agents-api --agent-runtime --agent-runtime-tools --provider-plugin-path # opt-in model-backed Codebox canary; use --provider claude-code and the carried ai-provider-for-claude-code path for Claude Code; add --run to execute" } diff --git a/scripts/verify-homeboy-codebox-canary.sh b/scripts/verify-homeboy-codebox-canary.sh index 9b36b47..78e2b56 100755 --- a/scripts/verify-homeboy-codebox-canary.sh +++ b/scripts/verify-homeboy-codebox-canary.sh @@ -298,14 +298,6 @@ for secret_env in "${SECRET_ENVS[@]}"; do COMMAND+=(--secret-env "$secret_env") done -if [ -n "$CHANNEL" ]; then - COMMAND+=(--channel "$CHANNEL") -fi - -if [ -n "$THREAD" ]; then - COMMAND+=(--thread "$THREAD") -fi - if [ -n "$MODEL" ]; then COMMAND+=(--model "$MODEL") fi diff --git a/setup.sh b/setup.sh index 354442a..8292b07 100755 --- a/setup.sh +++ b/setup.sh @@ -23,7 +23,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Source shared modules -for lib in common detect wordpress infrastructure data-machine homeboy skills summary cli-channel runtime-signature agents-md-guidance; do +for lib in common detect wordpress infrastructure data-machine carried-plugins homeboy skills summary cli-channel runtime-signature agents-md-guidance; do source "$SCRIPT_DIR/lib/${lib}.sh" done @@ -327,6 +327,7 @@ if [ "$RUNTIME_ONLY" != true ]; then create_service_user install_data_machine create_dm_agent + sync_carried_plugins install_extra_plugins setup_homeboy_project setup_nginx diff --git a/tests/carried-claude-code-plugin.sh b/tests/carried-claude-code-plugin.sh new file mode 100644 index 0000000..abbee80 --- /dev/null +++ b/tests/carried-claude-code-plugin.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# tests/carried-claude-code-plugin.sh — Claude Code runtime syncs its carried provider plugin. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +SITE_PATH="$TMP/site" +PROVIDER_DIR="$SCRIPT_DIR/carried-plugins/ai-provider-for-claude-code" +mkdir -p "$SITE_PATH/wp-content/plugins" + +for required_file in \ + "$PROVIDER_DIR/src/Provider/ClaudeCodeProvider.php" \ + "$PROVIDER_DIR/src/Provider/ClaudeCodeOAuthClient.php" \ + "$PROVIDER_DIR/src/Provider/ClaudeCodeRequestAuthentication.php" \ + "$PROVIDER_DIR/src/Provider/ClaudeCodeTokenStore.php"; do + if [ ! -f "$required_file" ]; then + printf 'Expected Claude Code provider file to exist: %s\n' "$required_file" >&2 + exit 1 + fi +done + +if [ -e "$PROVIDER_DIR/src/Runtime/ClaudeCodeProcess.php" ]; then + printf 'Claude Code provider must use OAuth/API auth, not the local CLI process runtime.\n' >&2 + exit 1 +fi + +export SCRIPT_DIR +export SITE_PATH +export DRY_RUN=true +export IS_STUDIO=false +export MULTISITE=false +export WP_CMD=wp +export WP_ROOT_FLAG= + +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/common.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/wordpress.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/carried-plugins.sh" + +DETECTED_RUNTIMES=(claude-code) +RUNTIME=claude-code + +output="$(sync_carried_plugins)" + +case "$output" in + *"Syncing carried plugin: ai-provider-for-claude-code"*"wp plugin activate ai-provider-for-claude-code"*) ;; + *) + printf 'Expected Claude Code carried plugin sync, got:\n%s\n' "$output" >&2 + exit 1 + ;; +esac + +DETECTED_RUNTIMES=(opencode) +RUNTIME=opencode + +output="$(sync_carried_plugins)" +if [ -n "$output" ]; then + printf 'Expected no carried plugin sync for opencode, got:\n%s\n' "$output" >&2 + exit 1 +fi + +echo "PASS: tests/carried-claude-code-plugin.sh" diff --git a/tests/homeboy-codebox-canary.sh b/tests/homeboy-codebox-canary.sh index 891d3ae..1d1a229 100755 --- a/tests/homeboy-codebox-canary.sh +++ b/tests/homeboy-codebox-canary.sh @@ -93,6 +93,15 @@ assert_contains() { fi } +assert_not_contains() { + local needle="$1" file="$2" + if grep -qF -- "$needle" "$file"; then + echo "FAIL: did not expect '$needle' in $file" + cat "$file" + exit 1 + fi +} + assert_json() { python3 - "$HOMEBOY_CAPTURE_CONFIG" <<'PY' import json @@ -109,6 +118,13 @@ assert config["provider"] == "openai" assert config["model"] == "gpt-4.1-mini" assert config["max_turns"] == 2 assert config["secret_env"] == ["OPENAI_API_KEY"] +assert config["routing"] == { + "channel": "canary-channel", + "thread": "canary-thread", + "client": "discord", + "ui": "kimaki", + "user_required": False, +} assert config["mounts"] == [ { "source": config["workspace_root"], @@ -136,12 +152,16 @@ OUTPUT="$TMP/output.log" --provider-plugin-path "$TMP/provider-openai" \ --model gpt-4.1-mini \ --max-turns 2 \ + --channel canary-channel \ + --thread canary-thread \ > "$OUTPUT" assert_contains "--secret-env" "$HOMEBOY_CAPTURE_ARGS" assert_contains "OPENAI_API_KEY" "$HOMEBOY_CAPTURE_ARGS" assert_contains "--backend" "$HOMEBOY_CAPTURE_ARGS" assert_contains "codebox" "$HOMEBOY_CAPTURE_ARGS" +assert_not_contains "--channel" "$HOMEBOY_CAPTURE_ARGS" +assert_not_contains "--thread" "$HOMEBOY_CAPTURE_ARGS" assert_contains "Canary validation passed" "$OUTPUT" assert_contains "Changed files: 0" "$OUTPUT" assert_json diff --git a/upgrade.sh b/upgrade.sh index 281d691..214ffd8 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -7,7 +7,8 @@ # 1. Detect environment (auto-detects local vs VPS, runtime, chat bridge — # supports kimaki, cc-connect, telegram). # 2. Update setup-installed Data Machine plugins to latest tagged releases, -# and sync WP Codebox (subtree-packaged) to its latest tag when installed. +# sync carried provider plugins, and sync WP Codebox (subtree-packaged) +# to its latest tag when installed. # 3. Sync chat-bridge config (dispatches per bridge) # kimaki: # VPS: /opt/kimaki-config (plugins + post-upgrade.sh + skill filters) @@ -66,7 +67,7 @@ TIMESTAMP="$(date +%Y%m%d-%H%M%S)" # Source shared modules (common, detect needed for environment resolution; # wordpress is needed for wp_cmd helper used by compose and plugin updates). -for lib in common detect wordpress data-machine wp-codebox homeboy skills cli-channel runtime-signature agents-md-guidance; do +for lib in common detect wordpress data-machine carried-plugins wp-codebox homeboy skills cli-channel runtime-signature agents-md-guidance; do source "$SCRIPT_DIR/lib/${lib}.sh" done @@ -176,7 +177,8 @@ NEVER TOUCHED: DEFAULT TOUCHES: - data-machine and data-machine-code — updates setup-installed git checkouts to their latest version tags. Non-git plugin directories are - skipped. Use --skip-plugins to skip this phase. + skipped. Carried provider plugins are synced from this repo when their + runtime is present. Use --skip-plugins to skip this phase. - opencode.json — additive repair. Adds managed plugin entries the user is missing (dm-context-filter.ts and dm-agent-sync.ts on Kimaki bridges) and migrates "agent.build.prompt" to top-level "instructions" @@ -324,6 +326,7 @@ _run_filter_active() { update_data_machine_plugins() { _run_filter_active plugins || return 0 upgrade_data_machine_plugins + sync_carried_plugins update_wp_codebox_plugin_subtree }