Search by

milpa / command

rodrigomx

The Command-as-atom core: the surface-agnostic Operation value object plus the CommandProvider and SurfaceProjector contracts. One operation, N surfaces.

Package info

github.com/getmilpa/command

Type:milpa-capability

pkg:composer/milpa/command

Statistics

Installs: 8 894

Dependents: 13

Suggesters: 0

Stars: 0

Open Issues: 1


README

Milpa

Milpa Command

The Command-as-atom core of Milpa: one surface-agnostic Operation value object — schema of inputs + handler + metadata (mutating / requiresConfirmation / scopes / outputSchema / version / path / surfaces) — plus two contracts, CommandProvider (the discovery seam a plugin implements to declare operations) and SurfaceProjector (the contract each surface projector implements). One operation, N surfaces — CLI, MCP, HTTP, web, TUI.

CI Packagist PHP License Docs

milpa/command carries the Command-as-atom contracts for Milpa — one operation, written once, that N surfaces materialize from. An Operation is a plain readonly value object: a name, a description, a handler, and the metadata a projector needs to decide how — and whether — to expose it. No projector, no kernel, no registry — just the atom and the two seams everything else binds to. The concrete surface projectors (CLI, MCP, HTTP, …) live host-side, in the skeleton; this package has zero package dependencies, Milpa or otherwise.

Install

composer require milpa/command

Quick example

use Milpa\Command\CommandProvider;
use Milpa\Command\Operation;
use Milpa\Command\SurfaceProjector;

final class PostsProvider implements CommandProvider
{
    public function operations(): array
    {
        return [
            new Operation(
                name: 'create_post',
                description: 'Create a post',
                handler: [PostsHandler::class, 'create'],
                inputSchema: ['type' => 'object', 'properties' => ['title' => ['type' => 'string']]],
                mutating: true,
                requiresConfirmation: true,
                scopes: ['posts:write'],
                path: '/posts',
            ),
        ];
    }
}

final class CliProjector implements SurfaceProjector
{
    public function surface(): string
    {
        return 'cli';
    }

    public function supports(Operation $op): bool
    {
        return $op->supportsSurface($this->surface());
    }
}

One Operationcreate_post — and a CliProjector turns it into a coa command, an MCP projector registers it as a tool, an HTTP projector synthesizes the POST /posts route. Each projector decides, per operation, whether and how to project it (supports() / supportsSurface()); milpa/command never runs any of them — that dispatch is the host's job.

Declare the intent; the framework derives the rest

The example above says everything twice: string $title in the handler AND ['type' => 'string'] in the schema; a sentence for the human in a docblock AND a description for the agent. Only one half of that is intent — what it mutates, whose authority it spends, which argument the human must NAME. The other half is mechanics a PHP type already answers.

So declare the first and derive the second:

use Milpa\Command\Declaration\{Because, Confirms, Mutates, Needs, Operation, Target};
use Milpa\Command\Effect\{Externality, Mutation, Reversibility, Subject};

#[Operation(name: 'posts:publish', description: 'Publish a post.')]
#[Mutates(Mutation::Persistent, Externality::None, Reversibility::Guaranteed, subject: Subject::Data)]
#[Needs(scopes: ['posts:write'])]
#[Confirms]
final readonly class PublishPost
{
    public function __construct(
        #[Target] #[Because('which post to publish')] public string $id,
        public Visibility $visibility = Visibility::Public,
    ) {
    }

    public function run(Posts $posts): Receipt
    {
        return $posts->publish($this->id, $this->visibility);
    }
}

The constructor IS the input contract — types become the schema, a parameter without a default is required, a PHP enum brings its own admissible values, #[Because] becomes the field's description. run() is the handler, with its collaborators injected and its return type declaring the output.

use Milpa\Command\Declaration\DeclaredOperation;

$operation = DeclaredOperation::from(PublishPost::class, fn (string $type) => $container->get($type));

What comes back is the same Operation value object the hand-written form builds, so every projector, the consent flow and the agent's catalogue read exactly what they read before. Both styles live side by side; the attribute wins where it exists.

Nothing about authority is ever inferred. A class that declares neither #[Mutates] nor #[Reads] is refused by name — silence would ship as «does not mutate» and every consumer would believe it. So are two #[Target]s, an input type no schema describes, and a run() whose collaborators nobody can resolve. Each refusal happens once, at declaration, and says what to declare.

Two contracts, one atom

Contract Role
CommandProvider The discovery seam a plugin implements to declare the operations it contributes — the kernel checks every booted plugin for it and merges operations() into the command table.
SurfaceProjector The contract every surface projector implements: reports the surface it targets (cli, mcp, http, …) and whether a given Operation opts into that surface.

The concrete projectors — the CLI command factory, the MCP tool registrar, the HTTP route synthesizer — live host-side, not in this package.

An operation can own its doubt

Since 0.5, an operation may declare that its target must be named by the human who asked:

new Operation(
    name: 'plugins.disable',
    // …
    namedTarget: 'name',   // the argument whose value must appear in the request
);

Declaring is the whole job of this package — the field is a contract, not a behaviour. Whoever holds the session enforces it (the framework's session gate does: an agent calling plugins.disable {name: X} when the request never named X gets a formal question back, not an execution). It exists because 160 measured runs showed an ambiguous imperative reads as authorisation at every link of the chain: ask "remove the old plugin" and a sampled candidate dies, with no fact saying why. With the contract declared, an unnamed target becomes a question instead. The doubt belongs to the operation that discovers a concrete operation cannot exist yet.

Requirements

  • PHP ≥ 8.3
  • Nothing else — milpa/command has no package dependencies, Milpa or otherwise

Documentation

Full API reference: getmilpa.github.io/command — generated straight from the source DocBlocks and dressed with the Milpa design system.

Contributing

Contributions are welcome — see CONTRIBUTING.md. Please report security issues via SECURITY.md, and note that this project follows a Code of Conduct.

License

Apache-2.0 © Rodrigo Vicente - TeamX Agency.

Milpa is designed, built, and maintained by Rodrigo Vicente - TeamX Agency.