@gjermundgaraba/effect-actions 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +16 -13
  2. package/dist/ActionCatalog.d.mts +2 -2
  3. package/dist/ActionCatalog.mjs +11 -10
  4. package/dist/ActionCli.d.mts +2 -2
  5. package/dist/ActionCli.mjs +7 -8
  6. package/dist/ActionCliClient.d.mts +9 -6
  7. package/dist/ActionCliClient.mjs +9 -11
  8. package/dist/ActionGroup.d.mts +17 -15
  9. package/dist/ActionGroup.mjs +12 -1
  10. package/dist/ActionHttp.d.mts +10 -5
  11. package/dist/ActionHttp.mjs +21 -38
  12. package/dist/ActionHttpClient.d.mts +45 -0
  13. package/dist/ActionHttpClient.mjs +24 -0
  14. package/dist/ActionMcp.d.mts +17 -43
  15. package/dist/ActionMcp.mjs +15 -19
  16. package/dist/ActionToolkit.d.mts +5 -17
  17. package/dist/ActionToolkit.mjs +2 -7
  18. package/dist/Testing.d.mts +31 -1
  19. package/dist/Testing.mjs +50 -2
  20. package/dist/TestingClient.d.mts +6 -5
  21. package/dist/TestingClient.mjs +6 -3
  22. package/dist/internal/actions.d.mts +18 -7
  23. package/dist/internal/actions.mjs +21 -2
  24. package/dist/internal/cli.d.mts +2 -2
  25. package/dist/internal/cli.mjs +34 -20
  26. package/dist/internal/implementation.d.mts +17 -6
  27. package/dist/internal/implementation.mjs +12 -3
  28. package/dist/internal/tools.d.mts +22 -0
  29. package/dist/internal/tools.mjs +3 -18
  30. package/docs/Action.md +22 -54
  31. package/docs/ActionCatalog.md +32 -35
  32. package/docs/ActionCli.md +25 -52
  33. package/docs/ActionCliClient.md +16 -30
  34. package/docs/ActionGroup.md +34 -56
  35. package/docs/ActionHttp.md +34 -46
  36. package/docs/ActionHttpClient.md +73 -0
  37. package/docs/ActionMcp.md +117 -89
  38. package/docs/ActionToolkit.md +28 -46
  39. package/docs/Authentication.md +18 -36
  40. package/docs/CONTEXT.md +6 -5
  41. package/docs/README.md +30 -24
  42. package/docs/Testing.md +62 -59
  43. package/docs/guarantees.md +13 -13
  44. package/docs/setup.md +7 -6
  45. package/package.json +5 -4
@@ -1,4 +1,4 @@
1
- import { projectedErrors } from "./actions.mjs";
1
+ import { assertDistinct, projectedErrors } from "./actions.mjs";
2
2
  import { dispatch } from "./implementation.mjs";
3
3
  import { Effect, Schema } from "effect";
4
4
  import { Tool, Toolkit } from "effect/unstable/ai";
@@ -17,15 +17,6 @@ const entries = (apps) => apps.flatMap(({ app, actions }) => actions.map((action
17
17
  app,
18
18
  action
19
19
  })));
20
- /** Fail before acquiring handlers when two exposed tools share a name. */
21
- const assertDistinctToolNames = (tools) => {
22
- const names = /* @__PURE__ */ new Set();
23
- for (const { action } of tools) {
24
- const name = action.mcp.name;
25
- if (names.has(name)) throw new Error(`Duplicate MCP tool: ${name}`);
26
- names.add(name);
27
- }
28
- };
29
20
  const annotate = (tool, mcp) => tool.annotate(Tool.Readonly, mcp.readOnly).annotate(Tool.Destructive, mcp.destructive).annotate(Tool.Idempotent, mcp.idempotent).annotate(Tool.OpenWorld, mcp.openWorld);
30
21
  /**
31
22
  * A native Effect AI tool. Its schemas retain action transforms and its result
@@ -49,20 +40,14 @@ const mcpTool = (action, errors) => annotate(Tool.make(action.mcp.name, {
49
40
  failure: Schema.toCodecJson(Schema.Union(errors)),
50
41
  failureMode: "return"
51
42
  }), action.mcp);
52
- /** MCP's input schema must describe a JSON object, including no-argument tools. */
53
- const assertMcpObjectInput = (action, tool) => {
54
- const root = Tool.getJsonSchemaFromSchema(tool.parametersSchema);
55
- if (root.$ref === void 0 && root.type !== "object") throw new Error(`${action.name}: MCP input must have an object root; omit input for no arguments`);
56
- };
57
43
  const project = (projection, entry, options) => {
58
44
  const errors = projectedErrors(entry.action, options.errors);
59
45
  const tool = projection === "native" ? nativeTool(entry.action, errors) : mcpTool(entry.action, errors);
60
46
  if (projection === "native") return tool;
61
- assertMcpObjectInput(entry.action, tool);
62
47
  return tool.annotate(Tool.Strict, true);
63
48
  };
64
49
  const handler = (projection, app, action, handlers, before) => {
65
- const run = (input) => dispatch(app.group, action, handlers, before)(input);
50
+ const run = dispatch(app.group, action, handlers, before);
66
51
  return projection === "native" ? run : (input) => Effect.map(run(input), (value) => ({ value }));
67
52
  };
