@smthrs/plugin 0.0.0-stage → 1.0.0-rc.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -2
  4. package/dist/cjs/Config.d.ts +129 -0
  5. package/dist/cjs/Config.d.ts.map +1 -0
  6. package/dist/cjs/Config.js +115 -0
  7. package/dist/cjs/Config.js.map +7 -0
  8. package/dist/cjs/Hooks.d.ts +176 -0
  9. package/dist/cjs/Hooks.d.ts.map +1 -0
  10. package/dist/cjs/Hooks.js +34 -0
  11. package/dist/cjs/Hooks.js.map +7 -0
  12. package/dist/cjs/Kernel.d.ts +81 -0
  13. package/dist/cjs/Kernel.d.ts.map +1 -0
  14. package/dist/cjs/Kernel.js +57 -0
  15. package/dist/cjs/Kernel.js.map +7 -0
  16. package/dist/cjs/Plugin.d.ts +71 -0
  17. package/dist/cjs/Plugin.d.ts.map +1 -0
  18. package/dist/cjs/Plugin.js +25 -0
  19. package/dist/cjs/Plugin.js.map +7 -0
  20. package/dist/cjs/PluginError.d.ts +50 -0
  21. package/dist/cjs/PluginError.d.ts.map +1 -0
  22. package/dist/cjs/PluginError.js +58 -0
  23. package/dist/cjs/PluginError.js.map +7 -0
  24. package/dist/cjs/Plugins.d.ts +97 -0
  25. package/dist/cjs/Plugins.d.ts.map +1 -0
  26. package/dist/cjs/Plugins.js +169 -0
  27. package/dist/cjs/Plugins.js.map +7 -0
  28. package/dist/cjs/Resolve.d.ts +128 -0
  29. package/dist/cjs/Resolve.d.ts.map +1 -0
  30. package/dist/cjs/Resolve.js +346 -0
  31. package/dist/cjs/Resolve.js.map +7 -0
  32. package/dist/cjs/index.d.ts +83 -0
  33. package/dist/cjs/index.d.ts.map +1 -0
  34. package/dist/cjs/index.js +45 -0
  35. package/dist/cjs/index.js.map +7 -0
  36. package/dist/cjs/internal/Boundary.d.ts +96 -0
  37. package/dist/cjs/internal/Boundary.d.ts.map +1 -0
  38. package/dist/cjs/internal/Boundary.js +129 -0
  39. package/dist/cjs/internal/Boundary.js.map +7 -0
  40. package/dist/cjs/internal/mergePluginLayers.d.ts +21 -0
  41. package/dist/cjs/internal/mergePluginLayers.d.ts.map +1 -0
  42. package/dist/cjs/internal/mergePluginLayers.js +57 -0
  43. package/dist/cjs/internal/mergePluginLayers.js.map +7 -0
  44. package/dist/cjs/internal/snapshotCacheEnvironment.d.ts +23 -0
  45. package/dist/cjs/internal/snapshotCacheEnvironment.d.ts.map +1 -0
  46. package/dist/cjs/internal/snapshotCacheEnvironment.js +92 -0
  47. package/dist/cjs/internal/snapshotCacheEnvironment.js.map +7 -0
  48. package/dist/cjs/package.json +1 -0
  49. package/dist/esm/Config.d.ts +129 -0
  50. package/dist/esm/Config.d.ts.map +1 -0
  51. package/dist/esm/Config.js +160 -0
  52. package/dist/esm/Config.js.map +1 -0
  53. package/dist/esm/Hooks.d.ts +176 -0
  54. package/dist/esm/Hooks.d.ts.map +1 -0
  55. package/dist/esm/Hooks.js +56 -0
  56. package/dist/esm/Hooks.js.map +1 -0
  57. package/dist/esm/Kernel.d.ts +81 -0
  58. package/dist/esm/Kernel.d.ts.map +1 -0
  59. package/dist/esm/Kernel.js +58 -0
  60. package/dist/esm/Kernel.js.map +1 -0
  61. package/dist/esm/Plugin.d.ts +71 -0
  62. package/dist/esm/Plugin.d.ts.map +1 -0
  63. package/dist/esm/Plugin.js +20 -0
  64. package/dist/esm/Plugin.js.map +1 -0
  65. package/dist/esm/PluginError.d.ts +50 -0
  66. package/dist/esm/PluginError.d.ts.map +1 -0
  67. package/dist/esm/PluginError.js +53 -0
  68. package/dist/esm/PluginError.js.map +1 -0
  69. package/dist/esm/Plugins.d.ts +97 -0
  70. package/dist/esm/Plugins.d.ts.map +1 -0
  71. package/dist/esm/Plugins.js +190 -0
  72. package/dist/esm/Plugins.js.map +1 -0
  73. package/dist/esm/Resolve.d.ts +128 -0
  74. package/dist/esm/Resolve.d.ts.map +1 -0
  75. package/dist/esm/Resolve.js +369 -0
  76. package/dist/esm/Resolve.js.map +1 -0
  77. package/dist/esm/index.d.ts +83 -0
  78. package/dist/esm/index.d.ts.map +1 -0
  79. package/dist/esm/index.js +48 -0
  80. package/dist/esm/index.js.map +1 -0
  81. package/dist/esm/internal/Boundary.d.ts +96 -0
  82. package/dist/esm/internal/Boundary.d.ts.map +1 -0
  83. package/dist/esm/internal/Boundary.js +162 -0
  84. package/dist/esm/internal/Boundary.js.map +1 -0
  85. package/dist/esm/internal/mergePluginLayers.d.ts +21 -0
  86. package/dist/esm/internal/mergePluginLayers.d.ts.map +1 -0
  87. package/dist/esm/internal/mergePluginLayers.js +41 -0
  88. package/dist/esm/internal/mergePluginLayers.js.map +1 -0
  89. package/dist/esm/internal/snapshotCacheEnvironment.d.ts +23 -0
  90. package/dist/esm/internal/snapshotCacheEnvironment.d.ts.map +1 -0
  91. package/dist/esm/internal/snapshotCacheEnvironment.js +64 -0
  92. package/dist/esm/internal/snapshotCacheEnvironment.js.map +1 -0
  93. package/docs/README.md +133 -0
  94. package/docs/api.md +524 -0
  95. package/docs/concepts/configuration.md +111 -0
  96. package/docs/concepts/hook-kinds.md +107 -0
  97. package/docs/concepts/resolution.md +132 -0
  98. package/docs/guides/cache-identity.md +106 -0
  99. package/docs/guides/contribute-services.md +84 -0
  100. package/docs/guides/host-your-own-hooks.md +129 -0
  101. package/docs/guides/order-handlers.md +76 -0
  102. package/docs/guides/select-plugins.md +72 -0
  103. package/docs/guides/testing.md +149 -0
  104. package/docs/installation.md +75 -0
  105. package/docs/quickstart.md +152 -0
  106. package/docs/troubleshooting.md +180 -0
  107. package/package.json +179 -3
  108. package/src/Config.ts +207 -0
  109. package/src/Hooks.ts +212 -0
  110. package/src/Kernel.ts +119 -0
  111. package/src/Plugin.ts +80 -0
  112. package/src/PluginError.ts +62 -0
  113. package/src/Plugins.ts +271 -0
  114. package/src/Resolve.ts +539 -0
  115. package/src/index.ts +91 -0
  116. package/src/internal/Boundary.ts +252 -0
  117. package/src/internal/mergePluginLayers.ts +57 -0
  118. package/src/internal/snapshotCacheEnvironment.ts +78 -0
