<h2 align="center">
    <a href="https://nullplatform.com" target="blank_">
        <img height="100" alt="Nullplatform" src="https://nullplatform.com/favicon/android-chrome-192x192.png" />
    </a>
    <br>
    <br>
    @nullplatform/plugin
    <br>
</h2>

TypeScript SDK for building nullplatform plugins. Handles transport (gRPC
go-plugin **and** subprocess-exec), action routing, action lifecycle reporting,
`--describe`, and `--publish`.

## Install

```bash
bun add @nullplatform/plugin
```

## Layers

The SDK is layered so the stable core is insulated from today's rough platform
APIs:

- **base** — the plugin runtime, transport (gRPC + subprocess-exec), and the
  action-lifecycle *protocol*. Stable.
- **compat** (`src/compat/`) — temporary shims for current APIs: token exchange,
  action `PATCH` shapes. Each is deletable in isolation once the real API lands.
- **goal layers** — `defineScope` today; `defineService`, `defineHook` next.

## Usage

### Scope plugin

```typescript
import { defineScope } from "@nullplatform/plugin/scope";

defineScope({
  name: "kubernetes-k3d",
  version: "0.0.1",
  category: "containers",
  provider: "kubernetes",

  schema: {
    type: "object",
    properties: {
      memory: { type: "string", default: "2Gi" },
    },
  },

  // Optional — how the platform routes actions to your worker. Defaults:
  // selector { package: <name> }, entrypoint /app/packages/<name>/entrypoint,
  // and channel sources DERIVED from your actions ("service", plus "telemetry"
  // when you declare data-fetch actions like log:read). All overridable —
  // sources are free-form; the notifications API is the validator.
  agent: {
    selector: { package: "kubernetes-k3d" },
    // sources: ["service", "telemetry", "approval"],
  },

  actions: {
    "create-scope": {
      input: { type: "object" },
      handler: async (ctx) => {
        ctx.log.step("provisioning the target");
        // provision infrastructure
        ctx.log.ok("scope ready");
        return { domain: "app.k3d.local" };  // becomes the action results
      },
    },
  },
});
```

#### The handler's context

Every handler receives ONE argument, `ctx` — everything hangs off it, so new
capabilities never change the signature:

| | |
|---|---|
| `ctx.api` | the authenticated platform API — `NP_API_KEY` is exchanged for a token (cached, refreshed before expiry, retried once on 401). `ctx.api.scope(id)`, `deployment(id)`, `parameters(nrn)`, `providers(nrn, cats)`, … |
| `ctx.entity` | the hierarchy, lazily resolved and memoized off the action's `entity_nrn`: `scope()`, `application()`, `namespace()`, `account()`, `deployment()`, `release()`, `assets()`, `parameters()`, `providers(cats)` |
| `ctx.step` | the unit of work: `await ctx.step("attach rule", { retries: 3, timeoutMs: 60_000 }, fn)` — logs start/outcome with elapsed time, retries, and failures that name the step |
| `ctx.log` | `step` / `detail` / `ok` / `fail` — ANSI-styled, streamed live onto the action (`NO_COLOR` opts out) |
| `ctx.deployment` | `reportTraffic(n)` — the ONE deployment write a handler is obligated to make. Orchestration (statuses, strategy stepping, finalization) belongs to the platform's orchestrator, never the SDK |
| `ctx.state` | scope infra state, read/merge |
| `ctx.attributes` / `ctx.notification` | your schema's values, and the raw wire payload as the escape hatch |

The SDK reports the action lifecycle automatically: `in_progress` on start,
`success` with your returned object, or `failed` on throw.

#### Settings

Configuration resolves the same three ways for every plugin, in one call —
environment (local-dev override) → NRN parameters (platform-managed, secrets
included) → derived defaults the platform already knows:

```ts
import { resolveSettings } from "@nullplatform/plugin/settings";

const { values, missing } = await resolveSettings(
  {
    workflow:   { env: "MY_WORKFLOW", parameter: "MY_WORKFLOW", required: true },
    repository: { derive: async () => (await ctx.entity.application()).repository_url, required: true },
  },
  { nrn: ctx.entity.nrn },
);
// `missing` names every source each absent value could have come from.
```

