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
13 changes: 10 additions & 3 deletions .github/workflows/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ jobs:
run: |
php drupalorg.phar skill:install
test -f .claude/skills/drupalorg-cli/SKILL.md
test -f .claude/skills/drupalorg-cli/references/work-on-issue.md
test -f .claude/skills/drupalorg-cli/references/patch-contribution.md
test -f .claude/skills/drupalorg-cli/references/gitlab-mr-contribution.md
test ! -d .claude/skills/drupalorg-issue-search
test ! -d .claude/skills/drupalorg-issue-summary-update
test ! -d .claude/skills/drupalorg-work-on-issue
grep -q "drupalorg skill:get drupalorg-cli" .claude/skills/drupalorg-cli/SKILL.md
- name: skill:get
run: |
php drupalorg.phar skill:get drupalorg-cli | grep -q "## Overview"
php drupalorg.phar skill:get drupalorg-cli --full | grep -q "## Reference:"
php drupalorg.phar skill:get drupalorg-issue-search | grep -q "drupalorg-issue-search"
php drupalorg.phar skill:get drupalorg-work-on-issue | grep -q "drupalorg-work-on-issue"
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ This CLI reads from Drupal.org and automates local merge request workflows. Its
| `project:release-notes` | `prn` | Displays release notes for a release |
| `project:link` | | Opens project page in browser |
| `project:kanban` | | Opens project kanban in browser |
| `skill:install` | | Installs discovery stubs into `.claude/skills/` |
| `skill:get` | | Outputs current skill content for agent consumption |

## Architecture

Expand Down
25 changes: 20 additions & 5 deletions skills/drupalorg-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,12 @@ drupalorg maintainer:release-notes <ref1> [ref2] [--format=json|md|html]
### Utility commands

```bash
# Install the drupalorg-cli agent skill into .claude/skills/drupalorg-cli/
# Install discovery stubs into .claude/skills/ in the current directory
drupalorg skill:install

# Output current skill content (use instead of reading stale installed files)
drupalorg skill:get <name>
drupalorg skill:get <name> --full # Include reference files
```

## Cache Bypass
Expand All @@ -203,10 +207,21 @@ drupalorg mr:list [nid] --format=llm --no-cache
| `Remote … does not exist` | `issue:checkout` run before `issue:setup-remote` | Run `issue:setup-remote <nid>` first |
| `429 / 503` | Drupal.org rate limit or maintenance | The client retries automatically; wait and retry if it persists |

## Available Skills

Fetch any skill on demand — content always reflects the installed version:

```bash
drupalorg skill:get drupalorg-cli --full # CLI reference + workflow references
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
drupalorg skill:get drupalorg-work-on-issue # End-to-end GitLab MR contribution workflow
```

## References

Detailed workflow guides are in the `references/` directory alongside this file:
Detailed workflow guides available via `--full`:

- `references/work-on-issue.md` — End-to-end GitLab MR workflow ("Work on this issue")
- `references/patch-contribution.md` — Classic patch-based contribution workflow
- `references/gitlab-mr-contribution.md` — GitLab MR contribution workflow reference
- `work-on-issue` — End-to-end GitLab MR workflow ("Work on this issue")
- `patch-contribution` — Classic patch-based contribution workflow
- `gitlab-mr-contribution` — GitLab MR contribution workflow reference
1 change: 1 addition & 0 deletions src/Cli/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ public function getCommands(): array
$commands[] = new Command\Maintainer\Issues();
$commands[] = new Command\Maintainer\ReleaseNotes();
$commands[] = new Command\Skill\Install();
$commands[] = new Command\Skill\Get();
$commands[] = new Command\Issue\GetFork();
$commands[] = new Command\Issue\SetupRemote();
$commands[] = new Command\Issue\Checkout();
Expand Down
88 changes: 88 additions & 0 deletions src/Cli/Command/Skill/Get.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace mglaman\DrupalOrgCli\Command\Skill;

use mglaman\DrupalOrgCli\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class Get extends Command
{
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');
}
Comment thread
mglaman marked this conversation as resolved.

