Skip to content

Commit f6eba45

Browse files
authored
refactor: make CLI agent guidance intent-based (#128)
1 parent 7677d1b commit f6eba45

7 files changed

Lines changed: 79 additions & 227 deletions

File tree

extrachill-cli.php

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,23 +23,16 @@
2323
|--------------------------------------------------------------------------
2424
| AGENTS.md — composable file section registration
2525
|--------------------------------------------------------------------------
26-
| Registers the Extra Chill CLI section in the AGENTS.md composable file
27-
| so that external agent runtimes (Claude Code, OpenCode, etc.) discover
28-
| the platform CLI surface automatically. Runs outside the WP_CLI guard
29-
| because the compose command and auto-regeneration may fire in non-CLI
30-
| WordPress contexts (e.g. plugin activation hooks).
26+
| Registers concise ownership, routing, multisite, and discovery guidance for
27+
| the Extra Chill CLI. Runs outside the WP_CLI guard because composition and
28+
| auto-regeneration may fire in non-CLI WordPress contexts.
3129
*/
3230
add_action( 'plugins_loaded', function () {
3331
if ( ! class_exists( '\DataMachine\Engine\AI\SectionRegistry' ) ) {
3432
return;
3533
}
3634

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

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

homeboy.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"remote_path": "wp-content/plugins/extrachill-cli",
99
"self_checks": {
1010
"test": [
11+
"php tests/AgentsMdSectionTest.php",
1112
"php tests/QRCodeCommandTest.php",
1213
"php tests/ExperimentsCommandTest.php",
1314
"php tests/ExperimentsOwnerContractTest.php",

inc/AgentsMdSection.php

Lines changed: 13 additions & 202 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,11 @@
22
/**
33
* AGENTS.md Section Generator
44
*
5-
* Generates the "Extra Chill CLI" section of the composed AGENTS.md file by
6-
* introspecting the real registered command tree instead of hand-maintaining
7-
* a heredoc. Walks every command registered in CommandRegistry, reflects over
8-
* each command class's public methods, and emits each namespace with its real
9-
* subcommands and their PHPDoc short descriptions.
10-
*
11-
* Context-safe: the section callback runs on `plugins_loaded` in web/cron
12-
* compose contexts where the WP-CLI runtime and the plugin's PSR-4 autoloader
13-
* (both guarded by `if ( WP_CLI )`) are NOT loaded. This generator therefore
14-
* resolves command class files directly from disk via the same PSR-4 layout
15-
* and reflects over the class files, never relying on the live WP-CLI runner.
16-
*
17-
* Pending Extra-Chill/data-machine#2613: once that shared
18-
* `CliCommandIntrospector` helper lands, this class can delegate the
19-
* reflection/parsing to it and keep only the CommandRegistry map local.
20-
*
215
* @package ExtraChill\CLI
226
*/
237

248
namespace ExtraChill\CLI;
259

26-
use ReflectionClass;
27-
use ReflectionMethod;
28-
2910
if ( ! defined( 'ABSPATH' ) ) {
3011
exit;
3112
}
@@ -35,198 +16,28 @@ class AgentsMdSection {
3516
/**
3617
* Build the Markdown body for the Extra Chill CLI AGENTS.md section.
3718
*
38-
* @param string $wp The `wp --allow-root --path=...` invocation prefix.
19+
* @param string $wp The composed WP-CLI invocation prefix.
3920
* @return string
4021
*/
4122
public static function render( $wp ) {
4223
$lines = array();
4324
$lines[] = '### Extra Chill CLI';
4425
$lines[] = '';
45-
$lines[] = 'Platform-specific tooling wrapping common operations into unified commands.';
46-
$lines[] = "Discover everything: `{$wp} extrachill --help`";
26+
$lines[] = 'Extra Chill CLI owns the unified WP-CLI surface for Extra Chill platform operations. Feature plugins own the underlying abilities and business behavior.';
4727
$lines[] = '';
48-
49-
foreach ( self::collect_commands() as $command => $subcommands ) {
50-
// Strip the leading "extrachill " for the human-readable summary,
51-
// but keep the full invocation in the code span.
52-
$summary = self::summarize_subcommands( $subcommands );
53-
54-
if ( '' !== $summary ) {
55-
$lines[] = "- `{$wp} {$command}` — {$summary}";
56-
} else {
57-
$lines[] = "- `{$wp} {$command}`";
58-
}
59-
60-
foreach ( $subcommands as $sub ) {
61-
if ( '__default' === $sub['name'] ) {
62-
continue;
63-
}
64-
$desc = $sub['description'];
65-
if ( '' !== $desc ) {
66-
$lines[] = " - `{$sub['name']}` — {$desc}";
67-
} else {
68-
$lines[] = " - `{$sub['name']}`";
69-
}
70-
}
71-
}
72-
28+
$lines[] = '**Default routing**';
29+
$lines[] = "- Platform health and analytics: `{$wp} extrachill platform health` and `{$wp} extrachill analytics summary`";
30+
$lines[] = "- Events and venues: `{$wp} extrachill events --help` and `{$wp} extrachill venues --help`";
31+
$lines[] = "- Artists, users, and community: `{$wp} extrachill artists --help`, `{$wp} extrachill users --help`, and `{$wp} extrachill community --help`";
32+
$lines[] = "- Content and newsletters: `{$wp} extrachill content --help` and `{$wp} extrachill newsletter --help`";
33+
$lines[] = "- Diagnostics: `{$wp} extrachill analytics errors`, `{$wp} extrachill analytics 404 summary`, and `{$wp} extrachill cache status`";
7334
$lines[] = '';
74-
$lines[] = 'All commands support `--help` for full options and subcommand discovery.';
35+
$lines[] = '**Multisite**';
36+
$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.';
37+
$lines[] = '';
38+
$lines[] = '**Discovery**';
39+
$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.";
7540

7641
return implode( "\n", $lines );
7742
}
78-
79-
/**
80-
* Walk the CommandRegistry and reflect each class into its subcommands.
81-
*
82-
* @return array<string, array<int, array{name:string, description:string}>>
83-
* command string => ordered list of subcommands.
84-
*/
85-
private static function collect_commands() {
86-
$out = array();
87-
88-
foreach ( CommandRegistry::map() as $command => $class ) {
89-
$subcommands = self::reflect_subcommands( $class );
90-
$out[ $command ] = $subcommands;
91-
}
92-
93-
return $out;
94-
}
95-
96-
/**
97-
* Reflect a command class into its list of public subcommands.
98-
*
99-
* Public methods are WP-CLI subcommands. The subcommand name is taken from
100-
* the `@subcommand <name>` annotation when present, otherwise the method
101-
* name with underscores converted to hyphens (WP-CLI's own convention).
102-
* `__invoke` / `@subcommand __default` represent the directly-invokable
103-
* namespace itself. Private, protected, static, and magic helper methods
104-
* are skipped.
105-
*
106-
* @param class-string $class Command class.
107-
* @return array<int, array{name:string, description:string}>
108-
*/
109-
private static function reflect_subcommands( $class ) {
110-
// The autoloader (registered before this section is composed) resolves
111-
// the class file and any traits it uses; class_exists triggers it.
112-
if ( ! class_exists( $class ) ) {
113-
return array();
114-
}
115-
116-
try {
117-
$reflection = new ReflectionClass( $class );
118-
} catch ( \Throwable $e ) {
119-
return array();
120-
}
121-
122-
$subcommands = array();
123-
124-
foreach ( $reflection->getMethods( ReflectionMethod::IS_PUBLIC ) as $method ) {
125-
// Only methods declared on the command class itself.
126-
if ( $method->getDeclaringClass()->getName() !== $reflection->getName() ) {
127-
continue;
128-
}
129-
130-
if ( $method->isStatic() || $method->isConstructor() || $method->isDestructor() ) {
131-
continue;
132-
}
133-
134-
$method_name = $method->getName();
135-
$doc = $method->getDocComment() ?: '';
136-
137-
// `__invoke` (or any method tagged @subcommand __default) maps to
138-
// the namespace itself, so it has no sub-word.
139-
$annotated = self::parse_subcommand_annotation( $doc );
140-
141-
if ( '__invoke' === $method_name && '' === $annotated ) {
142-
$annotated = '__default';
143-
}
144-
145-
if ( '' !== $annotated ) {
146-
$name = $annotated;
147-
} else {
148-
// Skip other magic methods that are not subcommands.
149-
if ( 0 === strpos( $method_name, '__' ) ) {
150-
continue;
151-
}
152-
$name = str_replace( '_', '-', $method_name );
153-
}
154-
155-
$subcommands[] = array(
156-
'name' => $name,
157-
'description' => self::parse_short_description( $doc ),
158-
);
159-
}
160-
161-
return $subcommands;
162-
}
163-
164-
/**
165-
* Build a comma-separated summary of subcommand names for the headline.
166-
*
167-
* @param array<int, array{name:string, description:string}> $subcommands Subcommands.
168-
* @return string
169-
*/
170-
private static function summarize_subcommands( $subcommands ) {
171-
$names = array();
172-
foreach ( $subcommands as $sub ) {
173-
if ( '__default' === $sub['name'] ) {
174-
continue;
175-
}
176-
$names[] = $sub['name'];
177-
}
178-
179-
return implode( ', ', $names );
180-
}
181-
182-
/**
183-
* Parse the `@subcommand <name>` annotation from a docblock.
184-
*
185-
* @param string $doc Raw docblock.
186-
* @return string Subcommand name, or '' when not annotated.
187-
*/
188-
private static function parse_subcommand_annotation( $doc ) {
189-
if ( preg_match( '/@subcommand\s+(\S+)/', $doc, $m ) ) {
190-
return $m[1];
191-
}
192-
return '';
193-
}
194-
195-
/**
196-
* Parse the short description (first prose line) from a docblock.
197-
*
198-
* Mirrors how WP-CLI derives a command's summary for `--help`: the first
199-
* non-empty content line of the docblock, before any `## SECTION` heading.
200-
*
201-
* @param string $doc Raw docblock.
202-
* @return string
203-
*/
204-
private static function parse_short_description( $doc ) {
205-
if ( '' === $doc ) {
206-
return '';
207-
}
208-
209-
// Strip the comment framing.
210-
$doc = preg_replace( '#^/\*\*|\*/$#', '', $doc );
211-
$lines = preg_split( '/\r\n|\r|\n/', $doc );
212-
213-
foreach ( $lines as $line ) {
214-
$line = preg_replace( '/^\s*\*\s?/', '', $line );
215-
$line = trim( $line );
216-
217-
if ( '' === $line ) {
218-
continue;
219-
}
220-
221-
// Stop at structured sections / annotations.
222-
if ( 0 === strpos( $line, '##' ) || 0 === strpos( $line, '@' ) ) {
223-
return '';
224-
}
225-
226-
// Drop a trailing period for a tighter inline summary.
227-
return rtrim( $line, '.' );
228-
}
229-
230-
return '';
231-
}
23243
}

inc/Autoloader.php

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@
33
* PSR-4 Autoloader
44
*
55
* Registers a namespace-scoped autoloader for the `ExtraChill\CLI\` namespace
6-
* rooted at the plugin's `inc/` directory. Used in both the WP-CLI runtime
7-
* (to load command classes on demand) and in non-CLI compose contexts (so the
8-
* AGENTS.md section generator can reflect over command classes and resolve
9-
* any traits or dependencies they `use` without a fatal error).
6+
* rooted at the plugin's `inc/` directory so WP-CLI can load command classes
7+
* on demand.
108
*
119
* @package ExtraChill\CLI
1210
*/

inc/CommandRegistry.php

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,8 @@
22
/**
33
* Command Registry
44
*
5-
* Single source of truth mapping `wp extrachill ...` command strings to their
6-
* implementing command classes. Both the WP-CLI bootstrap (which calls
7-
* WP_CLI::add_command for each entry) and the AGENTS.md section generator
8-
* (which reflects over each class to enumerate real subcommands) read from
9-
* this map, so the documented CLI surface can never drift from what is
10-
* actually registered.
5+
* Maps `wp extrachill ...` command strings to their implementing command
6+
* classes. The WP-CLI bootstrap registers every entry in this map.
117
*
128
* @package ExtraChill\CLI
139
*/
@@ -25,7 +21,7 @@ class CommandRegistry {
2521
*
2622
* Keys are the exact strings passed to WP_CLI::add_command (the command
2723
* namespace, e.g. "extrachill venues" or "extrachill users access").
28-
* Order here determines both registration order and documentation order.
24+
* Order here determines registration order.
2925
*
3026
* @return array<string, class-string>
3127
*/

inc/bootstrap.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@
1717
return;
1818
}
1919

20-
// Register every command from the single-source-of-truth map. The same map
21-
// drives the AGENTS.md section generator, so documentation cannot drift from
22-
// what is actually registered here.
20+
// Register every command from the command map.
2321
foreach ( ExtraChill\CLI\CommandRegistry::map() as $command => $command_class ) {
2422
WP_CLI::add_command( $command, $command_class );
2523
}

tests/AgentsMdSectionTest.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
/**
3+
* Focused contracts for concise Extra Chill CLI agent guidance.
4+
*/
5+
6+
if ( ! defined( 'ABSPATH' ) ) {
7+
define( 'ABSPATH', __DIR__ . '/' );
8+
}
9+
10+
require_once dirname( __DIR__ ) . '/inc/AgentsMdSection.php';
11+
12+
use ExtraChill\CLI\AgentsMdSection;
13+
14+
$prefix = 'host wp --url=https://community.example.test --path=/srv/wordpress';
15+
$output = AgentsMdSection::render( $prefix );
16+
$failures = array();
17+
18+
$check = static function ( $condition, $message ) use ( &$failures ) {
19+
if ( ! $condition ) {
20+
$failures[] = $message;
21+
}
22+
};
23+
24+
$routes = array(
25+
'extrachill platform health',
26+
'extrachill analytics summary',
27+
'extrachill events --help',
28+
'extrachill venues --help',
29+
'extrachill artists --help',
30+
'extrachill users --help',
31+
'extrachill community --help',
32+
'extrachill content --help',
33+
'extrachill newsletter --help',
34+
'extrachill analytics errors',
35+
'extrachill analytics 404 summary',
36+
'extrachill cache status',
37+
);
38+
39+
foreach ( $routes as $route ) {
40+
$check( false !== strpos( $output, "`{$prefix} {$route}`" ), "Missing default route: {$route}" );
41+
}
42+
43+
$check( false !== strpos( $output, "`{$prefix} extrachill --help`" ), 'Top-level live discovery command must be present.' );
44+
$check( false !== strpos( $output, "`{$prefix} extrachill <namespace> --help`" ), 'Nested live discovery command must be present.' );
45+
$check( false !== strpos( $output, 'Live `--help` is authoritative' ), 'Guidance must make live help authoritative.' );
46+
$check( false !== strpos( $output, 'Use `--url=<site-url>`' ), 'Guidance must explain multisite targeting.' );
47+
$check( false === strpos( $output, 'publish-campaign' ), 'Guidance must not exhaustively enumerate command actions.' );
48+
$check( 30 >= substr_count( $output, "\n" ) + 1, 'Guidance must remain bounded to 30 lines.' );
49+
50+
if ( ! empty( $failures ) ) {
51+
fwrite( STDERR, implode( "\n", $failures ) . "\n" );
52+
exit( 1 );
53+
}
54+
55+
fwrite( STDOUT, "AgentsMdSection tests passed.\n" );

0 commit comments

Comments
 (0)