@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.
- package/README.md +16 -13
- package/dist/ActionCatalog.d.mts +2 -2
- package/dist/ActionCatalog.mjs +11 -10
- package/dist/ActionCli.d.mts +2 -2
- package/dist/ActionCli.mjs +7 -8
- package/dist/ActionCliClient.d.mts +9 -6
- package/dist/ActionCliClient.mjs +9 -11
- package/dist/ActionGroup.d.mts +17 -15
- package/dist/ActionGroup.mjs +12 -1
- package/dist/ActionHttp.d.mts +10 -5
- package/dist/ActionHttp.mjs +21 -38
- package/dist/ActionHttpClient.d.mts +45 -0
- package/dist/ActionHttpClient.mjs +24 -0
- package/dist/ActionMcp.d.mts +17 -43
- package/dist/ActionMcp.mjs +15 -19
- package/dist/ActionToolkit.d.mts +5 -17
- package/dist/ActionToolkit.mjs +2 -7
- package/dist/Testing.d.mts +31 -1
- package/dist/Testing.mjs +50 -2
- package/dist/TestingClient.d.mts +6 -5
- package/dist/TestingClient.mjs +6 -3
- package/dist/internal/actions.d.mts +18 -7
- package/dist/internal/actions.mjs +21 -2
- package/dist/internal/cli.d.mts +2 -2
- package/dist/internal/cli.mjs +34 -20
- package/dist/internal/implementation.d.mts +17 -6
- package/dist/internal/implementation.mjs +12 -3
- package/dist/internal/tools.d.mts +22 -0
- package/dist/internal/tools.mjs +3 -18
- package/docs/Action.md +22 -54
- package/docs/ActionCatalog.md +32 -35
- package/docs/ActionCli.md +25 -52
- package/docs/ActionCliClient.md +16 -30
- package/docs/ActionGroup.md +34 -56
- package/docs/ActionHttp.md +34 -46
- package/docs/ActionHttpClient.md +73 -0
- package/docs/ActionMcp.md +117 -89
- package/docs/ActionToolkit.md +28 -46
- package/docs/Authentication.md +18 -36
- package/docs/CONTEXT.md +6 -5
- package/docs/README.md +30 -24
- package/docs/Testing.md +62 -59
- package/docs/guarantees.md +13 -13
- package/docs/setup.md +7 -6
- package/package.json +5 -4
package/dist/internal/tools.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
|
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`.
|
package/docs/ActionCatalog.md
CHANGED
|
@@ -1,57 +1,54 @@
|
|
|
1
1
|
# ActionCatalog
|
|
2
2
|
|
|
3
|
-
An offline JSON document
|
|
4
|
-
|
|
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
|
-
|
|
9
|
-
import * as ActionCatalog from "@gjermundgaraba/effect-actions/ActionCatalog";
|
|
8
|
+
Import `@gjermundgaraba/effect-actions/ActionCatalog`.
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
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
|
-
-
|
|
57
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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.
|
|
88
|
-
- `parameters` and `input` are supplied together or not at all. With them, the command has explicit native flags and arguments and
|
|
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
|
|
92
|
-
-
|
|
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
|
|
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
|
-
- `
|
|
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
|
|
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.
|
package/docs/ActionCliClient.md
CHANGED
|
@@ -5,37 +5,22 @@ handler locally and manages no credentials.
|
|
|
5
5
|
|
|
6
6
|
## API
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
package/docs/ActionGroup.md
CHANGED
|
@@ -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
|
-
|
|
10
|
-
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
9
|
+
Import `@gjermundgaraba/effect-actions/ActionGroup`.
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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_-]
|
|
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.
|
|
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,
|
|
131
|
-
-
|
|
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
|
-
-
|
|
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
|
-
- `
|
|
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.
|