diff --git a/README.md b/README.md index e55c56b..39e0379 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ Available commands: project:releases Lists available releases skill skill:install Installs the drupalorg-cli discovery skill into .claude/skills/ in the current directory. - skill:get Outputs current skill content for agent consumption. + skill:get Outputs current skill content for agent consumption. Lists available skills when no name is given. ```` ## GitLab work items @@ -182,9 +182,11 @@ Both methods install a discovery stub into `.claude/skills/drupalorg-cli/`. The | `drupalorg-issue-search` | Search issues across API, Drupal.org scrape, and web | | `drupalorg-issue-summary-update` | Analyse and draft updated issue summaries | -Fetch any skill on demand: +List the skills bundled with your installed version, or fetch one on demand: ```bash +drupalorg skill:get # list available skills +drupalorg skill:get --format=json # same list as json (md and llm also supported) drupalorg skill:get drupalorg-cli drupalorg skill:get drupalorg-work-on-issue ``` diff --git a/skill-data/drupalorg-cli/SKILL.md b/skill-data/drupalorg-cli/SKILL.md index 029ff5a..791de5d 100644 --- a/skill-data/drupalorg-cli/SKILL.md +++ b/skill-data/drupalorg-cli/SKILL.md @@ -211,6 +211,12 @@ drupalorg maintainer:release-notes [ref2] [--format=json|md|html] ```bash # Install the drupalorg-cli agent skill into .claude/skills/drupalorg-cli/ drupalorg skill:install + +# List the skills bundled with the installed CLI (name + description) +drupalorg skill:get --format=llm + +# Output a skill's content; --full appends its reference files +drupalorg skill:get [--full] ``` ## Cache Bypass diff --git a/skills/drupalorg-cli/SKILL.md b/skills/drupalorg-cli/SKILL.md index 2bfa923..aaf66b9 100644 --- a/skills/drupalorg-cli/SKILL.md +++ b/skills/drupalorg-cli/SKILL.md @@ -32,6 +32,7 @@ so instructions never go stale. ## Specialized skills ```bash +drupalorg skill:get --format=llm # list every bundled skill with its description drupalorg skill:get drupalorg-work-on-issue # end-to-end GitLab MR contribution workflow drupalorg skill:get drupalorg-issue-search # search issues across API, scrape, and web drupalorg skill:get drupalorg-issue-summary-update # analyse and update issue summaries diff --git a/src/Api/Action/Skill/ListSkillsAction.php b/src/Api/Action/Skill/ListSkillsAction.php new file mode 100644 index 0000000..57ea63a --- /dev/null +++ b/src/Api/Action/Skill/ListSkillsAction.php @@ -0,0 +1,95 @@ +skillsRoot = $resolved === false ? $skillsRoot : $resolved; + } + + public function __invoke(): SkillListResult + { + if (!is_dir($this->skillsRoot)) { + return new SkillListResult(skills: []); + } + + $skills = []; + foreach (new \DirectoryIterator($this->skillsRoot) as $dir) { + if ($dir->isDot() || !$dir->isDir()) { + continue; + } + $skillFile = $dir->getPathname() . '/SKILL.md'; + if (!is_file($skillFile)) { + continue; + } + $content = file_get_contents($skillFile); + if ($content === false) { + continue; + } + $frontmatter = self::parseFrontmatter($content); + $skills[] = new SkillItem( + name: $frontmatter['name'] ?? $dir->getFilename(), + description: $frontmatter['description'] ?? '', + path: $skillFile, + ); + } + + usort($skills, static fn(SkillItem $a, SkillItem $b) => strcmp($a->name, $b->name)); + + return new SkillListResult(skills: $skills); + } + + /** + * Reads top-level scalar keys from a SKILL.md frontmatter block. + * + * Supports plain `key: value` pairs and block scalars (`key: >` or + * `key: |`) whose indented continuation lines are joined into one line. + * + * @return array + */ + private static function parseFrontmatter(string $content): array + { + if (preg_match('/\A---\R(.*?)\R---(?:\R|\z)/s', $content, $matches) !== 1) { + return []; + } + + $values = []; + $currentKey = null; + $lines = preg_split('/\R/', $matches[1]); + if ($lines === false) { + return []; + } + foreach ($lines as $line) { + if (preg_match('/^([A-Za-z0-9_-]+):\s*(.*)$/', $line, $pair) === 1) { + $currentKey = $pair[1]; + $value = trim($pair[2]); + $values[$currentKey] = in_array($value, ['>', '|', '>-', '|-'], true) ? '' : $value; + continue; + } + if ($currentKey !== null && trim($line) !== '') { + $values[$currentKey] = trim($values[$currentKey] . ' ' . trim($line)); + } + } + + return $values; + } +} diff --git a/src/Api/Result/Skill/SkillItem.php b/src/Api/Result/Skill/SkillItem.php new file mode 100644 index 0000000..9407857 --- /dev/null +++ b/src/Api/Result/Skill/SkillItem.php @@ -0,0 +1,22 @@ + $this->name, + 'description' => $this->description, + 'path' => $this->path, + ]; + } +} diff --git a/src/Api/Result/Skill/SkillListResult.php b/src/Api/Result/Skill/SkillListResult.php new file mode 100644 index 0000000..1904c8e --- /dev/null +++ b/src/Api/Result/Skill/SkillListResult.php @@ -0,0 +1,26 @@ + array_map( + static fn(SkillItem $skill) => $skill->jsonSerialize(), + $this->skills + ), + ]; + } +} diff --git a/src/Cli/Command/Skill/Get.php b/src/Cli/Command/Skill/Get.php index a895cde..9b96c04 100644 --- a/src/Cli/Command/Skill/Get.php +++ b/src/Cli/Command/Skill/Get.php @@ -4,7 +4,10 @@ namespace mglaman\DrupalOrgCli\Command\Skill; +use mglaman\DrupalOrg\Action\Skill\ListSkillsAction; +use mglaman\DrupalOrg\Result\Skill\SkillItem; use mglaman\DrupalOrgCli\Command\Command; +use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -16,19 +19,34 @@ protected function configure(): void { $this ->setName('skill:get') - ->setDescription('Outputs current skill content for agent consumption.') - ->addArgument('name', InputArgument::REQUIRED, 'Skill name (e.g. drupalorg-cli)') - ->addOption('full', null, InputOption::VALUE_NONE, 'Include reference files'); + ->setDescription('Outputs current skill content for agent consumption. Lists available skills when no name is given.') + ->addArgument('name', InputArgument::OPTIONAL, 'Skill name (e.g. drupalorg-cli). Omit to list available skills.') + ->addOption('full', null, InputOption::VALUE_NONE, 'Include reference files') + ->addOption( + 'format', + 'f', + InputOption::VALUE_OPTIONAL, + 'Output options for the skill list: text, json, md, llm. Defaults to text.', + 'text' + ); } protected function execute(InputInterface $input, OutputInterface $output): int { - $name = (string) $input->getArgument('name'); - $skillFile = __DIR__ . '/../../../../skill-data/' . $name . '/SKILL.md'; + $name = $input->getArgument('name'); + if ($name === null || $name === '') { + return $this->listSkills((string) $input->getOption('format')); + } + $name = (string) $name; + + $skillFile = ListSkillsAction::DEFAULT_SKILLS_ROOT . '/' . $name . '/SKILL.md'; if (!is_file($skillFile)) { $this->stdErr->writeln(sprintf('Skill not found: %s', $name)); - $available = $this->getAvailableSkills(); + $available = array_map( + static fn(SkillItem $skill) => $skill->name, + (new ListSkillsAction())()->skills + ); if ($available !== []) { $this->stdErr->writeln('Available skills: ' . implode(', ', $available)); } @@ -44,7 +62,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->stdOut->write($content); if ((bool) $input->getOption('full')) { - $refDir = __DIR__ . '/../../../../skill-data/' . $name . '/references'; + $refDir = ListSkillsAction::DEFAULT_SKILLS_ROOT . '/' . $name . '/references'; if (is_dir($refDir)) { foreach (new \DirectoryIterator($refDir) as $fileInfo) { if ($fileInfo->isDot() || !$fileInfo->isFile() || $fileInfo->getExtension() !== 'md') { @@ -66,23 +84,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } - /** - * @return string[] - */ - private function getAvailableSkills(): array + private function listSkills(string $format): int { - $skillsRoot = __DIR__ . '/../../../../skill-data'; - if (!is_dir($skillsRoot)) { - return []; + $result = (new ListSkillsAction())(); + + if ($this->writeFormatted($result, $format)) { + return 0; } - $skills = []; - foreach (new \DirectoryIterator($skillsRoot) as $dir) { - if ($dir->isDot() || !$dir->isDir()) { - continue; - } - $skills[] = $dir->getFilename(); + + $table = new Table($this->stdOut); + $table->setHeaders(['Skill', 'Description']); + $table->setColumnMaxWidth(1, 80); + foreach ($result->skills as $skill) { + $table->addRow([$skill->name, $skill->description]); } - sort($skills); - return $skills; + $table->render(); + $this->stdOut->writeln(''); + $this->stdOut->writeln('Run: drupalorg skill:get '); + + return 0; } } diff --git a/src/Cli/Formatter/AbstractFormatter.php b/src/Cli/Formatter/AbstractFormatter.php index 826910c..be55aff 100644 --- a/src/Cli/Formatter/AbstractFormatter.php +++ b/src/Cli/Formatter/AbstractFormatter.php @@ -16,6 +16,7 @@ use mglaman\DrupalOrg\Result\Project\ProjectIssuesResult; use mglaman\DrupalOrg\Result\Project\ProjectReleasesResult; use mglaman\DrupalOrg\Result\ResultInterface; +use mglaman\DrupalOrg\Result\Skill\SkillListResult; abstract class AbstractFormatter implements FormatterInterface { @@ -35,6 +36,7 @@ final public function format(ResultInterface $result): string $result instanceof GitLabIssueResult => $this->formatGitLabIssue($result), $result instanceof GitLabIssuesResult => $this->formatGitLabIssues($result), $result instanceof SlashCommandResult => $this->formatSlashCommand($result), + $result instanceof SkillListResult => $this->formatSkillList($result), default => throw new \InvalidArgumentException( sprintf('Unsupported result type: %s', get_class($result)) ), @@ -54,4 +56,5 @@ abstract protected function formatMergeRequestDiff(MergeRequestDiffResult $resul abstract protected function formatGitLabIssue(GitLabIssueResult $result): string; abstract protected function formatGitLabIssues(GitLabIssuesResult $result): string; abstract protected function formatSlashCommand(SlashCommandResult $result): string; + abstract protected function formatSkillList(SkillListResult $result): string; } diff --git a/src/Cli/Formatter/LlmFormatter.php b/src/Cli/Formatter/LlmFormatter.php index 287c58b..990099a 100644 --- a/src/Cli/Formatter/LlmFormatter.php +++ b/src/Cli/Formatter/LlmFormatter.php @@ -16,6 +16,7 @@ use mglaman\DrupalOrg\Result\Issue\IssueSearchResult; use mglaman\DrupalOrg\Result\Project\ProjectIssuesResult; use mglaman\DrupalOrg\Result\Project\ProjectReleasesResult; +use mglaman\DrupalOrg\Result\Skill\SkillListResult; class LlmFormatter extends AbstractFormatter { @@ -318,6 +319,20 @@ protected function formatSlashCommand(SlashCommandResult $result): string XML; } + protected function formatSkillList(SkillListResult $result): string + { + $items = ''; + foreach ($result->skills as $skill) { + $name = $this->xmlEscape($skill->name); + $description = $this->xmlEscape($skill->description); + $items .= " \n"; + $items .= " {$name}\n"; + $items .= " {$description}\n"; + $items .= " \n"; + } + return "\n drupalorg skill:get <name>\n \n{$items} \n"; + } + private function toIso8601(int $timestamp): string { return (new \DateTimeImmutable())->setTimestamp($timestamp)->format(\DateTimeInterface::ATOM); diff --git a/src/Cli/Formatter/MarkdownFormatter.php b/src/Cli/Formatter/MarkdownFormatter.php index 5e766b5..e673762 100644 --- a/src/Cli/Formatter/MarkdownFormatter.php +++ b/src/Cli/Formatter/MarkdownFormatter.php @@ -16,6 +16,7 @@ use mglaman\DrupalOrg\Result\Issue\IssueSearchResult; use mglaman\DrupalOrg\Result\Project\ProjectIssuesResult; use mglaman\DrupalOrg\Result\Project\ProjectReleasesResult; +use mglaman\DrupalOrg\Result\Skill\SkillListResult; class MarkdownFormatter extends AbstractFormatter { @@ -242,4 +243,19 @@ protected function formatSlashCommand(SlashCommandResult $result): string $result->noteId, ); } + + protected function formatSkillList(SkillListResult $result): string + { + $lines = []; + $lines[] = '# Available skills'; + $lines[] = ''; + $lines[] = '| Skill | Description |'; + $lines[] = '|---|---|'; + foreach ($result->skills as $skill) { + $lines[] = "| `{$skill->name}` | {$skill->description} |"; + } + $lines[] = ''; + $lines[] = 'Run `drupalorg skill:get ` to read a skill.'; + return implode("\n", $lines); + } } diff --git a/tests/src/Action/Skill/ListSkillsActionTest.php b/tests/src/Action/Skill/ListSkillsActionTest.php new file mode 100644 index 0000000..47129d3 --- /dev/null +++ b/tests/src/Action/Skill/ListSkillsActionTest.php @@ -0,0 +1,65 @@ +skills); + + $names = array_map(static fn(SkillItem $skill) => $skill->name, $result->skills); + $sorted = $names; + sort($sorted); + self::assertSame($sorted, $names); + self::assertContains('drupalorg-cli', $names); + + foreach ($result->skills as $skill) { + self::assertNotSame('', $skill->description, "Skill {$skill->name} has no description"); + self::assertStringNotContainsString("\n", $skill->description); + self::assertStringEndsWith('/' . $skill->name . '/SKILL.md', $skill->path); + self::assertFileExists($skill->path); + } + } + + public function testFoldedDescriptionIsJoinedIntoOneLine(): void + { + $result = (new ListSkillsAction())(); + + $cli = array_values(array_filter( + $result->skills, + static fn(SkillItem $skill) => $skill->name === 'drupalorg-cli' + )); + self::assertCount(1, $cli); + self::assertStringStartsWith('CLI for Drupal.org issue lifecycle management.', $cli[0]->description); + } + + public function testMissingRootReturnsEmptyList(): void + { + $result = (new ListSkillsAction(__DIR__ . '/does-not-exist'))(); + + self::assertSame([], $result->skills); + } + + public function testJsonSerialize(): void + { + $result = (new ListSkillsAction())(); + + $decoded = json_decode(json_encode($result, JSON_THROW_ON_ERROR), true); + self::assertIsArray($decoded); + self::assertArrayHasKey('skills', $decoded); + self::assertSame(['name', 'description', 'path'], array_keys($decoded['skills'][0])); + } +} diff --git a/tests/src/Command/Skill/GetTest.php b/tests/src/Command/Skill/GetTest.php index 5de0e35..afcf6e3 100644 --- a/tests/src/Command/Skill/GetTest.php +++ b/tests/src/Command/Skill/GetTest.php @@ -5,6 +5,7 @@ use mglaman\DrupalOrgCli\Command\Skill\Get; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; #[CoversClass(Get::class)] class GetTest extends TestCase @@ -15,4 +16,63 @@ public function testClassExists(): void self::assertInstanceOf(Get::class, $command); self::assertSame('skill:get', $command->getName()); } + + public function testNoNameListsSkillsAsTable(): void + { + $tester = new CommandTester(new Get()); + $exitCode = $tester->execute([]); + + self::assertSame(0, $exitCode); + $display = $tester->getDisplay(); + self::assertStringContainsString('| Skill', $display); + self::assertStringContainsString('| Description', $display); + self::assertStringContainsString('drupalorg-cli', $display); + self::assertStringContainsString('drupalorg-work-on-issue', $display); + self::assertStringContainsString('Run: drupalorg skill:get ', $display); + } + + public function testNoNameListsSkillsAsJson(): void + { + $tester = new CommandTester(new Get()); + $exitCode = $tester->execute(['--format' => 'json']); + + self::assertSame(0, $exitCode); + $decoded = json_decode($tester->getDisplay(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($decoded); + $names = array_column($decoded['skills'], 'name'); + self::assertContains('drupalorg-cli', $names); + } + + public function testNoNameListsSkillsAsLlm(): void + { + $tester = new CommandTester(new Get()); + $exitCode = $tester->execute(['--format' => 'llm']); + + self::assertSame(0, $exitCode); + $display = $tester->getDisplay(); + self::assertStringStartsWith('', $display); + self::assertStringContainsString('drupalorg-cli', $display); + } + + public function testNamedSkillOutputsContent(): void + { + $tester = new CommandTester(new Get()); + $exitCode = $tester->execute(['name' => 'drupalorg-cli']); + + self::assertSame(0, $exitCode); + $display = $tester->getDisplay(); + self::assertStringStartsWith("---\nname: drupalorg-cli", $display); + self::assertStringNotContainsString('Run: drupalorg skill:get ', $display); + } + + public function testUnknownSkillFailsAndListsNames(): void + { + $tester = new CommandTester(new Get()); + $exitCode = $tester->execute(['name' => 'does-not-exist']); + + self::assertSame(1, $exitCode); + $display = $tester->getDisplay(); + self::assertStringContainsString('Skill not found: does-not-exist', $display); + self::assertStringContainsString('Available skills: drupalorg-cli', $display); + } } diff --git a/tests/src/Formatter/LlmFormatterTest.php b/tests/src/Formatter/LlmFormatterTest.php index 737912d..3b3088f 100644 --- a/tests/src/Formatter/LlmFormatterTest.php +++ b/tests/src/Formatter/LlmFormatterTest.php @@ -13,6 +13,8 @@ use mglaman\DrupalOrg\Result\Project\ProjectIssuesResult; use mglaman\DrupalOrg\Result\Project\ProjectReleasesResult; use mglaman\DrupalOrg\Result\ResultInterface; +use mglaman\DrupalOrg\Result\Skill\SkillItem; +use mglaman\DrupalOrg\Result\Skill\SkillListResult; use mglaman\DrupalOrgCli\Formatter\LlmFormatter; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -206,6 +208,22 @@ public function testProjectReleasesResult(): void self::assertStringContainsString('Security fixes', $output); } + public function testSkillListResult(): void + { + $result = new SkillListResult(skills: [ + new SkillItem(name: 'drupalorg-cli', description: 'Full CLI reference & more.', path: '/tmp/SKILL.md'), + ]); + + $formatter = new LlmFormatter(); + $output = $formatter->format($result); + + self::assertStringStartsWith('', $output); + self::assertStringContainsString('drupalorg skill:get <name>', $output); + self::assertStringContainsString('drupalorg-cli', $output); + self::assertStringContainsString('Full CLI reference & more.', $output); + self::assertStringEndsWith('', $output); + } + public function testUnsupportedResultTypeThrows(): void { $result = new class implements ResultInterface { diff --git a/tests/src/Formatter/MarkdownFormatterTest.php b/tests/src/Formatter/MarkdownFormatterTest.php index e66c397..1e88ce2 100644 --- a/tests/src/Formatter/MarkdownFormatterTest.php +++ b/tests/src/Formatter/MarkdownFormatterTest.php @@ -14,6 +14,8 @@ use mglaman\DrupalOrg\Result\Project\ProjectIssuesResult; use mglaman\DrupalOrg\Result\Project\ProjectReleasesResult; use mglaman\DrupalOrg\Result\ResultInterface; +use mglaman\DrupalOrg\Result\Skill\SkillItem; +use mglaman\DrupalOrg\Result\Skill\SkillListResult; use mglaman\DrupalOrgCli\Formatter\MarkdownFormatter; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -186,6 +188,20 @@ public function testProjectReleasesResult(): void self::assertStringContainsString('Security fixes', $output); } + public function testSkillListResult(): void + { + $result = new SkillListResult(skills: [ + new SkillItem(name: 'drupalorg-cli', description: 'Full CLI reference.', path: '/tmp/SKILL.md'), + ]); + + $formatter = new MarkdownFormatter(); + $output = $formatter->format($result); + + self::assertStringContainsString('# Available skills', $output); + self::assertStringContainsString('| `drupalorg-cli` | Full CLI reference. |', $output); + self::assertStringContainsString('drupalorg skill:get ', $output); + } + public function testIssueForkResult(): void { $result = new IssueForkResult(