diff --git a/docs/guides/overview.md b/docs/guides/overview.md new file mode 100644 index 0000000..6f8cdd6 --- /dev/null +++ b/docs/guides/overview.md @@ -0,0 +1,23 @@ +# LIT MCP – Übersicht + +Dieser Server pflegt eine WordPress-Website. Alle Änderungen laufen mit den Rechten +des verbundenen WordPress-Benutzers. + +## Grundregeln + +1. **Divi-nativ:** Inhalte werden als echte Divi-5-Module erzeugt, damit sie im Visual + Builder weiter bearbeitet werden können. Das Code-Modul nur verwenden, wenn kein + natives Modul passt. +2. **Designsystem nutzen:** Globale Farben, Variablen und Presets der Website vor festen + Werten verwenden. +3. **Status respektieren:** Entwürfe bleiben Entwürfe, veröffentlichte Inhalte bleiben + veröffentlicht. Veröffentlichen nur auf ausdrücklichen Wunsch. +4. **Lesen vor Schreiben:** Vor Änderungen den aktuellen Stand lesen; Schreib-Tools + verlangen den `rev` des letzten Lesevorgangs. + +## Verfügbare Werkzeuge (Etappe 0) + +- `lit-guide` – diese Anleitungen. Weitere Themen: siehe Feld `topics`. +- `lit-site-status` – Überblick über Website, aktive Integrationen und eigene Rechte. + +Weitere Werkzeuge kommen mit den nächsten Ausbaustufen hinzu. diff --git a/src/Guides/GuideRepository.php b/src/Guides/GuideRepository.php new file mode 100644 index 0000000..e9b3f4b --- /dev/null +++ b/src/Guides/GuideRepository.php @@ -0,0 +1,51 @@ + */ + public function topics(): array + { + $files = glob($this->directory . '/*.md'); + if ($files === false) { + return []; + } + + $topics = []; + foreach ($files as $file) { + $topic = basename($file, '.md'); + if (preg_match(self::TOPIC_PATTERN, $topic) === 1) { + $topics[] = $topic; + } + } + sort($topics); + + return $topics; + } + + public function get(string $topic): ?string + { + if (preg_match(self::TOPIC_PATTERN, $topic) !== 1 || ! in_array($topic, $this->topics(), true)) { + return null; + } + + $path = $this->directory . '/' . $topic . '.md'; + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $content = file_get_contents($path); + + return $content === false ? null : $content; + } +} diff --git a/src/Guides/InstructionsBuilder.php b/src/Guides/InstructionsBuilder.php new file mode 100644 index 0000000..0594612 --- /dev/null +++ b/src/Guides/InstructionsBuilder.php @@ -0,0 +1,40 @@ + $integration->id(), + $this->registry->available() + )); + + return implode("\n", [ + 'LIT MCP pflegt diese WordPress-Website (Inhalte, Medien, Divi 5, Pods).', + 'Aktive Integrationen: ' . $active . '.', + 'Regeln:', + '- Vor dem ersten Arbeitsschritt lit-guide (ohne Topic) aufrufen; Details je Thema über lit-guide ' + . 'mit topic.', + '- Änderungen erzeugen native Divi-Module; Variablen und Presets vor festen Werten; Code-Modul nur, ' + . 'wenn kein natives Modul passt.', + '- Den Status (Entwurf/veröffentlicht) nie ändern, außer der Nutzer verlangt es ausdrücklich.', + '- Schreib-Tools verlangen den aktuellen rev-Wert aus dem letzten Lesevorgang.', + '- Fehler enthalten einen hint – ihm folgen, statt blind zu wiederholen.', + ]); + } +} diff --git a/src/Integrations/Core/GuideAbility.php b/src/Integrations/Core/GuideAbility.php new file mode 100644 index 0000000..c2bfbfc --- /dev/null +++ b/src/Integrations/Core/GuideAbility.php @@ -0,0 +1,94 @@ + 'object', + 'properties' => [ + 'topic' => [ + 'type' => 'string', + 'description' => 'Thema, z. B. "overview" oder "divi-shortform".', + ], + ], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + return [ + 'type' => 'object', + 'properties' => [ + 'topic' => ['type' => 'string'], + 'topics' => ['type' => 'array', 'items' => ['type' => 'string']], + 'content' => ['type' => 'string'], + ], + 'required' => ['topic', 'topics', 'content'], + ]; + } + + public function annotations(): array + { + return ['readonly' => true, 'destructive' => false, 'idempotent' => true]; + } + + public function isDirect(): bool + { + return true; + } + + public function permission(array $input): bool|WP_Error + { + return true; + } + + public function execute(array $input): mixed + { + $topic = isset($input['topic']) && is_string($input['topic']) && $input['topic'] !== '' + ? $input['topic'] + : self::DEFAULT_TOPIC; + $topics = $this->guides->topics(); + $content = $this->guides->get($topic); + + if ($content === null) { + return Errors::notFound(sprintf( + 'Unbekanntes Thema. Verfügbar: %s', + implode(', ', $topics) + )); + } + + return ['topic' => $topic, 'topics' => $topics, 'content' => $content]; + } +} diff --git a/tests/Fixtures/guides/divi-shortform.md b/tests/Fixtures/guides/divi-shortform.md new file mode 100644 index 0000000..12de54a --- /dev/null +++ b/tests/Fixtures/guides/divi-shortform.md @@ -0,0 +1,2 @@ +# Divi-Kurzform +Testinhalt Kurzform. diff --git a/tests/Fixtures/guides/overview.md b/tests/Fixtures/guides/overview.md new file mode 100644 index 0000000..6ac5c5d --- /dev/null +++ b/tests/Fixtures/guides/overview.md @@ -0,0 +1,2 @@ +# Übersicht +Testinhalt Übersicht. diff --git a/tests/Unit/Guides/GuideRepositoryTest.php b/tests/Unit/Guides/GuideRepositoryTest.php new file mode 100644 index 0000000..fd9450d --- /dev/null +++ b/tests/Unit/Guides/GuideRepositoryTest.php @@ -0,0 +1,55 @@ +guides = new GuideRepository(dirname(__DIR__, 2) . '/Fixtures/guides'); + } + + public function testListsTopicsSortedWithoutExtension(): void + { + self::assertSame(['divi-shortform', 'overview'], $this->guides->topics()); + } + + public function testReturnsMarkdownOfKnownTopic(): void + { + self::assertStringContainsString('Testinhalt Kurzform', (string) $this->guides->get('divi-shortform')); + } + + /** + * @dataProvider maliciousTopics + */ + public function testRejectsAnythingThatIsNotAPlainTopicName(string $topic): void + { + self::assertNull($this->guides->get($topic)); + } + + /** @return array */ + public static function maliciousTopics(): array + { + return [ + 'traversal' => ['../../wp-config'], + 'with extension' => ['overview.md'], + 'empty' => [''], + 'absolute' => ['/etc/passwd'], + 'unknown' => ['does-not-exist'], + 'uppercase' => ['OVERVIEW'], + ]; + } + + public function testMissingDirectoryYieldsNoTopics(): void + { + self::assertSame([], (new GuideRepository('/nonexistent/dir'))->topics()); + } +} diff --git a/tests/Unit/Guides/InstructionsBuilderTest.php b/tests/Unit/Guides/InstructionsBuilderTest.php new file mode 100644 index 0000000..587ce69 --- /dev/null +++ b/tests/Unit/Guides/InstructionsBuilderTest.php @@ -0,0 +1,28 @@ +allows('id')->andReturn('core'); + $core->allows('isAvailable')->andReturn(true); + + $text = (new InstructionsBuilder(new IntegrationRegistry($core)))->build(); + + self::assertStringContainsString('Aktive Integrationen: core', $text); + self::assertStringContainsString('lit-guide', $text); + self::assertStringContainsString('Status', $text); + self::assertLessThan(2000, strlen($text), 'Instructions must stay short – they are loaded in every conversation.'); + } +} diff --git a/tests/Unit/Integrations/Core/GuideAbilityTest.php b/tests/Unit/Integrations/Core/GuideAbilityTest.php new file mode 100644 index 0000000..24c9387 --- /dev/null +++ b/tests/Unit/Integrations/Core/GuideAbilityTest.php @@ -0,0 +1,53 @@ +ability = new GuideAbility(new GuideRepository(dirname(__DIR__, 3) . '/Fixtures/guides')); + } + + public function testWithoutTopicReturnsOverviewAndTopicList(): void + { + $result = $this->ability->execute([]); + + self::assertSame('overview', $result['topic']); + self::assertSame(['divi-shortform', 'overview'], $result['topics']); + self::assertStringContainsString('Testinhalt Übersicht', $result['content']); + } + + public function testReturnsRequestedTopic(): void + { + $result = $this->ability->execute(['topic' => 'divi-shortform']); + + self::assertStringContainsString('Testinhalt Kurzform', $result['content']); + } + + public function testUnknownTopicIsNotFoundErrorListingTopics(): void + { + $result = $this->ability->execute(['topic' => '../../wp-config']); + + self::assertInstanceOf(WP_Error::class, $result); + self::assertSame('lit_not_found', $result->get_error_code()); + self::assertStringContainsString('divi-shortform', $result->get_error_message()); + } + + public function testIsDirectReadOnlyTool(): void + { + self::assertSame('lit/guide', $this->ability->name()); + self::assertTrue($this->ability->isDirect()); + self::assertTrue($this->ability->annotations()['readonly']); + } +}