protected function execute(InputInterface $input, OutputInterface $output): int
{
$name = (string) $input->getArgument('name');
$skillFile = __DIR__ . '/../../../../skills/' . $name . '/SKILL.md';

if (!is_file($skillFile)) {
$this->stdErr->writeln(sprintf('<error>Skill not found: %s</error>', $name));
$available = $this->getAvailableSkills();
if ($available !== []) {
$this->stdErr->writeln('Available skills: ' . implode(', ', $available));
}
return 1;
}

$content = file_get_contents($skillFile);
if ($content === false) {
$this->stdErr->writeln(sprintf('<error>Could not read skill: %s</error>', $name));
return 1;
}

$this->stdOut->write($content);

if ((bool) $input->getOption('full')) {
$refDir = __DIR__ . '/../../../../skills/' . $name . '/references';
if (is_dir($refDir)) {
foreach (new \DirectoryIterator($refDir) as $fileInfo) {
if ($fileInfo->isDot() || !$fileInfo->isFile() || $fileInfo->getExtension() !== 'md') {
continue;
}
$refContent = file_get_contents($fileInfo->getPathname());
if ($refContent !== false) {
$this->stdOut->writeln('');
$this->stdOut->writeln('---');
$this->stdOut->writeln('## Reference: ' . $fileInfo->getBasename('.md'));
$this->stdOut->writeln('---');
$this->stdOut->writeln('');
$this->stdOut->write($refContent);
}
}
}
}

return 0;
}

/**
* @return string[]
*/
private function getAvailableSkills(): array
{
$skillsRoot = __DIR__ . '/../../../../skills';
if (!is_dir($skillsRoot)) {
return [];
}
$skills = [];
foreach (new \DirectoryIterator($skillsRoot) as $dir) {
if ($dir->isDot() || !$dir->isDir()) {
continue;
}
$skills[] = $dir->getFilename();
}
sort($skills);
return $skills;
}
}
110 changes: 46 additions & 64 deletions src/Cli/Command/Skill/Install.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

declare(strict_types=1);

namespace mglaman\DrupalOrgCli\Command\Skill;

use mglaman\DrupalOrgCli\Command\Command;
Expand All @@ -8,98 +10,78 @@

