@gjermundgaraba/effect-actions 0.17.0 → 0.18.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 CHANGED
@@ -72,7 +72,9 @@ implementation is also:
72
72
  ```ts
73
73
  const { toolkit, layer } = ActionToolkit.make(greeting); // native Effect AI Toolkit and its handler layer
74
74
  const cli = ActionCli.make(greeting, { name: "greetings" }); // greetings greet --name Ada
75
- const remote = ActionCli.remoteCommand(Http, Greet, { client: { baseUrl } }); // greet --name Ada, over HTTP
75
+ const remote = ActionCli.remoteCommand(Http, Greet, {
76
+ client: ActionHttp.client(Http, { baseUrl }),
77
+ }); // greet --name Ada, over HTTP
76
78
  const local = Action.client(greeting); // the HTTP client's methods, in process
77
79
  const rpc = ActionRpc.layer(ActionRpc.make([Greet]), greeting); // Effect RPC, on the protocol the host provides
78
80
  ```
@@ -1,14 +1,13 @@
1
1
  import { BuiltIn } from "./contract/errors.mjs";
2
2
  import { ActionOf, BuildError, BuildServices, Holding, Known, Member, RequestOf, Selected, SelectedOf, Selection, Served } from "./contract/implementation.mjs";
3
3
  import { Any } from "./Action.mjs";
4
- import { VerifierError } from "./authentication/provider.mjs";
5
4
  import { Declaring } from "./contract/declared.mjs";
6
5
  import { Options, UserError } from "./cli/commands.mjs";
7
- import { AnyHttp, Options as Options$2 } from "./http/client.mjs";
6
+ import { AnyHttp, Client } from "./http/client.mjs";
8
7
  import "./ActionHttp.mjs";
9
- import { Effect, Schema } from "effect";
10
- import { HttpClient, HttpClientError } from "effect/http";
11
- import { Command } from "effect/cli";
8
+ import { Effect } from "effect";
9
+ import { HttpClient } from "effect/http";
10
+ import { CliError, Command } from "effect/cli";
12
11
  //#region src/cli/ActionCli.d.ts
