diff --git a/.gitignore b/.gitignore index 4f599a3..62ec72c 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ app/cache/* *.cache *.tmp .phpactor.json + +# generated by php sdf/cli agent install +.agents/ diff --git a/sdf/cli b/sdf/cli index 6f416fb..359d396 100755 --- a/sdf/cli +++ b/sdf/cli @@ -94,6 +94,9 @@ class CLI case "swagger": $this->handleSwagger(); break; + case "agent": + $this->handleAgent(); + break; default: $this->printUsage(); break; @@ -1448,6 +1451,74 @@ class CLI return !empty(shell_exec("lsof -i:$port")); } + /** Install agent skill to local or global agents directory. */ + private function handleAgent(): void + { + $sub = $this->argv[2] ?? null; + + if ($sub !== 'install') { + echo "Usage: php sdf/cli agent install [local|global]\n"; + echo " install local (default) Copy skill to .agents/skills//SKILL.md\n"; + echo " install global Copy skill to ~/.agents/skills//SKILL.md\n"; + exit(1); + } + + $scope = $this->argv[3] ?? 'local'; + + if ($scope !== 'local' && $scope !== 'global') { + echo "Usage: php sdf/cli agent install [local|global]\n"; + echo " install local (default) Copy skill to .agents/skills//SKILL.md\n"; + echo " install global Copy skill to ~/.agents/skills//SKILL.md\n"; + exit(1); + } + + // Determine source (project's wiki/agentic_workflow/skills/) + $projectRoot = dirname(__DIR__); + $skillDir = ($scope === 'global') + ? getenv('HOME') . '/.agents/skills' + : getcwd() . '/.agents/skills'; + + // Find all skill sources in the project wiki + $sourceDir = $projectRoot . '/wiki/agentic_workflow/skills'; + if (!is_dir($sourceDir)) { + echo "No skills found at $sourceDir. Create a .agents/skills//SKILL.md first.\n"; + exit(1); + } + + $installed = 0; + $items = scandir($sourceDir); + foreach ($items as $item) { + if ($item === '.' || $item === '..') continue; + $skillPath = $sourceDir . '/' . $item . '/SKILL.md'; + if (!is_file($skillPath)) continue; + + $destDir = $skillDir . '/' . $item; + $destFile = $destDir . '/SKILL.md'; + + // Skip if copying to the same path + if (realpath($skillPath) === realpath($destFile)) { + echo "Skill '$item' already in place at $destFile\n"; + $installed++; + continue; + } + + @mkdir($destDir, 0755, true); + if (copy($skillPath, $destFile)) { + echo "Installed skill '$item' to $destFile\n"; + $installed++; + } else { + echo "Failed to install skill '$item' to $destFile\n"; + } + } + + if ($installed === 0) { + echo "No SKILL.md files found under $sourceDir\n"; + exit(1); + } + + echo "\nDone. $installed skill(s) installed.\n"; + } + /** Print banner. */ private function showBanner(): void { @@ -1494,6 +1565,8 @@ class CLI echo "\n"; echo " swagger [action] OpenAPI / Swagger documentation\n"; echo " generate [output-path] Generate openapi.json from controllers\n"; + echo "\n"; + echo " agent install [local|global] Install agent skill (default: local)\n"; } } diff --git a/tests/CLIGeneratorTest.php b/tests/CLIGeneratorTest.php index 69c5163..93176f3 100644 --- a/tests/CLIGeneratorTest.php +++ b/tests/CLIGeneratorTest.php @@ -26,6 +26,38 @@ protected function tearDown(): void if (is_dir($this->tmpDir)) rmdir($this->tmpDir); } + public function test_agent_install_local(): void + { + $script = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(getcwd() . '/sdf/cli'); + $cmd = "cd " . escapeshellarg($this->tmpDir) . " && $script agent install"; + exec($cmd, $out, $exit); + $this->assertSame(0, $exit, "CLI exited non-zero: " . implode("\n", $out)); + $this->assertFileExists($this->tmpDir . '/.agents/skills/project-sdf/SKILL.md'); + $content = file_get_contents($this->tmpDir . '/.agents/skills/project-sdf/SKILL.md'); + $this->assertStringContainsString('name: project-sdf', $content); + } + + public function test_agent_install_global(): void + { + $script = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(getcwd() . '/sdf/cli'); + $globalDir = sys_get_temp_dir() . '/sdf_agent_global_' . uniqid(); + putenv('HOME=' . $globalDir); + $cmd = "$script agent install global"; + exec($cmd, $out, $exit); + $this->assertSame(0, $exit, "CLI exited non-zero: " . implode("\n", $out)); + $this->assertFileExists($globalDir . '/.agents/skills/project-sdf/SKILL.md'); + putenv('HOME'); + } + + public function test_agent_install_requires_subcommand(): void + { + $script = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(getcwd() . '/sdf/cli'); + $cmd = "cd " . escapeshellarg($this->tmpDir) . " && $script agent"; + exec($cmd, $out, $exit); + $this->assertNotSame(0, $exit); + $this->assertStringContainsString('Usage', implode('', $out)); + } + public function test_generate_unit_test_file(): void { $script = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg(getcwd() . '/sdf/cli'); diff --git a/wiki/agentic_workflow/skills/project-sdf.md b/wiki/agentic_workflow/skills/project-sdf.md deleted file mode 100644 index 9a14ba2..0000000 --- a/wiki/agentic_workflow/skills/project-sdf.md +++ /dev/null @@ -1,208 +0,0 @@ -# Project SDF — Agent Skill - -## Quick start -```bash -composer install -php sdf/cli serve -p 8000 # dev server at localhost:8000 -php sdf/cli serve -p 8000 --live # with auto-reload -php sdf/cli serve -p 8000 --clear-cache # clear caches before serving -``` - -## CLI reference -```bash -php sdf/cli g controller Home # generate a controller -php sdf/cli g model Post # generate a model -php sdf/cli g migration create_posts_table # generate a migration -php sdf/cli g test HomeController --type=controller --namespace=App\\Controllers\\User -php sdf/cli db migrate # run pending migrations -php sdf/cli cache clear # flush all caches -php sdf/cli format --dry-run -v # check code style -php sdf/cli swagger generate # generate OpenAPI spec -``` - -## Project structure -``` -├── app/ -│ ├── config/ # PHP/JSON config files → $config['key'] = [...] -│ ├── controllers/ # your controllers -│ ├── handlers/ # error handlers (eh_pathNotFound, eh_methodNotAllowed) -│ ├── models/ # your models -│ └── views/ # Fuse templates (.fuse.php) -├── sdf/ # framework core (don't touch) -├── public/ # static assets (images, css, js) -├── storage/ # logs, cache, uploads -├── tests/ -├── vendor/ -├── .env # environment variables -├── index.php # entry point (defines SDF, SDF_ENV, etc.) -└── routes.php # route definitions -``` - -## Routing (`routes.php`) -```php -// Closure -Router::add('/', function() { return 'Hello'; }); - -// Controller@method (namespaced) -Router::add('/users', '\App\Controllers\UserController@index'); - -// With params -Router::add('/post/{id}', '\App\Controllers\PostController@show'); - -// Route placeholders: {id}, {uuid}, {slug}, {date}, {all}, etc. -Router::add('/user/{uuid}', 'UserController@show'); -Router::add('/blog/{slug}', 'BlogController@show'); - -// HTTP method filter -Router::add('/users', 'UserController@store', 'POST'); - -// Named routes -Router::add('/login', 'AuthController@login', 'GET')->name('login'); -``` - -See [full route docs](wiki/app/routes.md) for all 18 available placeholders (`{uuid}`, `{hex}`, `{alpha}`, `{bool}`, `{datetime}`, etc.). - -## Controllers -```php -namespace App\Controllers; -class HomeController { - public function index() { - return view('home', ['title' => 'SDF']); - } -} -``` - -## Views (Fuse — Blade-like syntax) -```php - - - @yield('content') - - - -@extends('layouts.app') -@section('content') -

{{ $title }}

- @include('partials.nav') -@endsection -``` - -## Database (Spark ORM) -### Config (`app/config/database.php`) -```php -$config['database'] = [ - 'driver' => 'mysql', - 'host' => getenv('DB_HOST') ?: '127.0.0.1', - 'database' => getenv('DB_NAME'), - 'username' => getenv('DB_USER'), - 'password' => getenv('DB_PASS'), - 'charset' => 'utf8mb4', -]; -``` - -### Query builder -```php -$users = Spark::table('users')->where('active', 1)->get(); -$user = Spark::table('users')->find(1); -$posts = Spark::table('posts')->where('user_id', $id)->paginate(15); -``` - -### Models -```php -class Post extends \SDF\Spark\Model { - protected string $table = 'posts'; - protected string $primaryKey = 'id'; - protected bool $timestamps = true; // adds created_at/updated_at - protected bool $softDelete = false; // adds deleted_at - - // Relationships - public function author() { - return $this->belongsTo(User::class, 'user_id'); - } - public function comments() { - return $this->hasMany(Comment::class, 'post_id'); - } - public function tags() { - return $this->belongsToMany(Tag::class, 'post_tag', 'post_id', 'tag_id'); - } -} - -// Usage -$post = Post::find(1); -$post->title = 'Updated'; -$post->save(); -$post->delete(); // soft-delete if enabled -$post->forceDelete(); // permanent delete -Post::withTrashed()->get(); -Post::onlyTrashed()->get(); -``` - -## Authentication -### Guard types -- **SessionGuard** — session-based login. Configure in `app/config/auth.php`. -- **JwtGuard** — stateless HS256 JWT with refresh tokens. - -### Protecting routes -```php -Router::add('/dashboard', 'DashboardController@index')->middleware('auth'); -``` - -### Auth middleware stack -- `CsrfMiddleware` — validates `X-CSRF-TOKEN` or `_token` POST field. 419 on mismatch. -- `CorsMiddleware` — configurable origins/methods/headers. OPTIONS → 204. -- `RateLimitMiddleware` — 60 req/min per IP+route. 429 with `Retry-After`. -- `AuthMiddleware` — 401 if unauthenticated. - -## Cache (PSR-16) -```php -use SDF\Cache\Cache; - -Cache::set('key', 'value', 3600); -$value = Cache::get('key', 'default'); -Cache::delete('key'); -Cache::clear(); -``` - -Drivers: `file`, `redis`, `memcached`. Set in `app/config/cache.php`. - -## Validation -```php -$validator = new \SDF\Validation\Validator($data, [ - 'email' => 'required|email', - 'name' => 'required|min:3|max:255', - 'age' => 'numeric|between:18,99', -]); -if ($validator->fails()) { - $errors = $validator->errors(); -} -``` - -19 rules: required, email, min, max, between, numeric, integer, string, boolean, array, alpha, alpha_num, url, in, confirmed, same, different, regex, nullable. - -## Components -- **Encryption**: AES-256-CBC + HMAC-SHA256. `Encrypter::encrypt($plaintext)`, `decrypt($ciphertext)`. Key from `APP_KEY` env (32 bytes). -- **PSR-7 HTTP**: `ServerRequest` and `Response` — immutable (`with*` returns new instance). Legacy mutable classes also available. -- **Storage**: `Storage::disk('local')->put('file.txt', 'content')`. Drivers: `local`, `s3`. -- **Mail**: `Mail::send(...)` via configured driver. -- **Queues**: `Queue::dispatch(Job::class, $payload)` via `database` or `redis` driver. -- **Events**: PSR-14 `EventDispatcher::dispatch(new UserRegistered($user))`. -- **Logging**: PSR-3 `Logger::info('message')`, `Logger::error('message', $context)`. -- **Schema Builder**: `Schema::create('table', function($table) { ... })` for migrations. -- **Localization**: `__('messages.welcome')` with language files in `app/lang/`. - -## Deployment -### Docker -```bash -docker compose up -d # starts FrankenPHP + MySQL 8.4 + Redis 7 -``` - -Multi-stage Dockerfile: `base` (composer --no-dev), `dev` (xdebug), `production` (OPcache). Set `SDF_ENV=production` for production mode. - -### Static files -Served automatically in development mode. In production, use Caddy (included in Docker) or nginx to serve `public/`. - -## Wiki docs -Full documentation at `wiki/`: -- `wiki/libraries/` — component docs (auth, cache, csrf, cors, ratelimit, encryption, validation, session, flash, http, swagger, log, schema, events, localization, mail, queue, storage) -- `wiki/app/tutorials/` — tutorials (authentication, blog, docker-frankenphp) -- `wiki/sdf/` — CLI commands docs diff --git a/wiki/agentic_workflow/skills/project-sdf/SKILL.md b/wiki/agentic_workflow/skills/project-sdf/SKILL.md new file mode 100644 index 0000000..1536fd5 --- /dev/null +++ b/wiki/agentic_workflow/skills/project-sdf/SKILL.md @@ -0,0 +1,223 @@ +--- +name: project-sdf +description: Build and run SDF PHP projects — CLI, routing, Spark ORM, Fuse views, auth, cache, queues, mail, Docker deployment +license: MIT +compatibility: opencode +metadata: + audience: developers + framework: sdf +--- + +## What I do + +I help you build, scaffold, and maintain SDF PHP framework projects. I know the full stack: CLI generators, routing, Spark ORM models/migrations, Fuse templates, session/JWT auth, PSR-16 cache, mail, queues, events, schema builder, and Docker deployment. + +Invoke me with `/project-sdf` followed by what you need. + +## When to use me + +| Scenario | What I'll do | +|----------|-------------| +| Scaffold a new feature | Generate controller, model, migration, view, routes, and test | +| Fix a bug | Diagnose the issue, suggest or apply the fix, run tests | +| Add an API endpoint | Create controller + route + validation + test | +| Database changes | Generate and run migrations, update models | +| Docker/deploy | Review compose.yaml, Caddyfile, Dockerfile, suggest improvements | +| Code review | Inspect changed files with security in mind, enforce SDF conventions | +| Run the app | Start dev server, run tests, check code style | +| Install the skill | `php sdf/cli agent install [local|global]` | + +## Quick start +```bash +composer install +php sdf/cli serve -p 8000 # dev server at localhost:8000 +php sdf/cli serve -p 8000 --live # with auto-reload +``` + +## Common workflows + +### Scaffold a full CRUD +```bash +php sdf/cli g model Post +php sdf/cli g migration create_posts_table +php sdf/cli g controller PostController +php sdf/cli g test PostControllerTest --type=controller +``` +Then add routes in `routes.php`, fill in controller methods, define the migration schema with `Schema::create()`, and add Fuse views in `app/views/`. + +### Add auth to a route +```php +Router::add('/dashboard', 'DashboardController@index')->middleware('auth'); +``` +Guards: `SessionGuard` (session-based), `JwtGuard` (HS256 JWT with refresh tokens). + +### Fix code style + run checks +```bash +php sdf/cli format --dry-run -v # preview style issues +php sdf/cli fmt # auto-fix +vendor/bin/phpunit # run tests +composer analyze # PHPStan level 5 +``` + +### Spin up full stack +```bash +docker compose up -d # FrankenPHP + MySQL 8.4 + Redis 7 +``` + +## CLI reference +```bash +php sdf/cli g controller Home # generate a controller +php sdf/cli g model Post # generate a model +php sdf/cli g migration create_posts_table +php sdf/cli g test HomeController --type=controller --namespace=App\\Controllers\\User +php sdf/cli g middleware Auth +php sdf/cli g guard Api +php sdf/cli g seeder UserSeeder +php sdf/cli db migrate # run pending migrations +php sdf/cli db rollback # rollback last batch +php sdf/cli db seed # run all seeders +php sdf/cli db reset # rollback all + migrate + seed +php sdf/cli cache clear # flush all caches +php sdf/cli format --dry-run -v # check code style +php sdf/cli swagger generate # generate OpenAPI spec +php sdf/cli agent install # install this skill locally +php sdf/cli agent install global # install this skill globally +``` + +## Project structure +``` +├── app/ +│ ├── config/ # PHP/JSON config files → $config['key'] = [...] +│ ├── controllers/ # your controllers +│ ├── handlers/ # error handlers (eh_pathNotFound, etc.) +│ ├── models/ # your models +│ └── views/ # Fuse templates (.fuse.php) +├── sdf/ # framework core (don't touch) +├── public/ # static assets +├── storage/ # logs, cache, uploads +├── tests/ +├── vendor/ +├── .env # APP_KEY, DB_*, etc. +├── index.php # entry point +├── routes.php # route definitions +├── Dockerfile # multi-stage (base/dev/production) +├── compose.yaml # app + MySQL 8.4 + Redis 7 +└── Caddyfile # FrankenPHP/Caddy config +``` + +## Routing +```php +Router::add('/', function() { return 'Hello'; }); +Router::add('/users', '\App\Controllers\UserController@index'); +Router::add('/post/{id}', '\App\Controllers\PostController@show'); +Router::add('/user/{uuid}', 'UserController@show'); +Router::add('/login', 'AuthController@login', 'GET')->name('login'); +Router::add('/dashboard', 'DashboardController@index')->middleware('auth'); +``` +Available placeholders: `{id}`, `{uuid}`, `{uuid_simple}`, `{slug}`, `{date}`, `{all}`, `{hex}`, `{alpha}`, `{alnum}`, `{word}`, `{segment}`, `{file}`, `{bool}`, `{time}`, `{datetime}`, `{url}`, `{num}`. + +## Controllers +```php +namespace App\Controllers; +class PostController { + public function index() { /* list */ } + public function show(int $id) { /* single */ } + public function store() { /* create */ } + public function update(int $id) { /* update */ } + public function destroy(int $id) { /* delete */ } +} +``` + +## Views (Fuse — Blade-like syntax) +```php +@extends('layouts.app') +@section('content') +

{{ $title }}

+ @include('partials.nav') +@endsection +``` + +## Database (Spark ORM) +```php +// Query builder +$users = Spark::table('users')->where('active', 1)->get(); +$user = Spark::table('users')->find(1); +$posts = Spark::table('users')->where('age', '>', 18)->orderBy('name')->paginate(15); + +// Mass assignment protection +class Post extends \SDF\Spark\Model { + protected string $table = 'posts'; + protected string $primaryKey = 'id'; + protected bool $timestamps = true; + protected bool $softDelete = false; + protected array $fillable = ['title', 'content', 'published_at']; + // or: protected array $guarded = ['id', 'is_admin']; + + public function author() { return $this->belongsTo(User::class, 'user_id'); } + public function tags() { return $this->belongsToMany(Tag::class, 'post_tag', 'post_id', 'tag_id'); } +} + +$post = Post::find(1); +$post->title = 'Updated'; +$post->save(); +``` + +## Authentication +- **SessionGuard** — session-based login. Configure in `app/config/auth.php` +- **JwtGuard** — stateless HS256 JWT with refresh tokens. Min 32-byte key +```php +Router::add('/dashboard', 'DashboardController@index')->middleware('auth'); +``` +Middleware stack (applied in order): `CsrfMiddleware`, `CorsMiddleware`, `RateLimitMiddleware` (60 req/min), `AuthMiddleware`. + +## Cache (PSR-16) +```php +use SDF\Cache\Cache; +Cache::set('key', 'value', 3600); +$value = Cache::get('key', 'default'); +Cache::delete('key'); +Cache::clear(); +``` +Drivers: `file` (default), `redis`, `memcached`. Set in `app/config/cache.php`. + +## Validation +```php +$v = new \SDF\Validation\Validator($data, [ + 'email' => 'required|email', + 'name' => 'required|min:3|max:255', + 'age' => 'numeric|between:18,99', +]); +if ($v->fails()) { $errors = $v->errors(); } +``` +19 rules: required, email, min, max, between, numeric, integer, string, boolean, array, alpha, alpha_num, url, in, confirmed, same, different, regex, nullable. + +## Components quick ref + +| Component | Key class / method | +|-----------|-------------------| +| Encryption | `Encrypter::encrypt($text)` / `decrypt($cipher)` — AES-256-CBC + HMAC-SHA256 | +| PSR-7 HTTP | `ServerRequest`, `Response` — immutable (`with*` returns new instance) | +| Storage | `Storage::disk('local')->put('file.txt', 'content')` — drivers: local, s3 | +| Mail | `Mail::send($to, $subject, $body)` — via `NativeMailer` or SMTP | +| Queues | `Queue::dispatch(Job::class, $payload)` — drivers: `database`, `redis` | +| Events | `EventDispatcher::dispatch(new UserRegistered($user))` — PSR-14 | +| Logging | `Logger::info('msg')`, `Logger::error('msg', $context)` — PSR-3 | +| Schema | `Schema::create('table', fn($t) => $t->increments('id')...)` | +| Localization | `__('messages.welcome')` — language files in `app/lang/` | + +## Deployment +```bash +docker compose up -d # FrankenPHP + MySQL 8.4 + Redis 7 +``` +- `Dockerfile` multi-stage: `base` (composer deps), `dev` (xdebug), `production` (OPcache) +- Set `SDF_ENV=production` for production mode +- Static files served from `public/` (Caddy in Docker, dev server in dev mode) +- Caddyfile includes security headers, gzip, forbidden paths + +## Troubleshooting +- **500 error / blank page**: Check `storage/logs/` for framework logs. Enable `SDF_ENV=development` for detailed errors +- **419 page expired**: CSRF token mismatch — ensure `X-CSRF-TOKEN` header or `_token` POST field is sent +- **429 too many requests**: Rate limit hit (default 60 req/min per IP+route). Wait for `Retry-After` header +- **Cache not updating**: Run `php sdf/cli cache clear` to flush all caches. Or set `debug = true` in router config to disable route cache +- **Migration fails**: Run `php sdf/cli db reset` to rollback all + re-migrate. Check `database.php` config for correct credentials +- **Docker connection refused**: Containers may still be starting. Run `docker compose logs` to check each service