@gjermundgaraba/effect-actions 0.4.1 → 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.
- package/README.md +10 -8
- package/dist/ActionCatalog.d.mts +2 -2
- package/dist/ActionCatalog.mjs +10 -9
- package/dist/ActionCli.d.mts +2 -2
- package/dist/ActionCli.mjs +3 -8
- package/dist/ActionCliClient.d.mts +9 -6
- package/dist/ActionCliClient.mjs +9 -11
- package/dist/ActionGroup.d.mts +3 -5
- package/dist/ActionHttp.d.mts +4 -5
- package/dist/ActionHttp.mjs +10 -33
- package/dist/ActionMcp.d.mts +6 -21
- package/dist/ActionMcp.mjs +4 -12
- package/dist/ActionToolkit.d.mts +5 -17
- package/dist/ActionToolkit.mjs +2 -7
- package/dist/internal/actions.mjs +11 -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 +9 -1
- package/dist/internal/tools.d.mts +22 -0
- package/dist/internal/tools.mjs +2 -17
- 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 +16 -46
- package/docs/ActionHttp.md +27 -41
- package/docs/ActionMcp.md +115 -84
- package/docs/ActionToolkit.md +28 -46
- package/docs/Authentication.md +18 -36
- package/docs/CONTEXT.md +3 -3
- package/docs/README.md +13 -7
- package/docs/Testing.md +44 -52
- package/docs/guarantees.md +9 -9
- package/package.json +1 -1
package/dist/internal/cli.mjs
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
yield* Console.log(text);
|
|
14
|
+
yield* Console.log(render === void 0 ? JSON.stringify(encoded, null, 2) : render(value));
|
|
11
15
|
});
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
/**
|
|
34
|
-
export type
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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}`, {
|
|
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
|
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,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
|
-
|
|
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
|