Skip to content

v2.1.0: add the archetype command line - #90

Open
ajthinking wants to merge 4 commits into
masterfrom
release/2.1.0
Open

v2.1.0: add the archetype command line#90
ajthinking wants to merge 4 commits into
masterfrom
release/2.1.0

Conversation

@ajthinking

@ajthinking ajthinking commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Archetype has only ever been reachable from PHP. This adds a command line over the same engine, aimed at callers who work a command at a time — a terminal, a script, an AI agent.

./vendor/bin/archetype fillable app/Models/User.php
# is the same as
php artisan archetype:fillable app/Models/User.php

An operation named after an endpoint is that endpoint

Same arguments, same directives — as flags — same result. Give a value and it writes; give none and it reads. There is one vocabulary to learn, not two, and nothing is renamed on the way through.

PHP Command
$file->property('table') archetype property <target> table
$file->property('table', 'gdpr_users') archetype property <target> table gdpr_users
$file->add()->property('fillable', 'nickname') archetype property <target> fillable nickname --add
$file->remove()->property('table') archetype property <target> table --remove
$file->empty()->property('fillable') archetype property <target> fillable --empty
$file->private()->property('key', 'v') archetype property <target> key v --private
$file->className() archetype className <target>
$file->full()->className() archetype className <target> --full
$file->add()->use([...]) archetype use <target> ... --add
$file->hasMany('Task') archetype hasMany <target> Task

A test asserts that every operation listed as an endpoint is a real method on LaravelFile, so the two cannot drift. Another asserts that archetype hasMany <target> Task produces output byte-identical to $file->hasMany('Task') — with no options given, the command calls the endpoint rather than reimplementing it.

The operations

Endpoints — these mirror the PHP API.

Operation Usage
property <target> <name> [<value>] --add --remove --empty --clear --public --protected --private --static
className <target> [<NewName>] --full
extends <target> [<Class>]
implements <target> [<Interface>...] --add
namespace <target> [<Namespace>] --remove
use <target> [<FQCN>...] --add
useTrait <target> [<Trait>...] --add
classConstant <target> <NAME> [<value>] --add --remove --empty --clear
methodNames <target> read only
make <name> --file --extends= --implements= --trait= --force
errors pre-existing, unchanged
fillable hidden visible guarded unguarded casts dates <target> [<value>] --add --remove --empty --clear
table connection timestamps <target> [<value>]
hasOne hasMany <target> <Related> --name= --foreign-key= --local-key=
belongsTo <target> <Related> --name= --foreign-key= --owner-key=
belongsToMany <target> <Related> --table= --with-pivot= --with-timestamps --using=

The console's own — these have no PHP equivalent.

Operation Usage What it is for
inspect <target> [meta|traits|uses|consts|cases|props|methods|relations]... Structure of a file, method bodies left out
show <target> <method> The source of one method
find [<dir>] --type=all|models|controllers|providers|migrations Which files are there, and what they are
set-array-key <target> <method> <key> [<value>] --append --remove The array a method returns — rules(), toArray(), casts()
add-case <target> <Name> [<value>] An enum case
add-method <target> --code=<php> A method on a class, enum, interface or trait
replace-method <target> <name> --code=<php>
remove-method <target> <name>
apply [<file>] Several operations in one invocation
hasOneThrough hasManyThrough <target> <Related> --through= The relations LaravelFile does not cover
morphOne morphMany morphToMany morphedByMany <target> <Related> --morph-name=
morphTo <target> [--morph-name=]

archetype with no arguments prints exactly this list, in these two halves, so the naming rule is visible rather than documented elsewhere.

The contract

Three properties hold for every operation that writes, because a caller that cannot rely on them has to read the file back, which throws away the reason for asking:

  • It verifies. The file is re-rendered and compared. A change that matched nothing exits non-zero and says so.
  • It shows its work. Every mutation answers with a diff.
  • It is repeatable. A change already applied is SKIP, not OK and not an error.
$ archetype fillable app/Models/User.php nickname --add
OK app/Models/User.php $fillable added to
@@ 24 @@
+         'nickname',
      ];

$ archetype fillable app/Models/User.php nickname --add
SKIP app/Models/User.php $fillable unchanged
Status Meaning Exit
OK Changed and saved 0
DRY Would change; nothing written 0
SKIP Already in the desired state 0
ERR Could not do what was asked 1

One target, three meanings

archetype useTrait app/Models/User.php Auditable --add   # one file
archetype useTrait 'App\Models\User' Auditable --add     # the same file
archetype useTrait app/Models Auditable --add            # every class under app/Models

Directory targets narrow with --extends, --implements, --uses-trait and --matching. Those options are rejected on a single-file target rather than quietly ignored.

Every operation but errors takes --json. Every mutation takes --dry-run and --no-diff.

Values, read the way they were meant

nullable|date is a valid PHP expression — a bitwise or of two constants — and never what someone typing a validation rule meant. So a bare word is a string, and PHP is assumed only where the text announces it: a bracket, a quote, a $variable, a call, a Class::constant, a number or a boolean.

archetype set-array-key app/Http/Requests/StoreTask.php rules due_at 'nullable|date'
archetype set-array-key app/Http/Resources/TaskResource.php toArray budget '$this->budget_cents'

The PHP API is untouched