68
53
  /**
@@ -73,7 +58,7 @@ const handler = (projection, app, action, handlers, before) => {
73
58
  const bindTools = (apps, projection, options) => {
74
59
  const served = toolApps(apps);
75
60
  const selected = entries(served);
76
- assertDistinctToolNames(selected);
61
+ assertDistinct("MCP tool", selected.map(({ action }) => action.mcp.name));
77
62
  const toolkit = Toolkit.make(...selected.map((entry) => project(projection, entry, options)));
78
63
  return {
79
64
  toolkit,
package/docs/Action.md CHANGED
@@ -5,56 +5,25 @@ metadata. An action holds no behavior; handlers are bound by `ActionGroup.implem
5
5
 
6
6
  ## API
7
7
 
8
- ```ts
9
- import * as Action from "@gjermundgaraba/effect-actions/Action";
10
-
11
- /** Any service-free schema. */
12
- type Codec = Schema.Codec<unknown, unknown, never, never>;
13
-
14
- function make<Name, Input = NoInput, Output, Errors = [], Acc extends Access, Http = true, Mcp = undefined>(
15
- name: Name,
16
- options: Options<Input, Output, Errors, Acc, Http, Mcp>,
17
- ): Action<Name, Input, Output, Errors, Acc, Http, Mcp>;
18
-
19
- /** What the action does to its resource. Authorization metadata, not an MCP hint. */
20
- type Access = "read" | "write";
21
-
22
- interface Options<Input, Output, Errors, Acc, Http, Mcp> {
23
- readonly description: string;
24
- readonly input?: Input; // omit for an action without arguments
25
- readonly success: Output;
26
- readonly errors?: Errors; // ReadonlyArray<Codec>; defaults to []
27
- readonly access: Acc; // required, no default
28
- readonly http?: Http; // false hides the action from HTTP routes and clients
29
- readonly mcp?: Mcp; // false hides the action from MCP; otherwise McpOptions
30
- }
31
-
32
- interface McpOptions {
33
- readonly name?: string; // tool name; defaults to the action name; ^[A-Za-z0-9_-]{1,128}$
34
- readonly readOnly?: boolean; // readOnlyHint, default access === "read"
35
- readonly destructive?: boolean; // destructiveHint, default !readOnly
36
- readonly idempotent?: boolean; // idempotentHint, default false
37
- readonly openWorld?: boolean; // openWorldHint, default true
38
- }
39
-
40
- interface Action<Name, Input, Output, Errors, Acc, Http, Mcp> {
41
- readonly name: Name;
42
- readonly description: string;
43
- readonly input: Input;
44
- readonly success: Output;
45
- readonly errors: Errors;
46
- readonly access: Acc; // the literal it was declared with
47
- readonly http: Http;
48
- readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld }; // resolved
49
- }
50
-
51
- /** Receives decoded input; may fail only with the declared errors. */
52
- type Handler<A extends Any, R = never> = (
53
- input: A["input"]["Type"],
54
- ) => Effect.Effect<A["success"]["Type"], A["errors"][number]["Type"], R>;
55
-
56
- type Any; // any action with its schemas erased
57
- ```
8
+ Import `@gjermundgaraba/effect-actions/Action`.
9
+
10
+ | Export | Purpose |
11
+ | ------------------------------------------ | ---------------------------------------------------------------------------------- |
12
+ | `make(name, options)` | Define a pure contract; literal names, access and transport exclusions stay typed. |
13
+ | `Action`, `Any`, `Options` | Concrete contracts, erased contracts and construction options. |
14
+ | `Codec`, `Handler`, `Access`, `McpOptions` | Service-free codecs, typed handlers, read/write classification and tool metadata. |
15
+
16
+ | Option | Meaning |
17
+ | ---------------------------------- | ---------------------------------------------------------------------------- |
18
+ | `description`, `success`, `access` | Required description, success codec and `"read"` / `"write"` classification. |
19
+ | `input` | Optional codec; omission means an empty object. |
20
+ | `errors` | Declared error codecs; defaults to none. |
21
+ | `http` | Defaults to enabled; `false` removes routes and client methods. |
22
+ | `mcp` | Defaults to enabled; `false` removes tools, otherwise accepts `McpOptions`. |
23
+
24
+ MCP options: `name` defaults to the action name; `readOnly` to `access === "read"`;
25
+ `destructive` to `!readOnly`; `idempotent` to `false`; `openWorld` to `true`.
26
+ Handlers receive decoded input and return decoded success, failing only with declared errors.
58
27
 
59
28
  ## Canonical
60
29
 
@@ -120,11 +89,11 @@ export const Double = Action.make("double", {
120
89
  - Omit `input` for a no-argument action. The default is an empty object schema, which satisfies MCP's object-root requirement. Clients still pass `{ payload: {} }`.
121
90
  - `errors` is a list of schemas, default none. Each keeps its own `httpApiStatus` annotation; an unannotated error is served as HTTP 500. Group-level `errors` are appended to every action of the group (see [ActionGroup.md](ActionGroup.md)).
122
91
  - A handler may fail only with the declared errors. Anything else is a defect.
123
- - `access` is `"read"` or `"write"` and is required. `make` also checks it at runtime, so a caller the compiler never sees cannot define an action no rule classifies. It stays a literal on the action, so a rule may switch on it at the type level. It is authorization metadata for a surface's `before` hook (see [guarantees.md](guarantees.md)); the library itself authorizes nothing and no adapter reads it.
92
+ - `access` is `"read"` or `"write"` and is required. `make` also checks it at runtime, so a caller the compiler never sees cannot define an action no rule classifies. It stays a literal on the action, so a rule may switch on it at the type level. It is authorization metadata for a surface's `before` hook (see [guarantees.md](guarantees.md)); the library itself authorizes nothing. Its only built-in uses are default MCP hints and span/log annotations; adapters never enforce an authorization rule from it.
124
93
  - `access` is independent of `mcp`. A local-only action (`mcp: false`) still has one, and an action may set `access: "write"` with `mcp: { readOnly: true }` if the tool hint should say something else. Derive authorization from `access`, never from a tool hint.
125
94
  - Both transports are on by default. `http: false` removes the route and the client method. `mcp: false` removes the tool. Both `false` makes a local-only action, reachable through `ActionCli` only.
126
- - MCP input must have an object-root JSON Schema. Scalar or array input is fine for HTTP, but `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, and `ActionToolkit.make` throw when called with such an action. Success and error schemas may be any shape.
127
- - `mcp.name` is the tool name, at most 128 characters, unique within the group and within any Toolkit or MCP projection that serves the group. Default is the action name.
95
+ - MCP input must have an object-root JSON Schema, an identified or recursive root included. Scalar or array input is fine for HTTP and for a native Toolkit, but the native MCP server refuses it when an `ActionMcp` layer is built. Success and error schemas may be any shape.
96
+ - `mcp.name` is the tool name, matches `[A-Za-z0-9_-]+` except `then`, is at most 128 characters, unique within the group and within any Toolkit or MCP projection that serves the group. Default is the action name.
128
97
  - Hint defaults: `readOnly: access === "read"`, `destructive: !readOnly`, `idempotent: false`, `openWorld: true`. Hints are metadata for the model. They do not enforce authorization, approval, or retries.
129
98
  - Schemas must be service-free. Put service access in the handler.
130
99
  - The group name, not the action, is the OpenAPI tag and operation-ID prefix: `<group>.<action>`.
@@ -134,7 +103,6 @@ export const Double = Action.make("double", {
134
103
 
135
104
  - Throws at `make`: invalid name, name `then`, an `access` that is neither `"read"` nor `"write"`, `mcp.name` longer than 128 characters or not matching the pattern.
136
105
  - Type error `Effect<..., X, ...> is not assignable` in `implement`: the handler fails with an undeclared error `X`. Add it to `errors` or handle it.
137
- - `<action>: MCP input must have an object root` thrown by `ActionMcp.layerHttp`, `ActionMcp.layerStdio`, or `ActionToolkit.make`: an MCP-enabled action has non-object input. Wrap it in `Schema.Struct` or set `mcp: false`.
138
106
  - Handler receives a string where a number was expected: the schema is `Schema.String`, not a transforming codec such as `Schema.FiniteFromString`.
139
107
  - `Property 'access' is missing` at `make`: every action declares `"read"` or `"write"`. There is no default.
140
108
  - A tool shows `readOnlyHint: false` for a read: the action sets `mcp: { readOnly: false }` explicitly, which wins over `access`.
@@ -1,57 +1,54 @@
1
1
  # ActionCatalog
2
2
 
3
- An offline JSON document describing groups: identities, metadata, and standalone JSON
4
- schemas. Built from contracts alone. No implementation, service, or server is involved.
3
+ An offline JSON document of contract identities, metadata and encoded-value JSON Schemas.
4
+ Built from contracts alone: no implementation, service or server is involved.
5
5
 
6
6
  ## API
7
7
 
8
- ```ts
9
- import * as ActionCatalog from "@gjermundgaraba/effect-actions/ActionCatalog";
8
+ Import `@gjermundgaraba/effect-actions/ActionCatalog`.
10
9
 
11
- const make: (...groups: ReadonlyArray<ActionGroup.Any>) => Catalog;
12
-
13
- interface Catalog {
14
- readonly version: "1";
15
- readonly actions: ReadonlyArray<Entry>;
16
- }
17
-
18
- interface Entry {
19
- readonly id: string; // "<group>.<action>"
20
- readonly group: string;
21
- readonly name: string;
22
- readonly description: string;
23
- readonly http: boolean;
24
- readonly mcp: false | { name; readOnly; destructive; idempotent; openWorld };
25
- readonly input: JsonSchema.JsonSchema;
26
- readonly success: JsonSchema.JsonSchema;
27
- readonly errors: ReadonlyArray<JsonSchema.JsonSchema>; // own + inherited group errors
28
- readonly httpSchemaErrors: ReadonlyArray<JsonSchema.JsonSchema>; // policy errors, HTTP only
29
- }
30
- ```
10
+ `make(...groups)` returns `Catalog`: `version: "3"` and an ordered `actions` array of `Entry`.
11
+
12
+ | Entry field | Meaning |
13
+ | ------------------------------------ | ---------------------------------------------------------------------------- |
14
+ | `id`, `group`, `name`, `description` | Stable `<group>.<action>` identity and contract metadata. |
15
+ | `http`, `mcp` | HTTP enablement and resolved MCP enablement/name/hints. |
16
+ | `input`, `success` | Standalone draft 2020-12 JSON Schema objects, not wrappers. |
17
+ | `errors` | JSON Schema objects for own and inherited group errors. |
18
+ | `httpSchemaErrors` | JSON Schema objects for HTTP policy errors; empty for HTTP-disabled actions. |
19
+
20
+ Exported types are `Catalog` and `Entry`.
31
21
 
32
22
  ## Canonical
33
23
 
34
24
  ```ts
25
+ import { NodeRuntime } from "@effect/platform-node";
26
+ import { Console } from "effect";
35
27
  import * as ActionCatalog from "@gjermundgaraba/effect-actions/ActionCatalog";
36
28
  import { AuditActions, PublicActions, UserActions } from "./contracts.js";
37
29
 
38
- const catalog = ActionCatalog.make(PublicActions, UserActions, AuditActions);
39
-
40
- process.stdout.write(JSON.stringify(catalog, null, 2));
30
+ // Contract inspection requires no implementation or domain-service Layer.
31
+ Console.log(
32
+ JSON.stringify(ActionCatalog.make(PublicActions, UserActions, AuditActions), null, 2),
33
+ ).pipe(NodeRuntime.runMain);
41
34
  ```
42
35
 
43
36
  ## Rules
44
37
 
45
38
  - Takes groups, not implementations. Nothing is acquired or started.
39
+ - Group names must be unique. Entries preserve group declaration order, then action declaration order.
46
40
  - `id` is the stable `<group>.<action>` identity, equal to the HTTP operation ID.
47
- - Schemas describe encoded action values: the JSON on the wire, not MCP's `{ value }` envelope and not deployment URLs.
48
- - Each schema owns its `$defs`. Equal schema identifiers in different entries never replace each other. Recursive references stay local to their schema.
49
- - `errors` includes group-level errors. HTTP schema-policy errors are listed separately in `httpSchemaErrors`. Surface errors are not listed: they belong to a binding, not to the contract.
50
- - `access` is not in the document. It is authorization metadata for a surface's hook, and the catalog is descriptive; read it from the contract (`group.actions`) when you need it.
51
- - Local-only actions (`http: false`, `mcp: false`) are included. Presence in a catalog is descriptive. It is not authorization, tool publication, or proof that a route is mounted. The host decides what to publish.
52
- - The package adds no search, no catalog HTTP endpoint, and no TypeScript code generation.
41
+ - Schemas describe encoded action values through native `Schema.toJsonSchemaDocument`, which lowers to the JSON codec first. A `Date` is a string, an `Option` a tagged union, a `bigint` a string of digits. These are action values, not MCP's `{ value }` envelope or deployment URLs.
42
+ - JSON Schema does not preserve arbitrary Effect transformations or filters. Checks on the decoded side of a transformation, such as `FiniteFromString.check(isGreaterThan(0))`, are not described. The server's decode remains authoritative.
43
+ - Each schema root includes its own `$schema` and, where needed, `$defs`. Equal identifiers across entries or fields cannot replace each other; recursive references resolve locally.
44
+ - The catalog contains JSON Schema only. Use Effect's `SchemaRepresentation` directly when native schema persistence or revival is needed.
45
+ - `errors` includes group-level errors. HTTP schema-policy errors are separate in `httpSchemaErrors`. Surface errors belong to bindings and are not listed.
46
+ - `access` is omitted; read it from the contract when needed. Local-only actions are included. Presence grants no authorization or proof that an endpoint is mounted; the host decides what to publish.
47
+ - No search, catalog HTTP endpoint, or TypeScript code generation is provided.
53
48
 
54
49
  ## Failure modes
55
50
 
56
- - Type error passing an implementation: `make` takes groups. Pass `app.group` or the group value itself.
57
- - Consumers see `{ value }` in MCP responses but not in the catalog: expected. The catalog describes action values; MCP wraps them.
51
+ - `Duplicate catalog group: <name>`: two supplied groups share a name, including a group passed twice.
52
+ - Type error passing an implementation: pass `app.group` or the group value itself.
53
+ - Native JSON Schema conversion throws: the codec or its annotations cannot be converted.
54
+ - JSON Schema accepts a value the server rejects: custom validation or a decoded-side check is not expressible in the wire schema. Server validation remains authoritative.
package/docs/ActionCli.md CHANGED
@@ -5,47 +5,24 @@ nothing is fetched over HTTP.
5
5
 
6
6
  ## API
7
7
 
8
- ```ts
9
- import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
10
-
11
- /** One explicitly selected action. */
12
- const command: <G, Name extends G["actions"][number]["name"], H, EX, RX, EB, RB, Parameters = never>(
13
- app: Implementation<G, H, EX, RX>,
14
- name: Name,
15
- options?: Options<SuccessType, EB, RB, Parameters>,
16
- ) => Command.Command<string, never, {}, EX | EB | SchemaError | DeclaredErrors, Exclude<RX | RB | HandlerContext, Scope>>;
17
-
18
- /** Every action of the group, including local-only ones, under the group name. */
19
- const group: <G, H, EX, RX, EB, RB>(
20
- app: Implementation<G, H, EX, RX>,
21
- options?: GroupOptions<EB, RB>,
22
- ) => Command.Command<string, {}, {}, EX | EB | SchemaError | DeclaredErrors, ...>;
8
+ Import `@gjermundgaraba/effect-actions/ActionCli`.
23
9
 
24
- /** The hook both projections accept. A CLI encodes nothing, so it declares no errors. */
25
- interface BeforeOptions<EB, RB> {
26
- readonly before?: (action: Action.Any) => Effect.Effect<void, EB, RB>;
27
- }
10
+ | API | Purpose |
11
+ | ------------------------------ | ----------------------------------------------------------------- |
12
+ | `command(app, name, options?)` | One selected action as a native Effect `Command`. |
13
+ | `group(app, options?)` | Every action under a group command, including local-only actions. |
14
+ | `Options`, `GroupOptions` | Configuration for single and aggregate commands. |
28
15
 
29
- interface GroupOptions<EB, RB> extends BeforeOptions<EB, RB> {
30
- readonly name?: string; // override the group command name
31
- }
16
+ | Option | Meaning |
17
+ | --------------------- | ------------------------------------------------------------------------------------------------------------ |
18
+ | `name` | Override the command/group name. |
19
+ | `before` | Hook receiving the selected `Action.Any`; its failures and services join the command's channels. |
20
+ | `render` | Single command only: decoded success to human-readable string; adds `--json`. |
21
+ | `parameters`, `input` | Single command only; supply together. Native flag/argument config and parsed-config-to-encoded-JSON mapping. |
32
22
 
33
- type Options<Output, EB, RB, Parameters extends Command.Command.Config = never> =
34
- | (JsonOptions<Output> & BeforeOptions<EB, RB>)
35
- | (ParametersOptions<Output, Parameters> & BeforeOptions<EB, RB>);
36
-
37
- interface JsonOptions<Output> {
38
- readonly name?: string; // override the command name; default is the action name
39
- readonly render?: (output: Output) => string; // human output; --json still prints JSON
40
- }
41
-
42
- interface ParametersOptions<Output, Parameters> {
43
- readonly name?: string;
44
- readonly render?: (output: Output) => string;
45
- readonly parameters: Parameters; // native Effect Flag / Argument config
46
- readonly input: (parsed: Command.Command.Config.Infer<Parameters>) => Schema.Json; // encoded action input
47
- }
48
- ```
23
+ Without `parameters`/`input`, a command accepts `--input` and `--input-file`. Commands retain
24
+ handler, builder and hook requirements/failures, plus codec failures. The invocation owns its
25
+ scope. Success output is encoded; failures remain failures of the command Effect.
49
26
 
50
27
  ## Canonical
51
28
 
@@ -58,7 +35,6 @@ import { actors, authorize, CurrentActor } from "./auth.js";
58
35
  import { UserApp } from "./handlers.js";
59
36
  import { Users } from "./users.js";
60
37
 
61
- // Explicit syntax: `double --value 21`. No `--input` on this command.
62
38
  // The CLI binds the same hook as the servers; a local caller is not trusted more.
63
39
  const command = ActionCli.command(UserApp, "double", {
64
40
  parameters: { value: Flag.String("value") },
@@ -66,13 +42,10 @@ const command = ActionCli.command(UserApp, "double", {
66
42
  before: authorize,
67
43
  });
68
44
 
69
- // Default syntax for a whole group: `users double --input '{"value":"21"}'`.
70
- const all = ActionCli.group(UserApp, { before: authorize });
71
-
72
45
  Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
73
46
  Effect.tapCause((cause) => Console.error(cause)),
74
47
  Effect.provideService(Logger.LogToStderr, true),
75
- // The hook runs here too, so a local caller supplies the identity it reads
48
+ // The hook runs here too, so a local caller supplies an identity for it
76
49
  // exactly as HTTP middleware does for a request.
77
50
  Effect.provideService(CurrentActor, actors.alice),
78
51
  Effect.provide(Users.layerMemory),
@@ -84,22 +57,22 @@ Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
84
57
  ## Rules
85
58
 
86
59
  - `command(app, name)` selects one action by name from the bound group; the name is checked at the type level. `group(app)` puts every action under the group command, including actions with `http: false` and `mcp: false`.
87
- - Default syntax is `--input '<json>'` carrying the entire encoded input: nested objects, arrays, scalars. Omitting `--input` supplies `{}`, which must still satisfy the input schema.
88
- - `parameters` and `input` are supplied together or not at all. With them, the command has explicit native flags and arguments and no `--input`, so its syntax does not change when the schema changes.
60
+ - Default syntax is `--input '<json>'` or `--input-file <path>` carrying the entire encoded input: nested objects, arrays, scalars. The file is read and JSON-parsed by the native `Flag.FileSchema`. Both are decoded by the native parser; if both are given, the file is used. Omitting both decodes `{}` separately on every invocation; result identity follows the codec's behavior. An action that requires input then fails with a `SchemaError`, exactly as a `parameters` command's mapped input does.
61
+ - `parameters` and `input` are supplied together or not at all. With them, the command has explicit native flags and arguments and neither `--input` nor `--input-file`, so its syntax does not change when the schema changes.
89
62
  - `input(parsed)` returns encoded JSON. The action schema decodes it before dispatch; a mismatch is a `SchemaError` at runtime. Action fields are never turned into flags automatically.
90
63
  - Native `Flag` and `Argument` own names, aliases, ordering, defaults, and optionality. Use `Flag.optional` when omission must stay distinct from a default, and map the `Option` to a present or omitted field.
91
- - Output is validated and encoded before printing. Default output is JSON. `render(decoded)` gives human output; `--json` selects JSON again. Rendering cannot bypass validation.
92
- - Explicit flag names must not collide with the renderer's `--json` flag. A payload field named `json` is ordinary data; only the flag name is reserved.
64
+ - Output is validated and encoded before printing. Default output is JSON. `render(decoded)` gives human output and adds a `--json` flag to that command, which selects JSON again. Rendering cannot bypass validation.
65
+ - `--json` is a regular flag of the rendered command only, placed after the command name on a `group` path. A command without `render` has no such flag: its output is JSON already. Nothing is declared tree-wide, so a host CLI may declare its own `--json`, global or not. A rendered `parameters` command reserves the flag name; a payload field named `json` is ordinary data.
93
66
  - Each invocation acquires the implementation in a scope and releases it after the call. Domain services and authority come from the host's provided layers. There is no HTTP fallback.
94
67
  - `before` runs before the selected handler, on `command` and on every subcommand of `group`. Its failure joins the command's error channel and its services are the caller's to provide, so a local CLI supplies the identity the hook reads exactly as HTTP middleware does. A CLI is not a trusted bypass.
95
- - The CLI declares no `errors`: it encodes nothing, so a refusal is simply a typed failure of the command effect. The native parser also decodes `--input` before the command runs, so unlike HTTP the hook runs after input decoding.
68
+ - The CLI declares no surface `errors`: it does not serialize failures, so a refusal is simply a typed failure of the command effect. Success values are still validated and encoded for output. The native parser also decodes `--input` and `--input-file` before the command runs, so invalid input skips the hook and handler.
96
69
  - Group commands use the default JSON syntax. For a custom tree, compose individual `command` results with native `Command` combinators.
97
70
 
98
71
  ## Failure modes
99
72
 
100
- - `SchemaError` on a valid-looking `--input`: the JSON is decoded input, so transforming codecs expect the encoded form (`"21"` for `FiniteFromString`, not `21`).
101
- - Command rejects `--input`: the command was built with `parameters`. Use its flags.
73
+ - Native `CliError.ShowHelp` containing `InvalidValue` for a supplied `--input` or `--input-file`: the parser rejected its JSON or schema before the command handler ran. Supply encoded input (`"21"` for `FiniteFromString`, not `21`). With neither flag, decoding the default `{}` can fail with `SchemaError`; so can a `parameters` command's mapped input or an invalid success value.
74
+ - Command rejects `--input` or `--input-file`: the command was built with `parameters`. Use its flags.
102
75
  - Type error: `name` is not an action of the group. Check `group.actions` names, not MCP tool names.
103
76
  - Handler cannot find a service: provide its Layer to the runtime (`Effect.provide`) before `runMain`. The command does not supply services.
104
- - A command requires a request-identity tag no handler yields: the `before` hook yields it. Provide it, or build the command without a hook for local use.
105
- - `--json` flag conflict at definition: rename the native flag; the config property can keep its name.
77
+ - A command requires a request-identity tag no handler yields: the `before` hook yields it. Provide a trusted identity around the invocation; do not remove the authorization hook just to satisfy the service requirement.
78
+ - `--json` flag conflict at definition on a rendered `parameters` command: rename the native flag; the config property can keep its name.
@@ -5,37 +5,22 @@ handler locally and manages no credentials.
5
5
 
6
6
  ## API
7
7
 
8
- ```ts
9
- import * as ActionCliClient from "@gjermundgaraba/effect-actions/ActionCliClient";
10
-
11
- /** One HTTP-enabled action retained by the binding. */
12
- const command: <const Groups, const GroupName, const ActionName, Parameters = never>(
13
- http: Http<Groups>,
14
- groupName: GroupName,
15
- actionName: ActionName, // only actions with http: true
16
- options?: Options<SuccessType, Parameters>,
17
- ) => Command.Command<string, never, {}, ClientErrors, HttpClient.HttpClient | ClientServices>;
8
+ Import `@gjermundgaraba/effect-actions/ActionCliClient`.
18
9
 
19
- /** Every HTTP-enabled action of one group under the group name. */
20
- const group: <const Groups, const GroupName>(
21
- http: Http<Groups>,
22
- groupName: GroupName,
23
- options?: GroupOptions,
24
- ) => Command.Command<string, {}, {}, ClientErrors, HttpClient.HttpClient | ClientServices>;
10
+ | API | Purpose |
11
+ | ------------------------------------------------ | -------------------------------------------------------------------------- |
12
+ | `command(http, groupName, actionName, options?)` | Call one HTTP-enabled action through a native Effect `Command`. |
13
+ | `group(http, groupName, options?)` | Every HTTP-enabled action of a bound group under one command. |
14
+ | `Options`, `GroupOptions`, `Connection` | Single/aggregate command configuration and native HTTP connection options. |
25
15
 
26
- type Options<Output, Parameters> = ActionCli.Options<Output, Parameters> & {
27
- /** Passed directly to HttpApiClient.make: baseUrl, transformClient, transformResponse. */
28
- readonly connection?: Parameters<typeof HttpApiClient.make>[1];
29
- };
30
-
31
- interface GroupOptions {
32
- readonly name?: string;
33
- readonly connection?: Parameters<typeof HttpApiClient.make>[1]; // shared by every action
34
- }
35
- ```
16
+ Single-command options: `name`, `render`, paired `parameters`/`input`, and `connection`.
17
+ Group options: `name` and `connection`. Parsing/rendering semantics match [ActionCli.md](ActionCli.md).
18
+ `connection` accepts native `HttpApiClient.make` configuration, including `baseUrl`,
19
+ `transformClient` and `transformResponse`.
36
20
 
37
- Parsing and rendering options (`name`, `render`, `parameters`, `input`) are the same as
38
- [ActionCli.md](ActionCli.md).
21
+ Remote commands have no local `before` hook: the server owns authorization. Their Effects
22
+ retain the selected endpoint's client failures and middleware requirements, and require an
23
+ `HttpClient` supplied by the host. Group/action selectors stay checked against the bound API.
39
24
 
40
25
  ## Canonical
41
26
 
@@ -53,7 +38,8 @@ const command = ActionCliClient.command(Http, "public", "status", {
53
38
  Command.runWith(command, { version: "0.1.0" })(process.argv.slice(2)).pipe(
54
39
  Effect.tapCause((cause) => Console.error(cause)),
55
40
  Effect.provideService(Logger.LogToStderr, true),
56
- Effect.provide(NodeHttpClient.layerUndici), // the host supplies HttpClient
41
+ // The host supplies the native client and its connection configuration.
42
+ Effect.provide(NodeHttpClient.layerUndici),
57
43
  Effect.provide(NodeServices.layer),
58
44
  NodeRuntime.runMain({ disableErrorReporting: true }),
59
45
  );
@@ -66,7 +52,7 @@ Authentication: `connection: { transformClient: HttpClient.mapRequest(HttpClient
66
52
  - Selectors resolve from `http.groups` only. `groupName` must be a bound group; `actionName` must be an action of that group with `http: true`. Runtime guards reject dynamically supplied strings that the types would not accept.
67
53
  - The host provides `HttpClient` and its configuration. Endpoint selection, credentials, and storage are the host's; nothing is inferred from action arguments.
68
54
  - Input is decoded by the action schema before dispatch, then passed to the native client at its normal codec boundary. Do not pre-encode values.
69
- - Output is validated and printed as JSON, or through `render` with `--json` available, exactly as in `ActionCli`.
55
+ - Input syntax (`--input`, `--input-file`, or `parameters`) and output (`render` and its `--json` flag) are exactly as in `ActionCli`.
70
56
  - Errors are the client's: declared errors, policy errors, `SchemaError`, and `HttpClientError`.
71
57
 
72
58
  ## Failure modes
@@ -6,57 +6,29 @@ The pre-handler hook does not: each surface binds its own (see [guarantees.md](g
6
6
 
7
7
  ## API
8
8
 
9
- ```ts
10
- import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
9
+ Import `@gjermundgaraba/effect-actions/ActionGroup`.
11
10
 
12
- function make<Name, Actions, Errors = [], PolicyErrors = []>(
13
- options: Options<Name, Errors, PolicyErrors>,
14
- ...actions: Actions
15
- ): Group<Name, WithErrors<Actions, Errors>, PolicyErrors>;
16
-
17
- interface Options<Name, Errors, PolicyErrors> {
18
- readonly name: Name; // HttpApiGroup identifier, OpenAPI tag, operation-ID prefix
19
- readonly errors?: Errors; // added to every action's own errors
20
- readonly schemaError?: SchemaErrorPolicy<PolicyErrors>; // HTTP only; MCP keeps native answers
21
- }
22
-
23
- interface SchemaErrorPolicy<Errors extends ReadonlyArray<Codec>> {
24
- readonly errors: Errors;
25
- readonly map: (failure: HttpApiError.HttpApiSchemaError) => Errors[number]["Type"];
26
- }
27
-
28
- interface Group<Name, Actions, PolicyErrors> {
29
- readonly name: Name;
30
- readonly actions: Actions;
31
- readonly schemaError: SchemaErrorPolicy<PolicyErrors> | undefined;
32
- /** Bind every handler at once; build-time services resolve once per adapter layer. */
33
- readonly implement: <H extends HandlersFrom<Actions>, EX = never, RX = never>(
34
- build: H | Effect.Effect<H, EX, RX>,
35
- ) => Implementation<Group<Name, Actions, PolicyErrors>, H, EX, Exclude<RX, Scope.Scope>>;
36
- }
37
-
38
- /** Nominal. `group` is the bound contract; `build` acquires the handler record in a scope. */
39
- class Implementation<G, H, EX, RX> {
40
- readonly group: G;
41
- get build(): Effect.Effect<H, EX, RX | Scope.Scope>;
42
- }
43
-
44
- /** Every action of the supplied groups, keyed `<group>.<action>`. */
45
- function contracts<const Groups extends ReadonlyArray<Group>>(
46
- ...groups: Groups
47
- ): Contracts<Groups>;
48
-
49
- type Contracts<Groups>; // { "users.getUser": typeof GetUser, "users.renameUser": ... }
50
- ```
11
+ | API | Purpose |
12
+ | --------------------------------- | --------------------------------------------------------------------------------------- |
13
+ | `make(options, ...actions)` | Group actions under a required `name`; optional `errors` are inherited by each action. |
14
+ | `group.implement(recordOrEffect)` | Bind every action handler; an Effect builder carries startup requirements and failures. |
15
+ | `app.group`, `app.build` | Bound contract and scoped handler acquisition. |
16
+ | `contracts(...groups)` | Exact action types keyed by `<group>.<action>`. |
17
+
18
+ Exported types: `Group`, `Any`, `Options`, `Implementation`, `Contracts`, `SchemaErrorPolicy`,
19
+ `SchemaErrorAnswer`.
51
20
 
52
- `HandlersFrom<Actions>` is `{ [action name]: Handler<Action, R> }`, one key per action,
53
- all required.
21
+ The optional `schemaError` group option is a `SchemaErrorPolicy`: two answers, `invalid` and
22
+ `internal`, each a `schema` (a declared error codec) and `make(failure)` (the native
23
+ `HttpApiSchemaError` to that error's decoded value). The library decides which answer applies.
24
+ It affects HTTP only. Handler records require every action key. Each handler's input, success
25
+ and declared errors come from its action; request requirements stay distinct from the
26
+ builder's requirements.
54
27
 
55
28
  ## Canonical
56
29
 
57
30
  ```ts
58
31
  import { Effect, Schema } from "effect";
59
- import type { HttpApiError } from "effect/unstable/httpapi";
60
32
  import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
61
33
  import { CurrentActor } from "./auth.js";
62
34
  import { GetUser, RenameUser, WhoAmI } from "./contracts.js";
@@ -73,13 +45,17 @@ class InternalError extends Schema.TaggedError<InternalError>()(
73
45
  { httpApiStatus: 500 },
74
46
  ) {}
75
47
 
76
- // Shared across groups: annotate the parameter. Inline, `map` is inferred from `errors`.
48
+ // A request that does not decode is the caller's fault; a result that does not
49
+ // encode is the server's. Shared across groups as a constant.
77
50
  const schemaError = {
78
- errors: [InvalidRequest, InternalError],
79
- map: ({ kind }: HttpApiError.HttpApiSchemaError) =>
80
- kind === "Body" || kind === "ResponseHeaders"
81
- ? new InternalError({ message: "The request could not be completed." })
82
- : new InvalidRequest({ message: "The request does not match the action's input." }),
51
+ invalid: {
52
+ schema: InvalidRequest,
53
+ make: () => new InvalidRequest({ message: "The request does not match the action's input." }),
54
+ },
55
+ internal: {
56
+ schema: InternalError,
57
+ make: () => new InternalError({ message: "The request could not be completed." }),
58
+ },
83
59
  };
84
60
 
85
61
  export const UserActions = ActionGroup.make(
@@ -115,9 +91,9 @@ const renames: "renameUser" = contracts["users.renameUser"].name;
115
91
 
116
92
  ## Rules
117
93
 
118
- - `name` matches `[A-Za-z0-9_-]+` and must be unique within one `ActionHttp.make`. Action names must be unique within the group. MCP tool names must be unique within the group and within each Toolkit or MCP projection that serves it. Duplicates fail at `make`.
94
+ - `name` matches `[A-Za-z0-9_-]+`, cannot be `then`, and must be unique within one `ActionHttp.make`. Action names must be unique within the group. MCP tool names must be unique within the group and within each Toolkit or MCP projection that serves it. Duplicates fail at `make`.
119
95
  - Group `errors` are appended to each action's own `errors`, so every handler of the group may fail with them. A failure that only the surface produces belongs on the adapter instead (`ActionHttp.make`'s `errors`, `ActionMcp`'s and `ActionToolkit`'s `errors`), not here.
120
- - `implement` takes a complete record or an Effect producing one. The record must have exactly one handler per action; a missing key is a compile error. It takes nothing else.
96
+ - `implement` takes a complete record or an Effect producing one. Every action must have a handler; missing keys are compile errors. A record that still lacks one at runtime (plain JavaScript, a cast) throws at `implement`; a builder's record fails the adapter layer's build. Nothing is served with a handler missing. Additional keys are not dispatched. It takes nothing else.
121
97
  - The builder Effect runs once per adapter layer that serves the implementation, in that layer's scope. An implementation served by HTTP and MCP is built twice. Acquire shared state in a Layer you provide to the adapters, not in the builder.
122
98
  - Services yielded in the builder are build-time requirements (`RX`). Services yielded in a handler are request-time requirements (`R` of the handler). Adapters keep these separate in their types. Use distinct tags for each kind; never provide a request-identity tag at startup (see [guarantees.md](guarantees.md)).
123
99
  - `Implementation` is nominal. Spreading its properties does not produce an implementation. Each `implement` call is a separate binding.
@@ -127,14 +103,15 @@ const renames: "renameUser" = contracts["users.renameUser"].name;
127
103
  ### Schema-error policy
128
104
 
129
105
  - Without a policy, HTTP answers decoding and encoding failures with Effect's native empty 400.
130
- - With a policy, `map` receives the native `HttpApiSchemaError` (`kind`, `cause`) and returns one of `errors`. HTTP uses that error's `httpApiStatus`, and the error appears in `Http.api`, in clients, and in OpenAPI.
131
- - `kind` values: `Payload` (input decoding) and `Body` (success encoding). Actions declare no parameters, query, or headers, so no other kind occurs.
106
+ - With a policy, the library picks the answer from the native `HttpApiSchemaError`'s `kind`. Request-side kinds (`Payload`, `Params`, `Headers`, `Query`: the request did not decode) get `invalid`; response-side kinds (`Body`, `ResponseHeaders`: the handler's result did not encode) get `internal`. `make` receives the failure (`kind`, `cause`) and returns its schema's value. HTTP uses that error's `httpApiStatus`, and both errors appear in `Http.api`, in clients, and in OpenAPI.
107
+ - In practice only `Payload` and `Body` occur: actions declare no parameters, query, or headers, and no response headers.
108
+ - `invalid` and `internal` may share one schema; it is declared once.
132
109
  - MCP is unaffected. The native `McpServer` answers invalid arguments and unencodable results itself.
133
110
  - Policy errors extend the transport contract, not the handler contract. A handler cannot return them.
134
111
  - The policy runs only on the server. Client-side codec failures stay `SchemaError`.
135
112
  - HTTP decodes with `errors: "all"`, so `cause` carries every issue. Issues never retain the rejected values.
136
113
  - Groups served by one adapter may have different policies; each action answers with its own group's.
137
- - A `before` hook refuses before decoding, so a refused request never reaches the policy.
114
+ - Input schema-error policies run before the `before` hook. Invalid input is answered by the policy without invoking the hook or handler.
138
115
  - Domain errors, defects, interruptions, and protocol errors are not remapped. An unencodable declared error is a defect. A broken policy error is not recursively remapped.
139
116
 
140
117
  ## Failure modes
@@ -143,6 +120,7 @@ const renames: "renameUser" = contracts["users.renameUser"].name;
143
120
  - `Property 'x' is missing in type` at `implement`: the record lacks a handler for action `x`.
144
121
  - Handler compiles but returns an error not in `errors`: type error on the handler's error channel. Declare it on the action or the group.
145
122
  - `Expected 1 arguments, but got 2` at `implement`: the second options object is gone. Move `before` to the adapter that serves the group.
146
- - `map` is not typed from `errors` when declared as a standalone constant: annotate its parameter as `HttpApiError.HttpApiSchemaError` (type import from `effect/unstable/httpapi`).
123
+ - `Parameter 'failure' implicitly has an 'any' type` in a standalone policy constant: a `make` that reads the failure is typed only inline. Annotate the parameter as `HttpApiError.HttpApiSchemaError` (type import from `effect/unstable/httpapi`), or annotate the constant as `ActionGroup.SchemaErrorPolicy<typeof Invalid, typeof Internal>`.
124
+ - `Missing handlers for group "<group>": <actions>` thrown at `implement`, or failing the layer build: the handler record lacks those actions. Add them.
147
125
  - A service is resolved once and shared across requests when it should be per request: it was yielded in the builder. Move the `yield*` into the handler.
148
126
  - `Duplicate contract group` thrown by `contracts`: two groups share a name, so their keys would collide. Rename one.