@@ -0,0 +1,76 @@
1
+ ---
2
+ title: "Control the order handlers run in"
3
+ description: "Pick between the plugin-wide enforce group and the per-hook order object, and know which one wins when they disagree."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ Two levers decide when your handler runs. Use `enforce` when the plugin as a
9
+ whole belongs early or late. Use the per-hook `order` when one hook needs a
10
+ different position from the rest of the plugin.
11
+
12
+ ## Move the whole plugin with enforce
13
+
14
+ `enforce` puts a plugin in the `pre`, normal, or `post` group. Within a group,
15
+ plugins keep the order you listed them in:
16
+
17
+ ```ts
18
+ import { make } from "@smthrs/plugin"
19
+
20
+ const early = make({ name: "flows-plugin-early", enforce: "pre", hooks: { configResolved: handler } })
21
+ const late = make({ name: "flows-plugin-late", enforce: "post", hooks: { configResolved: handler } })
22
+ ```
23
+
24
+ The resulting list is the resolved plugin order, and it decides two things
25
+ beyond hook dispatch: the order `kernel.layer` merges plugin layers in, and the
26
+ order plugin identities appear in a declared cache identity.
27
+
28
+ ## Move one hook with the ordering object
29
+
30
+ Replace the bare handler with `{ order, handler }` to position that hook alone:
31
+
32
+ ```ts
33
+ const plugin = make({
34
+ name: "flows-plugin-mixed",
35
+ hooks: {
36
+ config: handler,
37
+ configResolved: { order: "pre", handler }
38
+ }
39
+ })
40
+ ```
41
+
42
+ Here `config` runs at the plugin's normal position and `configResolved` runs
43
+ early. The object form is the only way to give one plugin two different
44
+ positions.
45
+
46
+ ## The per-hook order wins
47
+
48
+ When the two levers disagree, the per-hook `order` wins, because it is applied
49
+ second and re-partitions the already-sorted list:
50
+
51
+ ```ts
52
+ const plugins = [
53
+ make({ name: "flows-plugin-a", enforce: "post", hooks: { configResolved: { order: "pre", handler } } }),
54
+ make({ name: "flows-plugin-b", enforce: "pre", hooks: { configResolved: { order: "post", handler } } })
55
+ ]
56
+ // configResolved order: flows-plugin-a, then flows-plugin-b.
57
+ ```
58
+
59
+ This is Vite's rule, kept verbatim, so a plugin ported from Vite orders the same
60
+ way here. The full statement of it is in
61
+ [Resolution](../concepts/resolution.md).
62
+
63
+ ## Do not order by list position alone
64
+
65
+ Within a group, the array order is stable and it is the tiebreaker. It is also
66
+ the weakest of the three signals: a host that concatenates presets, or a user
67
+ who reorders a config file, changes it without meaning to. Declare `enforce` or
68
+ `order` when the position matters, and leave both off when it does not.
69
+
70
+ ## Ordering is not a dependency system
71
+
72
+ There is no `dependsOn`, and no cycle detection, because there are no
73
+ dependencies to cycle. If your plugin needs another plugin's contribution, read
74
+ it from the value the hook threads through: a waterfall handler sees what the
75
+ earlier handlers produced, and a `configResolved` observer sees the whole
76
+ resolved configuration.
@@ -0,0 +1,72 @@
1
+ ---
2
+ title: "Include a plugin conditionally"
3
+ description: "Use apply to select a plugin by host target or by a predicate over the pre-resolution configuration, and know what selection does not excuse."
4
+ sidebar:
5
+ order: 3
6
+ ---
7
+
8
+ A plugin can decide it does not belong in this composition. Declare `apply`,
9
+ and the kernel drops the plugin before it contributes a handler, a layer, or a
10
+ cache identity.
11
+
12
+ ## Select by host target
13
+
14
+ `apply: "engine"` and `apply: "harness"` name the host the plugin is for. The
15
+ kernel compares the literal with the `target` option, which defaults to
16
+ `"engine"`:
17
+
18
+ ```ts
19
+ import { Kernel, make } from "@smthrs/plugin"
20
+
21
+ const list = [
22
+ make({ name: "flows-plugin-shared" }),
23
+ make({ name: "flows-plugin-harness-only", apply: "harness" })
24
+ ]
25
+
26
+ // Engine kernel: only flows-plugin-shared is selected.
27
+ const engine = Kernel.make(list)
28
+
29
+ // Harness kernel: both are selected.
30
+ const harness = Kernel.make(list, {}, { target: "harness" })
31
+ ```
32
+
33
+ A plugin with no `apply` is always selected. This is how one shared preset
34
+ serves two hosts: list every plugin once, and let each host take its half.
35
+
36
+ ## Select by a predicate over the configuration
37
+
38
+ A predicate receives the pre-resolution configuration, the same value you passed
39
+ to `Kernel.make`, and returns a boolean:
40
+
41
+ ```ts
42
+ const fast = make({
43
+ name: "flows-plugin-fast",
44
+ apply: (config) => config["mode"] === "fast"
45
+ })
46
+
47
+ const kernel = Kernel.make([fast], { mode: "fast" })
48
+ ```
49
+
50
+ The configuration a predicate sees is a snapshot taken before the first
51
+ predicate runs, so a predicate cannot observe another predicate's side effects,
52
+ and a caller cannot mutate the object between two predicate calls.
53
+
54
+ It is the _pre-resolution_ configuration: the `config` waterfall has not run
55
+ yet, so a predicate cannot see a namespace another plugin is about to
56
+ contribute. Selection has to settle before the waterfall, because the waterfall
57
+ runs the plugins selection chose.
58
+
59
+ A predicate that throws fails resolution with `apply_failed`, and its raw
60
+ failure is not retained. A predicate that returns anything other than a boolean
61
+ fails with `invalid_plugin`. Keep predicates pure and total.
62
+
63
+ ## What selection does not excuse
64
+
65
+ Every plugin record is validated before `apply` runs, so a malformed plugin
66
+ fails startup even when the predicate would have excluded it. Selection cannot
67
+ hide a typo.
68
+
69
+ Hook names are checked after selection, and that asymmetry is deliberate: a
70
+ shared preset may carry `apply: "harness"` plugins whose hooks only the harness
71
+ catalog declares, and it still resolves under a bare engine kernel because those
72
+ plugins were never selected.
@@ -0,0 +1,149 @@
1
+ ---
2
+ title: "Test a plugin"
3
+ description: "Exercise a plugin without booting a host: resolve a list, dispatch a hook, assert on ordering and typed failures, and stub the kernel out of a composition under test."
4
+ sidebar:
5
+ order: 6
6
+ ---
7
+
8
+ A plugin is a plain record and the dispatcher is a plain service, so a plugin
9
+ test needs no host, no engine, and no I/O. Resolve the list, dispatch the hook,
10
+ assert on the result.
11
+
12
+ ## Dispatch a hook without a kernel
13
+
14
+ `Resolve.resolve` gives you the catalog and `Plugins.make` gives you the
15
+ dispatcher. Use them directly when the test is about one hook, with the hook
16
+ interface as an explicit type argument:
17
+
18
+ ```ts
19
+ import { type FlowsPlugin, Plugins, Resolve, type WaterfallHook } from "@smthrs/plugin"
20
+ import * as Effect from "effect/Effect"
21
+ import { expect, it } from "vitest"
22
+
23
+ interface HostHooks {
24
+ readonly tools: WaterfallHook<
25
+ (tools: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<string> | void>
26
+ >
27
+ }
28
+
29
+ const subject: FlowsPlugin<HostHooks> = {
30
+ name: "flows-plugin-subject",
31
+ hooks: { tools: (tools) => Effect.succeed([...tools, "read", "write"]) }
32
+ }
33
+
34
+ it("contributes its tools", async () => {
35
+ const resolved = await Effect.runPromise(
36
+ Resolve.resolve<HostHooks>([subject], { hooks: { tools: "waterfall" } })
37
+ )
38
+ const tools = await Effect.runPromise(
39
+ Plugins.make<HostHooks>(resolved).waterfall("tools", [] as ReadonlyArray<string>, (_previous, next) => next)
40
+ )
41
+ expect(tools).toEqual(["read", "write"])
42
+ })
43
+ ```
44
+
45
+ Use `Kernel.make` instead when the test is about the whole startup path: the
46
+ waterfall, the freeze, the observers, and the merged layer together.
47
+
48
+ ## Startup hooks see no services
49
+
50
+ The shared `config` and `configResolved` hooks are context-free: `Kernel.make`
51
+ runs them before any plugin layer exists and supplies nothing itself, so a
52
+ handler that reads a service does not compile against `FlowsHooks`, and one
53
+ cast past the type fails at runtime with `hook_failed` on that hook. Provide
54
+ the service inside the hook, or, when a host's startup hooks genuinely require
55
+ services, declare a separate hook interface and provide them to the startup
56
+ Effect. `Kernel.make<H>` carries the requirement as `Kernel.StartupContext<H>`:
57
+
58
+ ```ts
59
+ interface HostHooks {
60
+ readonly config: WaterfallHook<
61
+ (config: FlowsConfig) => Effect.Effect<Partial<FlowsConfig> | void, never, Settings>
62
+ >
63
+ readonly configResolved: ParallelHook<(config: ResolvedConfig) => Effect.Effect<void, never, Settings>>
64
+ }
65
+
66
+ const kernel = await Effect.runPromise(
67
+ Kernel.make<HostHooks>([reader], {}, { hooks: { config: "waterfall", configResolved: "parallel" } }).pipe(
68
+ Effect.provideService(Settings, { enabled: true })
69
+ )
70
+ )
71
+ ```
72
+
73
+ Without the `provideService`, the program does not compile.
74
+
75
+ ## Assert the order, not just the membership
76
+
77
+ `resolved.plugins` is the resolved plugin order and
78
+ `resolved.handlers.get(hook)` is the ordered handler list for one hook, each
79
+ record carrying the plugin that declared it:
80
+
81
+ ```ts
82
+ const names = (resolved: Resolve.Resolved, hook: string) =>
83
+ (resolved.handlers.get(hook) ?? []).map((record) => record.plugin)
84
+
85
+ expect(names(resolved, "configResolved")).toEqual(["early", "normal", "late"])
86
+ ```
87
+
88
+ Assert both when a plugin declares a per-hook `order`: the two lists differ on
89
+ purpose, and only the handler list proves the hook is positioned the way you
90
+ meant.
91
+
92
+ ## Assert typed failures by code
93
+
94
+ Every refusal is a `PluginError` with a `code`, and most carry the plugin, the
95
+ hook, and the value path. Flip the Effect and match the fields you care about:
96
+
97
+ ```ts
98
+ import { Kernel } from "@smthrs/plugin"
99
+
100
+ const error = await Effect.runPromise(
101
+ Kernel.make([{ name: "duplicate" }, { name: "duplicate" }]).pipe(Effect.flip)
102
+ )
103
+ expect(error).toMatchObject({ code: "duplicate_name", plugin: "duplicate" })
104
+ ```
105
+
106
+ Match on `code`, `plugin`, `hook`, and `path`, not on `message`. The codes are a
107
+ closed set, listed in [Troubleshooting](../troubleshooting.md); the messages are
108
+ prose and may be reworded.
109
+
110
+ ## Observer failures are a return value
111
+
112
+ A failing `configResolved` handler does not fail startup, so an assertion that
113
+ the kernel succeeded proves nothing about it. Read `observerErrors`:
114
+
115
+ ```ts
116
+ const kernel = await Effect.runPromise(
117
+ Kernel.make([{ name: "noisy", hooks: { configResolved: () => Effect.fail("boom") } }])
118
+ )
119
+ expect(kernel.observerErrors.map((error) => error.plugin)).toEqual(["noisy"])
120
+ ```
121
+
122
+ ## Stub the kernel out of a composition under test
123
+
124
+ When the subject is the host rather than the plugins, give it a dispatcher with
125
+ no plugins at all:
126
+
127
+ ```ts
128
+ const dispatcher = Plugins.makeNoop()
129
+ ```
130
+
131
+ `Plugins.layerNoop` is the same thing as a layer, for a composition that resolves
132
+ the shared `Plugins` service tag. Both dispatch nothing: a parallel hook returns
133
+ an empty error list, a first hook answers `Option.none()`, and a waterfall
134
+ returns the value it was given.
135
+
136
+ ## Test the pure pieces directly
137
+
138
+ `Config.merge` and `Config.deepFreeze` are ordinary functions that throw a
139
+ `PluginError` rather than returning an Effect, so a merge-semantics test needs no
140
+ runtime:
141
+
142
+ ```ts
143
+ import { Config } from "@smthrs/plugin"
144
+
145
+ expect(Config.merge({ a: { b: 1 } }, { a: { c: 2 } })).toEqual({ a: { b: 1, c: 2 } })
146
+ ```
147
+
148
+ `Hooks.handlerOf` and `Hooks.orderOf` read either hook entry form and are the
149
+ same shape of test.
@@ -0,0 +1,75 @@
1
+ ---
2
+ title: "Installation"
3
+ description: "Install @smthrs/plugin, its runtime requirements, its import forms, and the subpaths its export map blocks."
4
+ sidebar:
5
+ order: 1
6
+ ---
7
+
8
+ ## Install the package
9
+
10
+ ```bash
11
+ npm install @smthrs/plugin@next effect
12
+ ```
13
+
14
+ The `next` tag carries the 1.0 release candidates.
15
+
16
+ Install `effect` explicitly, at exactly `4.0.5`. The kernel depends on it, but your
17
+ plugins import `Effect`, `Layer`, and `Option` directly, so `effect` belongs in
18
+ your own dependency list, inside the range the kernel accepts. A different
19
+ `effect` major gives you two copies of the runtime and two sets of service tags.
20
+
21
+ The package requires Node.js 26.4.0 or later and ships as both ESM and
22
+ CommonJS with TypeScript declarations. Its runtime dependencies install with it:
23
+ [`@smthrs/flow`](/api/flow) supplies `Action.CacheEnvironment` for
24
+ [cache identity](./guides/cache-identity.md), and
25
+ [`@smthrs/canonical`](/api/canonical) supplies immutable catalog maps.
26
+
27
+ ## Import forms
28
+
29
+ The root entry point exports the plugin, hook, and error declarations directly,
30
+ and the four stateful modules as namespaces:
31
+
32
+ ```ts
33
+ import { Config, Kernel, Plugins, Resolve } from "@smthrs/plugin"
34
+ import type { FlowsHooks, FlowsPlugin, PluginInput } from "@smthrs/plugin"
35
+ import { engineHooks, make, PluginError } from "@smthrs/plugin"
36
+ ```
37
+
38
+ Each module is also importable from its own subpath, which is the form the
39
+ [API reference](./api.md) uses:
40
+
41
+ ```ts
42
+ import * as Config from "@smthrs/plugin/Config"
43
+ import * as Kernel from "@smthrs/plugin/Kernel"
44
+ import * as Plugins from "@smthrs/plugin/Plugins"
45
+ import * as Resolve from "@smthrs/plugin/Resolve"
46
+ ```
47
+
48
+ The full list of public subpaths is `@smthrs/plugin/Config`,
49
+ `@smthrs/plugin/Hooks`, `@smthrs/plugin/Kernel`, `@smthrs/plugin/Plugin`,
50
+ `@smthrs/plugin/PluginError`, `@smthrs/plugin/Plugins`, and
51
+ `@smthrs/plugin/Resolve`. Two forms are blocked in the export map:
52
+ `@smthrs/plugin/internal/*` and `@smthrs/plugin/*/index`.
53
+ `@smthrs/plugin/package.json` is exported.
54
+
55
+ ## The augmentation specifier
56
+
57
+ A host that declares its own hooks augments the interface in the root module,
58
+ never in a subpath:
59
+
60
+ ```ts
61
+ declare module "@smthrs/plugin" {
62
+ interface FlowsHooks {
63
+ // your hooks
64
+ }
65
+ }
66
+ ```
67
+
68
+ `FlowsHooks` is declared in the entry point precisely so this specifier works:
69
+ TypeScript can augment an interface only in the module that declares it. See
70
+ [Declare hooks for your host](./guides/host-your-own-hooks.md).
71
+
72
+ ## Next step
73
+
74
+ Build a host with one hook of its own and run it in the
75
+ [Quickstart](./quickstart.md).
@@ -0,0 +1,152 @@
1
+ ---
2
+ title: "Quickstart"
3
+ description: "Build a host with a hook of its own: declare the catalog, write two plugins, run the kernel, and dispatch the hook to a typed result."
4
+ sidebar:
5
+ order: 2
6
+ ---
7
+
8
+ This quickstart builds a small host end to end. The host offers plugins one
9
+ extension point of its own, a `tools` waterfall, alongside the kernel's config
10
+ lifecycle. By the end you will have run the kernel, read a frozen configuration,
11
+ and dispatched a hook whose result is typed by the declaration you wrote.
12
+
13
+ The example uses two files: `host.ts` declares the hook catalog, and `main.ts`
14
+ defines the plugins and runs the host. There are no services to provide.
15
+
16
+ ## Prerequisites
17
+
18
+ - Node.js 26.4.0 or later.
19
+ - A TypeScript project with the package installed:
20
+ `npm install @smthrs/plugin@next effect`.
21
+
22
+ ## Declare the host's hook catalog
23
+
24
+ A host declares its hooks twice: once in the type system, so plugin authors get
25
+ completion and excess-key errors, and once as a runtime catalog, so the kernel
26
+ can refuse a hook name nobody dispatches.
27
+
28
+ Create `host.ts`:
29
+
30
+ ```ts
31
+ import { engineHooks, type WaterfallHook } from "@smthrs/plugin"
32
+ import type * as Effect from "effect/Effect"
33
+
34
+ declare module "@smthrs/plugin" {
35
+ interface FlowsHooks {
36
+ /** Contributes or rewrites the tool names the host offers. */
37
+ readonly tools: WaterfallHook<
38
+ (tools: ReadonlyArray<string>) => Effect.Effect<ReadonlyArray<string> | void>
39
+ >
40
+ }
41
+ }
42
+
43
+ /** The runtime catalog: the kernel's two config hooks plus this host's one. */
44
+ export const hooks = Object.freeze(
45
+ {
46
+ ...engineHooks,
47
+ tools: "waterfall"
48
+ } as const
49
+ )
50
+ ```
51
+
52
+ The two declarations must agree. The interface says what compiles; the runtime
53
+ catalog says what dispatches. `WaterfallHook` in the interface and
54
+ `"waterfall"` in the catalog are the same claim, written for the two audiences.
55
+
56
+ ## Write two plugins
57
+
58
+ Add all remaining TypeScript snippets to `main.ts` in order, starting with the
59
+ complete import block and the first plugin.
60
+
61
+ A plugin is a record. This one contributes a configuration namespace and two
62
+ tools.
63
+
64
+ Create `main.ts` beside `host.ts`:
65
+
66
+ ```ts
67
+ import { type FlowsHooks, Kernel, make } from "@smthrs/plugin"
68
+ import * as Effect from "effect/Effect"
69
+ import { hooks } from "./host.ts"
70
+
71
+ const editor = make<FlowsHooks>({
72
+ name: "flows-plugin-editor",
73
+ hooks: {
74
+ config: () => Effect.succeed({ editor: { readOnly: false } }),
75
+ tools: (tools) => Effect.succeed([...tools, "read", "write"])
76
+ }
77
+ })
78
+ ```
79
+
80
+ `make` is an identity function that pins the literal to `FlowsPlugin<H>`. Pass
81
+ the hook interface explicitly, as above: it is what types the `tools` parameter
82
+ as `ReadonlyArray<string>` and what makes a misspelled hook key fail at the
83
+ definition site instead of at startup.
84
+
85
+ The second plugin runs first, because it is declared `enforce: "pre"`, and it
86
+ reads what the host resolved rather than adding to it:
87
+
88
+ ```ts
89
+ const audit = make<FlowsHooks>({
90
+ name: "flows-plugin-audit",
91
+ enforce: "pre",
92
+ hooks: {
93
+ configResolved: (config) => Effect.log(`resolved namespaces: ${Object.keys(config).join(", ")}`)
94
+ }
95
+ })
96
+ ```
97
+
98
+ ## Run the kernel
99
+
100
+ `Kernel.make` takes the plugin list, the pre-resolution configuration, and the
101
+ host's options. It resolves the list, runs the config waterfall, freezes the
102
+ result, notifies the observers, and merges the plugin layers:
103
+
104
+ ```ts
105
+ const kernel = await Effect.runPromise(
106
+ Kernel.make([editor, audit], { host: { name: "quickstart" } }, { target: "harness", hooks })
107
+ )
108
+
109
+ console.log(kernel.config)
110
+ // { host: { name: "quickstart" }, editor: { readOnly: false } }
111
+ ```
112
+
113
+ `target` names which kind of host you are, and it is what a plugin's
114
+ `apply: "engine" | "harness"` selector is compared against. It defaults to
115
+ `"engine"`, meaning the Smithers durable engine, so pass `"harness"` for any
116
+ other host, including this one.
117
+
118
+ ## Dispatch the hook
119
+
120
+ `kernel.plugins` is the dispatcher. A waterfall needs one more argument than the
121
+ other kinds, the merge function that folds each handler's answer into the value
122
+ the next handler sees:
123
+
124
+ ```ts
125
+ const tools = await Effect.runPromise(
126
+ kernel.plugins.waterfall("tools", [] as ReadonlyArray<string>, (_previous, next) => next)
127
+ )
128
+
129
+ console.log(tools) // ["read", "write"]
130
+ ```
131
+
132
+ The literal `"tools"` is checked against the hook interface: a name that is not
133
+ a waterfall in `FlowsHooks` does not compile, and `tools` is typed as the
134
+ handler's argument type, not as `unknown`.
135
+
136
+ ## What you just built
137
+
138
+ - A hook catalog only your host dispatches. Another host augmenting the same
139
+ interface still dispatches only the catalog it supplied.
140
+ - A plugin list resolved once into a frozen catalog. Mutating `editor` after
141
+ this point changes nothing about dispatch.
142
+ - A configuration every plugin agreed on, deeply copied and frozen before the
143
+ first plugin saw it.
144
+
145
+ ## Next steps
146
+
147
+ - [Hook kinds](./concepts/hook-kinds.md) for what `sequential`, `parallel`,
148
+ `first`, and `waterfall` each promise.
149
+ - [Control the order handlers run in](./guides/order-handlers.md) for `enforce`
150
+ and the per-hook `order`.
151
+ - [Contribute services from a plugin](./guides/contribute-services.md) to add a
152
+ `layer` to the plugin above.