class Install extends Command
{

protected function configure(): void
{
$this
->setName('skill:install')
->setDescription('Installs all drupalorg-cli agent skills into .claude/skills/ in the current directory.');
->setDescription('Installs drupalorg-cli discovery skills into .claude/skills/ in the current directory.');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$skillsRootSrc = __DIR__ . '/../../../../skills';
$skillSrc = __DIR__ . '/../../../../skills/drupalorg-cli';
$cwd = (string) getcwd();
$skillsRootDest = $cwd . DIRECTORY_SEPARATOR . '.claude' . DIRECTORY_SEPARATOR . 'skills';

foreach (new \DirectoryIterator($skillsRootSrc) as $skillDir) {
if ($skillDir->isDot() || !$skillDir->isDir()) {
continue;
}
$result = $this->installSkill(
$skillDir->getPathname(),
$skillsRootDest . DIRECTORY_SEPARATOR . $skillDir->getFilename()
);
if ($result !== 0) {
return $result;
}
}
$skillDest = $cwd . DIRECTORY_SEPARATOR . '.claude' . DIRECTORY_SEPARATOR . 'skills' . DIRECTORY_SEPARATOR . 'drupalorg-cli';

return 0;
return $this->installDiscoveryStub($skillSrc, 'drupalorg-cli', $skillDest);
}

private function installSkill(string $srcDir, string $destDir): int
private function installDiscoveryStub(string $srcDir, string $skillName, string $destDir): int
{
if (!is_dir($destDir) && !mkdir($destDir, 0755, true) && !is_dir($destDir)) {
$this->stdErr->writeln(sprintf('<error>Failed to create directory: %s</error>', $destDir));
return 1;
}

$srcFile = $srcDir . DIRECTORY_SEPARATOR . 'SKILL.md';
$destFile = $destDir . DIRECTORY_SEPARATOR . 'SKILL.md';
$content = file_get_contents($srcFile);
if ($content === false) {
$this->stdErr->writeln(sprintf('<error>Could not read skill source: %s</error>', $srcFile));
return 1;
}
if (file_put_contents($destFile, $content) === false) {

$stub = $this->buildDiscoveryStub($content, $skillName);

if (!is_dir($destDir) && !mkdir($destDir, 0755, true) && !is_dir($destDir)) {
$this->stdErr->writeln(sprintf('<error>Failed to create directory: %s</error>', $destDir));
return 1;
}

$destFile = $destDir . DIRECTORY_SEPARATOR . 'SKILL.md';
if (file_put_contents($destFile, $stub) === false) {
$this->stdErr->writeln(sprintf('<error>Failed to write skill file: %s</error>', $destFile));
return 1;
}
$this->stdOut->writeln(sprintf('<comment>Skill installed to %s</comment>', $destFile));

$refSrcDir = $srcDir . DIRECTORY_SEPARATOR . 'references';
if (!is_dir($refSrcDir)) {
return 0;
}
return 0;
}

$refDestDir = $destDir . DIRECTORY_SEPARATOR . 'references';
if (!is_dir($refDestDir) && !mkdir($refDestDir, 0755, true) && !is_dir($refDestDir)) {
$this->stdErr->writeln(sprintf('<error>Failed to create directory: %s</error>', $refDestDir));
return 1;
private function buildDiscoveryStub(string $sourceContent, string $skillName): string
{
// Extract frontmatter block (name + description for trigger matching).
// Preserve it verbatim — the description is what Claude Code uses to
// decide whether to invoke the skill.
$frontmatter = '';
if (preg_match('/^---\n(.*?)\n---/s', $sourceContent, $matches)) {
$frontmatter = $matches[1];
}

try {
foreach (new \DirectoryIterator($refSrcDir) as $fileInfo) {
if ($fileInfo->isDot() || !$fileInfo->isFile() || $fileInfo->getExtension() !== 'md') {
continue;
}
$refSrc = $fileInfo->getPathname();
$refDest = $refDestDir . DIRECTORY_SEPARATOR . $fileInfo->getFilename();
if (!copy($refSrc, $refDest)) {
$lastError = error_get_last();
$errorDetail = (is_array($lastError) && $lastError['message'] !== '')
? ' Underlying error: ' . $lastError['message']
: '';
$this->stdErr->writeln(sprintf(
'<error>Failed to copy reference file from %s to %s.%s</error>',
$refSrc,
$refDest,
$errorDetail
));
return 1;
}
$this->stdOut->writeln(sprintf('<comment>Reference installed to %s</comment>', $refDest));
}
} catch (\UnexpectedValueException $e) {
$this->stdErr->writeln(sprintf(
'<error>Failed to read skill references directory: %s (%s)</error>',
$refSrcDir,
$e->getMessage()
));
return 1;
// Add allowed-tools so agents can call drupalorg without extra prompts.
if (!str_contains($frontmatter, 'allowed-tools:')) {
$frontmatter .= "\nallowed-tools: Bash(drupalorg:*), Bash(drupalorg skill:*)";
}

return 0;
$hasReferences = is_dir(__DIR__ . '/../../../../skills/' . $skillName . '/references');
$fullFlag = $hasReferences ? "\ndrupalorg skill:get {$skillName} --full # Include reference files" : '';

return <<<MD
---
{$frontmatter}
---

**Run `drupalorg skill:get {$skillName}` before using this skill.**
This stub does not contain instructions — they are served by the CLI and always reflect the installed version.

```bash
drupalorg skill:get {$skillName}{$fullFlag}
```

MD;
}
}
18 changes: 18 additions & 0 deletions tests/src/Command/Skill/GetTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace mglaman\DrupalOrg\Tests\Command\Skill;

use mglaman\DrupalOrgCli\Command\Skill\Get;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(Get::class)]
class GetTest extends TestCase
{
public function testClassExists(): void
{
$command = new Get();
self::assertInstanceOf(Get::class, $command);
self::assertSame('skill:get', $command->getName());
}
}