milpa / console
The projection layer of the Milpa PHP framework: surface projectors that turn one declared Operation into the shape each surface speaks — CLI flags, MCP tools — plus the signature gate that makes consent name the call.
Requires
- php: >=8.3
- milpa/command: ^0.3
- milpa/core: ^0.6
- milpa/http: ^0.1.5
- milpa/live: ^0.4
- milpa/live-tui: ^0.3
- milpa/plugin: ^0.6
- milpa/tool-runtime: ^0.9
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.65
- milpa/container: *
- nyholm/psr7: ^1.8
- phpstan/phpdoc-parser: ^2.3
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
README
Milpa Console
The projection layer of Milpa.
milpa/commanddeclares the atom — one surface-agnosticOperation; this package turns that atom into the shape each surface actually speaks. Today: CLI (flags, argument coercion, the signature gate) and MCP (tools an agent can call). One declaration, N surfaces.
Install
composer require milpa/console
Quick example
A plugin declares an operation once, through milpa/command's CommandProvider. It does not say
anything about flags, JSON-schema or terminals:
use Milpa\Command\Operation; new Operation( name: 'create_post', description: 'Create a draft post', handler: [PostService::class, 'create'], inputSchema: ['type' => 'object', 'properties' => ['title' => ['type' => 'string']]], mutating: true, requiresConfirmation: true, );
The projectors give it a surface:
use Milpa\Console\CliProjector; use Milpa\Console\McpProjector; // CLI: derives `--title=…` from the schema, coerces the string argv into typed input, and // enforces the signature gate before a mutating operation runs. (new CliProjector($authorizer))->run($operation, $argv, $container, $write); // MCP: the same operation becomes a tool an agent can list and call. (new McpProjector())->project($operations, $registry, $container);
Consent names the call
On a terminal, requiresConfirmation: true is not a --yes flag. A flag consents in the abstract —
the same yes covers removing any plugin on any host — so CliProjector asks for a signature that
names this call: the operation, its arguments, the host and a nonce. SchemaCoercer turns argv
strings into the types the schema declares before any of that, so what gets signed is what runs.
The pieces are seams, not concretions: OperationSigner is the port,
GnupgOperationSigner an adapter, and verification and nonce
spending live behind milpa/tool-runtime's OperationAuthorizer.
Testing your own surfaces
Milpa\Console\Testing\SignsOperations ships in src/ on purpose: Composer does not autoload a
dependency's autoload-dev, so a test helper that lives in tests/ is unreachable for whoever
consumes the package. The trait hands you an always-signing signer and an accepting authorizer, so a
test that just needs to get past the gate can do so without a real key.
Where this is going
ADR-0035 — a projection is a value, not an effect —
governs this package. Today CliProjector::run() executes and McpProjector::project() registers;
neither returns a surface model, and that is the thing being retrofitted: a projector will produce a
model and a renderer will materialize it, so a surface can change its renderer without touching its
projector.
HTTP, without dragging identity in
Milpa\Console\Http\HttpProjector is the fourth surface: one route per operation, plus the generic
controller those routes point at. It arrived in 0.4.0 — until then it lived in milpa/skeleton,
because moving it as-is would have dragged milpa/auth into a floor meant to run without it.
What made the move possible is that identity now sits behind an interface. The projector knows
nothing about who is calling; OperationHttpPolicy does, and
milpa/admin publishes the implementation that uses
milpa/auth. Write your own and the projector will use it.
use Milpa\Console\Http\HttpProjector; // $psr17 is any PSR-17 factory pair you already have (Nyholm, Guzzle, Laminas…). $projector = new HttpProjector($operations, $container, $psr17, $psr17, policy: $yourPolicy); $routes = $projector->routes(); // hand these to your router $model = $projector->project($op); // or just ask what an operation would expose
Unlike the other optional collaborators in this family, not knowing is not permission here: an
operation that declares scopes with no policy wired throws UnguardedOperationException (a 500)
rather than running unguarded. It stays a 500 and never a 401/403 — the caller did nothing wrong; the
host declared something protected and left it without a guard. An operation that declares neither
scopes nor a permission never touches any of this.
Requirements
- PHP >= 8.3
milpa/command,milpa/core,milpa/tool-runtime,milpa/httppsr/http-messageandpsr/http-factory— interfaces only. The HTTP projector asks for the PSR-17 factories instead of picking a PSR-7 implementation for you.
Contributing
See CONTRIBUTING.md and CODE_OF_CONDUCT.md.
License
Apache-2.0 © Rodrigo Vicente - TeamX Agency. See LICENSE and NOTICE.