src/ is the new src/Console tree plus two lines in the service provider. No endpoint, printer or query-builder change. Nothing an existing user calls behaves differently, which is what makes this a minor.

The console pays for that in reach, and says so. The endpoints address class declarations, so the endpoint-named operations refuse an enum, interface or trait:

$ archetype implements app/Enums/Status.php 'App\Contracts\HasColor' --add
ERR app/Enums/Status.php archetype:implements only works on classes, and this is an enum

Refusing before writing matters for the three operations that import a name before using it. implements on an enum would otherwise write the import, fail to add the interface, and report OK — once the file has changed, a half-done change is indistinguishable from a whole one, so render-and-compare cannot catch it.

inspect, show, find, the method operations, add-case and set-array-key have no such limit — they read and write the declaration through the console's own code, whatever it is.

Two smaller judgement calls worth a look:

  • useTrait, implements and extends add the import when given a fully qualified name, since a name used without one is never valid PHP. --no-import opts out.
  • archetype casts refuses on a model that declares Laravel 11's casts() method rather than writing $casts beside it, and points at set-array-key.

Test-harness changes

Two files outside src/, both so console tests can write in place rather than into the isolated .output directory:

  • tests/Pest.php — points the output root at the application for tests/Feature/Console.
  • tests/TestCase.php — a guard so cleanup never empties base_path(). Worth having regardless.

Tests

Console tests run through Artisan the way a caller runs them — one command line in, an exit code and the raw output back — plus six that exercise the archetype binary against a stub application, including that backslashes in a class name survive the shell.

Tests:    1 risky, 4 incomplete, 291 passed (673 assertions)

The risky and incomplete ones are pre-existing. PHPStan is clean.

Not in this PR

The natural next step is exposing the same operations over MCP, so they sit in an agent's tool list next to its file tools rather than behind a shell. Separate piece of work; changes nothing here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AjUMv5rFTVJMr6F1J7bx7x

ajthinking and others added 4 commits August 29, 2026 13:55
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
The previous commit widened five endpoints from `class` to `classLike`,
changed the PSR-2 printer's return-type spacing, added enum() and
enumCase() to the query builder, and put --json on archetype:errors.
None of that belongs in a change whose job is to add a command line, and
one of them was an outright regression: on a file declaring both an
interface and a class, className() started answering about the interface
and property() started writing into it, which is not valid PHP.

All of it is reverted. `src/` is now the new `src/Console` tree plus two
lines in the service provider, and nothing an existing user calls behaves
differently.

The console pays for that in reach, and says so rather than pretending
otherwise. The endpoints it drives address `class` declarations, so the
property, constant, interface, trait, parent-class and rename operations
refuse an enum, interface or trait outright:

    ERR app/Enums/Status.php archetype:add-implements only works on
    classes, and this is an enum

Refusing up front rather than after the fact matters for the three
operations that import a name before using it. add-implements on an enum
used to write the import, fail to add the interface, and report OK — a
half-done change is indistinguishable from a whole one once the file has
changed, so the render-and-compare check cannot catch it.

inspect, show, find, the method operations, add-case and set-array-key
have no such limit; they read and write the declaration through the
console's own code whatever it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjUMv5rFTVJMr6F1J7bx7x
The first cut invented a second vocabulary: set-property, add-to-property,
rename-class, add-use, set-extends. Half of those rename an endpoint that
already had a name, which means anyone who knows the PHP API has to learn
the CLI's dialect of it too. The directive system was sitting right there
and maps onto flags almost exactly.

So a command named after an endpoint is now that endpoint. Same
arguments, same directives as flags, same result: `archetype property
<target> fillable nickname --add` is `$file->add()->property('fillable',
'nickname')`. Give a value and it writes, give none and it reads.

    property className extends implements namespace use useTrait
    classConstant methodNames make
    fillable hidden visible guarded unguarded casts dates table
    connection timestamps
    hasOne hasMany belongsTo belongsToMany

Operations with names of their own are the ones with no PHP equivalent —
inspect, show, find, set-array-key, add-case, the method operations,
apply, and the seven relationship types LaravelFile does not cover. The
operation map prints in those two halves so the rule is visible rather
than documented somewhere else.

Two tests hold the line: every operation listed as an endpoint must be a
real method on LaravelFile, and `archetype hasMany <target> Task` must
produce byte-identical output to `$file->hasMany('Task')`. With no
options given the relationship commands call the endpoint rather than
reimplementing it.

Two bugs fell out of the rewrite, both from reading a value off a file
that already carried a directive — the endpoints read directives off the
file, so `$file->add()->use()` as a getter appends instead of answering.
State is now read from the syntax tree, and directives are applied at the
write itself. The second was mine from the first cut: useTrait() answers
with Node\Name objects, so class_basename() on them returned "Name" and
the already-uses check never matched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjUMv5rFTVJMr6F1J7bx7x
Constants in traits are PHP 8.2, and this package supports 8.1, so the
directive names move from the trait that uses them to a class of their
own. CI caught it; my local PHP is 8.4 and the lowest I can install here
is 8.2, so nothing on this machine would have.

Neither PHPStan with phpVersion 80100 nor php-parser's version-aware
parser flags the construct, so there is no local guard to add — the 8.1
job in CI is the check. A scan for the other 8.2-8.4 additions found
nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AjUMv5rFTVJMr6F1J7bx7x
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant