@gjermundgaraba/effect-actions 0.4.0 → 0.5.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.
@@ -2,31 +2,45 @@ import { Console, Effect, Option, Schema } from "effect";
2
2
  import { Command, Flag } from "effect/unstable/cli";
3
3
  //#region src/internal/cli.ts
4
4
  const isParametersOptions = (options) => options?.parameters !== void 0;
5
- const jsonOutput = Flag.Boolean("json").pipe(Flag.optional, Flag.withDescription("Print machine-readable JSON"));
6
- const output = (action, execute, input, render, useJson) => Effect.gen(function* () {
5
+ /**
6
+ * A command with a renderer takes `--json` as a flag of its own, so nothing is
7
+ * claimed tree-wide and a host's `--json`, global or not, is never contested.
8
+ * Without a renderer the output is JSON already and there is no flag.
9
+ */
10
+ const jsonFlag = Flag.Boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Print machine-readable JSON"));
11
+ const output = (action, execute, input, render) => Effect.gen(function* () {
7
12
  const value = yield* execute(input);
8
13
  const encoded = yield* Schema.encodeEffect(Schema.toCodecJson(action.success))(value);
9
- const text = render !== void 0 && !useJson ? render(value) : JSON.stringify(encoded, null, 2);
10
- yield* Console.log(text);
14
+ yield* Console.log(render === void 0 ? JSON.stringify(encoded, null, 2) : render(value));
11
15
  });
12
- const defaultCommand = (action, execute, options) => {
13
- const input = Flag.String("input").pipe(Flag.withDefault("{}"), Flag.withSchema(Schema.fromJsonString(Schema.toCodecJson(action.input))), Flag.withDescription("Whole canonical action input as JSON"));
14
- const config = options?.render === void 0 ? { input } : {
15
- input,
16
- output: { json: jsonOutput }
17
- };
18
- return Command.make(options?.name ?? action.name, config, (parsed) => Effect.gen(function* () {
19
- const useJson = "output" in parsed && Option.isSome(parsed.output.json) && parsed.output.json.value === true;
20
- return yield* output(action, execute, parsed.input, options?.render, useJson);
21
- })).pipe(Command.withDescription(action.description));
16
+ /**
17
+ * One native command. The action's own parameters are one nested config, so the
18
+ * flag added for a renderer never meets them in one record.
19
+ */
20
+ const make = (action, execute, options, config, decode) => {
21
+ const name = options?.name ?? action.name;
22
+ const render = options?.render;
23
+ return (render === void 0 ? Command.make(name, { parameters: config }, ({ parameters }) => Effect.flatMap(decode(parameters), (input) => output(action, execute, input, void 0))) : Command.make(name, {
24
+ parameters: config,
25
+ json: jsonFlag
26
+ }, ({ parameters, json }) => Effect.flatMap(decode(parameters), (input) => output(action, execute, input, json ? void 0 : render)))).pipe(Command.withDescription(action.description));
22
27
  };
23
- const parametersCommand = (action, execute, options) => {
24
- if (options.render === void 0) return Command.make(options.name ?? action.name, options.parameters, (parsed) => Effect.flatMap(Schema.decodeUnknownEffect(Schema.toCodecJson(action.input))(options.input(parsed)), (input) => output(action, execute, input, void 0, true))).pipe(Command.withDescription(action.description));
25
- return Command.make(options.name ?? action.name, {
26
- parameters: options.parameters,
27
- output: { json: jsonOutput }
28
- }, (parsed) => Effect.flatMap(Schema.decodeUnknownEffect(Schema.toCodecJson(action.input))(options.input(parsed.parameters)), (input) => output(action, execute, input, options.render, Option.isSome(parsed.output.json) && parsed.output.json.value === true))).pipe(Command.withDescription(action.description));
28
+ /**
29
+ * The whole encoded input, inline or from a file. The native parser decodes both,
30
+ * so an invalid value is rendered with the command's help wherever it came from.
31
+ */
32
+ const inputFlags = (codec) => ({
33
+ input: Flag.String("input").pipe(Flag.withSchema(Schema.fromJsonString(codec)), Flag.optional, Flag.withDescription("Whole canonical action input as JSON")),
34
+ inputFile: Flag.FileSchema("input-file", codec, { format: "json" }).pipe(Flag.optional, Flag.withDescription("File containing the whole canonical action input as JSON"))
35
+ });
36
+ const defaultCommand = (action, execute, options) => {
37
+ const codec = Schema.toCodecJson(action.input);
38
+ return make(action, execute, options, inputFlags(codec), (parsed) => Option.match(Option.orElse(parsed.inputFile, () => parsed.input), {
39
+ onNone: () => Schema.decodeUnknownEffect(codec)({}),
40
+ onSome: Effect.succeed
41
+ }));
29
42
  };
43
+ const parametersCommand = (action, execute, options) => make(action, execute, options, options.parameters, (parsed) => Schema.decodeUnknownEffect(Schema.toCodecJson(action.input))(options.input(parsed)));
30
44
  /** Build one native command around an action-bound operation. */
31
45
  const command = (action, execute, options) => {
32
46
  if (isParametersOptions(options)) return parametersCommand(action, execute, options);
@@ -30,10 +30,21 @@ export declare class Implementation<G extends Actions, H, EX, RX> {
30
30
  }
31
31
  /** Any nominal implementation of `G`, with its record and channels erased. */
32
32
  export type AnyImplementation<G extends Actions = Actions> = Implementation<G, any, any, any>;
33
- /** Per-request requirements of one implementation, or of a union of them. */
34
- export type RequestContext<App> = App extends Implementation<any, infer H, any, any> ? HandlersContext<H> : never;
35
- /** Handler-acquisition failures of one implementation, or of a union of them. */
36
- export type BuildError<App> = App extends Implementation<any, any, infer EX, any> ? EX : never;
37
- /** Handler-acquisition requirements of one implementation, or of a union of them. */
38
- export type BuildContext<App> = App extends Implementation<any, any, any, infer RX> ? RX : never;
33
+ /** The transports that serve a subset of a group's actions, by the contract flag that hides an action. */
34
+ export type Transport = "http" | "mcp";
35
+ /**
36
+ * Names of the actions of `G` that `T` may serve. Only an action hidden by a
37
+ * literal `false` is excluded; a flag decided at runtime keeps its requirements.
38
+ */
39
+ export type ServedNames<G extends Actions, T extends Transport> = Exclude<G["actions"][number], T extends "http" ? {
40
+ readonly http: false;
41
+ } : {
42
+ readonly mcp: false;
43
+ }>["name"];
44
+ /** Per-request requirements of the handlers `T` can invoke, or of a union of implementations. */
45
+ export type RequestContext<App, T extends Transport> = App extends Implementation<infer G, infer H, any, any> ? { readonly [K in Extract<ServedNames<G, T>, keyof H>]: HandlerContext<H, K>; }[Extract<ServedNames<G, T>, keyof H>] : never;
46
+ /** Handler-acquisition failures of the implementations `T` acquires, or of a union of them. */
47
+ export type BuildError<App, T extends Transport> = App extends Implementation<infer G, any, infer EX, any> ? [ServedNames<G, T>] extends [never] ? never : EX : never;
48
+ /** Handler-acquisition requirements of the implementations `T` acquires, or of a union of them. */
49
+ export type BuildContext<App, T extends Transport> = App extends Implementation<infer G, any, any, infer RX> ? [ServedNames<G, T>] extends [never] ? never : RX : never;
39
50
  //#endregion
@@ -31,8 +31,16 @@ var Implementation = class Implementation {
31
31
  const dispatch = (group, action, handlers, before) => {
32
32
  const handle = handlers[action.name];
33
33
  if (handle === void 0) throw new Error(`Missing handler: ${action.name}`);
34
+ const attributes = {
35
+ "action.group": group.name,
36
+ "action.name": action.name,
37
+ "action.access": action.access
38
+ };
34
39
  return (input) => {
35
- const handled = Effect.withSpan(Effect.suspend(() => handle(input)), `${group.name}.${action.name}`, { captureStackTrace: false });
40
+ const handled = Effect.withSpan(Effect.annotateLogs(Effect.suspend(() => handle(input)), attributes), `${group.name}.${action.name}`, {
41
+ captureStackTrace: false,
42
+ attributes
43
+ });
36
44
  return before === void 0 ? handled : Effect.flatMap(before(action), () => handled);
37
45
  };
38
46
  };
@@ -0,0 +1,22 @@
1
+ import { Action_d_exports, Any, Codec } from "../Action.mjs";
2
+ import "./implementation.mjs";
3
+ import { Effect, Layer } from "effect";
4
+ import { Tool, Toolkit } from "effect/unstable/ai";
5
+ //#region src/internal/tools.d.ts
6
+ /** What a tool surface binds around the implementations it projects. */
7
+ export interface SurfaceOptions<Errors extends ReadonlyArray<Codec>, R> {
8
+ /**
9
+ * Failures the surface answers with instead of a handler: authorization, rate
10
+ * limits. Declared on every tool, so a refusal is returned exactly like an
11
+ * action's own error.
12
+ */
13
+ readonly errors?: Errors;
14
+ /**
15
+ * Runs once per tool call, after the arguments are decoded and before the
16
+ * selected handler, with the action contract it is about to run. It fails with
17
+ * this surface's `errors`. Its services are request-time requirements, like a
18
+ * handler's.
19
+ */
20
+ readonly before?: (action: Any) => Effect.Effect<void, Errors[number]["Type"], R>;
21
+ }
22
+ //#endregion
@@ -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,16 +40,10 @@ 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) => {
@@ -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. 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>`.
@@ -132,9 +101,8 @@ export const Double = Action.make("double", {
132
101
 
133
102
  ## Failure modes
134
103
 
135
- - Throws at `make`: invalid name, name `then`, `mcp.name` longer than 128 characters or not matching the pattern.
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