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
15 changes: 4 additions & 11 deletions extrachill-cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,16 @@
|--------------------------------------------------------------------------
| AGENTS.md — composable file section registration
|--------------------------------------------------------------------------
| Registers the Extra Chill CLI section in the AGENTS.md composable file
| so that external agent runtimes (Claude Code, OpenCode, etc.) discover
| the platform CLI surface automatically. Runs outside the WP_CLI guard
| because the compose command and auto-regeneration may fire in non-CLI
| WordPress contexts (e.g. plugin activation hooks).
| Registers concise ownership, routing, multisite, and discovery guidance for
| the Extra Chill CLI. Runs outside the WP_CLI guard because composition and
| auto-regeneration may fire in non-CLI WordPress contexts.
*/
add_action( 'plugins_loaded', function () {
if ( ! class_exists( '\DataMachine\Engine\AI\SectionRegistry' ) ) {
return;
}

// This section can be composed in non-CLI (web/cron) contexts where the
// WP_CLI guard below has not registered the autoloader. Register it here
// so the generator can reflect over the real command classes (and any
// traits they use) regardless of context.
require_once EXTRACHILL_CLI_PATH . 'inc/Autoloader.php';
\ExtraChill\CLI\Autoloader::register( EXTRACHILL_CLI_PATH );
require_once EXTRACHILL_CLI_PATH . 'inc/AgentsMdSection.php';

$wp = 'wp --allow-root --path=' . ABSPATH;

Expand Down
1 change: 1 addition & 0 deletions homeboy.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"remote_path": "wp-content/plugins/extrachill-cli",
"self_checks": {
"test": [
"php tests/AgentsMdSectionTest.php",
"php tests/QRCodeCommandTest.php",
"php tests/ExperimentsCommandTest.php",
"php tests/ExperimentsOwnerContractTest.php",
Expand Down
215 changes: 13 additions & 202 deletions inc/AgentsMdSection.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,11 @@
/**
* AGENTS.md Section Generator
*
* Generates the "Extra Chill CLI" section of the composed AGENTS.md file by
* introspecting the real registered command tree instead of hand-maintaining
* a heredoc. Walks every command registered in CommandRegistry, reflects over
* each command class's public methods, and emits each namespace with its real
* subcommands and their PHPDoc short descriptions.
*
* Context-safe: the section callback runs on `plugins_loaded` in web/cron
* compose contexts where the WP-CLI runtime and the plugin's PSR-4 autoloader
* (both guarded by `if ( WP_CLI )`) are NOT loaded. This generator therefore
* resolves command class files directly from disk via the same PSR-4 layout
* and reflects over the class files, never relying on the live WP-CLI runner.
*
* Pending Extra-Chill/data-machine#2613: once that shared
* `CliCommandIntrospector` helper lands, this class can delegate the
* reflection/parsing to it and keep only the CommandRegistry map local.
*
* @package ExtraChill\CLI
*/

namespace ExtraChill\CLI;

use ReflectionClass;
use ReflectionMethod;

if ( ! defined( 'ABSPATH' ) ) {
exit;
}
Expand All @@ -35,198 +16,28 @@ class AgentsMdSection {
/**
* Build the Markdown body for the Extra Chill CLI AGENTS.md section.
*
* @param string $wp The `wp --allow-root --path=...` invocation prefix.
* @param string $wp The composed WP-CLI invocation prefix.
* @return string
*/
public static function render( $wp ) {
$lines = array();
$lines[] = '### Extra Chill CLI';
$lines[] = '';
$lines[] = 'Platform-specific tooling wrapping common operations into unified commands.';
$lines[] = "Discover everything: `{$wp} extrachill --help`";
$lines[] = 'Extra Chill CLI owns the unified WP-CLI surface for Extra Chill platform operations. Feature plugins own the underlying abilities and business behavior.';
$lines[] = '';

foreach ( self::collect_commands() as $command => $subcommands ) {
// Strip the leading "extrachill " for the human-readable summary,
// but keep the full invocation in the code span.
$summary = self::summarize_subcommands( $subcommands );

if ( '' !== $summary ) {
$lines[] = "- `{$wp} {$command}` — {$summary}";
} else {
$lines[] = "- `{$wp} {$command}`";
}

foreach ( $subcommands as $sub ) {
if ( '__default' === $sub['name'] ) {
continue;
}
$desc = $sub['description'];
if ( '' !== $desc ) {
$lines[] = " - `{$sub['name']}` — {$desc}";
} else {
$lines[] = " - `{$sub['name']}`";
}
}
}

$lines[] = '**Default routing**';
$lines[] = "- Platform health and analytics: `{$wp} extrachill platform health` and `{$wp} extrachill analytics summary`";
$lines[] = "- Events and venues: `{$wp} extrachill events --help` and `{$wp} extrachill venues --help`";
$lines[] = "- Artists, users, and community: `{$wp} extrachill artists --help`, `{$wp} extrachill users --help`, and `{$wp} extrachill community --help`";
$lines[] = "- Content and newsletters: `{$wp} extrachill content --help` and `{$wp} extrachill newsletter --help`";
$lines[] = "- Diagnostics: `{$wp} extrachill analytics errors`, `{$wp} extrachill analytics 404 summary`, and `{$wp} extrachill cache status`";
$lines[] = '';
$lines[] = 'All commands support `--help` for full options and subcommand discovery.';
$lines[] = '**Multisite**';
$lines[] = 'Keep the composed WP-CLI prefix and its site target. Use `--url=<site-url>` when a command needs data or abilities owned by another network site; without a site target, WP-CLI uses the main site.';
$lines[] = '';
$lines[] = '**Discovery**';
$lines[] = "Use `{$wp} extrachill --help` and `{$wp} extrachill <namespace> --help` for the complete live command, subcommand, and options contract. Live `--help` is authoritative; this section intentionally does not enumerate every action.";

return implode( "\n", $lines );
}

/**
* Walk the CommandRegistry and reflect each class into its subcommands.
*
* @return array<string, array<int, array{name:string, description:string}>>
* command string => ordered list of subcommands.
*/
private static function collect_commands() {
$out = array();

foreach ( CommandRegistry::map() as $command => $class ) {
$subcommands = self::reflect_subcommands( $class );
$out[ $command ] = $subcommands;
}

return $out;
}

/**
* Reflect a command class into its list of public subcommands.
*
* Public methods are WP-CLI subcommands. The subcommand name is taken from
* the `@subcommand <name>` annotation when present, otherwise the method
* name with underscores converted to hyphens (WP-CLI's own convention).
* `__invoke` / `@subcommand __default` represent the directly-invokable
* namespace itself. Private, protected, static, and magic helper methods
* are skipped.
*
* @param class-string $class Command class.
* @return array<int, array{name:string, description:string}>
*/
private static function reflect_subcommands( $class ) {
// The autoloader (registered before this section is composed) resolves
// the class file and any traits it uses; class_exists triggers it.
if ( ! class_exists( $class ) ) {
return array();
}

try {
$reflection = new ReflectionClass( $class );
} catch ( \Throwable $e ) {
return array();
}

$subcommands = array();

foreach ( $reflection->getMethods( ReflectionMethod::IS_PUBLIC ) as $method ) {
// Only methods declared on the command class itself.
if ( $method->getDeclaringClass()->getName() !== $reflection->getName() ) {
continue;
}

if ( $method->isStatic() || $method->isConstructor() || $method->isDestructor() ) {
continue;
}

$method_name = $method->getName();
$doc = $method->getDocComment() ?: '';

// `__invoke` (or any method tagged @subcommand __default) maps to
// the namespace itself, so it has no sub-word.
$annotated = self::parse_subcommand_annotation( $doc );

if ( '__invoke' === $method_name && '' === $annotated ) {
$annotated = '__default';
}

if ( '' !== $annotated ) {
$name = $annotated;
} else {
// Skip other magic methods that are not subcommands.
if ( 0 === strpos( $method_name, '__' ) ) {
continue;
}
$name = str_replace( '_', '-', $method_name );
}

$subcommands[] = array(
'name' => $name,
'description' => self::parse_short_description( $doc ),
);
}

return $subcommands;
}

/**
* Build a comma-separated summary of subcommand names for the headline.
*
* @param array<int, array{name:string, description:string}> $subcommands Subcommands.
* @return string
*/
private static function summarize_subcommands( $subcommands ) {
$names = array();
foreach ( $subcommands as $sub ) {
if ( '__default' === $sub['name'] ) {
continue;
}
$names[] = $sub['name'];
}

return implode( ', ', $names );
}

/**
* Parse the `@subcommand <name>` annotation from a docblock.
*
* @param string $doc Raw docblock.
* @return string Subcommand name, or '' when not annotated.
*/
private static function parse_subcommand_annotation( $doc ) {
if ( preg_match( '/@subcommand\s+(\S+)/', $doc, $m ) ) {
return $m[1];
}
return '';
}

/**
* Parse the short description (first prose line) from a docblock.
*
* Mirrors how WP-CLI derives a command's summary for `--help`: the first
* non-empty content line of the docblock, before any `## SECTION` heading.
*
* @param string $doc Raw docblock.
* @return string
*/
private static function parse_short_description( $doc ) {
if ( '' === $doc ) {
return '';
}

// Strip the comment framing.
$doc = preg_replace( '#^/\*\*|\*/$#', '', $doc );
$lines = preg_split( '/\r\n|\r|\n/', $doc );

foreach ( $lines as $line ) {
$line = preg_replace( '/^\s*\*\s?/', '', $line );
$line = trim( $line );

if ( '' === $line ) {
continue;
}

// Stop at structured sections / annotations.
if ( 0 === strpos( $line, '##' ) || 0 === strpos( $line, '@' ) ) {
return '';
}

// Drop a trailing period for a tighter inline summary.
return rtrim( $line, '.' );
}

return '';
}
}
6 changes: 2 additions & 4 deletions inc/Autoloader.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
* PSR-4 Autoloader
*
* Registers a namespace-scoped autoloader for the `ExtraChill\CLI\` namespace
* rooted at the plugin's `inc/` directory. Used in both the WP-CLI runtime
* (to load command classes on demand) and in non-CLI compose contexts (so the
* AGENTS.md section generator can reflect over command classes and resolve
* any traits or dependencies they `use` without a fatal error).
* rooted at the plugin's `inc/` directory so WP-CLI can load command classes
* on demand.
*
* @package ExtraChill\CLI
*/
Expand Down
10 changes: 3 additions & 7 deletions inc/CommandRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@
/**
* Command Registry
*
* Single source of truth mapping `wp extrachill ...` command strings to their
* implementing command classes. Both the WP-CLI bootstrap (which calls
* WP_CLI::add_command for each entry) and the AGENTS.md section generator
* (which reflects over each class to enumerate real subcommands) read from
* this map, so the documented CLI surface can never drift from what is
* actually registered.
* Maps `wp extrachill ...` command strings to their implementing command
* classes. The WP-CLI bootstrap registers every entry in this map.
*
* @package ExtraChill\CLI
*/
Expand All @@ -25,7 +21,7 @@ class CommandRegistry {
*
* Keys are the exact strings passed to WP_CLI::add_command (the command
* namespace, e.g. "extrachill venues" or "extrachill users access").
* Order here determines both registration order and documentation order.
* Order here determines registration order.
*
* @return array<string, class-string>
*/
Expand Down
4 changes: 1 addition & 3 deletions inc/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,7 @@
return;
}

// Register every command from the single-source-of-truth map. The same map
// drives the AGENTS.md section generator, so documentation cannot drift from
// what is actually registered here.
// Register every command from the command map.
foreach ( ExtraChill\CLI\CommandRegistry::map() as $command => $command_class ) {
WP_CLI::add_command( $command, $command_class );
}
55 changes: 55 additions & 0 deletions tests/AgentsMdSectionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php
/**
* Focused contracts for concise Extra Chill CLI agent guidance.
*/

if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}

require_once dirname( __DIR__ ) . '/inc/AgentsMdSection.php';

use ExtraChill\CLI\AgentsMdSection;

$prefix = 'host wp --url=https://community.example.test --path=/srv/wordpress';
$output = AgentsMdSection::render( $prefix );
$failures = array();

$check = static function ( $condition, $message ) use ( &$failures ) {
if ( ! $condition ) {
$failures[] = $message;
}
};

$routes = array(
'extrachill platform health',
'extrachill analytics summary',
'extrachill events --help',
'extrachill venues --help',
'extrachill artists --help',
'extrachill users --help',
'extrachill community --help',
'extrachill content --help',
'extrachill newsletter --help',
'extrachill analytics errors',
'extrachill analytics 404 summary',
'extrachill cache status',
);

foreach ( $routes as $route ) {
$check( false !== strpos( $output, "`{$prefix} {$route}`" ), "Missing default route: {$route}" );
}

$check( false !== strpos( $output, "`{$prefix} extrachill --help`" ), 'Top-level live discovery command must be present.' );
$check( false !== strpos( $output, "`{$prefix} extrachill <namespace> --help`" ), 'Nested live discovery command must be present.' );
$check( false !== strpos( $output, 'Live `--help` is authoritative' ), 'Guidance must make live help authoritative.' );
$check( false !== strpos( $output, 'Use `--url=<site-url>`' ), 'Guidance must explain multisite targeting.' );
$check( false === strpos( $output, 'publish-campaign' ), 'Guidance must not exhaustively enumerate command actions.' );
$check( 30 >= substr_count( $output, "\n" ) + 1, 'Guidance must remain bounded to 30 lines.' );

if ( ! empty( $failures ) ) {
fwrite( STDERR, implode( "\n", $failures ) . "\n" );
exit( 1 );
}

fwrite( STDOUT, "AgentsMdSection tests passed.\n" );