Skip to content

Commit fab6632

Browse files
ajthinkingclaude
andcommitted
Add the archetype command line
Archetype has only ever been reachable from PHP. This adds a command line over the same engine — 26 operations, each an Artisan command under `archetype:`, plus an `archetype` binary that finds the application and forwards to it. Nothing here reimplements AST rewriting; every mutation goes through the existing PHPFile/LaravelFile endpoints. Three properties hold for every operation that writes, because a caller that cannot rely on them has to read the file back and loses the point of asking: - the file is re-rendered and compared, so a change that matched nothing exits non-zero instead of reporting a success that wrote nothing; - the answer carries a diff of what changed; - a change already applied reports SKIP, which makes them safe to repeat. Every operation takes a single target — a path, a class name, or a directory, where a directory means every class beneath it, narrowed with --extends / --implements / --uses-trait / --matching. Every operation takes --json; every mutation takes --dry-run and --no-diff. Two operations exist because the PHP API cannot express them: set-array-key edits the array a method returns, which is where rules(), toArray() and casts() keep their contents, and set-casts writes to whichever casting mechanism a model already uses rather than adding a second one beside the first. add-relation covers all eleven Eloquent relation types with pivot tables, explicit keys and withPivot. Alongside, a few endpoints now reach constructs they previously matched but silently ignored: className(), classConstant(), useTrait() and property() work on any class-like, and implements() on classes and enums. Before this, adding an interface to an enum reported success and wrote nothing. The PSR-2 printer also prints `function name(): Type` rather than `function name() : Type`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjUMv5rFTVJMr6F1J7bx7x
1 parent 1866e69 commit fab6632

69 files changed

Lines changed: 4951 additions & 35 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,57 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
88

9+
## [2.1.0] - 2026-08-29
10+
11+
Adds a command line to Archetype. Every existing PHP API is untouched; a handful
12+
of endpoints now reach constructs they previously matched but silently ignored.
13+
14+
### Added
15+
16+
- **A command line.** 26 operations, each an Artisan command under `archetype:`,
17+
plus an `archetype` binary that finds the application and forwards to it. Run
18+
`archetype` with no arguments for the list, or see the
19+
[reference](docs.md#command-line-reference).
20+
21+
Reading: `inspect`, `show`, `find`, `errors`.
22+
Writing: `make`, `set-property`, `add-to-property`, `empty-property`,
23+
`remove-property`, `set-casts`, `add-relation`, `set-array-key`, `add-use`,
24+
`remove-use`, `add-trait`, `add-implements`, `set-extends`, `set-namespace`,
25+
`rename-class`, `set-const`, `remove-const`, `add-case`, `add-method`,
26+
`replace-method`, `remove-method`, `apply`.
27+
28+
- Every operation takes a single target, which is a path, a class name, or a
29+
directory — where a directory means every class beneath it, narrowed with
30+
`--extends`, `--implements`, `--uses-trait` or `--matching`.
31+
- Every operation takes `--json`.
32+
- Every mutation re-renders the file and compares before reporting. One that
33+
matched nothing exits non-zero rather than reporting a success that wrote
34+
nothing; one whose change is already present reports `SKIP`, which makes the
35+
operations safe to repeat.
36+
- Every mutation answers with a diff of what it changed, and takes `--dry-run`
37+
to show that diff without writing.
38+
- `archetype apply` runs a script of operations in one invocation, reading a file
39+
or standard input.
40+
- `set-casts` writes to whichever casting mechanism a model already uses — the
41+
`casts()` method Laravel 11 generates, or the `$casts` property — instead of
42+
adding a second one beside the first.
43+
- `set-array-key` edits the array a method returns, which is where `rules()`,
44+
`toArray()`, `casts()` and `definition()` keep their contents.
45+
- `add-relation` covers all eleven Eloquent relation types, with pivot tables,
46+
explicit keys, `withPivot`, `withTimestamps` and a custom pivot model.
47+
- `enum()` and `enumCase()` query methods on the `ASTQueryBuilder`.
48+
- `php artisan archetype:errors` takes `--json`.
49+
50+
### Changed
51+
52+
- `className()`, `classConstant()`, `useTrait()` and `property()` now match any
53+
class-like declaration rather than only `class`, so they work on enums,
54+
interfaces and traits. `implements()` matches classes and enums. Previously
55+
these silently did nothing on anything but a class.
56+
- The PSR-2 pretty printer prints `function name(): Type` rather than
57+
`function name() : Type`. Only newly printed declarations are affected;
58+
untouched code keeps its own formatting.
59+
960
## [2.0.1] - 2026-08-25
1061

1162
Maintenance only. No API changes, and nothing here can break existing usage.
@@ -67,7 +118,8 @@ Last release of the 1.x line, which requires `nikic/php-parser` ^4.11.
67118
Pest 3 and newer. If Composer refuses to resolve `ajthinking/archetype`, upgrade
68119
to 2.x.
69120

70-
[Unreleased]: https://github.com/ajthinking/archetype/compare/v2.0.1...HEAD
121+
[Unreleased]: https://github.com/ajthinking/archetype/compare/v2.1.0...HEAD
122+
[2.1.0]: https://github.com/ajthinking/archetype/compare/v2.0.1...v2.1.0
71123
[2.0.1]: https://github.com/ajthinking/archetype/compare/v2.0.0...v2.0.1
72124
[2.0.0]: https://github.com/ajthinking/archetype/compare/v1.1.5...v2.0.0
73125
[1.1.5]: https://github.com/ajthinking/archetype/releases/tag/v1.1.5

bin/archetype

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
/**
5+
* The `archetype` entry point.
6+
*
7+
* Everything of substance is an Artisan command under `archetype:*`; this walks
8+
* up from the working directory to find the application's `artisan` file and
9+
* forwards to it, so `archetype inspect app/Models/User.php` and
10+
* `php artisan archetype:inspect app/Models/User.php` are the same call.
11+
*/
12+
13+
$directory = getcwd();
14+
15+
while (! is_file($directory.'/artisan')) {
16+
$parent = dirname($directory);
17+
18+
if ($parent === $directory) {
19+
fwrite(STDERR, 'ERR could not find a Laravel artisan file at or above '.getcwd().PHP_EOL);
20+
exit(1);
21+
}
22+
23+
$directory = $parent;
24+
}
25+
26+
$arguments = array_slice($argv, 1);
27+
$operation = $arguments[0] ?? null;
28+
29+
// A bare `archetype` lists the operations; anything else is `archetype:<op>`.
30+
if ($operation === null || str_starts_with($operation, '-')) {
31+
array_unshift($arguments, 'archetype');
32+
} else {
33+
$arguments[0] = str_starts_with($operation, 'archetype:') ? $operation : 'archetype:'.$operation;
34+
}
35+
36+
passthru(
37+
implode(' ', array_map('escapeshellarg', array_merge([PHP_BINARY, $directory.'/artisan'], $arguments))),
38+
$status
39+
);
40+
41+
exit($status);

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141
]
4242
}
4343
},
44+
"bin": [
45+
"bin/archetype"
46+
],
4447
"autoload": {
4548
"psr-4": {
4649
"Archetype\\": "src/"

docs.md

Lines changed: 254 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,257 @@ $file->add()->use([
160160
Extra1::class,
161161
Extra2::class,
162162
])
163-
```
163+
```
164+
165+
## Command line reference
166+
167+
Every operation is an Artisan command named `archetype:<operation>`. The
168+
`archetype` binary walks up from the working directory to find your
169+
application's `artisan` file and forwards to it, so these are the same call:
170+
171+
```bash
172+
./vendor/bin/archetype inspect app/Models/User.php
173+
php artisan archetype:inspect app/Models/User.php
174+
```
175+
176+
### Targets
177+
178+
Every operation but `make` and `apply` takes one target, which is any of:
179+
180+
| Target | Means |
181+
|---|---|
182+
| `app/Models/User.php` | that file |
183+
| `App\Models\User` | that class, resolved to a path |
184+
| `app/Models` | every PHP class under that directory |
185+
186+
A directory target can be narrowed:
187+
188+
| Option | Keeps only classes |
189+
|---|---|
190+
| `--extends=Model` | extending that class |
191+
| `--implements=Auditable` | implementing that interface |
192+
| `--uses-trait=SoftDeletes` | using that trait |
193+
| `--matching=<regex>` | whose path matches |
194+
195+
These options are rejected on a single-file target rather than ignored.
196+
197+
### Options every operation takes
198+
199+
| Option | Effect |
200+
|---|---|
201+
| `--json` | Emit JSON instead of the compact line format |
202+
203+
### Options every mutation takes
204+
205+
| Option | Effect |
206+
|---|---|
207+
| `--dry-run` | Show the diff without writing |
208+
| `--no-diff` | Suppress the diff |
209+
210+
### Exit codes and statuses
211+
212+
| Status | Meaning | Exit |
213+
|---|---|---|
214+
| `OK <file> <detail>` | Changed and saved | 0 |
215+
| `DRY <file> <detail>` | Would change; nothing written | 0 |
216+
| `SKIP <file> <detail>` | Already in the desired state | 0 |
217+
| `ERR <file> <detail>` | Could not do what was asked | 1 |
218+
219+
A mutation that matches nothing reports `ERR`, never `OK`. That is what makes it
220+
safe not to read the file back.
221+
222+
### Reading
223+
224+
#### Summarise a file
225+
```bash
226+
archetype inspect app/Models/User.php
227+
```
228+
```
229+
app/Models/User.php
230+
class App\Models\User extends Authenticatable
231+
uses HasApiTokens, HasFactory, Notifiable
232+
import Illuminate\Foundation\Auth\User as Authenticatable
233+
prop protected $fillable = ["name","email","password"]
234+
prop protected $casts = {"email_verified_at":"datetime"}
235+
fn public posts() [4 lines]
236+
rel posts hasMany Post
237+
```
238+
239+
Limit it to the sections you need — `meta`, `traits`, `uses`, `consts`, `cases`,
240+
`props`, `methods`, `relations`:
241+
242+
```bash
243+
archetype inspect app/Models/User.php props relations
244+
```
245+
246+
#### Print one method
247+
```bash
248+
archetype show app/Http/Requests/StoreTaskRequest.php rules
249+
```
250+
251+
`inspect` deliberately leaves method bodies out; this is how you get one.
252+
253+
#### Find files
254+
```bash
255+
archetype find app
256+
archetype find app --type=models
257+
archetype find --type=migrations
258+
archetype find app --extends=FormRequest
259+
archetype find app --matching='Http/Controllers'
260+
```
261+
262+
`--type` is one of `all`, `models`, `controllers`, `providers`, `migrations`.
263+
The class types use reflection, so they only see classes the application can
264+
autoload; the other filters read the syntax tree and work on anything that
265+
parses.
266+
267+
#### List files that do not parse
268+
```bash
269+
archetype errors
270+
```
271+
272+
### Creating
273+
274+
```bash
275+
archetype make 'App\Services\Billing'
276+
archetype make app/Services/Billing.php
277+
archetype make 'App\Models\Invoice' \
278+
--extends='Illuminate\Database\Eloquent\Model' \
279+
--implements='App\Contracts\Payable' \
280+
--trait='Illuminate\Database\Eloquent\Factories\HasFactory'
281+
archetype make app/helpers.php --file
282+
```
283+
284+
Refuses to overwrite an existing file unless given `--force`.
285+
286+
### Properties
287+
288+
```bash
289+
archetype set-property app/Models/User.php table gdpr_users
290+
archetype set-property app/Models/User.php with '["profile","posts"]'
291+
archetype set-property app/Models/User.php perPage 25 --visibility=public
292+
archetype set-property app/Models/User.php connection # no default value
293+
294+
archetype add-to-property app/Models/User.php fillable nickname avatar
295+
archetype empty-property app/Models/User.php fillable
296+
archetype remove-property app/Models/User.php hidden
297+
```
298+
299+
Values are read as JSON when they are valid JSON, and as a plain string
300+
otherwise. Visibility is left as it is unless `--visibility` says otherwise.
301+
302+
### Eloquent
303+
304+
```bash
305+
archetype set-casts app/Models/User.php archived_at=datetime status=Status::class
306+
307+
archetype add-relation app/Models/Project.php hasMany Task
308+
archetype add-relation app/Models/Project.php belongsTo User --name=owner --foreign-key=owner_id
309+
archetype add-relation app/Models/Project.php belongsToMany Label \
310+
--table=label_project --with-pivot=sort,note --with-timestamps
311+
archetype add-relation app/Models/Project.php morphMany Comment --morph-name=commentable
312+
archetype add-relation app/Models/Project.php hasManyThrough Comment --through=Task
313+
```
314+
315+
`set-casts` writes to whichever mechanism the model already uses — the `casts()`
316+
method Laravel 11 generates, or the `$casts` property — rather than adding a
317+
second one beside it.
318+
319+
`add-relation` covers all eleven relation types: `hasOne`, `hasMany`,
320+
`belongsTo`, `belongsToMany`, `hasOneThrough`, `hasManyThrough`, `morphOne`,
321+
`morphMany`, `morphTo`, `morphToMany`, `morphedByMany`. The related class is
322+
imported when it needs to be.
323+
324+
### Arrays returned from methods
325+
326+
```bash
327+
archetype set-array-key app/Http/Requests/StoreTaskRequest.php rules due_at 'nullable|date'
328+
archetype set-array-key app/Http/Resources/TaskResource.php toArray budget '$this->budget_cents'
329+
archetype set-array-key app/Http/Requests/StoreTaskRequest.php rules tags "['array', 'max:5']"
330+
archetype set-array-key app/Http/Requests/StoreTaskRequest.php rules title --remove
331+
archetype set-array-key app/Providers/AppServiceProvider.php policies ignored Policy::class --append
332+
```
333+
334+
This reaches `rules()`, `toArray()`, `casts()`, `definition()` and everything
335+
else of that shape — the array a method returns directly, never one returned
336+
from a closure nested inside it.
337+
338+
A bare word is a string, so `nullable|date` is a validation rule rather than a
339+
bitwise or. Brackets, quotes, `$variables`, calls, `Class::constants`, numbers
340+
and booleans are read as PHP.
341+
342+
### Structure
343+
344+
```bash
345+
archetype add-use app/Models/User.php 'App\Contracts\Auditable' 'Illuminate\Support\Str'
346+
archetype remove-use app/Models/User.php 'Illuminate\Support\Str'
347+
archetype add-trait app/Models/User.php 'Illuminate\Database\Eloquent\SoftDeletes'
348+
archetype add-implements app/Models/User.php 'App\Contracts\Auditable'
349+
archetype set-extends app/Models/User.php 'Illuminate\Database\Eloquent\Model'
350+
archetype set-namespace app/Models/User.php 'App\Domain\Models'
351+
archetype rename-class app/Models/User.php Account
352+
```
353+
354+
`add-trait`, `add-implements` and `set-extends` add the import too, since a name
355+
used without one is never valid PHP.
356+
357+
`rename-class` renames the declaration only. It does not move the file or update
358+
references elsewhere.
359+
360+
### Constants and enum cases
361+
362+
```bash
363+
archetype set-const app/Models/User.php HOME /dashboard
364+
archetype remove-const app/Models/User.php HOME
365+
366+
archetype add-case app/Enums/ProjectStatus.php OnHold on_hold
367+
archetype add-case app/Enums/Suit.php Spades # pure enum, no backing value
368+
```
369+
370+
Constants work on classes, interfaces, enums and traits. New enum cases are
371+
added after the ones already there.
372+
373+
### Methods
374+
375+
```bash
376+
archetype add-method app/Models/Project.php \
377+
--code='public function scopeActive($query) { return $query->where("active", true); }'
378+
archetype replace-method app/Models/Project.php isActive \
379+
--code='public function isActive(): bool { return $this->active; }'
380+
archetype remove-method app/Models/Project.php isActive
381+
```
382+
383+
Methods can be added to a class, enum, interface or trait, and are appended
384+
after the methods already there.
385+
386+
### Several operations in one call
387+
388+
```bash
389+
archetype apply operations.txt
390+
archetype apply < operations.txt
391+
```
392+
393+
One operation per line, `#` for comments, the `archetype:` prefix optional:
394+
395+
```text
396+
# what this change needs
397+
add-to-property app/Models/Project.php fillable budget_cents
398+
set-casts app/Models/Project.php budget_cents=integer
399+
add-relation app/Models/Project.php hasMany Task
400+
```
401+
402+
Each operation keeps its own verification, diff and exit status. `apply` exits
403+
non-zero if any of them failed, and `--stop-on-failure` stops at the first.
404+
405+
### JSON
406+
407+
Every operation takes `--json`:
408+
409+
```bash
410+
archetype add-to-property app/Models/User.php fillable nickname --json
411+
```
412+
```json
413+
{"ok":true,"dryRun":false,"changed":1,"skipped":0,"failed":0,"results":[{"file":"app/Models/User.php","status":"changed","detail":"$fillable +1","diff":"@@ 24 @@\n+ 'nickname',\n ];"}]}
414+
```
415+
416+
An error answers with `{"ok":false,"error":"..."}` and exit code 1.

0 commit comments

Comments
 (0)