Well-known action slugs get their metadata (name, type, retryable, lifecycle)
for free — `create-scope`, `delete-scope`, `update-scope`, `start-initial`,
`start-blue-green`, `switch-traffic`, `finalize-blue-green`,
`rollback-deployment`, `delete-deployment`, `diagnose-scope`,
`diagnose-deployment`, `kill-instances`, `restart-pods`, `pause-autoscaling`,
`resume-autoscaling`, `set-desired-instance-count`. For a **custom** action, use
any slug and declare `name`/`type` inline:

```ts
"say-hello": { name: "Say Hello", type: "custom", input, handler },
```

#### Transport

Agents run the compiled plugin directly (subprocess-exec): the action arrives in
`NP_ACTION_CONTEXT`, the plugin runs it, reports status, prints the result, and
exits. The gRPC go-plugin server is also supported for hosts that use it. You
write handlers; the SDK picks the transport.

### Package (the package layer)

One helper per kind, one object each, the actions inside it. `service()`, `link()`
and `scope()` take the **service API's own fields in camelCase** (`useDefaultActions`
is `use_default_actions`); `event(name, fn)` answers a notification by name;
`execute(fn)` answers anything else the agent hands over.

```ts
import { definePackage, service, link, scope, event, execute, Actions, ActionError } from "@nullplatform/plugin/package";

const bucket = service({
  name: "AWS S3 Bucket",
  selectors: { category: "Storage", provider: "AWS", subCategory: "Object Storage" },
  attributes: { schema },
  actions: {
    create, update, delete: remove,                       // default actions: a function each
    "empty-bucket": { name: "Empty bucket", parameters: { schema }, results: { schema }, handler },
  },
});
const connect = link(bucket, { name: "Connect", attributes: { schema }, actions: { create, update, delete: revoke } });
const target = scope({
  name: "Fast containers",
  attributes: { schema },
  scopeType: { description: "Containers on the shared cluster" },   // optional; none → no scope_type
  telemetry: { logs: true },
  actions: { [Actions.CREATE_SCOPE]: createScope, [Actions.DELETE_SCOPE]: deleteScope, [Actions.LOG_READ]: readLogs },
});
const gate = event("checklist:item:validation_dispatched", async (ctx) => { /* ctx.event.context */ });
const raw = execute(async (ctx) => { /* ctx.payload (parsed), ctx.raw (string) */ });

export default definePackage({ components: [bucket, connect, target, gate, raw] });  // name, version, description from package.json
```

Definition time validates it: a service or link with default actions binds `create`,
`update` and `delete`; a scope's keys are well-known lifecycle actions, telemetry data
actions consistent with `telemetry`, or custom entries with a handler; slugs are unique.

**Dispatch.** A notification naming one of the package's specifications runs the bound
action (default by type, custom by slug, links by the link's slug, scopes by slug then
`action`); otherwise a `source` plus an event name (`event`, or `action` as the
notifications API spells it) runs the `event()` of that name; anything else runs
`execute()` — a package without one refuses with `UNKNOWN_ACTION`. What a handler returns
becomes the action's results, validated first (attributes schema for `create`/`update`,
the custom action's `results.schema` otherwise); a failure is logged as `✗ CODE: message`
before the failed transition.

**`--describe`** prints the one manifest, the one `np package publish` reads: one scope or
one service per package, with its links. `name` is the slug and `displayName` the human
name; `actions` are the custom actions by slug (`create`/`update`/`delete` are the
platform's); each `link()` sits under `links` with the same shape; `agent` is the routing.
For `bucket` and `connect` above:

```json
{
  "kind": "service",
  "useDefaultActions": true,
  "name": "aws-s3-bucket",
  "displayName": "AWS S3 Bucket",
  "version": "0.4.0",
  "category": "Storage",
  "provider": "AWS",
  "subCategory": "Object Storage",
  "schema": { "type": "object", "properties": { "…": {} } },
  "actions": {
    "empty-bucket": { "name": "Empty bucket", "type": "custom", "retryable": false, "lifecycle": true, "input": { "…": {} }, "output": { "…": {} } }
  },
  "links": {
    "connect": {
      "name": "connect",
      "displayName": "Connect",
      "schema": { "type": "object", "properties": { "…": {} } },
      "useDefaultActions": true,
      "actions": {}
    }
  },
  "agent": { "selector": { "package": "aws-s3-bucket" }, "entrypoint": "/app/packages/aws-s3-bucket/entrypoint", "sources": ["service"], "pinVersion": false, "manageChannel": true }
}
```

The package slug must equal its specification's (`package.json` name, or
`definePackage({ slug })`), and a package with two specifications is refused. `event()`
and `execute()` have no place in the manifest yet: the worker answers them, their
channels are registered by hand, and `--describe` says so on stderr. No API change is
required to publish with today's CLI.

