diff --git a/README.md b/README.md index e040015..a621f2f 100644 --- a/README.md +++ b/README.md @@ -124,11 +124,11 @@ what contexts might be useful. ctx generate ``` -3. Use with your favorite AI: - -- Copy the generated markdown files to your AI chat -- Or use the built-in MCP server with Claude Desktop -- Or process locally with open-source models +3. Use with your favorite AI: + +- Copy the generated markdown files to your AI chat +- Or use the built-in MCP server with your MCP client (e.g., Claude Desktop, Cursor, Continue, Windsurf) +- Or process locally with open-source models ## Real-World Use Cases @@ -220,58 +220,58 @@ Configuration β†’ Sources β†’ Filters β†’ Modifiers β†’ Output - **Modifiers**: How to transform content (extract signatures, remove comments) - **Output**: Structured markdown ready for AI consumption -## Connect to Claude Desktop (Optional) - -For a more seamless experience, you can connect Context Generator directly to Claude AI using the MCP server. - -```bash -# Auto-detect OS and generate configuration -ctx mcp:config -``` - -This command: - -- πŸ” **Auto-detects your OS** (Windows, Linux, macOS, WSL) -- 🎯 **Generates the right config** for your environment -- πŸ“‹ **Provides copy-paste ready** JSON for Claude Desktop -- 🧭 **Includes setup instructions** and troubleshooting tips - -**Global Registry Mode** (recommended for multiple projects): - -```json -{ - "mcpServers": { - "ctx": { - "command": "ctx", - "args": [ - "server" - ] - } - } -} -``` - -If you prefer manual setup, point the MCP client to the Context Generator server: - -```json -{ - "mcpServers": { - "ctx": { - "command": "ctx", - "args": [ - "server", - "-c", - "/path/to/project" - ] - } - } -} -``` - -> **Note:** Read more about [MCP Server](https://docs.ctxgithub.com/mcp/#setting-up) for detailed setup -> instructions and troubleshooting. - -Now you can ask Claude questions about your codebase without manually uploading context files! +## Connect to an MCP Client (Optional) + +For a more seamless experience, you can connect CTX to any MCP-compatible client using the built-in MCP server. + +```bash +# Interactive setup: detect OS and install config for your client +ctx mcp:config -i +``` + +This command: + +- πŸ” Auto-detects your OS (Windows, Linux, macOS, WSL) +- 🧩 Lets you choose your MCP client (e.g., Claude Desktop, Cursor, Continue, Windsurf) +- 🎯 Generates and optionally installs the correct config for your environment +- πŸ“‹ Provides copy‑paste ready JSON if you prefer manual setup +- 🧭 Includes setup instructions and troubleshooting tips + +**Global Registry Mode** (recommended for multiple projects/clients): + +```json +{ + "mcpServers": { + "ctx": { + "command": "ctx", + "args": [ + "server" + ] + } + } +} +``` + +If you prefer manual setup, point your MCP client to the CTX server: + +```json +{ + "mcpServers": { + "ctx": { + "command": "ctx", + "args": [ + "server", + "-c", + "/path/to/project" + ] + } + } +} +``` + +> Note: Read more about the [MCP server](https://docs.ctxgithub.com/mcp/#setting-up) for detailed setup instructions and troubleshooting. Specific config file locations vary by client. + +Now you can use your preferred MCP client (including Claude Desktop) to ask questions about your codebase without manually uploading context files. ## Custom Tools diff --git a/src/McpServer/Console/McpConfigCommand.php b/src/McpServer/Console/McpConfigCommand.php index 7fa8345..d870f9d 100644 --- a/src/McpServer/Console/McpConfigCommand.php +++ b/src/McpServer/Console/McpConfigCommand.php @@ -9,6 +9,7 @@ use Butschster\ContextGenerator\McpServer\McpConfig\ConfigGeneratorInterface; use Butschster\ContextGenerator\McpServer\McpConfig\Renderer\McpConfigRenderer; use Butschster\ContextGenerator\McpServer\McpConfig\Service\OsDetectionService; +use Butschster\ContextGenerator\McpServer\McpConfig\Client\ClientStrategyRegistry; use Spiral\Console\Attribute\Option; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -43,7 +44,7 @@ final class McpConfigCommand extends BaseCommand #[Option( name: 'client', shortcut: 'c', - description: 'MCP client type (claude, generic)', + description: 'MCP client type (claude, codex, cursor, generic)', )] protected string $client = 'generic'; @@ -80,20 +81,22 @@ public function __invoke( // Determine configuration approach $options = $this->buildConfigOptions($dirs); - // Generate configuration + // Resolve selected client strategy + $registry = new ClientStrategyRegistry(); + $strategy = $registry->getByKey($this->client) ?? $registry->getDefault(); + + // Generate configuration via vendor generator (supports claude/generic) $config = $configGenerator->generate( - client: $this->client, + client: $strategy->getGeneratorClientKey(), osInfo: $osInfo, projectPath: $options['project_path'] ?? (string) $dirs->getRootPath(), options: $options, ); - // Render the configuration - $renderer->renderConfiguration($config, $osInfo, $options); - - // Show explanations if requested + // Render using strategy + $strategy->renderConfiguration($renderer, $config, $osInfo, $options, $this->output); if ($this->explain) { - $renderer->renderExplanation($config, $osInfo, $options); + $strategy->renderExplanation($renderer, $config, $osInfo, $options, $this->output); } return Command::SUCCESS; @@ -108,12 +111,23 @@ private function runInteractiveMode( $renderer->renderInteractiveWelcome(); // Ask about client type - $clientType = $this->output->choice( + $registry = new ClientStrategyRegistry(); + + // Build interactive choices. We pass human labels for display, but + // also accept typed keys (e.g. "codex") as valid input. + $choice = $this->output->choice( 'Which MCP client are you configuring?', - ['claude' => 'Claude Desktop', 'generic' => 'Generic MCP Client'], - 'generic', + $registry->getChoiceLabels(), + $registry->getDefault()->getLabel(), ); + // Resolve strategy by label first, then by key (case-insensitive). + // This fixes a bug where typing a key like "codex" fell back to the + // default (Claude) because we only matched by label. + $strategy = $registry->getByLabel($choice) + ?? $registry->getByKey(\strtolower(\trim((string) $choice))) + ?? $registry->getDefault(); + // Auto-detect OS $osInfo = $osDetection->detect(); $renderer->renderDetectedEnvironment($osInfo); @@ -174,14 +188,14 @@ private function runInteractiveMode( // Generate and display configuration $config = $configGenerator->generate( - client: $clientType, + client: $strategy->getGeneratorClientKey(), osInfo: $osInfo, projectPath: $projectPath, options: $options, ); - $renderer->renderConfiguration($config, $osInfo, $options); - $renderer->renderExplanation($config, $osInfo, $options); + $strategy->renderConfiguration($renderer, $config, $osInfo, $options, $this->output); + $strategy->renderExplanation($renderer, $config, $osInfo, $options, $this->output); return Command::SUCCESS; } diff --git a/src/McpServer/McpConfig/Client/AbstractClientStrategy.php b/src/McpServer/McpConfig/Client/AbstractClientStrategy.php new file mode 100644 index 0000000..c7e4c02 --- /dev/null +++ b/src/McpServer/McpConfig/Client/AbstractClientStrategy.php @@ -0,0 +1,39 @@ +renderConfiguration($config, $osInfo, $options); + } + + public function renderExplanation( + McpConfigRenderer $renderer, + McpConfig $config, + OsInfo $osInfo, + array $options, + SymfonyStyle $output, + ): void { + // Default explanation delegates to shared renderer + $renderer->renderExplanation($config, $osInfo, $options); + + $this->renderAdditionalNotes($output, $osInfo, $options); + } + + protected function renderAdditionalNotes(SymfonyStyle $output, OsInfo $osInfo, array $options): void {} +} diff --git a/src/McpServer/McpConfig/Client/ClaudeDesktopClientStrategy.php b/src/McpServer/McpConfig/Client/ClaudeDesktopClientStrategy.php new file mode 100644 index 0000000..69134a5 --- /dev/null +++ b/src/McpServer/McpConfig/Client/ClaudeDesktopClientStrategy.php @@ -0,0 +1,23 @@ + */ + private array $strategies; + + public function __construct() + { + $this->strategies = []; + + $this->register(new ClaudeDesktopClientStrategy()); + $this->register(new CodexClientStrategy()); + $this->register(new CursorClientStrategy()); + $this->register(new GenericClientStrategy()); + } + + public function register(ClientStrategyInterface $strategy): void + { + $this->strategies[$strategy->getKey()] = $strategy; + } + + public function getByKey(string $key): ?ClientStrategyInterface + { + $key = \strtolower($key); + return $this->strategies[$key] ?? null; + } + + public function getByLabel(string $label): ?ClientStrategyInterface + { + foreach ($this->strategies as $strategy) { + if ($strategy->getLabel() === $label) { + return $strategy; + } + } + return null; + } + + /** + * @return string[] Human-friendly labels for interactive choice + */ + public function getChoiceLabels(): array + { + return \array_map(static fn(ClientStrategyInterface $s) => $s->getLabel(), $this->strategies); + } + + public function getDefault(): ClientStrategyInterface + { + return $this->strategies['claude']; + } +} diff --git a/src/McpServer/McpConfig/Client/CodexClientStrategy.php b/src/McpServer/McpConfig/Client/CodexClientStrategy.php new file mode 100644 index 0000000..305f9cc --- /dev/null +++ b/src/McpServer/McpConfig/Client/CodexClientStrategy.php @@ -0,0 +1,77 @@ +section('Generated Configuration'); + $output->text('Codex configuration (TOML):'); + $output->newLine(); + + $args = \array_map(static fn(string $arg): string => + // Basic escaping for double quotes + '"' . \str_replace('"', '\\"', $arg) . '"', $config->args); + + $toml = "[mcp_servers.ctx]\n" + . "command = \"{$config->command}\"\n" + . 'args = [' . \implode(', ', $args) . "]\n"; + + $output->writeln('' . $toml . ''); + $output->newLine(); + } + + #[\Override] + public function renderExplanation( + McpConfigRenderer $renderer, + McpConfig $config, + OsInfo $osInfo, + array $options, + SymfonyStyle $output, + ): void { + $output->section('Setup Instructions'); + + $steps = [ + '1. Copy the TOML snippet above to your Codex MCP client configuration.', + '2. Ensure the `ctx` binary is available in your PATH.', + '3. Restart the Codex client and verify the MCP server connection.', + ]; + + foreach ($steps as $step) { + $output->text(' ' . $step); + } + + $output->newLine(); + } +} diff --git a/src/McpServer/McpConfig/Client/CursorClientStrategy.php b/src/McpServer/McpConfig/Client/CursorClientStrategy.php new file mode 100644 index 0000000..3fd0afa --- /dev/null +++ b/src/McpServer/McpConfig/Client/CursorClientStrategy.php @@ -0,0 +1,38 @@ +note([ + 'Cursor setup:', + 'β€’ Use the generated Generic configuration to point Cursor to the CTX MCP server.', + 'β€’ Check Cursor documentation for where to place MCP server settings.', + 'β€’ Ensure the `ctx` command is available on your PATH.', + ]); + } +} diff --git a/src/McpServer/McpConfig/Client/GenericClientStrategy.php b/src/McpServer/McpConfig/Client/GenericClientStrategy.php new file mode 100644 index 0000000..74b3323 --- /dev/null +++ b/src/McpServer/McpConfig/Client/GenericClientStrategy.php @@ -0,0 +1,23 @@ +runMcpConfig(['--client' => 'claude']); + + $this->assertStringContainsString('Generated Configuration', $out); + $this->assertStringContainsString('Configuration type: claude', $out); + $this->assertStringContainsString('"mcpServers"', $out); // JSON config key for Claude Desktop + $this->assertStringContainsString('Claude Desktop configuration file location', $out); + } + + #[Test] + public function codex_client_outputs_toml_snippet(): void + { + $out = $this->runMcpConfig(['--client' => 'codex']); + + $this->assertStringContainsString('Generated Configuration', $out); + $this->assertStringContainsString('Codex configuration (TOML):', $out); + $this->assertStringContainsString('[mcp_servers.ctx]', $out); + $this->assertStringContainsString('command = "ctx"', $out); + $this->assertStringContainsString('args = ["server"]', $out); + } + + #[Test] + public function cursor_client_uses_generic_renderer(): void + { + $out = $this->runMcpConfig(['--client' => 'cursor']); + + $this->assertStringContainsString('Generated Configuration', $out); + $this->assertStringContainsString('Configuration type: generic', $out); + $this->assertStringContainsString('Generic MCP client configuration', $out); + $this->assertStringContainsString('"command": "ctx"', $out); + $this->assertStringContainsString('"args": [', $out); + } + + #[Test] + public function generic_client_outputs_generic_configuration(): void + { + $out = $this->runMcpConfig(['--client' => 'generic']); + + $this->assertStringContainsString('Generated Configuration', $out); + $this->assertStringContainsString('Configuration type: generic', $out); + $this->assertStringContainsString('Generic MCP client configuration', $out); + } + + private function runMcpConfig(array $args = [], ?int $verbosity = null): string + { + /** @var Console $console */ + $console = $this->getConsole(); + + // Prepare explicit input/output so our BaseCommand receives SymfonyStyle + $input = new ArrayInput($args); + $input->setInteractive(false); + + $buffer = new BufferedOutput(); + /** @psalm-suppress ArgumentTypeCoercion */ + $buffer->setVerbosity($verbosity ?? OutputInterface::VERBOSITY_NORMAL); + + $style = new SymfonyStyle($input, $buffer); + + $console->run('mcp:config', $input, $style); + + return $buffer->fetch(); + } +} diff --git a/tests/src/Feature/Console/McpConfig/McpConfigInteractiveTest.php b/tests/src/Feature/Console/McpConfig/McpConfigInteractiveTest.php new file mode 100644 index 0000000..974a278 --- /dev/null +++ b/tests/src/Feature/Console/McpConfig/McpConfigInteractiveTest.php @@ -0,0 +1,83 @@ +runInteractive([ + 'codex', // Which MCP client + '', // Project access -> default (global) + '', // Env vars? default (no) + ]); + + $this->assertStringContainsString('Generated Configuration', $out); + $this->assertStringContainsString('Codex configuration (TOML):', $out); + $this->assertStringContainsString('[mcp_servers.ctx]', $out); + $this->assertStringContainsString('command = "ctx"', $out); + $this->assertStringContainsString('args = ["server"]', $out); + } + + private function runInteractive(array $answers, array $args = []): string + { + $console = $this->getConsole(); + + // Prepare interactive input with a stream for answers + $argv = ['console', 'mcp:config', '--interactive']; + foreach ($args as $k => $v) { + if (\is_bool($v)) { + if ($v === true) { + $argv[] = $k; + } + } else { + $argv[] = $k . '=' . $v; + } + } + + $input = new ArgvInput($argv); + $input->setInteractive(true); + + $stream = \fopen('php://memory', 'rb+'); + \fwrite($stream, \implode("\n", $answers) . "\n"); + \rewind($stream); + $input->setStream($stream); + + // Use SymfonyStyle so BaseCommand assertion passes; capture output via buffer + $buffer = new BufferedOutput(); + /** @psalm-suppress ArgumentTypeCoercion */ + $buffer->setVerbosity(OutputInterface::VERBOSITY_NORMAL); + $style = new SymfonyStyle($input, $buffer); + + // Avoid stty-based interactive handling on non-tty streams + QuestionHelper::disableStty(); + + // Run via Console but avoid InputProxy by passing null command name + $prev = \getenv('SHELL_INTERACTIVE'); + \putenv('SHELL_INTERACTIVE=1'); + try { + $console->run(null, $input, $style); + } finally { + if ($prev === false) { + \putenv('SHELL_INTERACTIVE'); + } else { + \putenv('SHELL_INTERACTIVE=' . $prev); + } + } + + return $buffer->fetch(); + } +}