diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..d18c3bc
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,27 @@
+name: Lint code
+
+on: ["push","pull_request"]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.head_ref }}
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.2'
+ extensions: json, zip, pdo, pdo_sqlite
+ tools: composer:v2
+ coverage: none
+
+ - name: Install PHP dependencies
+ run: composer update --no-interaction --no-progress
+
+ - name: Run Linter
+ run: composer run lint -- --check
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..5917b8a
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,32 @@
+name: Run Tests
+
+on: ["push","pull_request"]
+
+jobs:
+ tests:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: true
+ matrix:
+ os: ["macos-latest","ubuntu-latest","windows-latest"]
+ php: ["8.5","8.4","8.3","8.2"]
+
+ name: PHP ${{ matrix.php }} - ${{ matrix.os }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: json, zip, pdo, pdo_sqlite
+ tools: composer:v2
+ coverage: xdebug
+
+ - name: Install PHP dependencies
+ run: composer update --no-interaction --no-progress
+
+ - name: Run Tests
+ run: composer run test -- --flags=coverage
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1f8ec3d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,93 @@
+
+
+
+
+
Leaf LLMs
+
+
+
+[](https://packagist.org/packages/leafs/llms)
+[](https://packagist.org/packages/leafs/llms)
+[](https://packagist.org/packages/leafs/llms)
+
+Coding agents do better work when a project tells them how it's built. `leafs/llms` reads your `composer.json` and folder layout, then writes the files coding agents such as Claude Code and Cursor look for. Your agent learns the commands your project really uses, so it doesn't have to guess.
+
+It works with Leaf, Leaf MVC, Laravel, Symfony and plain PHP projects.
+
+## Installation
+
+```bash
+composer require --dev leafs/llms
+```
+
+## Usage
+
+From your project root:
+
+```bash
+vendor/bin/llms init
+```
+
+That writes these files:
+
+| File | Read by |
+| --- | --- |
+| `AGENTS.md` | Most agents. It's the source of truth, covering your stack and commands plus the conventions to follow |
+| `CLAUDE.md` | Claude Code. It imports `AGENTS.md` |
+| `llms.txt` | Tools that follow [llmstxt.org](https://llmstxt.org), with links to your docs and your framework's docs |
+| `.cursor/rules/project.mdc` | Cursor, on every request |
+| `.cursor/rules/php.mdc` | Cursor, when a PHP file is involved |
+| `.claude/skills/*/SKILL.md` | Claude Code skills |
+| `.agents/skills/*/SKILL.md` | The same skills, for agents that read `.agents/` |
+
+Each project gets a `php-tests` skill that uses your test runner, plus skills for its framework:
+
+| Framework | Skills |
+| --- | --- |
+| Leaf | `leaf-routes` |
+| Leaf MVC | `leaf-mvc-feature`, `leaf-database` (schema files, models, seeds) |
+| Laravel | `laravel-feature`, `laravel-database` |
+| Symfony | `symfony-controller`, `symfony-doctrine` |
+| Plain PHP | `php-package` |
+
+To see what was detected without writing anything:
+
+```bash
+vendor/bin/llms detect
+vendor/bin/llms init --dry-run
+```
+
+If detection picks the wrong framework, set it yourself with `--framework=leaf-mvc`, `leaf`, `laravel`, `symfony` or `php`.
+
+## What it detects
+
+- The framework, from `composer.json`
+- The PHP version constraint and PSR-4 namespaces
+- The test runner (Pest, PHPUnit, or Pest through Alchemy) and the command that runs it
+- Code style tools (Pint, PHP CS Fixer, PHP_CodeSniffer) and static analysis (PHPStan, Larastan, Psalm, Rector)
+- Your Composer scripts, which it prefers over calling tools directly
+- Folders that exist, described for your framework
+
+## Re-running is safe
+
+Generated text sits between two markers:
+
+```md
+
+...
+
+```
+
+Running `init` again only replaces what's between them. Anything you write above or below stays. If a file already exists without markers, such as the `AGENTS.md` that ships with Leaf MVC, the generated section is added at the end and your text is left alone.
+
+Re-run it after you add a tool or change your scripts. You can add it to `composer.json` so it's easy to remember:
+
+```json
+"scripts": {
+ "llms": "llms init"
+}
+```
+
+## License
+
+MIT
diff --git a/alchemy.yml b/alchemy.yml
new file mode 100644
index 0000000..8b97b96
--- /dev/null
+++ b/alchemy.yml
@@ -0,0 +1,60 @@
+app:
+ - src
+
+tests:
+ engine: pest
+ parallel: false
+ paths:
+ - tests
+ files:
+ - '*.test.php'
+ coverage:
+ processUncoveredFiles: true
+
+lint:
+ preset: PSR12
+ rules:
+ single_quote: true
+ phpdoc_scalar: true
+ no_unused_imports: true
+ unary_operator_spaces: true
+ binary_operator_spaces: true
+ phpdoc_var_without_name: true
+ trailing_comma_in_multiline: true
+ phpdoc_single_line_var_spacing: true
+ single_trait_insert_per_statement: true
+ not_operator_with_successor_space: false
+ array_syntax:
+ syntax: short
+ ordered_imports:
+ sort_algorithm: alpha
+ method_argument_space:
+ on_multiline: ensure_fully_multiline
+ keep_multiple_spaces_after_comma: true
+ blank_line_before_statement:
+ statements:
+ - try
+ - break
+ - throw
+ - return
+ - declare
+ - continue
+
+actions:
+ run:
+ - lint
+ - tests
+ os:
+ - macos-latest
+ - ubuntu-latest
+ - windows-latest
+ php:
+ extensions: json, zip, pdo, pdo_sqlite
+ versions: # library supports ^7.4|^8.0, but pest 3 needs 8.2+
+ - '8.5'
+ - '8.4'
+ - '8.3'
+ - '8.2'
+ events:
+ - push
+ - pull_request
diff --git a/bin/llms b/bin/llms
new file mode 100755
index 0000000..0121b09
--- /dev/null
+++ b/bin/llms
@@ -0,0 +1,14 @@
+#!/usr/bin/env php
+add(new Leaf\Llms\Commands\InitCommand());
+$app->add(new Leaf\Llms\Commands\DetectCommand());
+$app->run();
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..94ff6a7
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,57 @@
+{
+ "name": "leafs/llms",
+ "description": "Generate llms.txt, AGENTS.md, CLAUDE.md, Cursor rules and agent skills for any PHP project",
+ "type": "library",
+ "license": "MIT",
+ "keywords": [
+ "llms.txt",
+ "agents.md",
+ "claude",
+ "cursor",
+ "ai",
+ "php",
+ "leaf",
+ "laravel",
+ "symfony"
+ ],
+ "homepage": "https://leafphp.dev",
+ "authors": [
+ {
+ "name": "Michael Darko",
+ "email": "mickdd22@gmail.com",
+ "homepage": "https://mychi.netlify.app",
+ "role": "Developer"
+ }
+ ],
+ "require": {
+ "php": "^8.2",
+ "symfony/console": "^6.4 || ^7.0"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "*",
+ "leafs/alchemy": "^5.0",
+ "pestphp/pest": "*"
+ },
+ "autoload": {
+ "psr-4": {
+ "Leaf\\Llms\\": "src/"
+ }
+ },
+ "bin": [
+ "bin/llms"
+ ],
+ "scripts": {
+ "test": "@php vendor/bin/alchemy test",
+ "lint": "@php vendor/bin/alchemy lint",
+ "fmt": "@php vendor/bin/alchemy fmt",
+ "ci": "@php vendor/bin/alchemy ci"
+ },
+ "config": {
+ "allow-plugins": {
+ "pestphp/pest-plugin": true
+ },
+ "sort-packages": true
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true
+}
diff --git a/kata.yml b/kata.yml
new file mode 100644
index 0000000..a45c0e8
--- /dev/null
+++ b/kata.yml
@@ -0,0 +1,4 @@
+surfaces:
+ - "README.md"
+rules:
+ preset: leaf-writing
diff --git a/src/Commands/DetectCommand.php b/src/Commands/DetectCommand.php
new file mode 100644
index 0000000..2a6b5fa
--- /dev/null
+++ b/src/Commands/DetectCommand.php
@@ -0,0 +1,47 @@
+addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Project root (defaults to the current directory)');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ try {
+ $project = (new Detector())->detect($input->getOption('path') ?: (string) getcwd());
+ } catch (\Throwable $e) {
+ $output->writeln("{$e->getMessage()}");
+
+ return Command::FAILURE;
+ }
+
+ $rows = [
+ 'Name' => $project->name,
+ 'Framework' => $project->framework,
+ 'PHP' => $project->php ?: '(not set)',
+ 'Tests' => $project->testRunner ?? '(none)',
+ ];
+
+ foreach ($project->commands as $label => $command) {
+ $rows[ucfirst($label)] = $command;
+ }
+
+ foreach ($rows as $label => $value) {
+ $output->writeln(sprintf('%-10s %s', $label, $value));
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Commands/InitCommand.php b/src/Commands/InitCommand.php
new file mode 100644
index 0000000..9eb3470
--- /dev/null
+++ b/src/Commands/InitCommand.php
@@ -0,0 +1,61 @@
+addOption('framework', 'f', InputOption::VALUE_REQUIRED, 'Skip detection: ' . implode(', ', Detector::FRAMEWORKS))
+ ->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Project root (defaults to the current directory)')
+ ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would change without writing anything');
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $root = $input->getOption('path') ?: (string) getcwd();
+ $dryRun = (bool) $input->getOption('dry-run');
+
+ try {
+ $project = (new Detector())->detect($root, $input->getOption('framework') ?: null);
+ } catch (\Throwable $e) {
+ $output->writeln("{$e->getMessage()}");
+
+ return Command::FAILURE;
+ }
+
+ $generator = new Generator($project);
+ $output->writeln("Detected {$generator->framework()->label()} in {$project->name}");
+
+ $results = (new Writer($project->root))->write($generator->files(), $dryRun);
+ $styles = [
+ Sections::CREATED => 'info',
+ Sections::UPDATED => 'comment',
+ Sections::APPENDED => 'comment',
+ Sections::UNCHANGED => 'fg=gray',
+ ];
+
+ foreach ($results as $path => $status) {
+ $label = $dryRun && $status !== Sections::UNCHANGED ? "would be $status" : $status;
+ $output->writeln(sprintf(' <%s>%-18s> %s', $styles[$status], $label, $path));
+ }
+
+ if ($dryRun) {
+ $output->writeln('Dry run: nothing was written.');
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Detector.php b/src/Detector.php
new file mode 100644
index 0000000..8bd1427
--- /dev/null
+++ b/src/Detector.php
@@ -0,0 +1,204 @@
+framework($packages);
+ $scripts = $this->scripts($composer['scripts'] ?? []);
+ [$testRunner, $commands] = $this->commands($root, $framework, $packages, $scripts);
+
+ $skeleton = in_array($composer['name'] ?? '', self::SKELETONS, true);
+
+ return new Project(
+ root: $root,
+ name: $skeleton || empty($composer['name']) ? basename($root) : (string) $composer['name'],
+ description: $skeleton ? '' : trim((string) ($composer['description'] ?? '')),
+ framework: $framework,
+ php: (string) ($composer['require']['php'] ?? ''),
+ psr4: $this->psr4($composer),
+ testRunner: $testRunner,
+ commands: $commands,
+ scripts: $scripts,
+ directories: array_values(array_filter(
+ self::DIRECTORIES,
+ fn ($dir) => is_dir($root . DIRECTORY_SEPARATOR . $dir)
+ )),
+ packages: $packages,
+ );
+ }
+
+ /**
+ * @param array $packages
+ */
+ public function framework(array $packages): string
+ {
+ return match (true) {
+ isset($packages['leafs/mvc-core']) || isset($packages['leafs/mvc']) => 'leaf-mvc',
+ isset($packages['laravel/framework']) => 'laravel',
+ isset($packages['symfony/framework-bundle']) => 'symfony',
+ isset($packages['leafs/leaf']) => 'leaf',
+ default => 'php',
+ };
+ }
+
+
+
+ /**
+ * @return array
+ */
+ protected function psr4(array $composer): array
+ {
+ $map = [];
+
+ foreach ([$composer['autoload']['psr-4'] ?? [], $composer['autoload-dev']['psr-4'] ?? []] as $psr4) {
+ foreach ($psr4 as $namespace => $paths) {
+ $map[$namespace] = implode(', ', (array) $paths);
+ }
+ }
+
+ // Drop sub-namespaces that just follow their parent's folder
+ // (App\Models => app/models/ under App\ => app/); they add noise.
+ foreach ($map as $namespace => $path) {
+ foreach ($map as $parent => $parentPath) {
+ if (
+ $parent !== $namespace
+ && str_starts_with($namespace, $parent)
+ && str_starts_with(strtolower($path), strtolower(rtrim($parentPath, '/') . '/'))
+ ) {
+ unset($map[$namespace]);
+
+ break;
+ }
+ }
+ }
+
+ return $map;
+ }
+
+ /**
+ * @return array script => "composer script"
+ */
+ protected function scripts(array $scripts): array
+ {
+ $result = [];
+
+ foreach (array_keys($scripts) as $script) {
+ foreach (self::HOOK_PREFIXES as $prefix) {
+ if (str_starts_with($script, $prefix)) {
+ continue 2;
+ }
+ }
+
+ $result[$script] = "composer $script";
+ }
+
+ return $result;
+ }
+
+ /**
+ * @return array{0: ?string, 1: array}
+ */
+ protected function commands(string $root, string $framework, array $packages, array $scripts): array
+ {
+ $has = fn (string $package) => isset($packages[$package]);
+ $commands = ['install' => 'composer install'];
+
+ $runner = match (true) {
+ $has('leafs/alchemy') => 'Alchemy (Pest)',
+ $has('pestphp/pest') => 'Pest',
+ $has('phpunit/phpunit') => 'PHPUnit',
+ default => null,
+ };
+
+ $commands['test'] = match (true) {
+ isset($scripts['test']) => 'composer test',
+ $framework === 'laravel' => 'php artisan test',
+ $has('leafs/alchemy') => 'vendor/bin/alchemy test',
+ $has('pestphp/pest') => 'vendor/bin/pest',
+ $has('phpunit/phpunit') && $framework === 'symfony' && is_file("$root/bin/phpunit") => 'php bin/phpunit',
+ $has('phpunit/phpunit') => 'vendor/bin/phpunit',
+ default => null,
+ };
+
+ $commands['lint'] = match (true) {
+ isset($scripts['lint']) => 'composer lint',
+ $has('laravel/pint') => 'vendor/bin/pint --test',
+ $has('leafs/alchemy') => 'vendor/bin/alchemy lint',
+ $has('friendsofphp/php-cs-fixer') => 'vendor/bin/php-cs-fixer fix --dry-run --diff',
+ $has('squizlabs/php_codesniffer') => 'vendor/bin/phpcs',
+ default => null,
+ };
+
+ $commands['format'] = match (true) {
+ isset($scripts['fmt']) => 'composer fmt',
+ isset($scripts['format']) => 'composer format',
+ $has('laravel/pint') => 'vendor/bin/pint',
+ $has('leafs/alchemy') => 'vendor/bin/alchemy fmt',
+ $has('friendsofphp/php-cs-fixer') => 'vendor/bin/php-cs-fixer fix',
+ $has('squizlabs/php_codesniffer') => 'vendor/bin/phpcbf',
+ default => null,
+ };
+
+ $commands['analyse'] = match (true) {
+ isset($scripts['analyse']) => 'composer analyse',
+ isset($scripts['analyze']) => 'composer analyze',
+ isset($scripts['phpstan']) => 'composer phpstan',
+ $has('phpstan/phpstan') || $has('larastan/larastan') => 'vendor/bin/phpstan analyse',
+ $has('vimeo/psalm') => 'vendor/bin/psalm',
+ default => null,
+ };
+
+ $commands['refactor'] = match (true) {
+ isset($scripts['refactor']) => 'composer refactor',
+ $has('rector/rector') => 'vendor/bin/rector process --dry-run',
+ default => null,
+ };
+
+ return [$runner, array_filter($commands)];
+ }
+}
diff --git a/src/File.php b/src/File.php
new file mode 100644
index 0000000..d2ce021
--- /dev/null
+++ b/src/File.php
@@ -0,0 +1,16 @@
+framework) {
+ 'leaf-mvc' => new LeafMvc($project),
+ 'leaf' => new Leaf($project),
+ 'laravel' => new Laravel($project),
+ 'symfony' => new Symfony($project),
+ default => new PlainPhp($project),
+ };
+ }
+
+ /** Human name, e.g. "Laravel". */
+ abstract public function label(): string;
+
+ /** Composer package that identifies the framework, if any. */
+ abstract public function package(): ?string;
+
+ /**
+ * Documentation links, title => url.
+ *
+ * @return array
+ */
+ abstract public function docs(): array;
+
+ /**
+ * What each well-known directory holds, directory => description.
+ *
+ * @return array
+ */
+ abstract public function directories(): array;
+
+ /**
+ * Rules an agent should follow in this framework.
+ *
+ * @return string[]
+ */
+ abstract public function conventions(): array;
+
+ /**
+ * Framework-specific skills, on top of the shared ones.
+ *
+ * @return Skill[]
+ */
+ abstract protected function frameworkSkills(): array;
+
+ /**
+ * Extra lines for the top of AGENTS.md, such as other files to read first.
+ *
+ * @return string[]
+ */
+ public function readFirst(): array
+ {
+ return [];
+ }
+
+ /**
+ * Framework CLI commands worth listing, label => command or commands.
+ *
+ * @return array
+ */
+ public function cliCommands(): array
+ {
+ return [];
+ }
+
+ /**
+ * @return Skill[]
+ */
+ public function skills(): array
+ {
+ return [$this->testSkill(), ...$this->frameworkSkills()];
+ }
+
+ protected function testSkill(): Skill
+ {
+ $test = $this->project->command('test');
+ $runner = $this->project->testRunner;
+ $lines = [];
+
+ $lines[] = '# Write and run tests';
+ $lines[] = '';
+
+ if ($test === null) {
+ $lines[] = 'This project has no test runner installed yet. Ask before adding one. For a new setup, '
+ . ($this->project->framework === 'leaf' || $this->project->framework === 'leaf-mvc'
+ ? '`composer require --dev leafs/alchemy` then `vendor/bin/alchemy init` sets up Pest with Leaf defaults.'
+ : '`composer require --dev pestphp/pest` then `vendor/bin/pest --init` is the usual start.');
+ } else {
+ $lines[] = "Run the suite with `$test`" . ($runner ? " ($runner)." : '.');
+ $lines[] = '';
+ $lines[] = '1. Find the existing test closest to what you are changing and copy its style: file location, naming, helpers and assertions.';
+ $lines[] = '2. Write a failing test for the behaviour first when you fix a bug, so the fix is proven.';
+ $lines[] = '3. Run only the file you are working on while iterating, then the full suite before you finish.';
+ $lines[] = '4. Do not delete or skip a failing test to get a green run. Fix the code, or explain why the test is wrong.';
+ }
+
+ foreach ($this->testHints() as $hint) {
+ $lines[] = '';
+ $lines[] = $hint;
+ }
+
+ return new Skill(
+ 'php-tests',
+ 'Write and run tests for this PHP project. Use when adding a feature, fixing a bug, or when asked to test something.',
+ implode("\n", $lines)
+ );
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function testHints(): array
+ {
+ $runner = $this->project->testRunner;
+
+ if ($runner === null) {
+ return [];
+ }
+
+ if (str_contains($runner, 'Pest')) {
+ return ['Tests use Pest syntax: `it(\'does something\', function () { expect($value)->toBe(1); });`. Filter with `--filter="name"`.'];
+ }
+
+ return ['Tests are PHPUnit classes extending `PHPUnit\\Framework\\TestCase` with `test*` methods. Filter with `--filter=testName`.'];
+ }
+}
diff --git a/src/Frameworks/Laravel.php b/src/Frameworks/Laravel.php
new file mode 100644
index 0000000..36b27e9
--- /dev/null
+++ b/src/Frameworks/Laravel.php
@@ -0,0 +1,99 @@
+ 'https://laravel.com/docs',
+ ];
+ }
+
+ public function directories(): array
+ {
+ return [
+ 'app' => 'application code: `Http/Controllers`, `Models`, `Providers` and your own classes',
+ 'routes' => 'route files (`web.php`, plus `api.php` and `console.php` when present)',
+ 'config' => 'config files, read through `config()`',
+ 'database' => 'migrations, plus factories and seeders for test data',
+ 'resources' => 'Blade views and front-end sources',
+ 'public' => 'web root',
+ 'storage' => 'logs, cache and uploads; not source code',
+ 'tests' => '`Feature` and `Unit` tests',
+ 'bootstrap' => 'framework boot files; rarely edited',
+ ];
+ }
+
+ public function cliCommands(): array
+ {
+ return [
+ 'Serve' => 'php artisan serve',
+ 'Generate' => 'php artisan make:',
+ 'Migrate' => 'php artisan migrate',
+ 'Routes' => 'php artisan route:list',
+ ];
+ }
+
+ public function conventions(): array
+ {
+ return [
+ 'Generate classes with `php artisan make:*` so names and locations follow Laravel defaults.',
+ 'Validate input in Form Requests (`php artisan make:request`) or `$request->validate()`, never by hand.',
+ 'Use Eloquent models and relationships for data access; avoid raw SQL unless the codebase already does it, and always bind values.',
+ 'Change the schema with a new migration. Never edit a migration that may already have run in another environment.',
+ 'Read configuration through `config()`; only `config/*.php` files should call `env()`.',
+ 'Prefer the framework\'s own tools (queues, events, policies, mail, cache) over custom code that does the same job.',
+ ];
+ }
+
+ protected function frameworkSkills(): array
+ {
+ return [
+ new Skill(
+ 'laravel-feature',
+ 'Add an endpoint or page to a Laravel app. Use when asked for a new route, controller action, form or API endpoint.',
+ implode("\n", [
+ '# Add a feature to a Laravel app',
+ '',
+ '1. Check `php artisan route:list` and the existing controllers for a pattern to follow.',
+ '2. Generate what you need: `php artisan make:controller PostController`, `php artisan make:request StorePostRequest`.',
+ '3. Register the route in `routes/web.php` (or `routes/api.php` if the project has it) and name it:',
+ '',
+ '```php',
+ 'Route::post(\'/posts\', [PostController::class, \'store\'])->name(\'posts.store\');',
+ '```',
+ '',
+ '4. Put validation rules and authorization in the Form Request and keep the controller action short.',
+ '5. Write a feature test (`php artisan make:test PostTest`) that hits the route, then run the suite.',
+ ])
+ ),
+ new Skill(
+ 'laravel-database',
+ 'Create or change tables, models, factories or seeders in a Laravel app. Use for any schema or Eloquent work.',
+ implode("\n", [
+ '# Change the database in Laravel',
+ '',
+ '1. Create a model with its migration and factory: `php artisan make:model Post -mf`.',
+ '2. For changes to an existing table, add a new migration: `php artisan make:migration add_status_to_posts_table`. Do not edit old migrations.',
+ '3. Fill in `up()` and a matching `down()`, then run `php artisan migrate`.',
+ '4. Add relationships, `$fillable` (or `$guarded`) and casts to the model.',
+ '5. Update the factory so tests can create valid records, and use it in tests rather than inserting rows by hand.',
+ ])
+ ),
+ ];
+ }
+}
diff --git a/src/Frameworks/Leaf.php b/src/Frameworks/Leaf.php
new file mode 100644
index 0000000..c64ee40
--- /dev/null
+++ b/src/Frameworks/Leaf.php
@@ -0,0 +1,87 @@
+ 'https://leafphp.dev/llms.txt',
+ 'Leaf agent skill' => 'https://leafphp.dev/ai/SKILL.md',
+ 'Leaf documentation' => 'https://leafphp.dev/docs/',
+ ];
+ }
+
+ public function directories(): array
+ {
+ return [
+ 'app' => 'application code',
+ 'src' => 'application code',
+ 'routes' => 'route files',
+ 'public' => 'web root; `public/index.php` boots the app',
+ 'views' => 'templates',
+ 'tests' => 'tests',
+ 'storage' => 'logs, cache and uploads; not source code',
+ ];
+ }
+
+ public function conventions(): array
+ {
+ return [
+ 'Leaf 5 exposes its services through global helpers: `app()`, `request()`, `response()`, `db()`, `auth()`. Use them instead of building objects by hand.',
+ 'Define routes with `app()->get()`, `->post()`, `->put()`, `->delete()` or `->match()`; group them with `app()->group()` and protect them with middleware.',
+ 'Read input with `request()->get(\'key\')` or `request()->validate([...])`, and reply with `response()->json(...)`, `response()->markup(...)` or `response()->redirect(...)`.',
+ 'Leaf is modular: add features with `leaf install ` or `composer require leafs/` (db, auth, session, mail, http...) instead of writing them from scratch.',
+ 'Query the database through `db()` (leafs/db) with bindings; never build SQL from raw input.',
+ ];
+ }
+
+ protected function frameworkSkills(): array
+ {
+ return [new Skill(
+ 'leaf-routes',
+ 'Add or change an HTTP route in a Leaf app. Use when asked for a new endpoint, page or API route.',
+ implode("\n", [
+ '# Add a Leaf route',
+ '',
+ '1. Find where routes live (usually `public/index.php`, `index.php` or a `routes/` folder) and follow the existing pattern.',
+ '2. Register the route:',
+ '',
+ '```php',
+ 'app()->post(\'/users\', function () {',
+ ' $data = request()->validate([',
+ ' \'name\' => \'string\',',
+ ' \'email\' => \'email\',',
+ ' ]);',
+ '',
+ ' if (!$data) {',
+ ' response()->exit(request()->errors(), 400);',
+ ' }',
+ '',
+ ' db()->insert(\'users\')->params($data)->execute();',
+ '',
+ ' response()->json([\'created\' => true], 201);',
+ '});',
+ '```',
+ '',
+ '3. Put shared logic in middleware (`app()->use()` or a route `middleware` option) rather than repeating it in handlers.',
+ '4. Add a test for the route, then run the suite.',
+ '',
+ 'Check https://leafphp.dev/llms.txt for the current API before using a helper you have not seen in this codebase.',
+ ])
+ )];
+ }
+}
diff --git a/src/Frameworks/LeafMvc.php b/src/Frameworks/LeafMvc.php
new file mode 100644
index 0000000..7420811
--- /dev/null
+++ b/src/Frameworks/LeafMvc.php
@@ -0,0 +1,141 @@
+project->has('leafs/mvc-core') ? 'leafs/mvc-core' : 'leafs/mvc';
+ }
+
+ public function docs(): array
+ {
+ return parent::docs() + [
+ 'Leaf MVC' => 'https://leafphp.dev/docs/mvc/',
+ 'Models (Eloquent)' => 'https://leafphp.dev/docs/database/models',
+ 'Schema files' => 'https://leafphp.dev/docs/database/files',
+ ];
+ }
+
+ public function readFirst(): array
+ {
+ $lines = [];
+
+ if ($this->project->hasPath('.leaf/CONTEXT.md')) {
+ $lines[] = 'Read `.leaf/CONTEXT.md` for the project goals and decisions, and update it when you finish a task.';
+ }
+
+ $lines[] = 'Run `leaf context` for a short map of the app.';
+
+ return $lines;
+ }
+
+ public function directories(): array
+ {
+ return [
+ 'app' => 'application code, split into the folders below',
+ 'app/routes' => 'route files; files starting with `_` are loaded automatically',
+ 'app/controllers' => 'controllers, extending `App\\Controllers\\Controller`',
+ 'app/models' => 'Eloquent models, extending `App\\Models\\Model`',
+ 'app/database' => 'schema files, one `.yml` per table',
+ 'app/views' => 'views (Blade by default)',
+ 'config' => 'config files, published with `leaf config:publish`',
+ 'public' => 'web root and built assets',
+ 'storage' => 'logs, cache and uploads; not source code',
+ 'tests' => 'tests',
+ '.leaf' => 'project context for agents (`CONTEXT.md`)',
+ ];
+ }
+
+ public function cliCommands(): array
+ {
+ return [
+ 'Serve' => 'leaf serve',
+ 'Generate' => ['leaf g:controller ', 'leaf g:model ', 'leaf g:schema ', 'leaf g:middleware '],
+ 'Migrate' => 'leaf db:migrate',
+ 'Seed' => 'leaf db:seed',
+ 'Roll back' => 'leaf db:rollback',
+ ];
+ }
+
+ public function conventions(): array
+ {
+ return [
+ 'Use the global `leaf` binary for console commands (no `php` prefix). It forwards project commands, and some commands such as `leaf context` only exist there.',
+ 'Generate files with `leaf g:*` instead of creating them by hand, so namespaces and base classes match.',
+ 'Controllers extend `App\\Controllers\\Controller` and reach the request and response through `request()` and `response()`. Keep them thin; put reusable logic in models or classes under `app/`.',
+ 'Models are Eloquent, so relationships and casts work the same way as in Laravel.',
+ 'Change the database through schema files in `app/database/*.yml`, then run `leaf db:migrate`. Do not hand-write migrations unless the project already has them.',
+ 'Configuration comes from `.env` and `config/`. Never commit secrets or a real `.env`.',
+ ];
+ }
+
+ protected function frameworkSkills(): array
+ {
+ return [
+ new Skill(
+ 'leaf-mvc-feature',
+ 'Add a page, endpoint or feature to a Leaf MVC app. Use when asked to build something that needs a route, controller or view.',
+ implode("\n", [
+ '# Add a feature to a Leaf MVC app',
+ '',
+ '1. Run `leaf context` to see what already exists.',
+ '2. Generate the controller: `leaf g:controller Posts` (add `--resource` for a CRUD controller).',
+ '3. Register routes in a partial such as `app/routes/_posts.php`:',
+ '',
+ '```php',
+ 'app()->get(\'/posts\', \'PostsController@index\');',
+ 'app()->post(\'/posts\', \'PostsController@store\');',
+ '```',
+ '',
+ '4. In the controller, validate with `request()->validate([...])` and reply with `response()->json(...)` or `response()->render(\'posts.index\', [...])`.',
+ '5. If the feature needs a table, follow the `leaf-database` skill.',
+ '6. Add a test, run the suite, and note the change in `.leaf/CONTEXT.md` if the project has one.',
+ ])
+ ),
+ new Skill(
+ 'leaf-database',
+ 'Create or change a database table, model, seed or factory in a Leaf MVC app. Use for any schema or model work.',
+ implode("\n", [
+ '# Change the database in Leaf MVC',
+ '',
+ 'Leaf MVC keeps each table in one schema file, `app/database/.yml`.',
+ '',
+ '1. Create one with `leaf g:schema posts`, or edit the existing file.',
+ '',
+ '```yml',
+ 'columns:',
+ ' title: string',
+ ' body: text',
+ ' published_at:',
+ ' type: timestamp',
+ ' nullable: true',
+ '',
+ 'relationships:',
+ ' - User',
+ '',
+ 'seeds:',
+ ' count: 10',
+ ' data:',
+ ' title: \'@faker.sentence\'',
+ ' body: \'@faker.paragraph\'',
+ '```',
+ '',
+ '2. Apply it with `leaf db:migrate` (or `leaf db:migrate posts` for one table). Leaf works out what changed.',
+ '3. Seed with `leaf db:seed`. Undo with `leaf db:rollback`.',
+ '4. Generate the model with `leaf g:model Post` and add relationships (`hasMany`, `belongsTo`) to match the schema file.',
+ '',
+ 'Read https://leafphp.dev/docs/database/files before using a schema key you have not seen in this project.',
+ ])
+ ),
+ ];
+ }
+}
diff --git a/src/Frameworks/PlainPhp.php b/src/Frameworks/PlainPhp.php
new file mode 100644
index 0000000..82fdb1c
--- /dev/null
+++ b/src/Frameworks/PlainPhp.php
@@ -0,0 +1,70 @@
+ 'https://www.php.net/manual/en/',
+ 'Composer documentation' => 'https://getcomposer.org/doc/',
+ 'PSR-12 coding style' => 'https://www.php-fig.org/psr/psr-12/',
+ ];
+ }
+
+ public function directories(): array
+ {
+ return [
+ 'src' => 'library or application code',
+ 'lib' => 'library code',
+ 'app' => 'application code',
+ 'public' => 'web root',
+ 'bin' => 'executables',
+ 'config' => 'configuration',
+ 'tests' => 'tests',
+ 'test' => 'tests',
+ 'docs' => 'documentation',
+ ];
+ }
+
+ public function conventions(): array
+ {
+ return [
+ 'Classes follow PSR-4, with one class per file and the namespace matching the folder mapped in `composer.json`.',
+ 'After adding a namespace or moving classes, run `composer dump-autoload`.',
+ 'Follow PSR-12 formatting unless the project\'s formatter says otherwise.',
+ 'Add dependencies with `composer require` (or `--dev` for tooling) and commit `composer.json`; check before adding a package for something a few lines of code can do.',
+ 'Keep the public API stable: a library\'s public classes and methods are a contract, so mark breaking changes clearly.',
+ ];
+ }
+
+ protected function frameworkSkills(): array
+ {
+ return [new Skill(
+ 'php-package',
+ 'Add a class, dependency or public API to a plain PHP project or Composer library. Use when the change is not tied to a framework.',
+ implode("\n", [
+ '# Change a PHP project or library',
+ '',
+ '1. Find the namespace in `composer.json` (`autoload.psr-4`) and put new classes in the matching folder.',
+ '2. Type everything: parameter, return and property types. Add `declare(strict_types=1);` if the surrounding files use it.',
+ '3. Add a dependency only with `composer require vendor/package`, and explain why it is needed.',
+ '4. Update the README when the public API changes.',
+ '5. Add tests and run the suite before you finish.',
+ ])
+ )];
+ }
+}
diff --git a/src/Frameworks/Symfony.php b/src/Frameworks/Symfony.php
new file mode 100644
index 0000000..7c51ca9
--- /dev/null
+++ b/src/Frameworks/Symfony.php
@@ -0,0 +1,110 @@
+ 'https://symfony.com/doc/current/index.html',
+ ];
+ }
+
+ public function directories(): array
+ {
+ return [
+ 'src' => 'application code: `Controller`, `Entity`, `Repository` and services',
+ 'config' => 'bundle, routing and service configuration (`services.yaml`)',
+ 'templates' => 'Twig templates',
+ 'migrations' => 'Doctrine migrations',
+ 'public' => 'web root; `public/index.php` is the front controller',
+ 'bin' => '`bin/console`',
+ 'tests' => 'tests',
+ 'var' => 'cache and logs; not source code',
+ ];
+ }
+
+ public function cliCommands(): array
+ {
+ $commands = [
+ 'Console' => 'php bin/console',
+ 'Routes' => 'php bin/console debug:router',
+ 'Clear cache' => 'php bin/console cache:clear',
+ ];
+
+ if ($this->project->has('symfony/maker-bundle')) {
+ $commands['Generate'] = 'php bin/console make:';
+ }
+
+ if ($this->project->has('doctrine/doctrine-migrations-bundle')) {
+ $commands['Migrate'] = 'php bin/console doctrine:migrations:migrate';
+ }
+
+ return $commands;
+ }
+
+ public function conventions(): array
+ {
+ return [
+ 'Services in `src/` are autowired and autoconfigured; inject dependencies through constructors instead of fetching them from the container.',
+ 'Define routes with `#[Route]` attributes on controller methods and give every route a name.',
+ 'Controllers extend `AbstractController` and stay thin; put business logic in services.',
+ 'Render HTML with Twig templates in `templates/`; return `JsonResponse` or `$this->json()` for APIs.',
+ 'Change the database by editing entities, then generating a Doctrine migration. Do not edit migrations that may have run elsewhere.',
+ 'Keep secrets in environment variables or Symfony secrets, never in committed config.',
+ ];
+ }
+
+ protected function frameworkSkills(): array
+ {
+ return [
+ new Skill(
+ 'symfony-controller',
+ 'Add a route or controller to a Symfony app. Use when asked for a new page, form or API endpoint.',
+ implode("\n", [
+ '# Add a Symfony controller',
+ '',
+ '1. Check `php bin/console debug:router` for existing routes and naming.',
+ '2. Generate the controller with `php bin/console make:controller PostController` (needs symfony/maker-bundle) or add a method to an existing one.',
+ '3. Route it with an attribute:',
+ '',
+ '```php',
+ '#[Route(\'/posts\', name: \'post_index\', methods: [\'GET\'])]',
+ 'public function index(PostRepository $posts): Response',
+ '{',
+ ' return $this->render(\'post/index.html.twig\', [\'posts\' => $posts->findAll()]);',
+ '}',
+ '```',
+ '',
+ '4. Inject services as action or constructor arguments; let autowiring resolve them.',
+ '5. Add a functional test with `WebTestCase`, then run the suite.',
+ ])
+ ),
+ new Skill(
+ 'symfony-doctrine',
+ 'Create or change Doctrine entities and migrations in a Symfony app. Use for any schema or entity work.',
+ implode("\n", [
+ '# Change the database with Doctrine',
+ '',
+ '1. Create or update an entity with `php bin/console make:entity Post`, or edit the class and its mapping attributes.',
+ '2. Generate a migration from the diff: `php bin/console make:migration`. Read the SQL it produced before keeping it.',
+ '3. Apply it with `php bin/console doctrine:migrations:migrate`.',
+ '4. Put custom queries in the entity\'s repository class using the query builder or DQL with parameters.',
+ ])
+ ),
+ ];
+ }
+}
diff --git a/src/Generator.php b/src/Generator.php
new file mode 100644
index 0000000..f4fec2b
--- /dev/null
+++ b/src/Generator.php
@@ -0,0 +1,312 @@
+framework = Framework::for($project);
+ }
+
+ public function framework(): Framework
+ {
+ return $this->framework;
+ }
+
+ /**
+ * @return File[]
+ */
+ public function files(): array
+ {
+ $files = [
+ new File('AGENTS.md', $this->agents()),
+ new File('CLAUDE.md', $this->claude()),
+ new File('llms.txt', $this->llms()),
+ new File('.cursor/rules/project.mdc', $this->cursorProject(), $this->mdc(
+ 'Project overview, commands and conventions for ' . $this->project->name,
+ null,
+ true
+ )),
+ new File('.cursor/rules/php.mdc', $this->cursorPhp(), $this->mdc(
+ $this->framework->label() . ' conventions for PHP files',
+ '**/*.php',
+ false
+ )),
+ ];
+
+ foreach ($this->framework->skills() as $skill) {
+ foreach (['.claude/skills', '.agents/skills'] as $dir) {
+ $files[] = new File("$dir/{$skill->name}/SKILL.md", $skill->body, $skill->frontmatter());
+ }
+ }
+
+ return $files;
+ }
+
+ protected function notice(): string
+ {
+ return '_Generated by [leafs/llms](https://github.com/leafsphp/llms). Re-run `vendor/bin/llms init` to refresh this section; anything outside the markers is yours and is kept._';
+ }
+
+ public function agents(): string
+ {
+ $p = $this->project;
+ $f = $this->framework;
+ $out = ['# ' . $p->name, ''];
+
+ if ($p->description !== '') {
+ $out[] = $p->description;
+ $out[] = '';
+ }
+
+ $out[] = $this->notice();
+ $out[] = '';
+
+ if ($read = $f->readFirst()) {
+ $out[] = '## Read first';
+ $out[] = '';
+
+ foreach ($read as $line) {
+ $out[] = "- $line";
+ }
+
+ $out[] = '';
+ }
+
+ $out[] = '## Stack';
+ $out[] = '';
+ $out = [...$out, ...$this->stack(), ''];
+
+ $out[] = '## Commands';
+ $out[] = '';
+ $out = [...$out, ...$this->commands(), ''];
+
+ if ($layout = $this->layout()) {
+ $out[] = '## Layout';
+ $out[] = '';
+ $out = [...$out, ...$layout, ''];
+ }
+
+ $out[] = '## Conventions';
+ $out[] = '';
+
+ foreach ($f->conventions() as $rule) {
+ $out[] = "- $rule";
+ }
+
+ $out[] = '';
+ $out[] = '## Before you finish';
+ $out[] = '';
+ $out = [...$out, ...$this->checklist(), ''];
+
+ $out[] = '## Docs';
+ $out[] = '';
+
+ foreach ($f->docs() as $title => $url) {
+ $out[] = "- [$title]($url)";
+ }
+
+ return implode("\n", $out);
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function stack(): array
+ {
+ $p = $this->project;
+ $lines = [];
+
+ if ($p->php !== '') {
+ $lines[] = "- PHP `{$p->php}`";
+ }
+
+ $package = $this->framework->package();
+
+ if ($package !== null && isset($p->packages[$package])) {
+ $lines[] = "- {$this->framework->label()} (`$package` `{$p->packages[$package]}`)";
+ } elseif ($package === null) {
+ $lines[] = '- Plain PHP with Composer, no framework';
+ }
+
+ if ($p->testRunner !== null) {
+ $lines[] = "- Tests: {$p->testRunner}";
+ }
+
+ foreach ($p->psr4 as $namespace => $path) {
+ $lines[] = '- `' . rtrim($namespace, '\\') . "` lives in `$path`";
+ }
+
+ return $lines;
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function commands(): array
+ {
+ $labels = [
+ 'install' => 'Install',
+ 'test' => 'Test',
+ 'lint' => 'Lint',
+ 'format' => 'Format',
+ 'analyse' => 'Static analysis',
+ 'refactor' => 'Refactor check',
+ ];
+ $lines = [];
+ $listed = [];
+
+ foreach ($labels as $key => $label) {
+ if ($command = $this->project->command($key)) {
+ $lines[] = "- $label: `$command`";
+ $listed[] = $command;
+ }
+ }
+
+ foreach ($this->framework->cliCommands() as $label => $command) {
+ $lines[] = "- $label: " . implode(', ', array_map(fn ($c) => "`$c`", (array) $command));
+ }
+
+ $scripts = array_diff($this->project->scripts, $listed);
+
+ if ($scripts) {
+ $lines[] = '- Composer scripts: ' . implode(', ', array_map(fn ($s) => "`$s`", $scripts));
+ }
+
+ return $lines;
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function layout(): array
+ {
+ $lines = [];
+
+ foreach ($this->framework->directories() as $dir => $description) {
+ if (is_dir($this->project->root . DIRECTORY_SEPARATOR . $dir)) {
+ $lines[] = "- `$dir/`: $description";
+ }
+ }
+
+ return $lines;
+ }
+
+ /**
+ * @return string[]
+ */
+ protected function checklist(): array
+ {
+ $p = $this->project;
+ $lines = [];
+
+ if ($test = $p->command('test')) {
+ $lines[] = "- Run `$test` and make sure it passes.";
+ }
+
+ $checks = array_filter([$p->command('lint'), $p->command('analyse')]);
+
+ if ($checks) {
+ $lines[] = '- Run ' . implode(' and ', array_map(fn ($c) => "`$c`", $checks)) . ' and fix what they report.';
+ }
+
+ $lines[] = '- Match the style of the code around your change, and keep the change focused on the task.';
+ $lines[] = '- Do not edit `vendor/`, generated files or lock files by hand.';
+
+ if ($p->hasPath('.leaf/CONTEXT.md')) {
+ $lines[] = '- Update `.leaf/CONTEXT.md` with what changed.';
+ }
+
+ return $lines;
+ }
+
+ public function claude(): string
+ {
+ return implode("\n", [
+ '@AGENTS.md',
+ '',
+ 'AGENTS.md is the source of truth for this project. Skills live in `.claude/skills/`.',
+ ]);
+ }
+
+ public function llms(): string
+ {
+ $p = $this->project;
+ $f = $this->framework;
+ $out = ['# ' . $p->name, ''];
+
+ $summary = $p->description !== '' ? $p->description : "A {$f->label()} project.";
+ $out[] = "> $summary";
+ $out[] = '';
+ $out[] = "Built with {$f->label()}" . ($p->php !== '' ? " on PHP `{$p->php}`" : '') . '. Agents should start with AGENTS.md.';
+ $out[] = '';
+ $out[] = '## Project';
+ $out[] = '';
+ $out[] = '- [AGENTS.md](AGENTS.md): stack, commands, layout and conventions';
+
+ foreach (['README.md', 'README.MD', 'readme.md'] as $readme) {
+ if ($p->hasPath($readme)) {
+ $out[] = "- [README]($readme): what the project is and how to use it";
+
+ break;
+ }
+ }
+
+ $out[] = '- [composer.json](composer.json): dependencies and scripts';
+
+ if ($p->hasPath('.leaf/CONTEXT.md')) {
+ $out[] = '- [.leaf/CONTEXT.md](.leaf/CONTEXT.md): goals and architectural decisions';
+ }
+
+ $out[] = '';
+ $out[] = '## Docs';
+ $out[] = '';
+
+ foreach ($f->docs() as $title => $url) {
+ $out[] = "- [$title]($url)";
+ }
+
+ return implode("\n", $out);
+ }
+
+ protected function mdc(string $description, ?string $globs, bool $always): string
+ {
+ return implode("\n", [
+ 'description: ' . $description,
+ 'globs: ' . ($globs ?? ''),
+ 'alwaysApply: ' . ($always ? 'true' : 'false'),
+ ]);
+ }
+
+ public function cursorProject(): string
+ {
+ return implode("\n", [
+ '# ' . $this->project->name,
+ '',
+ 'Read `AGENTS.md` at the project root before making changes. It lists the stack, the commands to run and the conventions to follow.',
+ '',
+ '## Commands',
+ '',
+ ...$this->commands(),
+ ]);
+ }
+
+ public function cursorPhp(): string
+ {
+ $out = ['# ' . $this->framework->label() . ' conventions', ''];
+
+ foreach ($this->framework->conventions() as $rule) {
+ $out[] = "- $rule";
+ }
+
+ return implode("\n", $out);
+ }
+}
diff --git a/src/Project.php b/src/Project.php
new file mode 100644
index 0000000..7db674f
--- /dev/null
+++ b/src/Project.php
@@ -0,0 +1,45 @@
+ $psr4 namespace => directory
+ * @param array $commands label => command
+ * @param array $scripts composer script => command to run it
+ * @param string[] $directories top-level directories that exist
+ */
+ public function __construct(
+ public readonly string $root,
+ public readonly string $name,
+ public readonly string $description,
+ public readonly string $framework,
+ public readonly string $php,
+ public readonly array $psr4,
+ public readonly ?string $testRunner,
+ public readonly array $commands,
+ public readonly array $scripts,
+ public readonly array $directories,
+ public readonly array $packages,
+ ) {
+ }
+
+ public function has(string $package): bool
+ {
+ return isset($this->packages[$package]);
+ }
+
+ public function hasPath(string $path): bool
+ {
+ return file_exists($this->root . DIRECTORY_SEPARATOR . $path);
+ }
+
+ public function command(string $label): ?string
+ {
+ return $this->commands[$label] ?? null;
+ }
+}
diff --git a/src/Sections.php b/src/Sections.php
new file mode 100644
index 0000000..e515467
--- /dev/null
+++ b/src/Sections.php
@@ -0,0 +1,49 @@
+';
+ public const END = '';
+
+ public const CREATED = 'created';
+ public const UPDATED = 'updated';
+ public const APPENDED = 'appended';
+ public const UNCHANGED = 'unchanged';
+
+ /**
+ * Merge a generated body into a file's current contents.
+ *
+ * - New file: frontmatter (if any), then the marked section.
+ * - File with markers: only the text between them is replaced.
+ * - File without markers: the marked section is added at the end.
+ *
+ * @return array{0: string, 1: string} new contents and what happened
+ */
+ public static function merge(?string $existing, string $body, ?string $frontmatter = null): array
+ {
+ $block = self::START . "\n" . trim($body) . "\n" . self::END;
+
+ if ($existing === null) {
+ $head = $frontmatter !== null ? "---\n" . trim($frontmatter) . "\n---\n\n" : '';
+
+ return [$head . $block . "\n", self::CREATED];
+ }
+
+ $start = strpos($existing, self::START);
+ $end = $start === false ? false : strpos($existing, self::END, $start);
+
+ if ($start !== false && $end !== false) {
+ $updated = substr($existing, 0, $start) . $block . substr($existing, $end + strlen(self::END));
+
+ return [$updated, $updated === $existing ? self::UNCHANGED : self::UPDATED];
+ }
+
+ return [rtrim($existing) . "\n\n" . $block . "\n", self::APPENDED];
+ }
+}
diff --git a/src/Skill.php b/src/Skill.php
new file mode 100644
index 0000000..145d4ad
--- /dev/null
+++ b/src/Skill.php
@@ -0,0 +1,24 @@
+ 64) {
+ throw new \InvalidArgumentException("Invalid skill name \"$name\"");
+ }
+ }
+
+ public function frontmatter(): string
+ {
+ return "name: {$this->name}\ndescription: " . json_encode($this->description, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ }
+}
diff --git a/src/Writer.php b/src/Writer.php
new file mode 100644
index 0000000..c2caf65
--- /dev/null
+++ b/src/Writer.php
@@ -0,0 +1,51 @@
+root = rtrim($root, '/\\');
+ }
+
+ /**
+ * @param File[] $files
+ * @return array path => status (see Sections constants)
+ */
+ public function write(array $files, bool $dryRun = false): array
+ {
+ $results = [];
+
+ foreach ($files as $file) {
+ $target = $this->root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $file->path);
+ $existing = is_file($target) ? (string) file_get_contents($target) : null;
+
+ if ($existing !== null) {
+ $existing = str_replace("\r\n", "\n", $existing);
+ }
+
+ [$contents, $status] = Sections::merge($existing, $file->body, $file->frontmatter);
+ $results[$file->path] = $status;
+
+ if ($dryRun || $status === Sections::UNCHANGED) {
+ continue;
+ }
+
+ $dir = dirname($target);
+
+ if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
+ throw new \RuntimeException("Could not create $dir");
+ }
+
+ if (file_put_contents($target, $contents) === false) {
+ throw new \RuntimeException("Could not write $target");
+ }
+ }
+
+ return $results;
+ }
+}
diff --git a/tests/Pest.php b/tests/Pest.php
new file mode 100644
index 0000000..39ece4f
--- /dev/null
+++ b/tests/Pest.php
@@ -0,0 +1,95 @@
+ $files
+ */
+function makeProject(array $composer, array $dirs = [], array $files = []): string
+{
+ $root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'llms-' . bin2hex(random_bytes(6));
+ mkdir($root, 0755, true);
+ file_put_contents("$root/composer.json", json_encode($composer, JSON_PRETTY_PRINT));
+
+ foreach ($dirs as $dir) {
+ mkdir("$root/$dir", 0755, true);
+ }
+
+ foreach ($files as $path => $contents) {
+ if (!is_dir(dirname("$root/$path"))) {
+ mkdir(dirname("$root/$path"), 0755, true);
+ }
+
+ file_put_contents("$root/$path", $contents);
+ }
+
+ return $root;
+}
+
+function laravelProject(): string
+{
+ return makeProject([
+ 'name' => 'laravel/laravel',
+ 'description' => 'The skeleton application for the Laravel framework.',
+ 'require' => ['php' => '^8.2', 'laravel/framework' => '^12.0'],
+ 'require-dev' => ['laravel/pint' => '^1.13', 'pestphp/pest' => '^3.0', 'larastan/larastan' => '^3.0'],
+ 'autoload' => ['psr-4' => ['App\\' => 'app/', 'Database\\Factories\\' => 'database/factories/']],
+ 'scripts' => ['dev' => ['npx concurrently'], 'post-autoload-dump' => ['@php artisan package:discover']],
+ ], ['app/Models', 'routes', 'database/migrations', 'resources', 'tests/Feature', 'bootstrap']);
+}
+
+function symfonyProject(): string
+{
+ return makeProject([
+ 'name' => 'acme/shop',
+ 'description' => 'Acme shop',
+ 'require' => [
+ 'php' => '>=8.2',
+ 'symfony/framework-bundle' => '7.2.*',
+ 'doctrine/doctrine-migrations-bundle' => '^3.3',
+ ],
+ 'require-dev' => ['phpunit/phpunit' => '^11.0', 'symfony/maker-bundle' => '^1.61', 'phpstan/phpstan' => '^2.0'],
+ 'autoload' => ['psr-4' => ['App\\' => 'src/']],
+ ], ['src/Controller', 'config', 'templates', 'migrations', 'bin', 'tests', 'var'], ['bin/phpunit' => '']);
+}
+
+function leafProject(): string
+{
+ return makeProject([
+ 'name' => 'acme/api',
+ 'description' => 'A tiny Leaf API',
+ 'require' => ['php' => '^8.2', 'leafs/leaf' => '^5.0', 'leafs/db' => '^5.1'],
+ 'require-dev' => ['leafs/alchemy' => '^5.0'],
+ 'scripts' => ['test' => '@php vendor/bin/alchemy test', 'lint' => '@php vendor/bin/alchemy lint'],
+ ], ['public', 'tests']);
+}
+
+function leafMvcProject(): string
+{
+ return makeProject([
+ 'name' => 'leafs/mvc',
+ 'description' => 'A lightweight framework for people who just want to ship',
+ 'require' => ['php' => '^8.2', 'leafs/mvc-core' => '^5.0', 'leafs/leaf' => '^5.0'],
+ 'autoload' => ['psr-4' => ['App\\' => 'app/', 'App\\Models\\' => 'app/models/']],
+ ], ['app/routes', 'app/controllers', 'app/models', 'app/database', 'public', '.leaf'], [
+ '.leaf/CONTEXT.md' => "# Project Context\n",
+ 'AGENTS.md' => "# Agent Instructions\n\nKeep this line.\n",
+ ]);
+}
+
+function plainProject(): string
+{
+ return makeProject([
+ 'name' => 'acme/money',
+ 'description' => 'Money value objects',
+ 'require' => ['php' => '^8.1'],
+ 'require-dev' => ['phpunit/phpunit' => '^10.5', 'friendsofphp/php-cs-fixer' => '^3.0'],
+ 'autoload' => ['psr-4' => ['Acme\\Money\\' => 'src/']],
+ ], ['src', 'tests'], ['README.md' => "# Money\n"]);
+}
+
+function projectFile(string $root, string $path): string
+{
+ return (string) file_get_contents("$root/$path");
+}
diff --git a/tests/detector.test.php b/tests/detector.test.php
new file mode 100644
index 0000000..b6f062f
--- /dev/null
+++ b/tests/detector.test.php
@@ -0,0 +1,73 @@
+detect(laravelProject())->framework)->toBe('laravel');
+ expect($detector->detect(symfonyProject())->framework)->toBe('symfony');
+ expect($detector->detect(leafProject())->framework)->toBe('leaf');
+ expect($detector->detect(leafMvcProject())->framework)->toBe('leaf-mvc');
+ expect($detector->detect(plainProject())->framework)->toBe('php');
+});
+
+test('a framework option skips detection', function () {
+ expect((new Detector())->detect(plainProject(), 'leaf')->framework)->toBe('leaf');
+});
+
+test('rejects an unknown framework option', function () {
+ (new Detector())->detect(plainProject(), 'rails');
+})->throws(InvalidArgumentException::class);
+
+test('fails clearly without composer.json', function () {
+ $dir = sys_get_temp_dir() . '/llms-empty-' . bin2hex(random_bytes(4));
+ mkdir($dir);
+
+ (new Detector())->detect($dir);
+})->throws(RuntimeException::class, 'No composer.json');
+
+test('uses the folder name for starter-kit projects', function () {
+ $root = laravelProject();
+ $project = (new Detector())->detect($root);
+
+ expect($project->name)->toBe(basename($root));
+ expect($project->description)->toBe('');
+});
+
+test('reads php version, namespaces and test runner', function () {
+ $project = (new Detector())->detect(plainProject());
+
+ expect($project->name)->toBe('acme/money');
+ expect($project->php)->toBe('^8.1');
+ expect($project->psr4)->toBe(['Acme\\Money\\' => 'src/']);
+ expect($project->testRunner)->toBe('PHPUnit');
+});
+
+test('drops sub-namespaces that follow their parent folder', function () {
+ $project = (new Detector())->detect(leafMvcProject());
+
+ expect($project->psr4)->toBe(['App\\' => 'app/']);
+});
+
+test('picks the commands each project really uses', function () {
+ $detector = new Detector();
+
+ $laravel = $detector->detect(laravelProject());
+ expect($laravel->command('test'))->toBe('php artisan test');
+ expect($laravel->command('lint'))->toBe('vendor/bin/pint --test');
+ expect($laravel->command('analyse'))->toBe('vendor/bin/phpstan analyse');
+ expect($laravel->scripts)->toBe(['dev' => 'composer dev']);
+
+ $symfony = $detector->detect(symfonyProject());
+ expect($symfony->command('test'))->toBe('php bin/phpunit');
+
+ $leaf = $detector->detect(leafProject());
+ expect($leaf->command('test'))->toBe('composer test');
+ expect($leaf->command('lint'))->toBe('composer lint');
+ expect($leaf->testRunner)->toBe('Alchemy (Pest)');
+
+ $plain = $detector->detect(plainProject());
+ expect($plain->command('test'))->toBe('vendor/bin/phpunit');
+ expect($plain->command('lint'))->toBe('vendor/bin/php-cs-fixer fix --dry-run --diff');
+});
diff --git a/tests/generator.test.php b/tests/generator.test.php
new file mode 100644
index 0000000..649b680
--- /dev/null
+++ b/tests/generator.test.php
@@ -0,0 +1,138 @@
+detect($root);
+
+ return (new Writer($root))->write((new Generator($project))->files());
+}
+
+test('writes every file for every framework', function (string $root) {
+ $results = runInit($root);
+
+ foreach (['AGENTS.md', 'CLAUDE.md', 'llms.txt', '.cursor/rules/project.mdc', '.cursor/rules/php.mdc', '.claude/skills/php-tests/SKILL.md', '.agents/skills/php-tests/SKILL.md'] as $file) {
+ expect($results)->toHaveKey($file);
+ expect(is_file("$root/$file"))->toBeTrue();
+ }
+
+ $skills = array_filter(array_keys($results), fn ($path) => str_ends_with($path, 'SKILL.md'));
+ expect(count($skills))->toBeGreaterThanOrEqual(4);
+})->with([
+ 'laravel' => fn () => laravelProject(),
+ 'symfony' => fn () => symfonyProject(),
+ 'leaf' => fn () => leafProject(),
+ 'leaf mvc' => fn () => leafMvcProject(),
+ 'plain php' => fn () => plainProject(),
+]);
+
+test('a second run changes nothing', function () {
+ $root = laravelProject();
+ runInit($root);
+
+ expect(array_unique(array_values(runInit($root))))->toBe([Sections::UNCHANGED]);
+});
+
+test('re-runs keep edits made outside the markers', function () {
+ $root = symfonyProject();
+ runInit($root);
+
+ file_put_contents("$root/AGENTS.md", "Team note at the top.\n\n" . projectFile($root, 'AGENTS.md') . "\nTeam note at the bottom.\n");
+ file_put_contents("$root/composer.json", str_replace('Acme shop', 'Acme online shop', projectFile($root, 'composer.json')));
+
+ $results = runInit($root);
+ $agents = projectFile($root, 'AGENTS.md');
+
+ expect($results['AGENTS.md'])->toBe(Sections::UPDATED);
+ expect($agents)->toStartWith('Team note at the top.');
+ expect($agents)->toContain('Acme online shop');
+ expect($agents)->toEndWith("Team note at the bottom.\n");
+});
+
+test('existing AGENTS.md without markers is kept', function () {
+ $root = leafMvcProject();
+ $results = runInit($root);
+ $agents = projectFile($root, 'AGENTS.md');
+
+ expect($results['AGENTS.md'])->toBe(Sections::APPENDED);
+ expect($agents)->toStartWith("# Agent Instructions\n\nKeep this line.");
+ expect($agents)->toContain(Sections::START);
+});
+
+test('AGENTS.md lists the real commands and layout', function () {
+ $root = laravelProject();
+ runInit($root);
+ $agents = projectFile($root, 'AGENTS.md');
+
+ expect($agents)
+ ->toContain('Laravel (`laravel/framework` `^12.0`)')
+ ->toContain('- Test: `php artisan test`')
+ ->toContain('- Lint: `vendor/bin/pint --test`')
+ ->toContain('- Composer scripts: `composer dev`')
+ ->toContain('- `app/`:')
+ ->toContain('- `database/`:')
+ ->not->toContain('- `config/`:')
+ ->not->toContain('post-autoload-dump');
+});
+
+test('Leaf MVC output points at CONTEXT.md and the leaf CLI', function () {
+ $root = leafMvcProject();
+ runInit($root);
+
+ expect(projectFile($root, 'AGENTS.md'))
+ ->toContain('.leaf/CONTEXT.md')
+ ->toContain('`leaf g:schema `')
+ ->toContain('https://leafphp.dev/llms.txt');
+ expect(is_file("$root/.claude/skills/leaf-database/SKILL.md"))->toBeTrue();
+});
+
+test('CLAUDE.md imports AGENTS.md', function () {
+ $root = plainProject();
+ runInit($root);
+
+ expect(projectFile($root, 'CLAUDE.md'))->toContain("\n@AGENTS.md\n");
+});
+
+test('skills have valid frontmatter for Claude Code', function () {
+ $root = symfonyProject();
+ runInit($root);
+
+ foreach (glob("$root/.claude/skills/*/SKILL.md") as $file) {
+ $contents = (string) file_get_contents($file);
+ $folder = basename(dirname($file));
+
+ expect($contents)->toMatch('/^---\nname: ' . preg_quote($folder, '/') . "\ndescription: \"[^\"]{20,1024}\"\n---\n/");
+ expect(projectFile($root, ".agents/skills/$folder/SKILL.md"))->toBe($contents);
+ }
+});
+
+test('cursor rules have mdc frontmatter', function () {
+ $root = leafProject();
+ runInit($root);
+
+ expect(projectFile($root, '.cursor/rules/project.mdc'))->toStartWith("---\ndescription: ")->toContain("alwaysApply: true\n---");
+ expect(projectFile($root, '.cursor/rules/php.mdc'))->toContain("globs: **/*.php\nalwaysApply: false\n---");
+});
+
+test('llms.txt follows the llmstxt.org shape', function () {
+ $root = plainProject();
+ runInit($root);
+
+ expect(projectFile($root, 'llms.txt'))
+ ->toContain("# acme/money\n\n> Money value objects\n")
+ ->toContain('- [README](README.md)')
+ ->toContain('## Docs');
+});
+
+test('dry run writes nothing', function () {
+ $root = plainProject();
+ $project = (new Detector())->detect($root);
+ $results = (new Writer($root))->write((new Generator($project))->files(), true);
+
+ expect($results['AGENTS.md'])->toBe(Sections::CREATED);
+ expect(is_file("$root/AGENTS.md"))->toBeFalse();
+});
diff --git a/tests/sections.test.php b/tests/sections.test.php
new file mode 100644
index 0000000..d0c177d
--- /dev/null
+++ b/tests/sections.test.php
@@ -0,0 +1,33 @@
+toBe(Sections::CREATED);
+ expect($contents)->toBe("---\nname: x\ndescription: y\n---\n\n" . Sections::START . "\nHello\n" . Sections::END . "\n");
+});
+
+test('only the marked section is replaced', function () {
+ $existing = "Mine above\n\n" . Sections::START . "\nold\n" . Sections::END . "\n\nMine below\n";
+ [$contents, $status] = Sections::merge($existing, 'new');
+
+ expect($status)->toBe(Sections::UPDATED);
+ expect($contents)->toBe("Mine above\n\n" . Sections::START . "\nnew\n" . Sections::END . "\n\nMine below\n");
+});
+
+test('identical output is reported as unchanged', function () {
+ [$first] = Sections::merge(null, 'same');
+ [$second, $status] = Sections::merge($first, 'same');
+
+ expect($status)->toBe(Sections::UNCHANGED);
+ expect($second)->toBe($first);
+});
+
+test('files without markers keep their text and get the section appended', function () {
+ [$contents, $status] = Sections::merge("# Notes\n\nKeep me.\n", 'generated');
+
+ expect($status)->toBe(Sections::APPENDED);
+ expect($contents)->toStartWith("# Notes\n\nKeep me.\n\n" . Sections::START);
+});