**Tests are one call.** `pkg.run(bucket, "create", input, { service, link, scope, providers, parameters, notification, signal })`,
`pkg.run(gate, payload)` for an event (payload = the notification context), `pkg.execute(document)`
for execute — each returns `{ status, results, error, data, logs, steps, traffic }`.

The handler's context, on top of the scope context above:

| | |
|---|---|
| `ctx.params` | the entity's attributes merged with the action's parameters, typed from the declared schema (`ContextOf<typeof bucket>`) |
| `ctx.service`, `ctx.link`, `ctx.scope` | what the platform sent, as it sent it |
| `ctx.deploymentId`, `ctx.assetUrl`, `ctx.weight`, `ctx.deployment.reportTraffic(n)` | deployment actions |
| `ctx.api` | the platform API typed from its OpenAPI specs: `ctx.api.GET("/scope/{id}", { params: { path: { id } } })`; in `pkg.run` calls are recorded in `run.requests`, never sent |
| `ctx.settings.providers(cats)`, `ctx.settings.parameters()`, `ctx.settings.buildContext(opts?)` | providers by category, NRN parameters, and the `np service workflow build-context` map (resources, secrets, providerCategories, dimensions), memoized per action; `pkg.run(…, { context })` answers it |
| `ctx.step`, `ctx.log`, `ctx.state` | the unit of work, the logger, scope state |
| `ctx.event` | the event block as data; for `event()` handlers also `name`, `source` and `context` |
| `ctx.payload`, `ctx.raw` | `execute()` only: the document parsed, and as received |
| `ctx.secrets.generate(n)`, `ctx.signal` | a CSPRNG secret; an `AbortSignal` the worker aborts on SIGTERM/SIGINT |

**Tracing.** With `NP_TRACING_URL` set and `@nullplatform/tracing` installed, the SDK
continues the platform's trace: one run per action, one step per `ctx.step`. Pass your
own client with `definePackage({ tracing })` in tests — and in a worker compiled to one
binary (`bun build --compile`): the SDK loads the tracing package lazily, by a dynamic
import the binary cannot resolve, so build the `Tracer` yourself where the import is static:

```typescript
import { Tracer } from "@nullplatform/tracing";
const tracing = process.env.NP_TRACING_URL
  ? new Tracer({ baseURL: process.env.NP_TRACING_URL, apiKey: process.env.NP_API_KEY ?? "", producer: "my-package" })
  : undefined;
definePackage({ …, ...(tracing ? { tracing } : {}) });
```

### Simple plugin (custom command)

```typescript
import { createPlugin, registerManifest } from "@nullplatform/plugin";

registerManifest({ name: "my-plugin", version: "0.0.1", command_types: ["custom"] });

createPlugin({
  async execute(req) {
    return { success: true, data: { message: "done" } };
  },
}).start();
```

## Subpath exports

| Import | Description |
|---|---|
| `@nullplatform/plugin` | Core: createPlugin, registerManifest, types |
| `@nullplatform/plugin/package` | definePackage, service, link, scope, scopeType, resource, kinds, handler, on, ActionError |
| `@nullplatform/plugin/scope` | defineScope API |
| `@nullplatform/plugin/schema` | JSON Schema type inference |
| `@nullplatform/plugin/testing` | Test harness: startPluginProcess, factories |
| `@nullplatform/plugin/settings` | The env → NRN-parameters → derived resolution ladder |
| `@nullplatform/plugin/term` | ANSI styling for logs: dim/bold/cyan/green/yellow/red, kv(), elapsed() |
| `@nullplatform/plugin/workflow` | Workflow integration (StreamingExecutionObserver) |

## CLI integration

Packages are scaffolded and driven with the `np package` commands, which are
thin wrappers over the template's own tasks:

```bash
np package init       # scaffold from a git template
np package dev        # local dev UI + hot reload   (→ mise/bun run dev)
np package test       # run tests                   (→ mise/bun run test)
np package build --image   # build the worker image
np package run        # run the worker as a real local agent (docker)
np package publish    # register on the platform (specs + channel + artifact)
```

`publish` reads the manifest from `--describe`, so the SDK owns the manifest
shape and the CLI stays decoupled from it. See the CLI's `docs/packages.md`.
