Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```
Expand Down
6 changes: 6 additions & 0 deletions skill-data/drupalorg-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ drupalorg maintainer:release-notes <ref1> [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 <name> [--full]
```

## Cache Bypass
Expand Down
1 change: 1 addition & 0 deletions skills/drupalorg-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions src/Api/Action/Skill/ListSkillsAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php

namespace mglaman\DrupalOrg\Action\Skill;

use mglaman\DrupalOrg\Action\ActionInterface;
use mglaman\DrupalOrg\Result\Skill\SkillItem;
use mglaman\DrupalOrg\Result\Skill\SkillListResult;

/**
* Lists the skills bundled in skill-data/, sorted by name.
*
* Each skill is a directory containing a SKILL.md whose YAML frontmatter
* carries a name and a description. The frontmatter is parsed by hand so
* the phar does not need a YAML dependency.
*/
class ListSkillsAction implements ActionInterface
{
public const DEFAULT_SKILLS_ROOT = __DIR__ . '/../../../../skill-data';

private readonly string $skillsRoot;

public function __construct(string $skillsRoot = self::DEFAULT_SKILLS_ROOT)
{
// realpath() cannot resolve phar:// paths, so keep the raw path there.
$resolved = realpath($skillsRoot);
$this->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<string, string>
*/
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;
}
}
22 changes: 22 additions & 0 deletions src/Api/Result/Skill/SkillItem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace mglaman\DrupalOrg\Result\Skill;

final class SkillItem implements \JsonSerializable
{
public function __construct(
public readonly string $name,
public readonly string $description,
public readonly string $path,
) {
}

public function jsonSerialize(): mixed
{
return [
'name' => $this->name,
'description' => $this->description,
'path' => $this->path,
];
}
}
26 changes: 26 additions & 0 deletions src/Api/Result/Skill/SkillListResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace mglaman\DrupalOrg\Result\Skill;

use mglaman\DrupalOrg\Result\ResultInterface;

class SkillListResult implements ResultInterface
{
/**
* @param SkillItem[] $skills
*/
public function __construct(
public readonly array $skills,
) {
}

public function jsonSerialize(): mixed
{
return [
'skills' => array_map(
static fn(SkillItem $skill) => $skill->jsonSerialize(),
$this->skills
),
];
}
}
63 changes: 41 additions & 22 deletions src/Cli/Command/Skill/Get.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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('<error>Skill not found: %s</error>', $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));
}
Expand All @@ -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') {
Expand All @@ -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 <name>');

return 0;
}
}
3 changes: 3 additions & 0 deletions src/Cli/Formatter/AbstractFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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))
),
Expand All @@ -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;
}
15 changes: 15 additions & 0 deletions src/Cli/Formatter/LlmFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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 .= " <skill>\n";
$items .= " <name>{$name}</name>\n";
$items .= " <description>{$description}</description>\n";
$items .= " </skill>\n";
}
return "<skills>\n <usage>drupalorg skill:get &lt;name&gt;</usage>\n <items>\n{$items} </items>\n</skills>";
}

private function toIso8601(int $timestamp): string
{
return (new \DateTimeImmutable())->setTimestamp($timestamp)->format(\DateTimeInterface::ATOM);
Expand Down
16 changes: 16 additions & 0 deletions src/Cli/Formatter/MarkdownFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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 <name>` to read a skill.';
return implode("\n", $lines);
}
}
Loading