13
12
  interface AnyBinding extends Declaring {
14
13
  readonly actions: ReadonlyArray<Any>;
@@ -24,7 +23,7 @@ interface AnyBinding extends Declaring {
24
23
  * `Effect.tapError(() => Console.error(…))`: a `UserError` wrapped around the result would
25
24
  * replace its JSON.
26
25
  */
27
- export declare const toUserError: (calls: AnyBinding | ReadonlyArray<Any>) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, UserError<Exclude<E, import("effect/cli/CliError").CliError>> | Exclude<E, Exclude<E, import("effect/cli/CliError").CliError>>, R>;
26
+ export declare const toUserError: (calls: AnyBinding | ReadonlyArray<Any>) => <A, E, R>(self: Effect.Effect<A, E, R>) => Effect.Effect<A, UserError<Exclude<E, CliError.CliError>> | Exclude<E, Exclude<E, CliError.CliError>>, R>;
28
27
  /**
29
28
  * An aggregate command of the actions `A`: its name, the actions that are its subcommands,
30
29
  * and their options.
@@ -69,39 +68,28 @@ type Local<App, A extends Any> = Effect.Effect<A["success"]["Type"], A["error"][
69
68
  * as JSON when it is not a struct. The flag of an array of strings or numbers is repeated for each value, a boolean
70
69
  * field is a switch, `--no-<name>` sending `false` as a required one left out does, and a field's `description` is its flag's help. It needs what the authorization, its handler and its builder
71
70
  * need; the host provides the identity. It prints the result on stdout; a failure is a
72
- * `UserError`, which `Command.run` prints on stderr. `remoteCommand` calls a server instead.
71
+ * `UserError`, or an Effect CLI error as it is, such as a builder's `CliError.UserError`, which
72
+ * `Command.run` prints on stderr. `remoteCommand` calls a server instead.
73
73
  */
74
- export declare function command<const Apps extends Served, A extends ActionOf<Member<Apps>>, const O = {}>(implementations: Apps, action: A, options?: O & NoInfer<Options<A>> & NoInfer<Known<O, Options<Any>>>): Command.Command<string, never, {}, UserError<Effect.Error<Local<Owning<Member<Apps>, A>, A>>>, Effect.Services<Local<Owning<Member<Apps>, A>, A>>>;
74
+ export declare function command<const Apps extends Served, A extends ActionOf<Member<Apps>>, const O = {}>(implementations: Apps, action: A, options?: O & NoInfer<Options<A>> & NoInfer<Known<O, Options<Any>>>): Command.Command<string, never, {}, Failing<Effect.Error<Local<Owning<Member<Apps>, A>, A>>>, Effect.Services<Local<Owning<Member<Apps>, A>, A>>>;
75
75
  /**
76
76
  * Project one action of an HTTP binding into a native Effect CLI command, derived as `command`
77
- * derives it, that calls the action over HTTP through its `ActionHttp.client` method, made with
78
- * the `client` options on the host's `HttpClient`, and fails as the method does. It runs
79
- * nothing locally.
77
+ * derives it, that calls the action through `client`'s method and fails as the method does, or
78
+ * as the Effect building the client does. `client` is an Effect of the binding's client, such as
79
+ * `ActionHttp.client(Http, { baseUrl })`, run each time the command runs; omitted, it is
80
+ * `ActionHttp.client(binding)` on the host's `HttpClient`. It runs nothing locally.
80
81
  */
81
- export declare function remoteCommand<const H extends AnyHttp, A extends H["actions"][number], const O = {}>(binding: H, action: A, options?: O & NoInfer<RemoteCommandOptions<A>> & NoInfer<Known<O, RemoteCommandOptions<Any>>>): Command.Command<string, never, {}, HttpUserError<H, A>, HttpClient.HttpClient>;
82
- /** A remote command's options, its action `A`. */
83
- export type RemoteCommandOptions<A extends Any = Any> = Options<A> & {
84
- /**
85
- * Its client's options, as `ActionHttp.client` takes them: `baseUrl`, and
86
- * `transformClient` for credentials. They reach this command's requests alone.
87
- */
88
- readonly client?: Options$2;
89
- };
90
- /** A remote aggregate command's options, its actions `A`. */
91
- export type RemoteOptions<A extends Any = Any> = Options$1<A> & {
92
- /**
93
- * Every subcommand's client options, as `ActionHttp.client` takes them: `baseUrl`, and
94
- * `transformClient` for credentials. They reach this aggregate's requests alone. A URL or a
95
- * token read from `Config` goes instead into the `HttpClient` provided on the command, with
96
- * `Command.provideEffect(HttpClient.HttpClient, …)`: it is read when a command runs, so
97
- * `--help` needs neither, and a command of your own in `subcommands` calls through it too.
98
- * A required one that is not set fails that Effect with a `ConfigError`, which is no
99
- * command's failure and prints as a crash line: map the read,
100
- * `Effect.mapError((cause) => new CliError.UserError({ cause, userMessage: "TOKEN is not set" }))`.
101
- */
102
- readonly client?: Options$2;
82
+ export declare function remoteCommand<const H extends AnyHttp, A extends H["actions"][number], const O = {}, C extends ClientOf<H> | undefined = undefined>(binding: H, action: A, options?: O & NoInfer<Options<A>> & NoInfer<Known<O, RemoteKeys<Options<Any>>>> & {
83
+ readonly client?: C;
84
+ }): Command.Command<string, never, {}, Failing<CallFailure<H, C, A>>, CallServices<C, A>>;
85
+ type ClientOf<H extends AnyHttp> = Effect.Effect<Client<H, unknown, unknown>, unknown, unknown>;
86
+ type RemoteKeys<Keys> = Keys & {
87
+ readonly client?: unknown;
103
88
  };
104
- type HttpUserError<H extends AnyHttp, A extends Any> = UserError<A["error"][number]["Type"] | H["error"][number]["Type"] | VerifierError<A, H["authentication"]>["Type"] | BuiltIn | HttpClientError.HttpClientError | Schema.SchemaError>;
89
+ type Failing<F> = UserError<Exclude<F, CliError.CliError>> | Extract<F, CliError.CliError>;
90
+ type Called<M, A extends Any> = A extends unknown ? A["name"] extends keyof M ? M[A["name"]] extends ((...input: never) => infer Call) ? Call : never : never : never;
91
+ type CallFailure<H extends AnyHttp, C, A extends Any> = C extends Effect.Effect<infer M, infer CE, unknown> ? CE | Effect.Error<Called<M, A>> : Effect.Error<Called<Client<H>, A>>;
92
+ type CallServices<C, A extends Any> = C extends Effect.Effect<infer M, unknown, infer CR> ? CR | Effect.Services<Called<M, A>> : HttpClient.HttpClient;
105
93
  /**
106
94
  * Project every implemented action as a subcommand of one aggregate command, each named after
107
95
  * its action in kebab case and run in process. `commands` gives a subcommand the options
@@ -113,16 +101,17 @@ type HttpUserError<H extends AnyHttp, A extends Any> = UserError<A["error"][numb
113
101
  */
114
102
  export declare function make<const Apps extends Served, const O extends Selection<ActionOf<Member<Apps>>> = {}, const Own extends Subcommands = []>(implementations: Apps, options: O & NoInfer<Options$1<ActionOf<Member<Apps>>>> & NoInfer<KnownOptions<O, Options$1, ActionOf<Member<Apps>>>> & {
115
103
  readonly subcommands?: Own | undefined;
116
- }): Command.Command<string, {}, {}, UserError<Effect.Error<Local<Holding<Member<Apps>, SelectedOf<O, Apps>>, SelectedOf<O, Apps>>>> | Command.Error<OwnCommand<Own>>, Effect.Services<Local<Holding<Member<Apps>, SelectedOf<O, Apps>>, SelectedOf<O, Apps>>> | Command.Services<OwnCommand<Own>>>;
104
+ }): Command.Command<string, {}, {}, Failing<Effect.Error<Local<Holding<Member<Apps>, SelectedOf<O, Apps>>, SelectedOf<O, Apps>>>> | Command.Error<OwnCommand<Own>>, Effect.Services<Local<Holding<Member<Apps>, SelectedOf<O, Apps>>, SelectedOf<O, Apps>>> | Command.Services<OwnCommand<Own>>>;
117
105
  /**
118
106
  * Project every action of an HTTP binding as a subcommand of one aggregate command, derived as
119
- * `make` derives it, each calling its action over HTTP. `client` configures every derived
120
- * subcommand's client; `commands` gives one the options `command` takes; `subcommands` adds
121
- * commands of your own.
107
+ * `make` derives it, each calling its action through `client`'s method, as `remoteCommand`
108
+ * does. `commands` gives one the options `command` takes; `subcommands` adds commands of your
109
+ * own, which call through the same client by yielding the Effect given as `client`.
122
110
  */
123
- export declare function remote<const H extends AnyHttp, const O extends Selection<H["actions"][number]> = {}, const Own extends Subcommands = []>(binding: H, options: O & NoInfer<RemoteOptions<H["actions"][number]>> & NoInfer<KnownOptions<O, RemoteOptions<Any>, H["actions"][number]>> & {
111
+ export declare function remote<const H extends AnyHttp, const O extends Selection<H["actions"][number]> = {}, const Own extends Subcommands = [], C extends ClientOf<H> | undefined = undefined>(binding: H, options: O & NoInfer<Options$1<H["actions"][number]>> & NoInfer<KnownOptions<O, RemoteKeys<Options$1<Any>>, H["actions"][number]>> & {
112
+ readonly client?: C;
124
113
  readonly subcommands?: Own | undefined;
125
- }): Command.Command<string, {}, {}, HttpUserError<H, Selected<O, H["actions"][number]>> | Command.Error<OwnCommand<Own>>, HttpClient.HttpClient | Command.Services<OwnCommand<Own>>>;
114
+ }): Command.Command<string, {}, {}, Failing<CallFailure<H, C, Selected<O, H["actions"][number]>>> | Command.Error<OwnCommand<Own>>, CallServices<C, Selected<O, H["actions"][number]>> | Command.Services<OwnCommand<Own>>>;
126
115
  /**
127
116
  * A program whose stdout carries only its results, such as a CLI feeding scripts or an MCP
128
117
  * subprocess, applied last, before `NodeRuntime.runMain`: the default logger of every layer
@@ -17,7 +17,7 @@ import { Command } from "effect/cli";
17
17
  * `Effect.tapError(() => Console.error(…))`: a `UserError` wrapped around the result would
18
18
  * replace its JSON.
19
19
  */
20
- const toUserError = (calls) => "actions" in calls ? failingAsDerived(calls.actions, (action) => bindingErrors(action, calls)) : failingAsDerived(calls, (action) => projectedErrors(action));
20
+ const toUserError = (calls) => "actions" in calls ? failingAsDerived(calls.actions.flatMap((action) => bindingErrors(action, calls))) : failingAsDerived(calls.flatMap((action) => projectedErrors(action)));
21
21
  const runWithBuildOfItsOwn = (app, action, input) => {
22
22
  const thisActionOnly = Implementation.share([action], app);
23
23
  return Effect.flatMap(acquire([thisActionOnly]), (bound) => {
@@ -37,7 +37,7 @@ const inProcess = (apps, action, options) => {
37
37
  };
38
38
  const overHttp = (binding, action, options, client$1) => {
39
39
  if (!binding.actions.includes(action)) throw new Error(`Action "${action.name}" is not in this HTTP binding`);
40
- return command$1(action, (input) => Effect.flatMap(client(binding, client$1), (methods) => {
40
+ return command$1(action, (input) => Effect.flatMap(client$1 ?? client(binding), (methods) => {
41
41
  const method = methods[action.name];
42
42
  if (method === void 0) throw new Error(`No client method for ${action.name}`);
43
43
  return method(input);
@@ -69,17 +69,12 @@ const checkedCommandsOf = (commands, actions) => {
69
69
  * as JSON when it is not a struct. The flag of an array of strings or numbers is repeated for each value, a boolean
70
70
  * field is a switch, `--no-<name>` sending `false` as a required one left out does, and a field's `description` is its flag's help. It needs what the authorization, its handler and its builder
71
71
  * need; the host provides the identity. It prints the result on stdout; a failure is a
72
- * `UserError`, which `Command.run` prints on stderr. `remoteCommand` calls a server instead.
72
+ * `UserError`, or an Effect CLI error as it is, such as a builder's `CliError.UserError`, which
73
+ * `Command.run` prints on stderr. `remoteCommand` calls a server instead.
73
74
  */
74
75
  function command(implementations, action, options) {
75
76
  return inProcess(toList(implementations), action, options);
76
77
  }
77
- /**
78
- * Project one action of an HTTP binding into a native Effect CLI command, derived as `command`
79
- * derives it, that calls the action over HTTP through its `ActionHttp.client` method, made with
80
- * the `client` options on the host's `HttpClient`, and fails as the method does. It runs
81
- * nothing locally.
82
- */
83
78
  function remoteCommand(binding, action, options) {
84
79
  const { client, ...syntax } = options ?? {};
85
80
  return overHttp(binding, action, options === void 0 ? void 0 : syntax, client);
@@ -99,12 +94,6 @@ function make(implementations, options) {
99
94
  const served = select(apps, options.actions);
100
95
  return aggregateCommand(options.name, served.flatMap((app) => app.actions), commands, options.subcommands ?? [], (action, syntax) => inProcess(served, action, syntax));
101
96
  }
102
- /**
103
- * Project every action of an HTTP binding as a subcommand of one aggregate command, derived as
104
- * `make` derives it, each calling its action over HTTP. `client` configures every derived
105
- * subcommand's client; `commands` gives one the options `command` takes; `subcommands` adds
106
- * commands of your own.
107
- */
108
97
  function remote(binding, options) {
109
98
  const listed = options.actions;
110
99
  if (listed !== void 0) assertHeld("the binding does not hold it", listed, binding.actions);
@@ -6,11 +6,11 @@ import { Any as Any$1, DescriptorOf, Identity, Matching, PublicOnly, RemoteReque
6
6
  import { Certain, ErrorsOf } from "./contract/declared.mjs";
7
7
  import { AnyHttp, Client, MethodError, Options } from "./http/client.mjs";
8
8
  import { Context, Effect, FileSystem, Layer, Path, Schema, Types } from "effect";
9
- import { Etag, HttpClient, HttpPlatform, HttpRouter } from "effect/http";
9
+ import { Etag, HttpClient, HttpClientError, HttpPlatform, HttpRouter } from "effect/http";
10
10
  import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware } from "effect/http-api";
11
11
  //#region src/http/ActionHttp.d.ts
12
12
  /** What `fetchClient` takes: `client`'s options, and the `fetch` its calls send with. */
13
- export interface FetchClientOptions extends Options {
13
+ export interface FetchClientOptions<E = HttpClientError.HttpClientError, R = never> extends Options<E, R> {
14
14
  /**
15
15
  * What each call sends with. Omitted: the global `fetch`, looked up on every call, so one
16
16
  * installed after the client is built, such as a test's stub, is used.
@@ -116,18 +116,21 @@ export declare function layer<const H extends AnyHttp, const Apps extends Served
116
116
  * `InvalidInput`, `Unauthenticated` or `Forbidden`), a native `HttpClientError` when the
117
117
  * server could not be reached or answered with a status or body the contract does not
118
118
  * declare, or a `SchemaError` when the success does not decode. An input that does not encode
119
- * fails with `InvalidInput`, and nothing is sent.
119
+ * fails with `InvalidInput`, and nothing is sent. A call also fails with what the client
120
+ * `transformClient` returns fails with, as it is, and requires what that client requires.
120
121
  */
121
122
  export declare function client<const H extends AnyHttp>(binding: H, options?: Options): Effect.Effect<Client<H>, never, HttpClient.HttpClient>;
123
+ export declare function client<const H extends AnyHttp, E, R>(binding: H, options: Options<E, R>): Effect.Effect<Client<H, E, R>, never, HttpClient.HttpClient>;
122
124
  /**
123
125
  * `client` built once over `fetch`, for code that holds a client outside an Effect, such as
124
- * a browser app: its methods are the same Effects and require nothing, so each call runs
125
- * alone, as `Effect.runPromise(api.greet({ name }))`. The options are `client`'s, and
126
- * `fetch`.
126
+ * a browser app: its methods are the same Effects, which require only what `transformClient`
127
+ * requires, so a call without one runs alone, as `Effect.runPromise(api.greet({ name }))`. The
128
+ * options are `client`'s, and `fetch`.
127
129
  *
128
130
  * Each call sends with `options.fetch`, or with the global `fetch` as it is when the call
129
131
  * runs.
130
132
  */
131
133
  export declare function fetchClient<const H extends AnyHttp>(binding: H, options?: FetchClientOptions): Client<H>;
134
+ export declare function fetchClient<const H extends AnyHttp, E, R>(binding: H, options: FetchClientOptions<E, R>): Client<H, E, R>;
132
135
  //#endregion
133
136
  export { type AnyHttp as Any, type Client, type Options as ClientOptions, type MethodError, Options$1 as Options };
@@ -1,6 +1,6 @@
1
1
  import "../contract/errors.mjs";
2
2
  import { Any } from "../Action.mjs";
3
- import { Effect, Runtime, Types } from "effect";
3
+ import { Effect, Option, Runtime, Types } from "effect";
4
4
  import { CliError, Command } from "effect/cli";
5
5
  //#region src/cli/commands.d.ts
6
6
  type Field<A extends Any> = A["input"]["Encoded"] extends (infer E) ? true extends Types.IsUnion<E> ? never : E extends ReadonlyArray<unknown> ? never : E extends object ? string extends keyof E ? never : Extract<keyof E, string> : never : never;
@@ -206,8 +206,8 @@ const userErrorOf = (errors) => {
206
206
  userMessage
207
207
  }))));
208
208
  };
209
- const failingAsDerived = (actions, errorsOf) => {
210
- const failure = userErrorOf([...new Set(actions.flatMap(errorsOf))]);
209
+ const failingAsDerived = (errors) => {
210
+ const failure = userErrorOf([...new Set(errors)]);
211
211
  return (self) => Effect.catchIf(self, (error) => !CliError.isCliError(error), failure);
212
212
  };
213
213
  const unreadableStdinField = (field, message) => new InvalidInput({
@@ -274,7 +274,7 @@ const command = (action, execute, options, errors) => {
274
274
  const name = options?.name ?? kebab(action.name);
275
275
  const render = options?.render;
276
276
  const { flags, positional, decode, stdinHelp } = inputConfig(action, options?.positional ?? [], options?.aliases ?? {}, options?.stdin);
277
- const failure = userErrorOf(errors);
277
+ const failing = failingAsDerived(errors);
278
278
  assertDistinct("flag", [
279
279
  ...Object.keys(flags).map((field) => [`--${kebab(field)}`, `field ${field}`]),
280
280
  ...render === void 0 ? [] : [["--json", "render's --json"]],
@@ -285,7 +285,7 @@ const command = (action, execute, options, errors) => {
285
285
  ...input,
286
286
  ...Object.fromEntries(positional.map(({ field }, index) => [field, values[index] ?? Option.none()]))
287
287
  });
288
- const run = (parsed, rendered) => decode(parsed).pipe(Effect.flatMap(execute), Effect.catch(failure), Effect.flatMap((value) => printedOutput(action, value, rendered)), withStderrConsole, Effect.flatMap(Option.match({
288
+ const run = (parsed, rendered) => decode(parsed).pipe(Effect.flatMap(execute), failing, Effect.flatMap((value) => printedOutput(action, value, rendered)), withStderrConsole, Effect.flatMap(Option.match({
289
289
  onNone: () => Effect.void,
290
290
  onSome: Console.log
291
291
  })));
@@ -7,11 +7,19 @@ import { HttpClient, HttpClientError } from "effect/http";
7
7
  import { HttpApi, HttpApiClient } from "effect/http-api";
8
8
  //#region src/http/client.d.ts
9
9
  /**
10
- * The native `HttpApiClient.make` options except `transformResponse`: `baseUrl` and
11
- * `transformClient`. `transformResponse` may change a call's success, failure or
12
- * services, which the method types cannot follow.
10
+ * The native `HttpApiClient.make` options except `transformResponse`, which may change a call's
11
+ * success, failure or services the method types cannot follow: `baseUrl`, and `transformClient`,
12
+ * which may return a client failing with `E` or requiring `R` of its own, as
13
+ * `HttpApiClient.makeWith` takes one.
13
14
  */
14
- export type Options = Omit<NonNullable<Parameters<typeof HttpApiClient.make>[1]>, "transformResponse">;
15
+ export interface Options<E = HttpClientError.HttpClientError, R = never> extends Pick<NonNullable<Parameters<typeof HttpApiClient.make>[1]>, "baseUrl"> {
16
+ /**
17
+ * Wraps the native `HttpClient` the calls send with: a bearer token is
18
+ * `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`. A method also fails with what
19
+ * the returned client fails with beyond an `HttpClientError`, and requires what it requires.
20
+ */
21
+ readonly transformClient?: ((client: HttpClient.HttpClient) => HttpClient.HttpClient.With<E, R>) | undefined;
22
+ }
15
23
  type BindingError = Errors[number];
16
24
  /**
17
25
  * What one call of `A` fails with: a declared error value (the action's own, one `E` of the
@@ -20,7 +28,7 @@ type BindingError = Errors[number];
20
28
  * the success does not decode. Input that does not encode is `InvalidInput`, and nothing is sent.
21
29
  */
22
30
  export type MethodError<A extends Any, E extends BindingError = never> = A["error"][number]["Type"] | E["Type"] | BuiltIns["Type"] | HttpClientError.HttpClientError | Schema.SchemaError;
23
- type Method<A extends Any, E extends BindingError> = Call<A, Effect.Effect<A["success"]["Type"], MethodError<A, E>>>;
31
+ type Method<A extends Any, E extends BindingError, CE, CR> = Call<A, Effect.Effect<A["success"]["Type"], MethodError<A, E> | CE, CR>>;
24
32
  /**
25
33
  * Any HTTP binding, with its actions erased: what `layer` and `client` read. The API is a native
26
34
  * constraint here because native groups are invariant in their endpoints; `actions` carries the
@@ -35,8 +43,9 @@ export interface AnyHttp {
35
43
  }
36
44
  /**
37
45
  * Every action of the binding `H`, as `client.<action>(input)`: a protected one may also fail
38
- * with what its descriptor's verifier declares.
46
+ * with what its descriptor's verifier declares. Each call also fails with `E` and requires `R`,
47
+ * what its `transformClient` adds.
39
48
  */
40
- export type Client<H extends AnyHttp> = { readonly [A in H["actions"][number] as A["name"]]: Method<A, H["error"][number] | VerifierError<A, H["authentication"]>>; };
49
+ export type Client<H extends AnyHttp, E = never, R = never> = { readonly [A in H["actions"][number] as A["name"]]: Method<A, H["error"][number] | VerifierError<A, H["authentication"]>, E, R>; };
41
50
  //#endregion
42
51
  export {};
@@ -1,8 +1,12 @@
1
1
  import { checked } from "../contract/call.mjs";
2
2
  import { Effect } from "effect";
3
+ import { HttpClient } from "effect/http";
3
4
  import { HttpApiClient } from "effect/http-api";
4
5
  //#region src/http/client.ts
5
- const methods = (http, options = {}) => Effect.map(HttpApiClient.make(http.api, options), (client) => {
6
+ const methodsWith = (http, httpClient, baseUrl) => Effect.map(HttpApiClient.makeWith(http.api, {
7
+ baseUrl,
8
+ httpClient
9
+ }), (client) => {
6
10
  const native = client;
7
11
  return Object.fromEntries(http.actions.map((action) => {
8
12
  const method = native[action.name];
@@ -10,5 +14,6 @@ const methods = (http, options = {}) => Effect.map(HttpApiClient.make(http.api,
10
14
  return [action.name, checked(action, (payload) => method({ payload }))];
11
15
  }));
12
16
  });
17
+ const methods = (http, { baseUrl, transformClient } = {}) => Effect.flatMap(HttpClient.HttpClient, (native) => transformClient === void 0 ? methodsWith(http, native, baseUrl) : methodsWith(http, transformClient(native), baseUrl));
13
18
  //#endregion
14
19
  export { methods };
@@ -16,8 +16,11 @@ export interface Params {
16
16
  };
17
17
  readonly [key: string]: Schema.Json | undefined;
18
18
  }
19
- /** Where `client` sends, through what client, and what the endpoint's authentication declares. */
20
- export interface ClientOptions<D extends Any$1 | undefined = Any$1 | undefined> {
19
+ /**
20
+ * Where `client` sends, through what client, and what the endpoint's authentication declares: the
21
+ * client `transformClient` returns may fail with `E` and require `R`.
22
+ */
23
+ export interface ClientOptions<D extends Any$1 | undefined = Any$1 | undefined, E = HttpClientError.HttpClientError, R = never> {
21
24
  /**
22
25
  * The endpoint, resolved by the `HttpClient`: relative under `Testing.layer`. Defaults to `/mcp`,
23
26
  * the default `layerHttp` path.
@@ -27,7 +30,7 @@ export interface ClientOptions<D extends Any$1 | undefined = Any$1 | undefined>
27
30
  * Wraps the native `HttpClient`, as `ActionHttp.client` takes it: a bearer token is
28
31
  * `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`.
29
32
  */
30
- readonly transformClient?: (client: HttpClient.HttpClient) => HttpClient.HttpClient;
33
+ readonly transformClient?: (client: HttpClient.HttpClient) => HttpClient.HttpClient.With<E, R>;
31
34
  /**
32
35
  * The endpoint's descriptor, as `layerHttp` takes it: an error its verifier declares, the
33
36
  * endpoint's HTTP answer for a protected tool, is then that error's decoded value, as over
@@ -89,8 +92,11 @@ declare const McpCallError_base: Schema.Class<McpCallError, Schema.TaggedStruct<
89
92
  */
90
93
  export declare class McpCallError extends McpCallError_base {}
91
94
  type CallError<A extends Any, D> = A["error"][number]["Type"] | VerifierError<A, D>["Type"] | BuiltIn | Schema.SchemaError | HttpClientError.HttpClientError | McpCallError;
92
- /** Every action of `Actions` as `client.<action>(input)`, calling its tool. */
93
- export type Client<Actions extends ReadonlyArray<Any>, D = undefined> = { readonly [A in Actions[number] as A["name"]]: Call<A, Effect.Effect<A["success"]["Type"], CallError<A, D>>>; };
95
+ /**
96
+ * Every action of `Actions` as `client.<action>(input)`, calling its tool. Each call also fails
97
+ * with `E` and requires `R`, what its `transformClient` adds.
98
+ */
99
+ export type Client<Actions extends ReadonlyArray<Any>, D = undefined, E = never, R = never> = { readonly [A in Actions[number] as A["name"]]: Call<A, Effect.Effect<A["success"]["Type"], CallError<A, D> | E, R>>; };
94
100
  /**
95
101
  * A client of an MCP endpoint served by `layerHttp`, one method per action calling
96
102
  * its tool with one stateless request, as `ActionHttp.client` calls routes: the input is
@@ -103,6 +109,7 @@ export type Client<Actions extends ReadonlyArray<Any>, D = undefined> = { readon
103
109
  * native `HttpClient`, such as the one `Testing.layer` provides.
104
110
  */
105
111
  export declare function client<const Actions extends ReadonlyArray<Any>, D extends Any$1 | undefined = undefined>(actions: Actions, options?: ClientOptions<D>): Effect.Effect<Client<Actions, D>, never, HttpClient.HttpClient>;
112
+ export declare function client<const Actions extends ReadonlyArray<Any>, E, R, D extends Any$1 | undefined = undefined>(actions: Actions, options: ClientOptions<D, E, R>): Effect.Effect<Client<Actions, D, E, R>, never, HttpClient.HttpClient>;
106
113
  /**
107
114
  * One stateless MCP request of `method` with `params`, as `layerHttp` serves it,
108
115
  * as the native request value: the JSON-RPC envelope, the 2026-07-28 headers (`Accept`,
@@ -4,7 +4,7 @@ import { assertOnce } from "../contract/names.mjs";
4
4
  import { projectedErrors } from "../contract/declared.mjs";
5
5
  import { checked } from "../contract/call.mjs";
6
6
  import { defaultPath, httpProtocol, liftOf, unliftedFrom } from "./protocol.mjs";
7
- import { Effect, Match, Option, Predicate, Schema, identity } from "effect";
7
+ import { Effect, Match, Option, Predicate, Schema } from "effect";
8
8
  import { McpSchema } from "effect/ai";
9
9
  import { HttpClient, HttpClientRequest } from "effect/http";
10
10
  import { Sse } from "effect/encoding";
@@ -134,10 +134,10 @@ const callTool = (client, url, action, lift, refusedWith, input) => {
134
134
  return yield* Schema.decodeUnknownEffect(Schema.toCodecJson(action.success))(unliftedFrom(lift, result));
135
135
  });
136
136
  };
137
- function client(actions, { url = defaultPath, transformClient = identity, authentication } = {}) {
137
+ function client(actions, { url = defaultPath, transformClient, authentication } = {}) {
138
138
  assertOnce("action", actions);
139
139
  return Effect.map(HttpClient.HttpClient, (native) => {
140
- const http = transformClient(native);
140
+ const http = transformClient === void 0 ? native : transformClient(native);
141
141
  return Object.fromEntries(actions.map((action) => {
142
142
  const lift = liftOf(action.success);
143
143
  const refusedWith = [...Refusal.members, ...action.caller === Anyone ? [] : authentication?.error ?? []];
@@ -63,7 +63,10 @@ The tasks are in [ActionCli.md](ActionCli.md).
63
63
  `client`. To choose by a condition, build a command from each:
64
64
  `remotely ? ActionCli.remote(Http, options) : ActionCli.make(users, options)`.
65
65
  - `Type 'HttpClient' is not assignable to type 'never'` in the pipe that runs a remote command:
66
- provide `NodeHttpClient.layerUndici` (or `FetchHttpClient.layer`).
66
+ provide `NodeHttpClient.layerUndici` (or `FetchHttpClient.layer`). Another service named there
67
+ is what its `client` requires: provide it too.
68
+ - An argument of type `Binding<…>` not assignable at `remote` or `remoteCommand`, beside a
69
+ `client`: the client is another binding's. Make it with `ActionHttp.client` of this one.
67
70
  - A local command requires an identity service no handler yields: its action is protected, and
68
71
  requires the identity its contract declares. Provide it on the command
69
72
  ([Name the caller](ActionCli.md#name-the-caller)); do not make the contract public to satisfy
@@ -79,20 +82,18 @@ The tasks are in [ActionCli.md](ActionCli.md).
79
82
  - `--help` or a mistyped flag connects to a database, or fails reading config: services provided
80
83
  around `Command.run` are built before parsing. Provide them on the command,
81
84
  `Command.provide(layer)`.
82
- - `ERROR (#n): ConfigError: SchemaError(Expected string at ["NOTES_TOKEN"])` from a public
83
- command, exit 1, on stdout, or on stderr under `logToStderr`: the client provided on the
84
- command requires a token, and a failure of what a command is provided is `runMain`'s report,
85
- not the command's JSON. Read the token with `Config.option` and add the header only when it is
86
- there. Where every command needs it, keep it required and map the failure, in the Effect that
87
- provides the client, to `new CliError.UserError({ cause, userMessage })`:
85
+ - `ConfigError: …` or another error's `Tag: message` on stderr from a remote command, a public
86
+ one too: its `client` failed, as when a required token is not set. Read an optional one with
87
+ `Config.option` and add the header only when it is there. Where every command needs it, map
88
+ the failure to a tagged error whose `message` says what to do:
88
89
  [notes-service/src/commands.ts](../examples/notes-service/src/commands.ts).
89
90
  - `HttpClientError` whose `reason._tag` is `InvalidUrlError`, from a remote command: its client
90
91
  has no `baseUrl` and prepends no URL.
91
92
  - 401 `Unauthenticated` from a remote command: a remote command adds no headers of its own. Add
92
- credentials in `client`'s `transformClient`, or on the `HttpClient` the command is provided.
93
+ credentials in its client's `transformClient`.
93
94
  - Another command's request goes to the API's URL, or carries its token: an `HttpClient`
94
95
  configured around the whole program rewrites every request the program makes. Configure it in
95
- the `client` option, or provide it on the remote command alone.
96
+ the `client` option.
96
97
  - A report with a stack on stdout, `ERROR (#1): ...`, instead of JSON on stderr: a defect, or a
97
98
  failure of a layer the host provides, which `runMain` reports. Fix the defect; apply
98
99
  `ActionCli.logToStderr` to keep such reports off stdout.
package/docs/ActionCli.md CHANGED
@@ -36,10 +36,10 @@ Import `@gjermundgaraba/effect-actions/ActionCli`.
36
36
  | `make`, `remote`: `actions` | The actions that are subcommands; default: every one. |
37
37
  | `make`, `remote`: `commands` | Each subcommand's `command` options, by action name: `{ getNote: { render } }`. |
38
38
  | `make`, `remote`: `subcommands` | Commands of your own beside the derived ones: `[exportNotes]`. |
39
- | `remoteCommand`, `remote`: `client` | The `ActionHttp.client` options, `baseUrl` and `transformClient`. |
39
+ | `remoteCommand`, `remote`: `client` | The client its commands call: `ActionHttp.client(Http, { baseUrl })`. |
40
40
 
41
- Exported types: `Options<A>`, `RemoteOptions<A>`, `CommandOptions<A>`, `RemoteCommandOptions<A>`,
42
- and `UserError<E>`, a type only: what a command fails with when its action fails with `E`.
41
+ Exported types: `Options<A>` of `make` and `remote`, `CommandOptions<A>` of `command` and
42
+ `remoteCommand`, and `UserError<E>`, a type only: what a command fails with when its action fails with `E`.
43
43
 
44
44
  ## Run actions as local commands
45
45
 
@@ -81,21 +81,22 @@ Run it with Effect's own `Command.run`:
81
81
  Pass the HTTP binding to `remote`. The program imports the binding alone, never a handler; each
82
82
  command calls its action through the binding's `ActionHttp.client` method
83
83
  ([ActionHttp.md](ActionHttp.md#call-the-api-from-effect)) and fails as that method does. The host
84
- provides `HttpClient` (`NodeHttpClient.layerUndici`) and says where the server is:
85
- [notes-service/src/commands.ts](../examples/notes-service/src/commands.ts) and
86
- [notes-service/src/cli.ts](../examples/notes-service/src/cli.ts).
84
+ provides `HttpClient` (`NodeHttpClient.layerUndici`) in
85
+ [notes-service/src/cli.ts](../examples/notes-service/src/cli.ts); the client says where the
86
+ server is in [notes-service/src/commands.ts](../examples/notes-service/src/commands.ts).
87
87
  [kitchen-sink/src/cli.ts](../examples/kitchen-sink/src/cli.ts) adds a tenant header and uses
88
88
  every command option.
89
89
 
90
- - **Choose where the connection comes from.** A fixed URL or token goes in the `client` option:
91
- `client: { baseUrl, transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)) }`.
92
- One read from `Config` configures the `HttpClient` provided on the command, with
93
- `Command.provideEffect(HttpClient.HttpClient, …)` as `notes-service/src/cli.ts` does, so
94
- `--help` needs neither. A command of your own in `subcommands` calls through the same client.
95
- - **Configure the client on the command, never around the program.** Around the program it
96
- rewrites every request the program makes, an absolute URL too, and sends each one the token.
97
- - **A token every command needs is required, its failure mapped to a `CliError.UserError`**, or a
98
- missing one prints a raw `ConfigError` log line: `connection` in
90
+ - **The connection is the `client` option**, an Effect of the binding's client run each time a
91
+ command runs: `client: ActionHttp.client(Http, { baseUrl, transformClient })`. One reading
92
+ `Config` is an `Effect.gen` returning `yield* ActionHttp.client(…)`, so `--help` reads nothing.
93
+ Its failures and its methods' are the command's, printed as `Tag: message`, a
94
+ `CliError.UserError` as its `userMessage`; what they require, the host provides. Credentials go in `transformClient`
95
+ ([ActionHttp.md](ActionHttp.md#call-the-api-from-effect)).
96
+ - **Never transform the `HttpClient` around the program**: it rewrites every request the program
97
+ makes, an absolute URL too, and sends each one the token.
98
+ - **A token every command needs is required, its failure mapped to a tagged error whose `message`
99
+ says what to do**, or a missing one prints as `ConfigError: …`: `api` in
99
100
  [notes-service/src/commands.ts](../examples/notes-service/src/commands.ts). Where a public
100
101
  command must answer signed out, read it with `Config.option` and add the header when it is
101
102
  there: [kitchen-sink/src/cli.ts](../examples/kitchen-sink/src/cli.ts).
@@ -156,15 +157,15 @@ in `subcommands`: `notes export <file>` takes `Argument.String("file")`
156
157
  ([notes-service/src/commands.ts](../examples/notes-service/src/commands.ts)):
157
158
 
158
159
  ```ts
159
- const notes = ActionCli.remote(Http, { name: "notes", subcommands: [exportNotes] });
160
+ const notes = ActionCli.remote(Http, { name: "notes", client: api, subcommands: [exportNotes] });
160
161
  ```
161
162
 
162
163
  - **Never pipe `Command.withSubcommands` onto `make` or `remote`.** It replaces the derived
163
164
  commands, which then answer with the usage text.
164
- - **Call the actions through a client**, in the command's own handler: `ActionHttp.client`, which
165
- needs no scope and uses the `HttpClient` provided on the aggregate, or `Action.client`, acquired
166
- inside `Effect.scoped` ([Action-in-process.md](Action-in-process.md#call-actions-in-process)).
167
- `authorize` still runs. The `client` option reaches the derived commands alone.
165
+ - **Call the actions through a client**, in the command's own handler: `yield* api`, the Effect
166
+ the aggregate's `client` is, which needs no scope, or `Action.client`, acquired inside
167
+ `Effect.scoped` ([Action-in-process.md](Action-in-process.md#call-actions-in-process)).
168
+ `authorize` still runs.
168
169
  - **Its output is what it prints**, with `Console.log`: the output rules below are a derived
169
170
  command's. **Pipe its whole Effect through `ActionCli.toUserError(Http)`**, the binding its
170
171
  client is made from, HTTP or RPC, or `toUserError([GetNote])` over `Action.client`, to fail as a derived command does: a declared
@@ -53,6 +53,12 @@ The tasks are in [ActionHttp.md](ActionHttp.md).
53
53
  the binding's `error`, given inline so its type is certain, or move the check to the handlers
54
54
  and declare the error on their actions.
55
55
 
56
+ - `No overload matches this call` at `client`, naming `Options<unknown, unknown>`: options built
57
+ before the call hold the generic `HttpClient.mapRequestEffect(authorize)`, whose failures the
58
+ call cannot infer, or a helper types them as `Parameters<typeof ActionHttp.client>[1]`. Write
59
+ the transform as `(client) => HttpClient.mapRequestEffect(client, authorize)`, and type such
60
+ options as `ActionHttp.ClientOptions<E, R>`.
61
+
56
62
  ## Requests
57
63
 
58
64
  - A route answers without credentials: its contract is `caller: Action.Anyone`.
@@ -36,7 +36,7 @@ Exported types: `Binding`; `Any`, any binding; `Client<typeof Http>`, a client's
36
36
  | `layer`: `actions` | The bound actions it serves: `[GetUser]`. Defaults to every one the implementations hold. |
37
37
  | `layer`: `middleware` | Native `HttpApiMiddleware` around every route it serves, the first innermost. |
38
38
  | `client`: `baseUrl` | What routes resolve against: `https://api.example.com`. Omitted: relative ones, a page's origin. |
39
- | `client`: `transformClient` | Wraps the native `HttpClient`: `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`. |
39
+ | `client`: `transformClient` | Wraps the native `HttpClient`: a token, or a sign-in per request, whose failures each call adds. |
40
40
  | `fetchClient`: `fetch` | Its `fetch`, beside `client`'s options. Omitted: the global `fetch`, looked up on every call. |
41
41
 
42
42
  Each action is served at `POST <prefix>/<action>`, a `readOnly` one too. One without `success`
@@ -185,9 +185,12 @@ Files: [quickstart/src/client.ts](../examples/quickstart/src/client.ts),
185
185
  [kitchen-sink/src/seed.ts](../examples/kitchen-sink/src/seed.ts) (`baseUrl` from `Config`,
186
186
  headers, errors by tag).
187
187
 
188
- - Outside a browser, set `baseUrl`. A token or any other header goes through `transformClient`.
189
- Where no client is built by hand, as under `ActionCli.remote`, transform the `HttpClient`
190
- itself (`connection` in [notes-service/src/commands.ts](../examples/notes-service/src/commands.ts)).
188
+ - Outside a browser, set `baseUrl`. A token or any other header goes through `transformClient`:
189
+ `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`. A credential read or refreshed
190
+ as each request is sent, which may fail, is
191
+ `(client) => HttpClient.mapRequestEffect(client, authorize)`: each call then also fails with
192
+ `authorize`'s own errors, as they are, and requires its services. `ActionMcp.client` takes the
193
+ same transform, and `ActionCli.remote` the client itself as its `client`.
191
194
  - Leave the argument out when `{}` is a valid input. A given argument is the decoded input, so
192
195
  it states a defaulted field.
193
196
  - A declared error is a typed failure, as its decoded value: the action's own, the binding's,
@@ -215,6 +218,8 @@ with `Effect.runPromise`. Files:
215
218
  - A page on another origin passes `baseUrl`, and the server must allow that origin with
216
219
  `HttpRouter.cors`
217
220
  ([kitchen-sink/src/request-policy.ts](../examples/kitchen-sink/src/request-policy.ts), `cors`).
221
+ - A `transformClient` requiring a service, such as the session's token, makes each call require
222
+ it: provide it on the call before `Effect.runPromise`.
218
223
  - An unhandled declared error rejects the promise as its decoded value, so
219
224
  `error instanceof Action.Unauthenticated` works in a `catch`. Handle an expected one by its tag
220
225
  before `Effect.runPromise`.
package/docs/ActionMcp.md CHANGED
@@ -44,7 +44,7 @@ options. Its `protocols` and `allowSessionTermination` are not options.
44
44
 
45
45
  `client` takes `url`, default `/mcp`, `transformClient`, and the endpoint's `authentication`, so an
46
46
  error its verifier declares decodes; `request` takes `url` and `headers`.
47
- Types: `Client<Actions, D>`, `ClientOptions`, `RequestOptions`, `Params`.
47
+ Types: `Client<Actions, D, E, R>`, `ClientOptions`, `RequestOptions`, `Params`.
48
48
 
49
49
  ## Serve tools over HTTP
50
50
 
@@ -3,6 +3,7 @@ import { Config, Effect, flow, Option } from "effect";
3
3
  import { Command } from "effect/cli";
4
4
  import { HttpClient, HttpClientRequest } from "effect/http";
5
5
  import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
6
+ import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
6
7
  import { Http } from "./Api.js";
7
8
 
8
9
  // Entrypoint: `node --import tsx examples/kitchen-sink/src/cli.ts list-issues`.
@@ -41,27 +42,24 @@ const tracker = ActionCli.remote(Http, {
41
42
  `${attachment.name}: ${image.mimeType}, ${image.data.byteLength} bytes`,
42
43
  },
43
44
  },
44
- }).pipe(
45
- // The connection is read from `Config` when a command runs, so `--help` needs none of it.
46
- // It is provided on this command, so it reaches these commands' requests and no others.
47
- // The token and the workspace are optional: `tracker get-status` is public and needs neither,
48
- // and any other command without them gets the server's 401.
49
- Command.provideEffect(
50
- HttpClient.HttpClient,
51
- Effect.gen(function* () {
52
- const url = yield* Config.String("TRACKER_URL").pipe(
53
- Config.withDefault("http://127.0.0.1:3000"),
54
- );
45
+ // The connection, read from `Config` when a command runs, so `--help` needs none of it. It is
46
+ // these commands' client, so it reaches their requests and no others. The token and the
47
+ // workspace are optional: `tracker get-status` is public and needs neither, and any other
48
+ // command without them gets the server's 401.
49
+ client: Effect.gen(function* () {
50
+ const baseUrl = yield* Config.String("TRACKER_URL").pipe(
51
+ Config.withDefault("http://127.0.0.1:3000"),
52
+ );
55
53
 
56
- const token = yield* Config.option(Config.Redacted("TRACKER_TOKEN"));
57
- const workspace = yield* Config.option(Config.String("TRACKER_WORKSPACE"));
54
+ const token = yield* Config.option(Config.Redacted("TRACKER_TOKEN"));
55
+ const workspace = yield* Config.option(Config.String("TRACKER_WORKSPACE"));
58
56
 
59
- const unchanged = (request: HttpClientRequest.HttpClientRequest) => request;
57
+ const unchanged = (request: HttpClientRequest.HttpClientRequest) => request;
60
58
 
61
- return HttpClient.mapRequest(
62
- yield* HttpClient.HttpClient,
59
+ return yield* ActionHttp.client(Http, {
60
+ baseUrl,
61
+ transformClient: HttpClient.mapRequest(
63
62
  flow(
64
- HttpClientRequest.prependUrl(url),
65
63
  Option.match(token, {
66
64
  onNone: () => unchanged,
67
65
  onSome: (value) => HttpClientRequest.bearerToken(value),
@@ -71,10 +69,10 @@ const tracker = ActionCli.remote(Http, {
71
69
  onSome: (slug) => HttpClientRequest.setHeader("x-workspace", slug),
72
70
  }),
73
71
  ),
74
- );
75
- }),
76
- ),
77
- );
72
+ ),
73
+ });
74
+ }),
75
+ });
78
76
 
79
77
  // A result is printed on stdout. A failure goes to stderr, under an `ERROR` heading, as the JSON
80
78
  // HTTP sends, such as `{"_tag":"IssueNotFound","id":"ACME-9"}`, and the command exits 1.
@@ -104,7 +104,7 @@ src/
104
104
  ├── worker.ts entrypoint: a fetch handler for a serverless host
105
105
  ├── stdio.ts entrypoint: MCP for a local agent
106
106
  ├── commands.ts the commands calling the server, one written by hand, and their connection
107
- ├── cli.ts entrypoint: the commands, given that connection
107
+ ├── cli.ts entrypoint: the commands
108
108
  └── browser.ts client module: promises for a web page
109
109
  test/
110
110
  ├── notes/ in process, with Action.client
@@ -144,7 +144,7 @@ test/
144
144
  | A CLI derived from the binding: an argument, a field on stdin | `src/commands.ts` |
145
145
  | A command written by hand beside the derived ones | `src/commands.ts` |
146
146
  | A bulk import as one action, its command reading a file | `src/notes/`, `src/commands.ts` |
147
- | The CLI's connection, read from config when a command runs | `src/commands.ts`, `src/cli.ts` |
147
+ | The CLI's connection, read from config when a command runs | `src/commands.ts` |
148
148
  | A missing token as one message, not a `ConfigError` report | `src/commands.ts` |
149
149
  | A promise client handling each declared error by its tag | `src/browser.ts` |
150
150
  | Testing handlers and authorization with no transport | `test/notes/notes.test.ts` |
@@ -1,21 +1,15 @@
1
1
  import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node";
2
2
  import { Effect } from "effect";
3
3
  import { Command } from "effect/cli";
4
- import { HttpClient } from "effect/http";
5
4
  import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
6
- import { commands, connection } from "./commands.js";
5
+ import { commands } from "./commands.js";
7
6
 
8
7
  // Entrypoint: `node --import tsx examples/notes-service/src/cli.ts list-notes`.
9
- // The commands of commands.ts, connected to a server.
10
- const notes = commands.pipe(
11
- // Provided on the command, never around `Command.run`: the URL and the token are then read
12
- // when a command runs, so `--help` and a mistyped flag need neither.
13
- Command.provideEffect(HttpClient.HttpClient, connection),
14
- );
8
+ // The commands of commands.ts, which read where the server is and the token when one runs.
15
9
 
16
10
  // A result is printed on stdout. A failure goes to stderr, under an `ERROR` heading, as the JSON
17
11
  // HTTP sends, such as `{"_tag":"NoteNotFound","id":"9"}`, and the command exits 1.
18
- Command.run(notes, { version: "1.0.0" }).pipe(
12
+ Command.run(commands, { version: "1.0.0" }).pipe(
19
13
  Effect.provide([NodeHttpClient.layerUndici, NodeServices.layer]),
20
14
  ActionCli.logToStderr,
21
15
  NodeRuntime.runMain,
@@ -1,10 +1,44 @@
1
- import { Config, Console, Effect, FileSystem, flow } from "effect";
1
+ import { Config, Console, Effect, FileSystem, Schema } from "effect";
2
2
  import { Argument, CliError, Command } from "effect/cli";
3
3
  import { HttpClient, HttpClientRequest } from "effect/http";
4
4
  import * as ActionCli from "@gjermundgaraba/effect-actions/ActionCli";
5
5
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
6
6
  import { Http } from "./Api.js";
7
7
 
8
+ // What a command fails with when no token is configured: a tagged error of the CLI's own, printed
9
+ // on stderr as `MissingToken: <message>` under an `ERROR` heading, and exit 1.
10
+ class MissingToken extends Schema.TaggedError<MissingToken>()("MissingToken", {
11
+ message: Schema.String,
12
+ }) {}
13
+
14
+ // The binding's typed client, connected to where the server is and as who is calling, both read
15
+ // from config. It is an Effect, not a client: every command here, the derived ones and your own,
16
+ // runs it when it runs, so `--help` and a mistyped flag read nothing. It reaches these commands'
17
+ // requests and no others.
18
+ const api = Effect.gen(function* () {
19
+ const baseUrl = yield* Config.String("NOTES_URL").pipe(
20
+ Config.withDefault("http://127.0.0.1:3000"),
21
+ );
22
+
23
+ // Required, since every command but `get-status` is refused without one. Left as it is, a
24
+ // missing token prints as `ConfigError: …`; mapped, it prints the message, which says what to
25
+ // do. A CLI whose public commands must answer signed out reads the token with `Config.option`
26
+ // instead and adds the header when it is there.
27
+ const token = yield* Config.Redacted("NOTES_TOKEN").pipe(
28
+ Effect.mapError(
29
+ () =>
30
+ new MissingToken({
31
+ message: "NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
32
+ }),
33
+ ),
34
+ );
35
+
36
+ return yield* ActionHttp.client(Http, {
37
+ baseUrl,
38
+ transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
39
+ });
40
+ });
41
+
8
42
  // The file's format: each note a `# ` title line, a blank line and its body, the notes apart by
9
43
  // a blank line. A body line that starts as a title would is written with a backslash before it,
10
44
  // as Markdown escapes one, and `import` takes it off, so every note reads back as it was.
@@ -15,14 +49,14 @@ const unescape = (body: string) => body.replaceAll(/^\\(?=\\*# )/gmu, "");
15
49
  // A command of your own, for what no single action does: this one calls two and writes a file.
16
50
  // It is a native Effect CLI command, so its syntax and its output are what you write: `notes
17
51
  // export notes.md` takes its argument from `Argument`, as a flag would come from `Flag`. It
18
- // calls the server through the binding's typed client, on the `HttpClient` the derived commands
19
- // use, and writes with the `FileSystem` every Effect CLI command has.
52
+ // calls the server through `api`, the client the derived commands call, and writes with the
53
+ // `FileSystem` every Effect CLI command has.
20
54
  const exportNotes = Command.make(
21
55
  "export",
22
56
  { file: Argument.String("file").pipe(Argument.withDescription("The Markdown file to write.")) },
23
57
  ({ file }) =>
24
58
  Effect.gen(function* () {
25
- const client = yield* ActionHttp.client(Http);
59
+ const client = yield* api;
26
60
  const files = yield* FileSystem.FileSystem;
27
61
  const { notes } = yield* client.listNotes();
28
62
  const whole = yield* Effect.forEach(notes, ({ id }) => client.getNote({ id }));
@@ -63,7 +97,7 @@ const importNotes = Command.make(
63
97
  },
64
98
  ({ text }) =>
65
99
  Effect.gen(function* () {
66
- const client = yield* ActionHttp.client(Http);
100
+ const client = yield* api;
67
101
 
68
102
  if (text === "") return yield* Console.log("Created 0 notes");
69
103
 
@@ -93,6 +127,7 @@ const importNotes = Command.make(
93
127
  // it: test/commands.test.ts.
94
128
  export const commands = ActionCli.remote(Http, {
95
129
  name: "notes",
130
+ client: api,
96
131
  commands: {
97
132
  // `notes create-note --title Draft < draft.md`: the body is piped in, not a flag.
98
133
  createNote: { stdin: "body" },
@@ -104,34 +139,3 @@ export const commands = ActionCli.remote(Http, {
104
139
  // go here.
105
140
  subcommands: [exportNotes, importNotes],
106
141
  });
107
-
108
- // Where the server is and who is calling, read from config: the `HttpClient` every command
109
- // above calls with. It is an Effect, not a value, so that cli.ts provides it on the commands
110
- // and it runs when one of them does.
111
- export const connection = Effect.gen(function* () {
112
- const url = yield* Config.String("NOTES_URL").pipe(Config.withDefault("http://127.0.0.1:3000"));
113
-
114
- // Required, since every command but `get-status` is refused without one. A failure of what a
115
- // command is provided is no action's error: left as it is, a missing token is reported as a
116
- // crash, the log line `ERROR (#n): ConfigError: SchemaError(Expected string at
117
- // ["NOTES_TOKEN"])`. Mapped to a `UserError`, it is printed as any failure of a command is:
118
- // the message under an `ERROR` heading on stderr, and exit 1. `userMessage` is all that is
119
- // printed, never `cause`: what the user needs from the cause goes into the message. A CLI
120
- // whose public commands must answer signed out reads the token with `Config.option` instead
121
- // and adds the header when it is there.
122
- const token = yield* Config.Redacted("NOTES_TOKEN").pipe(
123
- Effect.mapError(
124
- (cause) =>
125
- new CliError.UserError({
126
- cause,
127
- userMessage:
128
- "NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
129
- }),
130
- ),
131
- );
132
-
133
- return HttpClient.mapRequest(
134
- yield* HttpClient.HttpClient,
135
- flow(HttpClientRequest.prependUrl(url), HttpClientRequest.bearerToken(token)),
136
- );
137
- });
@@ -16,7 +16,7 @@ import { TestConsole } from "effect/testing";
16
16
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
17
17
  import * as Testing from "@gjermundgaraba/effect-actions/Testing";
18
18
  import { Http } from "../src/Api.js";
19
- import { commands, connection } from "../src/commands.js";
19
+ import { commands } from "../src/commands.js";
20
20
  import { ImportNotes, Note, NoteNotFound } from "../src/notes/Notes.js";
21
21
  import { NoteStore } from "../src/notes/store.js";
22
22
  import { routes } from "../src/routes.js";
@@ -26,17 +26,16 @@ import { routes } from "../src/routes.js";
26
26
  // service a builder yields is provided here too (routes.test.ts).
27
27
  const served = Testing.layer(routes).pipe(Layer.provide(NoteStore.layerMemory));
28
28
 
29
- // Effect's own runner, given arguments instead of the process's. The console of `it.effect`
30
- // keeps every line printed, so a run returns the lines it added.
31
- const run = (token: string, args: ReadonlyArray<string>, stdin = "") =>
29
+ // Effect's own runner, given arguments instead of the process's, and what the commands read from
30
+ // the environment as a Layer, so the test decides whether a token is configured, whatever the
31
+ // shell holds. The console of `it.effect` keeps every line printed, so a run returns the lines
32
+ // it added.
33
+ const connected = (environment: Record<string, string>, args: ReadonlyArray<string>, stdin = "") =>
32
34
  Effect.gen(function* () {
33
35
  const before = (yield* TestConsole.logLines).length;
34
36
 
35
37
  yield* Command.runWith(commands, { version: "0" })(args).pipe(
36
- Effect.updateService(
37
- HttpClient.HttpClient,
38
- HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
39
- ),
38
+ Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(environment))),
40
39
  Effect.provide(Stdio.layerTest({ stdin: Stream.make(new TextEncoder().encode(stdin)) })),
41
40
  Effect.provide(NodeServices.layer),
42
41
  );
@@ -44,15 +43,9 @@ const run = (token: string, args: ReadonlyArray<string>, stdin = "") =>
44
43
  return (yield* TestConsole.logLines).slice(before);
45
44
  });
46
45
 
47
- // The commands as cli.ts connects them, with what it reads from the environment given as a
48
- // Layer, so the test decides whether a token is configured, whatever the shell holds.
49
- const connected = (environment: Record<string, string>, args: ReadonlyArray<string>) =>
50
- Command.runWith(commands.pipe(Command.provideEffect(HttpClient.HttpClient, connection)), {
51
- version: "0",
52
- })(args).pipe(
53
- Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnvRecord(environment))),
54
- Effect.provide(NodeServices.layer),
55
- );
46
+ // A run as the caller `token` names.
47
+ const run = (token: string, args: ReadonlyArray<string>, stdin = "") =>
48
+ connected({ NOTES_TOKEN: token }, args, stdin);
56
49
 
57
50
  const fitsAFile = (text: string) =>
58
51
  new TextDecoder().decode(new TextEncoder().encode(text)) === text;
@@ -270,9 +263,10 @@ describe("the commands", () => {
270
263
  const refused = yield* Effect.flip(connected({}, ["list-notes"]));
271
264
 
272
265
  expect(refused).toBeInstanceOf(CliError.UserError);
266
+ expect(refused).toHaveProperty("cause._tag", "MissingToken");
273
267
  expect(refused).toHaveProperty(
274
268
  "userMessage",
275
- "NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
269
+ "MissingToken: NOTES_TOKEN is not set. Set it to your token: export NOTES_TOKEN=<your token>",
276
270
  );
277
271
 
278
272
  // What a person sees: that message, once, on stderr, and no report of a `ConfigError`.
@@ -280,7 +274,7 @@ describe("the commands", () => {
280
274
  expect.stringContaining("NOTES_TOKEN is not set."),
281
275
  ]);
282
276
 
283
- // The token is read when a command runs, so its help needs none.
277
+ // The token is read when a command runs, so help needs none.
284
278
  yield* connected({}, ["list-notes", "--help"]);
285
279
 
286
280
  expect(yield* TestConsole.logLines).toHaveLength(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/effect-actions",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Define Effect action contracts once and project them onto HTTP, MCP, Effect RPC, native Toolkits, and CLIs.",
5
5
  "keywords": [
6
6
  "ai-agents",