diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 47eb301..bbe5ff1 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -27,6 +27,18 @@ "authentication": "ON_INSTALL" }, "category": "Productivity" + }, + { + "name": "effect-kit", + "source": { + "source": "local", + "path": "./plugins/effect-kit" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" } ] } diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3d0cb36..fe25bf8 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -64,6 +64,34 @@ "cross-tool", "codex" ] + }, + { + "name": "effect-kit", + "source": "./plugins/effect-kit", + "description": "Cross-tool Effect-TS skill kit: repo setup (effect-ts-setup), idiomatic Effect 3.x patterns (effect-ts-specialist), and Fastify/Next.js/React → Effect porting (effect-ts-port).", + "version": "0.1.1", + "author": { + "name": "Eduardo Marquez" + }, + "license": "MIT", + "homepage": "https://github.com/DocksDocks/docks", + "repository": "https://github.com/DocksDocks/docks", + "keywords": [ + "effect", + "effect-ts", + "typescript", + "skills", + "cross-tool", + "codex" + ], + "category": "engineering-workflows", + "tags": [ + "effect", + "typescript", + "cross-tool", + "skills", + "codex" + ] } ] } diff --git a/AGENTS.md b/AGENTS.md index 9778a9d..764c426 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ node scripts/ci.mjs # guards + scorers — must │ ├── agents/ (Claude-only) plan-manager + plan-review thin opus plan-lifecycle wrappers │ └── hooks/ (cross-tool) context-tree-nudge PostToolUse hook (Claude + Codex) ├── plugins/session-relay/ 2nd plugin (cross-tool: Claude + Codex): cross-session/cross-project/cross-tool agent message bus — MCP bus server + shared SessionStart hook + relay CLI; self-versioned, gated by its own ci.mjs section +├── plugins/effect-kit/ 3rd plugin (cross-tool): Effect-TS skill kit — effect-ts-setup / effect-ts-specialist / effect-ts-port (skills-only; depends on docks for plan-lifecycle + authoring skills); self-versioned ├── .claude-plugin/marketplace.json Claude marketplace catalog ├── .agents/plugins/marketplace.json Codex marketplace catalog ├── .agents/skills/ project-local skills (canonical, multi-tool) @@ -42,6 +43,7 @@ Per-area conventions load lazily from nested `AGENTS.md` nodes. Each is paired w | `docs/scaffold/AGENTS.md` | scaffold spec + templates — what the `scaffold` skill seeds into new projects | | `plugins/docks/skills/AGENTS.md` | skill authoring — description CSO, frontmatter, body rules, scoring | | `plugins/session-relay/AGENTS.md` | the relay plugin — layout, binary-release discipline, its CI gates | +| `plugins/effect-kit/skills/AGENTS.md` | effect-kit skill authoring — Effect 3.x version-pinned conventions | | `scripts/AGENTS.md` | validators, edit→release workflow, double-layer gating, versioning | | `.github/AGENTS.md` | CI trigger model, keep-in-sync with `ci.mjs` | diff --git a/docs/plans/active/effect-kit-migration.md b/docs/plans/active/effect-kit-migration.md index 92e53b0..bb3ce79 100644 --- a/docs/plans/active/effect-kit-migration.md +++ b/docs/plans/active/effect-kit-migration.md @@ -3,7 +3,7 @@ title: Migrate effect-kit into the docks multi-plugin repo goal: Move the effect-kit plugin payload from ~/projects/effect-kit into plugins/effect-kit/ here, wire it into the registry-driven CI/release/marketplace machinery, and retire the standalone repo. status: ongoing created: "2026-07-03T17:07:03-03:00" -updated: "2026-07-03T17:35:57-03:00" +updated: "2026-07-03T17:47:02-03:00" started_at: "2026-07-03T17:35:57-03:00" assignee: claude tags: [effect-kit, multi-plugin, marketplace, migration] @@ -46,12 +46,12 @@ Do the migration on a feature branch (`plan/effect-kit-migration`) and open a PR | # | Task | Files | Depends | Status | |---|---|---|---|---| -| 1 | Copy the payload: `cp -r ~/projects/effect-kit/plugins/effect-kit plugins/` (plain copy — DECIDED OQ1; history stays in the archived repo; payload = both plugin manifests + `skills/` incl. the node pair). Verify file count matches source (`find ... \| wc -l` both sides) | `plugins/effect-kit/` (new) | — | planned | -| 2 | Registry descriptor in `scripts/lib/plugins.mjs`: `{ name: 'effect-kit', root: 'plugins/effect-kit', skills: 'plugins/effect-kit/skills', agents: null, codex: true, selftest: null, rust: null, extraJson: [], transformGuard: false, install: '/plugin marketplace update docks\n/plugin install effect-kit@docks' }` | `scripts/lib/plugins.mjs` | 1 | planned | -| 3 | Marketplace entries: `.claude-plugin/marketplace.json` gains the effect-kit plugin entry (source `./plugins/effect-kit`, version matching both plugin.jsons — lockstep gate); `.agents/plugins/marketplace.json` gains the Codex entry (local source path + policy block, mirroring the session-relay entry shape via the `codex-plugin-mirror` project skill) | both catalogs | 2 | planned | -| 4 | Rewrite `plugins/effect-kit/skills/AGENTS.md`: (a) replace `bash scripts/skills/guard.sh plugins/effect-kit/skills` → `node scripts/ci.mjs --plugin effect-kit`; (b) append one line — `Full authoring contract (frontmatter, CSO, scoring, content-hash idempotency, durable-anchors grammar): see plugins/docks/skills/AGENTS.md`; (c) add NO `path:NN` live anchor (durable-anchors is repo-wide). Root `AGENTS.md`: add a `plugins/effect-kit/` block to the Repository-scope tree, and the row `\| plugins/effect-kit/skills/AGENTS.md \| effect-kit skill authoring — Effect 3.x version-pinned conventions \|` to the Context-tree table | `plugins/effect-kit/skills/AGENTS.md`, `AGENTS.md` | 1 | planned | -| 5 | Modularity checklist (maintainer requirement): `scripts/AGENTS.md` multi-plugin section gains the concrete "adding plugin N+1" checklist — descriptor fields → two catalog entries → optional context node → `--plugin` release; each item naming its gate | `scripts/AGENTS.md` | 2,3 | planned | -| 6 | Gates: `node scripts/skills/content-hash.mjs --check-only plugins/effect-kit/skills` (backfill if the old repo's hash algorithm drifted); full `node scripts/ci.mjs` exit 0; exercise the lockstep cue once for effect-kit (bump one manifest alone → `--plugin effect-kit` must fail; revert); commit | — | 1–5 | planned | +| 1 | Copy the payload: `cp -r ~/projects/effect-kit/plugins/effect-kit plugins/` (plain copy — DECIDED OQ1; history stays in the archived repo; payload = both plugin manifests + `skills/` incl. the node pair). Verify file count matches source (`find ... \| wc -l` both sides) | `plugins/effect-kit/` (new) | — | done | +| 2 | Registry descriptor in `scripts/lib/plugins.mjs`: `{ name: 'effect-kit', root: 'plugins/effect-kit', skills: 'plugins/effect-kit/skills', agents: null, codex: true, selftest: null, rust: null, extraJson: [], transformGuard: false, install: '/plugin marketplace update docks\n/plugin install effect-kit@docks' }` | `scripts/lib/plugins.mjs` | 1 | done | +| 3 | Marketplace entries: `.claude-plugin/marketplace.json` gains the effect-kit plugin entry (source `./plugins/effect-kit`, version matching both plugin.jsons — lockstep gate); `.agents/plugins/marketplace.json` gains the Codex entry (local source path + policy block, mirroring the session-relay entry shape via the `codex-plugin-mirror` project skill) | both catalogs | 2 | done | +| 4 | Rewrite `plugins/effect-kit/skills/AGENTS.md`: (a) replace `bash scripts/skills/guard.sh plugins/effect-kit/skills` → `node scripts/ci.mjs --plugin effect-kit`; (b) append one line — `Full authoring contract (frontmatter, CSO, scoring, content-hash idempotency, durable-anchors grammar): see plugins/docks/skills/AGENTS.md`; (c) add NO `path:NN` live anchor (durable-anchors is repo-wide). Root `AGENTS.md`: add a `plugins/effect-kit/` block to the Repository-scope tree, and the row `\| plugins/effect-kit/skills/AGENTS.md \| effect-kit skill authoring — Effect 3.x version-pinned conventions \|` to the Context-tree table | `plugins/effect-kit/skills/AGENTS.md`, `AGENTS.md` | 1 | done | +| 5 | Modularity checklist (maintainer requirement): `scripts/AGENTS.md` multi-plugin section gains the concrete "adding plugin N+1" checklist — descriptor fields → two catalog entries → optional context node → `--plugin` release; each item naming its gate | `scripts/AGENTS.md` | 2,3 | done | +| 6 | Gates: `node scripts/skills/content-hash.mjs --check-only plugins/effect-kit/skills` (backfill if the old repo's hash algorithm drifted); full `node scripts/ci.mjs` exit 0; exercise the lockstep cue once for effect-kit (bump one manifest alone → `--plugin effect-kit` must fail; revert); commit | — | 1–5 | done | | 7 | Release + retire (DECIDED OQ2/OQ3): after the PR merges, release `effect-kit--v0.2.0` (`node scripts/release.mjs --plugin effect-kit minor`, user-gated picker as always); then retire the old repo — final commit there rewriting its README to point at the docks marketplace (`/plugin marketplace add DocksDocks/docks` → `/plugin install effect-kit@docks`), then `gh repo archive DocksDocks/effect-kit --yes` (read-only, reversible; old tags/releases stay visible) | manifests; DocksDocks/effect-kit | 6 | planned | ## Interfaces & data shapes @@ -124,6 +124,12 @@ Do the migration on a feature branch (`plan/effect-kit-migration`) and open a PR - **OQ2 old repo fate → archive + pointer README** — final commit redirects to the docks marketplace, then `gh repo archive` (read-only, reversible; tags/releases stay visible). - **OQ3 first release → `effect-kit--v0.2.0`** — minor bump immediately after the migration PR merges, so consumers get the new marketplace coordinates. +## Notes — execution record (2026-07-03) + +- Steps 1–6 executed on branch `plan/effect-kit-migration`. File-count parity 19=19; hashes were already in sync (`--check-only`: 3× unchanged — same hasher lineage). +- **On-goal deviation:** docks' refs-guard TOC rule (added after the fork) failed two references >100 lines (`effect-ts-port/references/react.md`, `effect-ts-specialist/references/services-and-layers.md`) — added `## Contents` TOCs, bumped both skills' `metadata.updated`, re-backfilled hashes. Payload-parity diff therefore shows exactly: the AGENTS.md node (planned), 2 reference TOCs + 2 SKILL.md hash/updated bumps (this deviation). +- Cue exercises: lockstep probe fired on BOTH axes (`plugin.json=0.1.2 marketplace.json=0.1.1` AND `codex=0.1.1 claude=0.1.2`), reverted clean. Full `node scripts/ci.mjs` → "All ci.mjs checks passed — 3 plugin(s) + repo-wide". Scorer 16/14/16. + ## Self-review Score: 86→93/100 · trajectory 86→93 · stopped: plateau after applying the fresh-context draft review (big-plan tier). The reviewer independently re-verified the audit claims against the source repo (descriptor fields exact, trigger-collision per-plugin confirmed, dependency-field placement semantics confirmed) and caught 5 defects, all applied verbatim: D1 both catalog entries now verbatim in Interfaces (full field set matching repo convention; `author.name` "Eduardo Marquez" for catalog consistency); D2 the no-`dependencies`-in-catalog rule added as a gotcha; D3 step 4 rewritten paste-ready; D4 feature-branch + PR flow made explicit in Environment; D5 the source docs/plans correctly described as a legacy v1 five-folder layout. OQ1–3 were subsequently answered via the picker and encoded under `## Decisions` — no residual guesses remain. diff --git a/plugins/effect-kit/.claude-plugin/plugin.json b/plugins/effect-kit/.claude-plugin/plugin.json new file mode 100644 index 0000000..fae6137 --- /dev/null +++ b/plugins/effect-kit/.claude-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "effect-kit", + "description": "Cross-tool Effect-TS skill kit: repo setup, idiomatic Effect 3.x patterns, and Fastify/Next.js/React → Effect porting", + "version": "0.1.1", + "author": { + "name": "DocksDocks" + }, + "license": "MIT", + "skills": [ + "./skills/engineering" + ], + "dependencies": [ + { + "name": "docks", + "marketplace": "docks" + } + ] +} diff --git a/plugins/effect-kit/.codex-plugin/plugin.json b/plugins/effect-kit/.codex-plugin/plugin.json new file mode 100644 index 0000000..223074e --- /dev/null +++ b/plugins/effect-kit/.codex-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "effect-kit", + "version": "0.1.1", + "description": "Cross-tool Effect-TS skill kit: repo setup, idiomatic Effect 3.x patterns, and Fastify/Next.js/React → Effect porting", + "author": { + "name": "DocksDocks" + }, + "license": "MIT", + "skills": "./skills/", + "interface": { + "displayName": "effect-kit", + "shortDescription": "Cross-tool Effect-TS skill kit: repo setup, idiomatic Effect 3.x patterns, and Fastify/Next.js/React → Effect porting", + "category": "Productivity" + } +} diff --git a/plugins/effect-kit/skills/AGENTS.md b/plugins/effect-kit/skills/AGENTS.md new file mode 100644 index 0000000..eb05cbf --- /dev/null +++ b/plugins/effect-kit/skills/AGENTS.md @@ -0,0 +1,13 @@ +# Authoring skills (plugins/effect-kit/skills/) + +Skills are the cross-tool payload — each surfaces in Claude Code, Codex, and any agentskills.io runtime. Each skill is a directory `//SKILL.md` (+ optional `references/`). Sole category: `engineering/` (the Effect payload). Plan-lifecycle and authoring skills come from the sibling docks plugin — don't re-bundle them here. **Effect-only scope:** every skill in this plugin targets the Effect ecosystem (the `effect` package and official `@effect/*` / `@effect-atom/*` packages); non-Effect skills belong in docks or their own plugin. + +- Description starts "Use when …" (CSO); ≤500 chars for full scorer credit. +- `name` matches the parent directory; kebab-case. +- Body ≤500 lines (sweet spot 80–310) — every line loads on activation. +- Run `node scripts/ci.mjs --plugin effect-kit` before commit (repo-wide checks still run). +- After changing a skill's meaning, bump `metadata.updated` and re-sync the hash: `node scripts/skills/content-hash.mjs --backfill plugins/effect-kit/skills`. + +The Effect skills (`engineering/effect-ts-setup`, `engineering/effect-ts-port`, `engineering/effect-ts-specialist`) target **Effect 3.x stable**. Keep version-specific API claims (Schema in `effect/Schema`, `@effect/platform` HttpApi, `@effect-atom/atom-react`) grounded — verify against current docs before changing them. + +Use the `write-skill` skill (from the sibling docks plugin) to author new skills from scratch. Full authoring contract (frontmatter, CSO, scoring, content-hash idempotency, durable-anchors grammar): see `plugins/docks/skills/AGENTS.md`. diff --git a/plugins/effect-kit/skills/CLAUDE.md b/plugins/effect-kit/skills/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/plugins/effect-kit/skills/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/plugins/effect-kit/skills/engineering/effect-ts-port/SKILL.md b/plugins/effect-kit/skills/engineering/effect-ts-port/SKILL.md new file mode 100644 index 0000000..e011f60 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-port/SKILL.md @@ -0,0 +1,152 @@ +--- +name: effect-ts-port +description: "Use when porting an existing TypeScript codebase to Effect-TS (3.x) — Fastify routes, Next.js App Router handlers / server actions, or React components. Detects the framework, asks scope, writes a tiered incremental-migration plan to docs/plans/ (via plan-manager), then migrates one boundary at a time (`Effect.tryPromise` + `ManagedRuntime`), test-gated. Not for first-time Effect setup (use effect-ts-setup) or writing fresh Effect patterns (use effect-ts-specialist)." +user-invocable: true +metadata: + pattern: pipeline + updated: "2026-07-03" + content_hash: "bb722d7c03d2027c09f840fbfffdb9cef1b30d6efafa203880db03776f6d73d2" +--- + +# Effect-TS Port (cross-tool pipeline) + +Migrate an existing Fastify / Next.js / React codebase to Effect 3.x as one sequential pass: detect the framework, map the surface, agree the scope, write a tiered plan, gate on approval, then port one boundary at a time with tests as the ratchet. Single-agent and cross-tool — no slash command, no subagent dispatch, no Plan Mode. Framework specifics live in `references/`; this body is the orchestration. Pattern mirrors the `security` / `refactor` pipelines. + + +Single-agent sequential, gated on the plan lifecycle — NOT Plan Mode. Run the phases IN ORDER, in THIS context. Phases 0–3 are read-only analysis; the deliverable is a plan file under `docs/plans/`. Do NOT call `ExitPlanMode` (Claude-only) and do NOT edit source until the user approves via `start `. If `docs/plans/` is absent, run `plan-init` first; hand the written plan to `plan-manager` and tell the user "review and say `start ` to migrate." Append each phase's output to the plan file under its exact heading so a mid-run compaction resumes by re-reading the file. + + + +Boundary-first, incremental — never a big-bang rewrite. Wrap existing Promise/throwing code with `Effect.tryPromise({ try, catch })`, run it through a single `ManagedRuntime` at the framework edge, and migrate the highest-value slice first, expanding outward (strangler-fig). Port ONE slice at a time: change it, run the type-checker + tests, and on failure REVERT immediately (`git restore`) and log `REVERTED: ` — do not try to "fix forward". The app stays green and shippable after every slice. + + + +Don't guess Effect APIs — defer to the `effect-ts-specialist` skill and verify against current docs (context7 `effect`, or `bunx effect-solutions@latest show `). Target **Effect 3.x stable**: `Schema` is `effect/Schema`; for HTTP prefer **`@effect/platform` HttpApi** (NOT the deprecated `effect-http`); for React use **`@effect-atom/atom-react`** (the renamed successor to `@effect-rx/rx-react`). A wrong API in a migration is worse than asking. + + +## When to use + +- A service or app on Fastify / Next.js App Router / React that you want on Effect, incrementally. +- You want a reviewable, tiered migration plan before any code changes — and tests guarding every slice. + +## When NOT to use + +| Situation | Use instead | +|---|---| +| Effect not installed / no tsconfig yet | `effect-ts-setup` (this pipeline runs its detection as Phase 0) | +| Writing new Effect code (no migration) | `effect-ts-specialist` | +| Generic dead-code / SOLID cleanup | `refactor` | +| Security review | `security` | + +## Pipeline + +Run in order. Each phase reads its reference (where listed), then writes output to the plan file under the exact heading (the resume anchor — keep it verbatim). + +| # | Phase | Reference | Output heading | +|---|---|---|---| +| 0 | Detection (framework, package manager, Effect present?) | — (inline) | `## Phase 0: Detection` | +| 1 | Surface map (entry points, async edges, shared deps) | `references/boundary-strategy.md` | `## Phase 1: Surface Map` | +| 2 | Scope interview (which surfaces, depth, pilot) | — (inline, ask → STOP) | `## Phase 2: Scope` | +| 3 | Migration plan (tiered slices + test strategy) | framework reference(s) | `## Phase 3: Migration Plan` | +| — | **GATE** — write plan to `docs/plans/`, await `start ` | — | — | +| 4 | Implementation (one slice at a time, boundary-first) | framework reference(s) | `## Phase 4: Implementation Log` | +| 5 | Verification (type-check, tests, no scope bleed) | — (inline) | `## Phase 5: Verification` | + +## How to run each phase + +1. Anchor the date once (`date "+%Y-%m-%d"`); record scope (a path arg, or the whole project). +2. Create/open the plan file (below). Run Phases 0→3, writing each under its heading; confirm the prior heading landed before the next. A phase with nothing to report writes "none" — never silently skip. +3. At the GATE, hand off (below). Resume at Phase 4 only after `start `. + +## The plan file (IPC + deliverable) + +```text +docs/plans/planned/-effect-port-.md (preferred — tracked by plan-manager) +docs/effect-port-.md (fallback when docs/plans/ is absent) +``` + +Write as you go — never hold all phase output in context and dump at the end. The plan's `## Steps` table is the slice list; `## Mistakes & Dead Ends` records every `REVERTED:` slice so a resumed run skips known dead ends. + +## Phase 0 — Detection (inline) + +```bash +ls package.json tsconfig.json pnpm-lock.yaml bun.lock package-lock.json 2>/dev/null +``` + +Identify the framework(s) and whether Effect is already present (`grep '"effect"' package.json`). If Effect is absent, run **`effect-ts-setup`** first (deps + tsconfig + language service), then return. Record framework, package manager, and Effect presence under `## Phase 0: Detection`. + +| Signal | Framework | Primary reference | +|---|---|---| +| `fastify` in deps, `*.route.ts`, `fastify()` | Fastify | `references/fastify.md` | +| `next` in deps, `app/**/route.ts`, `"use server"` | Next.js App Router | `references/nextjs.md` | +| `react`/`react-dom`, `.tsx` components, hooks | React | `references/react.md` | + +## Phase 1 — Surface map + +Read `references/boundary-strategy.md`. Enumerate the edges where async/impure work happens — route handlers, server actions, data loaders, React event handlers/effects, external API/DB calls. For each, note the current error handling and what it depends on. This is the candidate slice list. Pick the **run boundary** (one `ManagedRuntime`) and the first pilot slice (highest value, lowest blast radius). + +## Phase 2 — Scope interview (ask → STOP) + +Ask the user, then STOP (end the turn; on Claude, `AskUserQuestion` may collect these): + +1. **Which surfaces** to port now — all detected, one framework, or a single route group / component tree? +2. **Depth** — *wrap* (keep the framework, run Effect inside handlers) or *replace* (e.g. Fastify routes → `@effect/platform` HttpApi)? +3. **Pilot** — start with one slice end-to-end, or convert a whole module? +4. **Constraints** — must tests stay green throughout (default yes)? deploy target (serverless/edge changes the `ManagedRuntime` lifecycle — see the references)? + +Record answers under `## Phase 2: Scope`. Do not proceed to the plan until the user replies. + +## Phase 3 — Migration plan → GATE + +Read the relevant framework reference(s). Write `## Phase 3: Migration Plan` and populate the plan's `## Steps` table with ordered slices (each: file:line, wrap-or-replace, the Effect shape it becomes, test command, risk). Tier them: **(1)** shared boundary (the `ManagedRuntime` + base layers), **(2)** leaf slices (one handler/component), **(3)** structural (replace a router, lift state to atoms). Then STOP: "Migration plan written to ``; review and say `start ` to begin." Approval flows through the plan lifecycle — never `ExitPlanMode`. + +## Phase 4 — Implementation (after `start `) + +1. Establish a baseline: run the type-checker + test suite. Note any pre-existing failures. +2. Build the **shared boundary first** (Tier 1): the `ManagedRuntime` from your `MainLive` layer, and the base services. Verify it compiles before touching any handler. +3. For each slice in tier order: read the framework reference, apply the boundary pattern, then run the type-checker + tests. On green, log `APPLIED: `; on failure, `git restore` the slice and log `REVERTED: ` in `## Mistakes & Dead Ends`, then continue. ONE slice per test cycle — never batch. +4. Keep ephemeral UI-local state in `useState`; lift shared/async/server state into Effect/atoms (React). Wrap, don't rewrite, until a slice is fully green. + +## Phase 5 — Verification (inline) + +Write `## Phase 5: Verification`: type-check clean, tests green (vs the Phase 4 baseline), and a scope check — every changed file must trace to a planned slice (`git diff --name-only` ⊆ the plan's `affected_paths`). An out-of-scope change ⇒ `git restore` it. Report slices applied vs reverted, and any follow-up slices deferred to a new plan. + +## Framework references + +| Read for | File | +|---|---| +| Incremental strategy, the run boundary, what to port first, `Effect.tryPromise` | `references/boundary-strategy.md` | +| Fastify handlers (wrap) and `@effect/platform` HttpApi (replace) | `references/fastify.md` | +| Next.js App Router route handlers, server actions, module-scope runtime | `references/nextjs.md` | +| React via `@effect-atom/atom-react` (atoms, `Result`, `Atom.runtime`) | `references/react.md` | + +## Boundary pattern — the mistake that breaks ports + +```ts +// BAD — a fresh runtime per request: every layer (pools, clients) is rebuilt and leaked +export async function GET(_req: Request, { params }: { params: { id: string } }) { + return Response.json(await Effect.runPromise(getUser(params.id).pipe(Effect.provide(MainLive)))) +} +// GOOD — one module-scope ManagedRuntime; handlers run through it and stay R-free +import { runtime } from "@/lib/runtime" // ManagedRuntime.make(MainLive), built once +export async function GET(_req: Request, { params }: { params: { id: string } }) { + return Response.json(await runtime.runPromise(getUser(params.id))) +} +``` + +## Gotchas + +| Gotcha | Consequence | Right move | +|---|---|---| +| Big-bang rewrite of all handlers at once | Can't tell which slice broke; app un-shippable | One slice → type-check + test → keep/revert | +| `Effect.runPromise` inside every handler (new runtime each call) | Layers rebuilt per request; pools leak | One module-scope `ManagedRuntime`; `runtime.runPromise` per call | +| Targeting `effect-http` for HTTP | Deprecated since 2024 | `@effect/platform` HttpApi | +| Using `@effect-rx/rx-react` for React | Renamed/superseded | `@effect-atom/atom-react` | +| Editing code during Phases 0–3 | Breaks the read-only-then-approve gate | Analysis only until `start ` | +| Module-scope runtime on edge/serverless without a caveat | Cold-start surprises | Note the deploy target in Phase 2; see the references | +| `docs/plans/` assumed to exist in a consumer repo | Plan write lands nowhere | Check first; `plan-init`, or use the fallback path | + +## When this skill does NOT apply + +- Effect isn't set up yet — run **`effect-ts-setup`** (Phase 0 will send you there). +- You're authoring new Effect code, not migrating — use **`effect-ts-specialist`**. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-port/references/boundary-strategy.md b/plugins/effect-kit/skills/engineering/effect-ts-port/references/boundary-strategy.md new file mode 100644 index 0000000..9153213 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-port/references/boundary-strategy.md @@ -0,0 +1,64 @@ +# Boundary Strategy — incremental adoption + +You don't rewrite an app into Effect; you grow Effect outward from the edges. The whole strategy is three ideas: **wrap impure code at the boundary**, **run through one runtime**, and **migrate one slice at a time** so the app is green after every step (strangler-fig). + +## 1. The run boundary — one `ManagedRuntime` + +Build a single runtime from your composed layer at module scope, and run every Effect through it at each framework entry point. This is the seam every framework reference plugs into. + +```ts +// lib/runtime.ts — built once, reused +import { ManagedRuntime } from "effect" +import { MainLive } from "./layers" // Layer composing your services (Db, Config, etc.) + +export const runtime = ManagedRuntime.make(MainLive) +// on shutdown / HMR teardown: await runtime.dispose() +``` + +`runtime.runPromise(effect)` / `runtime.runPromiseExit(effect)` run an effect with every service in `MainLive` already provided — so handlers and components stay `R`-free. NEVER call `Effect.runPromise` (a fresh runtime) per request: it rebuilds layers and leaks pooled resources. + +## 2. Wrap impure code — `Effect.tryPromise` + +Existing Promise/throwing code becomes an Effect at the boundary, with the failure typed: + +```ts +import { Effect, Data } from "effect" +class DbError extends Data.TaggedError("DbError")<{ cause: unknown }> {} + +// existing: async function getUser(id) { return db.user(id) } +const getUser = (id: string) => + Effect.tryPromise({ try: () => db.user(id), catch: (cause) => new DbError({ cause }) }) +``` + +Start by wrapping the leaf calls (DB, HTTP, fs), then compose them with `Effect.gen`. You don't have to convert the whole call tree at once — an Effect that calls a still-Promise function via `tryPromise` is perfectly valid. + +## 3. What to port first + +| Order | Target | Why | +|---|---|---| +| 1 | The shared boundary: `ManagedRuntime` + base layers (Config, Db, Logger) | Everything else depends on it; get it compiling alone | +| 2 | One leaf slice end-to-end (a single handler/loader + its services) | Proves the pattern with minimal blast radius — the pilot | +| 3 | Remaining leaf slices, one at a time | Each is independent; tests guard each | +| 4 | Structural moves (replace a router with HttpApi, lift state to atoms) | Higher risk; do after the leaves are stable | + +Prefer the highest-complexity/highest-value leaf as the pilot — that's where Effect's error/dependency typing pays off first. + +## 4. Stay green + +- One slice per test cycle: change → type-check → test → keep or `git restore`. +- Map typed errors to the framework's response at the boundary (status code, error shape) — see each framework reference. +- Keep ephemeral UI/local state as-is; only lift shared/async/server state into Effect (or atoms in React). +- A slice that fights you for more than one revert is a sign the boundary is wrong — re-map it in the plan rather than forcing it. + +## 5. Map errors at the edge + +```ts +import { Cause, Exit } from "effect" +const exit = await runtime.runPromiseExit(getUser(id)) +if (Exit.isSuccess(exit)) return ok(exit.value) +// inspect the typed failure to choose a status: +const failure = Cause.failureOption(exit.cause) // Option +// match on failure.value._tag → 404 / 400 / 503 ; defects (Cause.isDie) → 500 + log +``` + +This `runPromiseExit` + `Cause`/`Exit` inspection is the portable error-mapping idiom reused by the Fastify and Next.js references. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-port/references/fastify.md b/plugins/effect-kit/skills/engineering/effect-ts-port/references/fastify.md new file mode 100644 index 0000000..a026868 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-port/references/fastify.md @@ -0,0 +1,80 @@ +# Fastify → Effect + +Two tracks. **Wrap** keeps Fastify and runs Effects inside handlers — the low-risk incremental default. **Replace** swaps Fastify for `@effect/platform` HttpApi — more work, but you gain a typed client + OpenAPI + Schema validation for free. Most migrations do Wrap first and Replace later (per route group). + +## Track A — Wrap (keep Fastify, run Effect inside) + +```ts +import Fastify from "fastify" +import { Cause, Exit } from "effect" +import { runtime } from "./lib/runtime" // one ManagedRuntime (see boundary-strategy.md) + +const app = Fastify() + +app.get("/users/:id", async (req, reply) => { + const exit = await runtime.runPromiseExit(getUser((req.params as { id: string }).id)) + if (Exit.isSuccess(exit)) return reply.send(exit.value) + + const failure = Cause.failureOption(exit.cause) // Option + if (failure._tag === "Some") { + switch (failure.value._tag) { + case "UserNotFound": return reply.code(404).send({ error: "not found" }) + case "DbError": return reply.code(503).send({ error: "unavailable" }) + } + } + reply.code(500).send({ error: "internal" }) // defect + return reply +}) + +app.addHook("onClose", async () => { await runtime.dispose() }) +``` + +One slice = one route. Validate the request body with Schema before handing it to the effect: + +```ts +import { Schema } from "effect" +const CreateUser = Schema.Struct({ name: Schema.String, email: Schema.String }) +app.post("/users", async (req, reply) => { + const exit = await runtime.runPromiseExit( + Schema.decodeUnknown(CreateUser)(req.body).pipe(Effect.flatMap(createUser)) + ) + // ParseError → 400; map as above +}) +``` + +A small `respond(reply, exit)` helper centralizes the `Exit`/`Cause` → status mapping so each route stays a one-liner. + +## Track B — Replace (Fastify → @effect/platform HttpApi) + +`@effect/platform` HttpApi declares the API once (endpoints + Schema), then gives you a server implementation, a type-safe client, and an OpenAPI doc. Shape: + +```ts +import { HttpApi, HttpApiGroup, HttpApiEndpoint, HttpApiBuilder } from "@effect/platform" +import { Schema } from "effect" + +// 1. declare the spec (endpoints carry Schema for path/body/success/error) +const UsersApi = HttpApi.make("api").add( + HttpApiGroup.make("users") + .add(HttpApiEndpoint.get("getUser", "/users/:id").addSuccess(User)) + .add(HttpApiEndpoint.post("createUser", "/users").setPayload(CreateUser).addSuccess(User)), +) + +// 2. implement each group with HttpApiBuilder — handlers are Effects that use your services +const UsersLive = HttpApiBuilder.group(UsersApi, "users", (handlers) => + handlers + .handle("getUser", ({ path }) => getUser(path.id)) + .handle("createUser", ({ payload }) => createUser(payload)), +) + +// 3. serve (Node): HttpApiBuilder.api(UsersApi) + a platform HttpServer layer +``` + +> The exact HttpApi DSL (method chains, `HttpApiSchema.param`, error mapping) moves between `@effect/platform` minors — verify the current shape via context7 (`@effect/platform`) or the docs before writing it. Tagged errors added with `.addError(...)` map to HTTP status codes automatically. + +`effect-http` (sukovanej) was the precursor and is **deprecated** (2024) in favor of HttpApi — don't target it. Migration of names: `Api`→`HttpApi`, `ApiGroup`→`HttpApiGroup`, `ApiEndpoint`→`HttpApiEndpoint`, `RouterBuilder`→`HttpApiBuilder`. + +## Choosing per route group + +- Hot path you can't risk → **Wrap**, ship, move on. +- A route group you're already reworking, or one that needs a typed client/OpenAPI → **Replace** with HttpApi. +- You can mix: Fastify mounts the platform web handler for some groups while others stay native during the transition. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-port/references/nextjs.md b/plugins/effect-kit/skills/engineering/effect-ts-port/references/nextjs.md new file mode 100644 index 0000000..9991943 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-port/references/nextjs.md @@ -0,0 +1,81 @@ +# Next.js App Router → Effect + +The App Router calls your code (route handlers, server actions) — you don't own the entry point, so the `ManagedRuntime`-at-module-scope pattern is exactly right. Two integration depths: run effects *inside* a handler (simple), or hand a whole `HttpApp` to a web handler (full platform). + +## The shared runtime (once) + +```ts +// lib/runtime.ts +import { ManagedRuntime } from "effect" +import { MainLive } from "./layers" +export const runtime = ManagedRuntime.make(MainLive) +``` + +Import this from every route/action. It's reused across invocations within a warm server instance. + +## Route handler — run inside (simple) + +```ts +// app/api/users/[id]/route.ts +import { Cause, Exit } from "effect" +import { runtime } from "@/lib/runtime" + +export async function GET(_req: Request, { params }: { params: { id: string } }) { + const exit = await runtime.runPromiseExit(getUser(params.id)) + if (Exit.isSuccess(exit)) return Response.json(exit.value) + const f = Cause.failureOption(exit.cause) + const status = f._tag === "Some" && f.value._tag === "UserNotFound" ? 404 : 500 + return Response.json({ error: f._tag === "Some" ? f.value._tag : "internal" }, { status }) +} +``` + +Validate the body with Schema before the effect runs: + +```ts +import { Schema } from "effect" +export async function POST(req: Request) { + const body = await req.json() + const exit = await runtime.runPromiseExit( + Schema.decodeUnknown(CreateUser)(body).pipe(Effect.flatMap(createUser)), + ) + // ParseError → 400 ; success → 201 +} +``` + +## Route handler — web handler (full platform) + +When the route is a whole `HttpApp` (built from `@effect/platform` `HttpApiBuilder` or `HttpRouter`), convert it to a runtime-bound web handler: + +```ts +import { HttpApp } from "@effect/platform" +import { runtime } from "@/lib/runtime" + +const handler = HttpApp.toWebHandlerRuntime(runtime)(httpApp) // (req: Request) => Promise +export const POST = (req: Request) => handler(req) +// inside httpApp: HttpServerRequest.schemaBodyJson(CreateUser) parses+validates the body, +// HttpServerResponse.json(...) responds; tagged errors map to status automatically. +``` + +## Server actions + +```ts +"use server" +import { runtime } from "@/lib/runtime" + +export async function createUserAction(form: FormData) { + return runtime.runPromise(handleCreateUser(form)) // throws → Next surfaces it; or runPromiseExit to handle +} +``` + +Same runtime, same pattern — the action body is an Effect run through `runtime`. + +## Caveats (state in Phase 2 scope) + +- **Serverless / cold starts**: the module-scope runtime is reused within a warm lambda but rebuilt on cold start. Keep `MainLive` construction cheap; lazy-init expensive resources inside services. +- **Edge runtime**: not all Node platform layers run on the edge — use `@effect/platform` (web) layers, not `@effect/platform-node`, for edge routes. +- **Dev HMR**: a module-scope runtime can survive HMR and leak; guard with a `globalThis` singleton in dev, and `runtime.dispose()` on teardown. +- **RPC alternative**: `@effect/rpc` can replace a tRPC layer in the App Router (a typed effectful RPC client) — a Track-B-style move, plan it as a structural slice. + +## Slicing + +One route file or one server action = one slice. Build `lib/runtime.ts` + `MainLive` first (Tier 1), then convert leaf routes one at a time, each guarded by its test (or a request smoke test). diff --git a/plugins/effect-kit/skills/engineering/effect-ts-port/references/react.md b/plugins/effect-kit/skills/engineering/effect-ts-port/references/react.md new file mode 100644 index 0000000..602ea35 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-port/references/react.md @@ -0,0 +1,123 @@ +# React → Effect (effect-atom) + +The React binding is **`@effect-atom/atom-react`** (`tim-smart/effect-atom`, MIT — the successor to `@effect-rx/rx-react`). Atoms are fine-grained reactive cells with first-class Effect integration: an Effect-backed atom surfaces a `Result` (Initial / Success / Failure), so loading and error states are modeled, not improvised. + +## Contents + +- [Install + (optional) provider](#install--optional-provider) +- [Basic state + hooks](#basic-state--hooks) +- [Server/async state — Effect-backed atoms + `Result`](#serverasync-state--effect-backed-atoms--result) +- [Services & layers in components — `Atom.runtime`](#services--layers-in-components--atomruntime) +- [Actions / mutations — `Atom.fn`](#actions--mutations--atomfn) +- [Migration map](#migration-map) +- [Slicing](#slicing) + +## Install + (optional) provider + +```bash +pnpm add @effect-atom/atom-react # peers: effect ^3.19, react >=18 <20 +``` + +No provider is required — hooks fall back to a default registry. Wrap with `RegistryProvider` only for SSR initial values or custom GC config: + +```tsx +import { RegistryProvider } from "@effect-atom/atom-react" +{children} +``` + +`atom-react` re-exports `Atom`, `Result`, `Registry` — import everything from it. + +## Basic state + hooks + +```tsx +import { Atom, useAtom, useAtomValue, useAtomSet } from "@effect-atom/atom-react" + +const countAtom = Atom.make(0) + +function Counter() { + const [count, setCount] = useAtom(countAtom) // [value, setter]; setCount(c => c + 1) + // or split: const count = useAtomValue(countAtom); const setCount = useAtomSet(countAtom) +} +``` + +Derived/computed atoms take a getter; they recompute when any `get(...)` dependency changes: + +```tsx +const doubledAtom = Atom.make((get) => get(countAtom) * 2) +const label = Atom.map(countAtom, (n) => `Count: ${n}`) +``` + +## Server/async state — Effect-backed atoms + `Result` + +```tsx +import { Atom, Result, useAtomValue } from "@effect-atom/atom-react" +import { Effect, Schema } from "effect" + +const userAtom = Atom.make((get) => + Effect.gen(function* () { + const id = get(userIdAtom) + const res = yield* Effect.tryPromise(() => fetch(`/api/users/${id}`)) + return yield* Schema.decodeUnknown(User)(yield* Effect.tryPromise(() => res.json())) + }), +) // value type is Result; re-runs automatically when userIdAtom changes + +function UserCard() { + const result = useAtomValue(userAtom) + return Result.match(result, { + onInitial: () => , + onFailure: (f) => , + onSuccess: (s) =>
{s.value.name}
, + }) +} +``` + +`success.waiting` is the stale-while-revalidate flag (a refresh is in flight but the old value still shows). There's also a fluent `Result.builder(result).onInitial(...).onErrorTag("NotFound", ...).onSuccess(...).render()`. + +## Services & layers in components — `Atom.runtime` + +```tsx +import { Atom } from "@effect-atom/atom-react" +import { Effect } from "effect" + +const runtimeAtom = Atom.runtime(Users.Default) // bridge an Effect Layer into atoms +const usersAtom = runtimeAtom.atom( + Effect.gen(function* () { return yield* (yield* Users).getAll }), +) // Atom> +// global layers once: Atom.runtime.addGlobalLayer(LoggerLive) +``` + +## Actions / mutations — `Atom.fn` + +```tsx +import { Atom, useAtomSet } from "@effect-atom/atom-react" +const createUserAtom = runtimeAtom.fn( + Effect.fnUntraced(function* (name: string) { return yield* (yield* Users).create(name) }), +) +function NewUser() { + const create = useAtomSet(createUserAtom, { mode: "promiseExit" }) + const onSubmit = async (name: string) => { + const exit = await create(name) // Promise> + if (exit._tag === "Success") { /* ... */ } + } +} +``` + +Keyed atoms: `const todoAtom = Atom.family((id: string) => runtimeAtom.atom(getTodo(id)))` → `useAtomValue(todoAtom(id))`. + +## Migration map + +| Today | With effect-atom | +|---|---| +| `useState` for **local/ephemeral UI** (form input, toggles) | **Keep it** — atoms are for shared/async/server state | +| `useState`/`useReducer` for **shared** state | `Atom.make(value)` + `useAtom`/`useAtomValue`/`useAtomSet` | +| Selectors / `useMemo` over store | derived `Atom.make((get) => ...)` / `Atom.map` | +| React Query / SWR query | `runtimeAtom.atom(effect).pipe(Atom.withReactivity(keys))`; `Result` replaces `{isLoading,isError,data}` | +| React Query mutation + `invalidateQueries` | `runtimeAtom.fn(effect, { reactivityKeys })` — completing it refreshes matching query atoms | +| Optimistic update boilerplate | `Atom.optimistic` / `Atom.optimisticFn` (auto-rollback on failure) | +| Zustand / Jotai store | atoms (same mental model; `useAtom` trio mirrors Jotai) | + +## Slicing + +Lift one piece of shared/server state into an atom at a time, leaving `useState` for local concerns. A component reading an Effect-backed atom doesn't need a `ManagedRuntime` in the tree — `Atom.runtime(Layer)` carries the services. One atom (+ its consumers) per slice; the component renders the three `Result` states explicitly. + +> effect-atom is pre-1.0 (0.5.x) — the API is settling. Verify hook/combinator names against the current docs (atom.kitlangton.com examples, or `tim-smart/effect-atom`) before relying on a less-common one. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-setup/SKILL.md b/plugins/effect-kit/skills/engineering/effect-ts-setup/SKILL.md new file mode 100644 index 0000000..41c0602 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-setup/SKILL.md @@ -0,0 +1,158 @@ +--- +name: effect-ts-setup +description: "Use when bootstrapping Effect-TS in a repo — detect the package manager (pnpm/npm/bun), install `effect` (+ `@effect/platform`/`@effect/cli` by project type), wire the `@effect/language-service` tsconfig plugin + `prepare` patch, apply the recommended tsconfig, add a `typecheck` script, and write an Effect best-practices block into AGENTS.md/CLAUDE.md. Effect 3.x. Not for porting Fastify/Next/React code (use effect-ts-port) or writing Effect patterns (use effect-ts-specialist)." +user-invocable: true +metadata: + pattern: tool-wrapper + updated: "2026-06-03" + content_hash: "533b5b4341d872fca01fb6ea127c98b46398b07e6666fc8d0368565eb3d24694" +--- + +# Effect-TS Setup (one-time repo bootstrap) + +Configure a repository to work well with Effect: the right dependencies, the language-service diagnostics, a strict-enough tsconfig, a type-check script, and an agent-instruction block so future AI sessions know the conventions. Modeled on Kit Langton's effect.solutions agent-guided setup, but self-contained — it needs no external CLI (and opportunistically uses `effect-solutions` when present). + + +Gate every mutation behind explicit confirmation. Before initializing a project, installing packages, editing `tsconfig.json`, editing `package.json` scripts, writing into `AGENTS.md`/`CLAUDE.md`, or cloning a source tree — print the exact change as your FINAL message and STOP. Do not call `Write`/`Edit` or run the install/clone command until the user replies. (On Claude, `AskUserQuestion` may collect the package-manager / project-type choices; the STOP gate before each write still applies.) This is the only portable pause — "STOP and await" without ending the turn gets bypassed. + + + +Target **Effect 3.x stable**. Install with NO version pin (let the package manager take latest 3.x). `Schema` lives in **`effect/Schema`** — NEVER install `@effect/schema` (deprecated, folded into core in 3.10). The source-reference clone (Step 7) is **`Effect-TS/effect`** (the v3 monorepo), not `effect-smol` (that's the v4 beta). + + + +Detect before you write — never clobber. Read existing `tsconfig.json`, `package.json` scripts, and agent files first; MERGE recommended settings into what's there rather than overwriting, and keep a `typecheck` script the repo already defines. Write the agent block only between the `` / `` markers (replace in place if they exist) so re-running is idempotent. + + +## Checklist (show once at the start) + +```text +- [ ] Detect repo state + package manager +- [ ] Install Effect dependencies +- [ ] Wire @effect/language-service +- [ ] Apply tsconfig settings +- [ ] Add typecheck script +- [ ] Write agent-instruction block +- [ ] (optional) Clone Effect source reference +- [ ] Summary +``` + +## Step 1 — Detect (read-only) + +```bash +ls -la package.json tsconfig.json bun.lock pnpm-lock.yaml package-lock.json yarn.lock .vscode AGENTS.md CLAUDE.md .claude .cursorrules 2>/dev/null +file AGENTS.md CLAUDE.md 2>/dev/null | grep -i link # detect symlinks +``` + +Resolve the package manager from the lock file, then confirm: + +| Lock file | Package manager | +|---|---| +| `pnpm-lock.yaml` | pnpm | +| `bun.lock` | bun | +| `package-lock.json` | npm | +| `yarn.lock` | yarn | +| multiple | ASK which to use | +| none | ASK preference (default pnpm); `package.json` absent → offer ` init` first | + +Infer project type from deps/files (drives Step 2): a CLI (bin entry), an HTTP server/client (fastify/express/next/fetch usage), a React app, or a plain library. + +## Step 2 — Install dependencies (gate) + +| Project type | Packages (no version pin) | +|---|---| +| Always | `effect` | +| CLI app | `+ @effect/cli @effect/platform-node` | +| HTTP server/client | `+ @effect/platform` (+ `@effect/platform-node` on Node) | +| React app | `+ @effect-atom/atom-react` | +| Tests | `-D @effect/vitest vitest` | + +```bash +# example (pnpm) — confirm before running: +pnpm add effect @effect/platform +``` + +Never add `@effect/schema`. Print the exact command, STOP, then run on confirmation. + +## Step 3 — Language service (gate) + +`@effect/language-service` adds edit-time + build-time Effect diagnostics (floating effects, missing context, anti-patterns). Install it, register the tsconfig plugin, add the `prepare` patch, and set the editor to the workspace TypeScript. Full steps + the diagnostics catalog: `references/language-service.md`. + +## Step 4 — tsconfig (gate) + +Compare the repo's `tsconfig.json` to the recommended strict baseline and MERGE (don't overwrite). The exact `compilerOptions`, the "bundler vs `tsc`" rule of thumb, and the VS Code/Cursor settings: `references/tsconfig.md`. + +## Step 5 — Package scripts (gate) + +If no type-check script exists, add one (keep an existing one): + +```jsonc +// simple project: +"typecheck": "tsc --noEmit" +// monorepo with project references: +"typecheck": "tsc --build --noEmit" +``` + +## Step 6 — Agent-instruction block (gate) + +Write this managed block so future agents follow the conventions. Insert between the markers (replace in place if present — idempotent): + +```markdown + +## Effect Best Practices + +Target **Effect 3.x stable**. Before writing Effect code, consult the `effect-ts-specialist` skill — services & layers, tagged errors, `effect/Schema`, `Config`, `ManagedRuntime`, `@effect/vitest`. `Schema` is `effect/Schema`, never `@effect/schema`. + +Deeper references when available: `bunx effect-solutions@latest show ` (Bun CLI), context7 (`effect`), or a cloned `Effect-TS/effect` tree. Never guess an Effect API — verify first. + +``` + +Placement by file state: + +| State | Action | +|---|---| +| Both `AGENTS.md` + `CLAUDE.md` exist, not symlinked | Write the block into both | +| One exists | Write into it; optionally create the other as a symlink/`@AGENTS.md` shim | +| One is a symlink of the other | Write the real file only | +| Neither | Create `AGENTS.md` with the block; add `CLAUDE.md` = `@AGENTS.md` | + +## Step 7 — Effect source reference (optional, gate) + +For grep-able ground truth on Effect 3.x APIs, offer a shallow clone of the **v3** monorepo to a shared path: + +```bash +git clone --depth 1 https://github.com/Effect-TS/effect.git ~/.local/share/effect-kit/effect +# update later: git -C ~/.local/share/effect-kit/effect pull --depth 1 +``` + +Then add a one-line `## Local Effect Source` note pointing at that path. Optional — skip if the user declines. + +## Step 8 — Summary + +Report: package manager, steps completed vs skipped (with reasons), files created/modified, any errors + how resolved. Offer to continue with `effect-ts-specialist` (patterns) or `effect-ts-port` (migrate existing code). + +> Large monorepo / multi-package setup? This bootstrap is single-session and mechanical — but if the work spans many packages, offer to write a tracked plan via **`plan-manager`** instead of doing it all inline. + +## Gotchas + +| Gotcha | Consequence | Right move | +|---|---|---| +| Installing `@effect/schema` | Deprecated package, wrong types | `effect/Schema` ships in core | +| Pinning an Effect version | Drifts from latest 3.x, peer-dep friction | Install unpinned; let the PM resolve | +| Overwriting an existing `tsconfig.json` | Wipes the user's settings | Read first; merge recommended keys | +| `tsc` ignores the LSP plugin at build time | No build-time Effect diagnostics | Run `effect-language-service patch` in a `prepare` script | +| Editor uses the bundled TS, not the workspace | Plugin diagnostics never show | Set `typescript.tsdk` + select workspace version | +| Appending the agent block twice on re-run | Duplicated section | Replace between the `` markers | +| Cloning `effect-smol` for a v3 project | v4 APIs mislead the agent | Clone `Effect-TS/effect` for v3 ground truth | + +## References + +| Read for | File | +|---|---| +| `@effect/language-service` install, plugin config, build patch, diagnostics | `references/language-service.md` | +| Recommended `compilerOptions`, bundler-vs-tsc rule, editor settings | `references/tsconfig.md` | + +## When this skill does NOT apply + +- The repo already uses Effect and you're writing code — use **`effect-ts-specialist`**. +- Migrating an existing Fastify/Next/React app — use **`effect-ts-port`** (it runs setup detection as its phase 0). diff --git a/plugins/effect-kit/skills/engineering/effect-ts-setup/references/language-service.md b/plugins/effect-kit/skills/engineering/effect-ts-setup/references/language-service.md new file mode 100644 index 0000000..b71ed27 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-setup/references/language-service.md @@ -0,0 +1,70 @@ +# @effect/language-service + +A TypeScript language-service plugin that adds Effect-aware diagnostics: it catches floating effects, missing requirements/errors in the type, and a long list of anti-patterns — at edit time in the editor, and (via a patch) at build time under `tsc`. + +## 1. Install (dev dependency) + +```bash +pnpm add -D @effect/language-service # or npm i -D / bun add -d +``` + +## 2. Register the tsconfig plugin + +```jsonc +{ + "$schema": "https://raw.githubusercontent.com/Effect-TS/language-service/refs/heads/main/schema.json", + "compilerOptions": { + "plugins": [{ "name": "@effect/language-service" }] + } +} +``` + +The plugin accepts options (all optional): `diagnostics`, `diagnosticSeverity`, `refactors`, `quickinfo`, `completions`, `goto`, `inlays`, `barrelImportPackages`, `namespaceImportPackages`. Start with the bare `{ "name": "@effect/language-service" }` and tune later. + +## 3. Point the editor at the workspace TypeScript + +A TS language-service plugin only runs under the workspace TypeScript, not VS Code's bundled copy. + +```json +// .vscode/settings.json +{ + "typescript.tsdk": "./node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} +``` + +Then: Command Palette → "TypeScript: Select TypeScript Version" → "Use Workspace Version". (Cursor is identical; JetBrains/NVim-vtsls/Emacs have their own workspace-TS settings.) + +## 4. Build-time diagnostics (patch `tsc`) + +`tsc` does not load language-service plugins, so the package patches the local `typescript`/`tsc` to emit Effect diagnostics even with `noEmit`/composite builds. Make it persistent with a `prepare` script: + +```bash +pnpm exec effect-language-service patch # one-off +``` + +```json +// package.json +{ "scripts": { "prepare": "effect-language-service patch" } } +``` + +CLI verbs: `setup`, `config`, `patch`, `unpatch`, `check`, `diagnostics`, `quickfixes`, `codegen`, `overview`, `layerinfo`. + +## 5. Per-line control + +```ts +// @effect-diagnostics effect/floatingEffect:off +// @effect-diagnostics effect/floatingEffect:error +// @effect-diagnostics *:off +``` + +## Diagnostics catalog (what it catches) + +| Category | Examples | +|---|---| +| **Correctness** | `floatingEffect` (Effect not yielded/run), `missingEffectContext`, `missingEffectError`, `missingLayerContext`, `missingStarInYieldEffectGen` (`yield` vs `yield*`) | +| **Anti-pattern** | `tryCatchInEffectGen`, `runEffectInsideEffect`, `multipleEffectProvide`, `strictEffectProvide` (provide at entry only), `leakingRequirements`, `scopeInLayerEffect` (use `Layer.scoped`) | +| **Effect-native** (migration) | `processEnvInEffect` → `Config`, `globalFetchInEffect` → Effect HTTP, `globalConsoleInEffect` → `Effect.log`, `globalDateInEffect` → `Clock`/`DateTime`, `globalRandom` → `Random`, `globalTimersInEffect` → `Effect.sleep`/`Schedule`, `instanceOfSchema` → `Schema.is` | +| **Style** | `effectDoNotation` (prefer `Effect.gen`/`Effect.fn`), `schemaStructWithTag` → `Schema.TaggedStruct`, `unnecessaryEffectGen`, `unnecessaryPipe` | + +Plus refactors/codegens: async-function → `Effect.gen`/`Effect.fn`, `Effect.Service` ↔ `Context.Tag` conversion, "Layer Magic" auto-composition, structural-type → `Schema`, and a mermaid Layer-graph in quickinfo. These are the same anti-patterns the `effect-ts-specialist` skill warns about — the LSP enforces them mechanically. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-setup/references/tsconfig.md b/plugins/effect-kit/skills/engineering/effect-ts-setup/references/tsconfig.md new file mode 100644 index 0000000..c3f8c49 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-setup/references/tsconfig.md @@ -0,0 +1,72 @@ +# Recommended tsconfig + +Effect leans on precise types — a strict tsconfig is what makes the requirement/error channels catch mistakes. MERGE these into the repo's existing `tsconfig.json`; don't overwrite settings the user already chose. + +## Baseline `compilerOptions` + +```jsonc +{ + "compilerOptions": { + // build performance + "incremental": true, + "composite": true, + + // module / target + "target": "ES2022", + "module": "NodeNext", + "moduleDetection": "force", + + // imports + "verbatimModuleSyntax": true, + "rewriteRelativeImportExtensions": true, + + // type-safety (the part that matters most for Effect) + "strict": true, + "exactOptionalPropertyTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + + // dev ergonomics + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "skipLibCheck": true, + + // the language-service plugin (see language-service.md) + "plugins": [{ "name": "@effect/language-service" }] + } +} +``` + +`exactOptionalPropertyTypes` + `strict` are the load-bearing flags — they make `Schema` optional fields and the error channel behave as written. + +## Rule of thumb: who compiles your code? + +| Situation | Settings | +|---|---| +| A bundler (Vite, esbuild, Next, tsx) compiles your code; `tsc` only type-checks | `"module": "preserve"`, `"moduleResolution": "bundler"`, `"noEmit": true` | +| `tsc` compiles (a library, an npm package, a Node app/CLI) | `"module": "NodeNext"`, `"declaration": true` (+ `"composite"`/`"declarationMap"` for a monorepo) | + +So a Next.js or Vite app uses the bundler row; a published library or a `tsc`-built CLI uses the `tsc` row. + +## Monorepo (project references) + +Each package extends a shared `tsconfig.base.json` and the root composes them with `references`. Use `"composite": true` per package and a root `typecheck` of `tsc --build --noEmit`. Keep one `tsconfig.base.json` holding the language-service plugin so every package inherits the diagnostics. + +## Editor settings (recap) + +```json +// .vscode/settings.json — required for the LSP plugin to run +{ + "typescript.tsdk": "./node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} +``` + +## Notes + +- If the repo has no `tsconfig.json`, create one with the baseline above (pick the bundler vs `tsc` row by project type). +- Don't silently flip a setting the user set differently (e.g. they intentionally disabled `noUnusedLocals`) — surface the diff and let them choose. +- `skipLibCheck: true` is recommended for speed; it does not weaken your own code's type-checking. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-specialist/SKILL.md b/plugins/effect-kit/skills/engineering/effect-ts-specialist/SKILL.md new file mode 100644 index 0000000..8cc3013 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-specialist/SKILL.md @@ -0,0 +1,133 @@ +--- +name: effect-ts-specialist +description: "Use when writing or reviewing idiomatic Effect-TS (`effect` 3.x) — services & layers (`Context.Tag`/`Effect.Service`), dependency injection, tagged errors (`Data.TaggedError`/`Schema.TaggedError`), `effect/Schema` data modeling, `Config`, running effects (`Effect.runPromise`/`ManagedRuntime`), `Scope` resources, `@effect/vitest`+`TestClock` testing. Not for porting a Fastify/Next/React app (use effect-ts-port) or first-time repo bootstrap (use effect-ts-setup)." +user-invocable: false +metadata: + pattern: patterns-reference + updated: "2026-07-03" + content_hash: "7f5ffc3c888330facd797868d588c74a661ab4dd5c6876fe5a5296d28a4d6681" +--- + +# Effect-TS Specialist (idiomatic Effect 3.x) + +Effect rewards a small set of idioms and punishes guessing — the type-level error and requirement channels mean a wrong pattern shows up as a confusing `R`/`E` mismatch, not a runtime bug. This skill is the decision layer: which idiom to reach for, and the two or three mistakes that cost the most. Depth per topic lives in `references/`. + + +Never guess an Effect API from memory — the surface is large and moves between minor versions. Target **Effect 3.x stable** (the `effect` package). `Schema` is imported from **`effect/Schema`**, never the deprecated `@effect/schema` (folded into core in 3.10). Before writing an unfamiliar combinator, verify it: `bunx effect-solutions@latest show `, context7 (`resolve-library-id effect` → `query-docs`), or grep a cloned `effect` source tree. A wrong API shape is worse than asking. + + + +Errors live in the **typed error channel**, not in `throw`. Model expected domain failures (validation, not-found, permission-denied) as tagged errors so they appear in `E` and `catchTag` can recover them. Reserve `throw`/defects for genuine bugs and invariant violations — promote those with `Effect.orDie`. NEVER use `try/catch` inside `Effect.gen`; wrap promise-returning code with `Effect.tryPromise({ try, catch })` so the failure is typed. + + + +Provide layers **once, at the application boundary** (`Effect.provide(program, MainLive)` or a single `ManagedRuntime`). Service method signatures carry `R = never` — push every dependency into the service's Layer, not the call site. Scattering `provide` calls or leaking requirements into method types is the most common structural smell (the language service flags it as `multipleEffectProvide` / `leakingRequirements`). + + +## Decision table — reach for X when you want Y + +| You want | Reach for | Reference | +|---|---|---| +| A service with one obvious implementation | `Effect.Service` (bundles Tag + `Default` layer) | `services-and-layers.md` | +| A service defined interface-first / multiple impls | `Context.Tag` + a separate `Layer` | `services-and-layers.md` | +| A recoverable domain failure | `Data.TaggedError` (in-process) / `Schema.TaggedError` (crosses a boundary) | `error-handling.md` | +| To wrap an existing Promise | `Effect.tryPromise({ try, catch })` | `error-handling.md` | +| A product/record type with methods | `Schema.Class` | `data-modeling.md` | +| A sum/variant type | `Schema.TaggedClass` + `Schema.Union` | `data-modeling.md` | +| A primitive that must not be mixed up | `Schema.brand` (`UserId` ≠ `PostId`) | `data-modeling.md` | +| Typed env/secret access | `Config.*` (+ `Config.redacted` for secrets) | `config.md` | +| To run an Effect at a framework edge | `ManagedRuntime.make(layer)` then `runtime.runPromise` | `running-effects.md` | +| A resource with guaranteed cleanup | `Effect.acquireRelease` + `Layer.scoped` | `running-effects.md` | +| Deterministic tests (time, services) | `@effect/vitest` `it.effect` + `TestClock` | `testing.md` | + +## Services & layers (the spine) + +```ts +// GOOD — Effect.Service bundles the Tag and a Default layer; deps go in the layer, not the methods +class Users extends Effect.Service()("app/Users", { + effect: Effect.gen(function* () { + const sql = yield* Sql // dependency resolved by the layer + const getAll = sql.query("select * from users") // R = never on the method + return { getAll } as const + }), + dependencies: [SqlLive], +}) {} +// access: const users = yield* Users +// provide: Effect.provide(program, Users.Default) +``` + +```ts +// BAD — dependency leaks into the method's requirement type, and provide is scattered per-call +const getAll = (sql: Sql) => Effect.provide(sql.query("..."), SqlLive) // R leaks; provided too deep +``` + +Layer naming is camelCase + `Layer`/`Default` suffix; compose with `Layer.merge` / `Layer.provide` / `Layer.provideMerge`. **Memoize parameterized layers** (store `const pgLayer = Postgres.layer({...})` once) so reference identity dedupes shared resources like connection pools. Full patterns: `references/services-and-layers.md`. + +## Errors: typed channel vs defects + +```ts +// GOOD — domain failure is tagged → shows up in E, recoverable with catchTag +class UserNotFound extends Data.TaggedError("UserNotFound")<{ id: string }> {} +const find = (id: string) => + Effect.tryPromise({ try: () => db.user(id), catch: (e) => new DbError({ cause: e }) }).pipe( + Effect.flatMap((u) => (u ? Effect.succeed(u) : new UserNotFound({ id }))) + ) +find("7").pipe(Effect.catchTag("UserNotFound", () => Effect.succeed(guestUser))) +``` + +```ts +// BAD — throw inside gen becomes an untyped defect; try/catch defeats the error channel +const find = (id: string) => Effect.gen(function* () { + try { const u = yield* Effect.promise(() => db.user(id)); if (!u) throw new Error("nope"); return u } + catch (e) { throw e } // ❌ language service: tryCatchInEffectGen +}) +``` + +Use `Schema.TaggedError` (serializable) when the error crosses a network/DB boundary (e.g. `HttpApi`); `Data.TaggedError` when it stays in-process. Recover with `catchTag`/`catchTags`; promote unrecoverable failures with `Effect.orDie`. Detail: `references/error-handling.md`. + +## Running at the boundary + +You almost never call `Effect.runPromise` in app code — you build a runtime once and run effects through it at each framework entry point (route handler, server action, React event). `ManagedRuntime.make(MainLive)` is the bridge; see `references/running-effects.md`. This is the seam the **`effect-ts-port`** skill plugs every framework into. + +## Hybrid knowledge source (opportunistic, never required) + +The `references/` here are self-contained. When more depth is needed and the tools exist, prefer ground truth over memory: + +```bash +bunx effect-solutions@latest list # Kit Langton's idiomatic-Effect docs CLI (needs Bun; optional) +bunx effect-solutions@latest show # run `list` first for the exact topic slugs +# else: context7 resolve-library-id `effect` → query-docs; or grep a cloned effect source tree +``` + +Topics roughly correspond to the reference files below. Absence of these tools is never a blocker — the references stand alone. + +## Gotchas + +| Gotcha | Consequence | Right move | +|---|---|---| +| `try/catch` inside `Effect.gen` | Failure escapes the typed channel | `Effect.tryPromise({ try, catch })` | +| `throw` for an expected failure | Becomes an unrecoverable defect | Tagged error in `E`; recover with `catchTag` | +| `provide` scattered through the call tree | Layers re-built, requirements leak | Provide once at the boundary | +| `Layer.effect` for a service needing cleanup | Finalizer never runs | `Layer.scoped` + `acquireRelease` (LSP: `scopeInLayerEffect`) | +| Re-creating a parameterized layer per use | Duplicate pools/connections | Memoize in a `const`; reference identity dedupes | +| `process.env` / `new Date()` / `Math.random()` inside an Effect | Untestable, impure | `Config.*` / `Clock` / `Random` (LSP flags each) | +| `installing @effect/schema` | Deprecated package, wrong types | Import from `effect/Schema` (core since 3.10) | +| Plain `JSON.parse` of external input | Unvalidated `any` | `Schema.decodeUnknown` / `Schema.parseJson` | + +Install the **`@effect/language-service`** tsconfig plugin (the `effect-ts-setup` skill wires it) — it catches most of the above at edit time. + +## References + +| Read for | File | +|---|---| +| `Context.Tag` vs `Effect.Service`, layer composition, memoization, provide-once | `references/services-and-layers.md` | +| Tagged errors, defects vs typed errors, `tryPromise`, `catchTag`/`catchTags` | `references/error-handling.md` | +| `Schema.Class`/`TaggedClass`/`Union`, brands, decode/encode, JSON | `references/data-modeling.md` | +| `Config.*`, `redacted`, defaults, `ConfigProvider`, config-as-service | `references/config.md` | +| `runPromise`/`runFork`, `ManagedRuntime`, `Scope`/`acquireRelease` | `references/running-effects.md` | +| `@effect/vitest` `it.effect`/`it.scoped`, `TestClock`, mocking layers | `references/testing.md` | + +## When this skill does NOT apply + +- Migrating an existing Fastify / Next.js / React codebase to Effect — use **`effect-ts-port`** (detect → plan → migrate). +- Bootstrapping `effect` deps, tsconfig, and the language service in a fresh repo — use **`effect-ts-setup`**. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/config.md b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/config.md new file mode 100644 index 0000000..06f4212 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/config.md @@ -0,0 +1,80 @@ +# Config + +`Config` reads environment/configuration as a typed, composable value with validation and secret-hiding built in. Replaces ad-hoc `process.env.FOO!` access (which the language service flags as `processEnvInEffect`). + +## Primitives + +```ts +import { Config, Effect } from "effect" + +const port = Config.integer("PORT") +const host = Config.string("HOST") +const debug = Config.boolean("DEBUG") +const timeout = Config.duration("REQUEST_TIMEOUT") // "5 seconds" → Duration +const apiUrl = Config.url("API_URL") +const apiKey = Config.redacted("API_KEY") // Redacted — never printed in logs/errors +``` + +`Config.redacted` wraps the value so it renders as `` everywhere; unwrap only at the point of use with `Redacted.value(key)`. + +## Defaults, fallbacks, nesting + +```ts +const port = Config.integer("PORT").pipe(Config.withDefault(3000)) +const key = Config.redacted("API_KEY").pipe(Config.orElse(() => Config.redacted("LEGACY_KEY"))) + +// group related keys under a prefix: DB_HOST, DB_PORT +const db = Config.all({ + host: Config.string("HOST"), + port: Config.integer("PORT").pipe(Config.withDefault(5432)), +}).pipe(Config.nested("DB")) +``` + +## Reading config + +```ts +const program = Effect.gen(function* () { + const cfg = yield* db // Config is itself an Effect that fails with ConfigError + return connect(cfg.host, cfg.port) +}) +``` + +A missing/invalid key fails with `ConfigError` in the `E` channel — so config problems surface at layer construction, not as a `undefined` three calls deep. + +## Config as a service (the idiom) + +Wrap config in a service so the rest of the app depends on a typed value, and tests inject a fixed one: + +```ts +class AppConfig extends Effect.Service()("app/Config", { + effect: Config.all({ + port: Config.integer("PORT").pipe(Config.withDefault(3000)), + apiKey: Config.redacted("API_KEY"), + }), +}) {} + +// prod: AppConfig.Default reads from the environment +// test: a fixed layer +const TestConfig = Layer.succeed(AppConfig, { port: 0, apiKey: Redacted.make("test") } as any) +``` + +## Providers (where values come from) + +```ts +import { ConfigProvider, Effect, Layer } from "effect" + +// default provider reads process.env — nothing to wire for the common case. +// override for tests with an in-memory map: +const testProvider = ConfigProvider.fromMap( + new Map([["PORT", "0"], ["API_KEY", "test-key"]]) +) +program.pipe(Effect.withConfigProvider(testProvider)) +// or as a layer: Layer.setConfigProvider(testProvider) +``` + +## Checklist + +- Never read `process.env` inside an Effect — use `Config.*`. +- Secrets → `Config.redacted`; unwrap with `Redacted.value` only at use. +- Wrap config in a service with a `Default` (env) layer and a fixed test layer. +- Tests inject values via `ConfigProvider.fromMap`, not by mutating `process.env`. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/data-modeling.md b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/data-modeling.md new file mode 100644 index 0000000..f64bbb7 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/data-modeling.md @@ -0,0 +1,95 @@ +# Data Modeling with Schema + +`Schema` (from **`effect/Schema`** — not `@effect/schema`) is one declaration that gives you a runtime decoder/encoder *and* a static type. Model your domain in Schema and validation, serialization, and types stay in sync. + +## Records / product types → `Schema.Class` + +```ts +import { Schema } from "effect" + +class User extends Schema.Class("User")({ + id: UserId, // a branded primitive (below) + name: Schema.String, + email: Schema.String, + createdAt: Schema.DateFromString, // decodes ISO string → Date, encodes back +}) { + get displayName() { return `${this.name} <${this.email}>` } +} + +User.make({ id, name, email, createdAt: new Date() }) // construct (validates) +``` + +`Schema.Class` gives an opaque class type, a constructor, and getters/methods — preferred over a bare `Schema.Struct` when the type is a domain entity. + +## Variants / sum types → `Schema.TaggedClass` + `Schema.Union` + +```ts +class Pending extends Schema.TaggedClass()("Pending", {}) {} +class Shipped extends Schema.TaggedClass()("Shipped", { trackingNo: Schema.String }) {} +class Delivered extends Schema.TaggedClass()("Delivered", { at: Schema.DateFromString }) {} + +const OrderStatus = Schema.Union(Pending, Shipped, Delivered) +type OrderStatus = Schema.Schema.Type + +// exhaustive match on the _tag: +import { Match } from "effect" +const label = Match.type().pipe( + Match.tag("Pending", () => "waiting"), + Match.tag("Shipped", (s) => `tracking ${s.trackingNo}`), + Match.tag("Delivered", () => "done"), + Match.exhaustive, +) +``` + +## Brand primitives — stop ID mix-ups + +```ts +const UserId = Schema.String.pipe(Schema.brand("UserId")) +const PostId = Schema.String.pipe(Schema.brand("PostId")) +type UserId = Schema.Schema.Type // string & Brand<"UserId"> + +declare function getUser(id: UserId): void +getUser(somePostId) // ❌ compile error — PostId is not assignable to UserId +``` + +Brand nearly every domain primitive (ids, emails, slugs, money). It's the cheapest bug-prevention Schema gives you. + +## Decode external input — never trust `JSON.parse` + +```ts +// from unknown (HTTP body, queue message): +const user = yield* Schema.decodeUnknown(User)(payload) // Effect +// from a JSON string in one step: +const user = yield* Schema.decodeUnknownSync // sync variant throws ParseError +const cfg = yield* Schema.decode(Schema.parseJson(Settings))(rawString) +// encode back to the wire shape: +const wire = yield* Schema.encode(User)(user) +``` + +`decodeUnknown` returns an `Effect` whose error is `ParseError` — pipe it straight into your tagged-error handling. + +## Refinements, transforms, optional fields + +```ts +const Age = Schema.Number.pipe(Schema.int(), Schema.between(0, 150)) +const Email = Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+$/), Schema.brand("Email")) + +const Settings = Schema.Struct({ + theme: Schema.Literal("light", "dark"), + retries: Schema.optionalWith(Schema.Number, { default: () => 3 }), // default on decode + nickname: Schema.optional(Schema.String), // may be absent +}) + +// custom bidirectional transform: +const Trimmed = Schema.transform(Schema.String, Schema.String, { + decode: (s) => s.trim(), + encode: (s) => s, +}) +``` + +## Gotchas + +- Import from `effect/Schema`. `@effect/schema` is deprecated (folded into core in Effect 3.10). +- `Schema.Struct` is the plain record; reach for `Schema.Class` when you want a nominal domain type with methods. +- Decoding is effectful (`ParseError` in `E`) — handle it, don't `decodeUnknownSync` on untrusted input in a request path. +- For a discriminated union, give each member a distinct tag via `Schema.TaggedClass`/`Schema.TaggedStruct` so `Match.tag` + `Match.exhaustive` can prove totality. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/error-handling.md b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/error-handling.md new file mode 100644 index 0000000..66ee9a6 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/error-handling.md @@ -0,0 +1,89 @@ +# Error Handling + +Effect splits failures into two kinds: + +- **Typed errors** (the `E` channel) — *expected* domain failures: validation, not-found, permission-denied, a 4xx from an upstream API. Recoverable, enumerated in the type. +- **Defects** — *unexpected* bugs and broken invariants. Not in `E`; they bubble as `Cause.Die`. You usually let them crash (and log), not `catch` them. + +The skill is choosing the right channel and never letting a `throw` silently become a defect. + +## Defining tagged errors + +```ts +import { Data, Schema } from "effect" + +// in-process only (cheapest): +class UserNotFound extends Data.TaggedError("UserNotFound")<{ id: string }> {} +class DbError extends Data.TaggedError("DbError")<{ cause: unknown }> {} + +// crosses a boundary (HTTP/RPC/queue) → serializable, schema-validated: +class ValidationError extends Schema.TaggedError()("ValidationError", { + field: Schema.String, + message: Schema.String, +}) {} +``` + +Rule of thumb: **`Data.TaggedError` until the error has to leave the process**, then `Schema.TaggedError` (it encodes/decodes and integrates with `HttpApi` status mapping). The `_tag` field is what `catchTag` matches on. + +## Wrapping promise / throwing code + +```ts +import { Effect } from "effect" + +// GOOD — failure is typed as DbError, never a defect +const query = (id: string) => + Effect.tryPromise({ + try: () => db.user(id), + catch: (cause) => new DbError({ cause }), + }) + +// no `catch` → fails with the untyped UnknownException (fine for throwaway scripts, not domain code) +const loose = Effect.tryPromise(() => fetch(url)) +// synchronous throwing code → Effect.try({ try, catch }) +``` + +Never `try/catch` inside `Effect.gen` — the language service flags `tryCatchInEffectGen`, and the caught error escapes the `E` channel. + +## Recovering + +```ts +program.pipe( + Effect.catchTag("UserNotFound", (e) => Effect.succeed(guestFor(e.id))), + Effect.catchTags({ + DbError: (e) => Effect.logError(e.cause).pipe(Effect.zipRight(Effect.fail(new ServiceUnavailable()))), + ValidationError: (e) => Effect.fail(new BadRequest({ field: e.field })), + }), +) + +// map/translate without recovering: +program.pipe(Effect.mapError((e) => new PublicError({ cause: e }))) +// recover by value: Effect.catchAll / Effect.orElse / Effect.catchAllCause (sees defects too) +``` + +## Typed error → defect (and back) + +```ts +// at the composition root, an unrecoverable config failure should crash, not be a typed error: +const config = loadConfig.pipe(Effect.orDie) // E becomes a defect +const configMsg = loadConfig.pipe(Effect.orDieWith((e) => new Error(`bad config: ${e}`))) + +// inspect the full failure (typed errors + defects + interruptions): +program.pipe(Effect.catchAllCause((cause) => Effect.logError(cause))) +``` + +## Retry & timeouts (typed errors are ret-riable) + +```ts +import { Effect, Schedule } from "effect" +query(id).pipe( + Effect.retry(Schedule.exponential("100 millis").pipe(Schedule.compose(Schedule.recurs(3)))), + Effect.timeout("5 seconds"), // adds TimeoutException to E +) +``` + +## Checklist + +- Expected failure → tagged error in `E`. Bug/invariant → defect (`orDie`, or just let it throw outside Effect). +- `Data.TaggedError` in-process; `Schema.TaggedError` across a boundary. +- Wrap every Promise with `Effect.tryPromise({ try, catch })`; wrap sync throwers with `Effect.try`. +- Recover with `catchTag`/`catchTags`; translate with `mapError`; see everything with `catchAllCause`. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/running-effects.md b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/running-effects.md new file mode 100644 index 0000000..191ae39 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/running-effects.md @@ -0,0 +1,79 @@ +# Running Effects & Resource Scopes + +An `Effect` is a *description*; nothing happens until it's run. Where and how you run it is the boundary between Effect-land and the host (Node, a framework, the browser). This is the seam every `effect-ts-port` integration plugs into. + +## The run functions (boundaries only) + +```ts +import { Effect } from "effect" + +Effect.runPromise(program) // Promise, rejects on failure +Effect.runPromiseExit(program) // Promise> — inspect success/failure without throwing +Effect.runFork(program) // RuntimeFiber — fire-and-forget, cancellable +Effect.runSync(program) // A — only for fully-synchronous effects (throws otherwise) +``` + +Call these at the **edge** (top of `main`, a request handler, an event callback) — never inside business logic. A program you run usually has `R = never`; if `R` isn't `never`, you forgot to provide a layer. + +## `ManagedRuntime` — the framework bridge + +Most apps don't own their entry point (Next.js calls your route, React calls your handler). Build a runtime **once** from your layer, then run effects through it: + +```ts +import { Effect, ManagedRuntime } from "effect" + +// module scope — built once, reused across invocations +const runtime = ManagedRuntime.make(MainLive) + +// at each entry point: +export async function handler(req: Request) { + return runtime.runPromise(handleRequest(req)) // effect can use every service in MainLive +} + +// on shutdown / HMR teardown: +await runtime.dispose() +``` + +`runtime.runPromise` / `runPromiseExit` / `runFork` mirror the `Effect.run*` family but carry the provided context, so handlers stay `R`-free. This is exactly what `effect-ts-port` wires into Fastify handlers, Next.js route handlers / server actions, and React. + +> Caveat (serverless/edge): module-scope runtime is reused within a warm instance but rebuilt on cold start; awaiting `runtime.runtime()` at module top-level is fine for Node servers, but verify it against your deploy target (edge runtimes, HMR in dev). + +## Resource management — `Scope` + `acquireRelease` + +A `Scope` guarantees finalizers run (success, failure, or interruption). Acquire/release pairs are the building block: + +```ts +import { Effect } from "effect" + +const withFile = Effect.acquireRelease( + Effect.sync(() => openSync(path)), // acquire + (fd) => Effect.sync(() => closeSync(fd)), // release — always runs +) + +// use within a scope; the file is closed when the scope closes: +const program = Effect.scoped( + Effect.gen(function* () { + const fd = yield* withFile + return yield* read(fd) + }) +) +``` + +### Scoped layers + +When a *service* owns a resource (pool, client, subscription), build it with `Layer.scoped` so its finalizer runs when the app's scope closes: + +```ts +const DbLive = Layer.scoped( + Db, + Effect.acquireRelease(connect(url), (c) => c.close()), +) +// BAD: Layer.effect(Db, connect(url)) — connection never closed (LSP: scopeInLayerEffect) +``` + +## Checklist + +- Run at the edge with `Effect.run*`; everywhere else, return Effects. +- Don't control the entry point? Build a `ManagedRuntime` once and `runtime.runPromise` per call; `dispose()` on teardown. +- A program you run should have `R = never` — a lingering requirement means a missing `provide`. +- Resource with cleanup → `acquireRelease`; resource owned by a service → `Layer.scoped`, never `Layer.effect`. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/services-and-layers.md b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/services-and-layers.md new file mode 100644 index 0000000..64aa5f3 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/services-and-layers.md @@ -0,0 +1,112 @@ +# Services & Layers + +A *service* is an interface in the requirement (`R`) channel. A *layer* is a recipe that builds the service (and may require other services to do so). Wiring = composing layers and providing the composite once. + +## Contents + +- [Two ways to define a service](#two-ways-to-define-a-service) +- [Rules that keep `R` clean](#rules-that-keep-r-clean) +- [Building layers](#building-layers) +- [Composition](#composition) +- [Memoize parameterized layers](#memoize-parameterized-layers) +- [Provide once](#provide-once) + +## Two ways to define a service + +### `Effect.Service` — default when there's one obvious implementation + +```ts +import { Effect } from "effect" + +class Cache extends Effect.Service()("app/Cache", { + // `effect` builds the implementation; deps it needs go in `dependencies` + effect: Effect.gen(function* () { + const store = new Map() + const get = (k: string) => Effect.sync(() => store.get(k)) + const set = (k: string, v: string) => Effect.sync(() => void store.set(k, v)) + return { get, set } as const + }), + dependencies: [], // other layers this service needs +}) {} + +// access inside any Effect: +const program = Effect.gen(function* () { + const cache = yield* Cache + yield* cache.set("k", "v") +}) +// provide the bundled default layer at the boundary: +Effect.runPromise(Effect.provide(program, Cache.Default)) +``` + +`Effect.Service` auto-generates `Cache.Default` (a `Layer`) and the Tag. Use `{ sync: () => ... }` for a pure impl, `{ effect: ... }` for an effectful one, `{ scoped: ... }` when construction needs a finalizer. + +### `Context.Tag` — interface-first, or many implementations + +```ts +import { Context, Effect, Layer } from "effect" + +class Clock extends Context.Tag("app/Clock") +}>() {} + +const SystemClockLive = Layer.succeed(Clock, { now: Effect.sync(() => Date.now()) }) +const TestClockLive = Layer.succeed(Clock, { now: Effect.succeed(0) }) +``` + +Pick `Context.Tag` when you want to sketch the interface before any impl, or swap implementations (prod vs test, multiple backends). Pick `Effect.Service` for the common "one impl, give me a default layer" case. The `@effect/language-service` ships a refactor that converts between the two. + +## Rules that keep `R` clean + +- **Service ID is unique and namespaced**: `"app/Users"`, `"@org/Billing"`. Duplicate IDs silently collide. +- **Methods have `R = never`.** A method's type is `Effect` — never `Effect`. Dependencies are resolved when the *layer* is built, not when the method is called. Leaking a requirement into a method type is the `leakingRequirements` smell. +- **`readonly` props only.** No exposed mutable fields; encapsulate state behind methods. + +## Building layers + +```ts +import { Effect, Layer } from "effect" + +// from an effect that yields the service shape, pulling its own deps: +const UsersLive = Layer.effect( + Users, + Effect.gen(function* () { + const sql = yield* Sql // Users layer now requires Sql + return { getAll: sql.query("select * from users") } + }) +) + +// from a constant: Layer.succeed(Tag, impl) +// needing a finalizer: Layer.scoped(Tag, Effect.acquireRelease(open, close)) +``` + +## Composition + +| Combinator | Meaning | +|---|---| +| `Layer.merge(a, b)` | Both services available; deps of each still required | +| `Layer.provide(a, b)` | `b` satisfies `a`'s requirements (wires `b` *into* `a`) | +| `Layer.provideMerge(a, b)` | Like `provide`, but keeps `b` in the output too | + +```ts +const MainLive = UsersLive.pipe( + Layer.provide(SqlLive), // SqlLive satisfies Users' need for Sql + Layer.provideMerge(ConfigLive) // Config available to everything AND exported +) +``` + +## Memoize parameterized layers + +```ts +// GOOD — one instance, reference identity dedupes the pool across repos +const pgLayer = Postgres.layer({ url: dbUrl }) +const UserRepoLive = UserRepo.Default.pipe(Layer.provide(pgLayer)) +const OrderRepoLive = OrderRepo.Default.pipe(Layer.provide(pgLayer)) // same pgLayer + +// BAD — two distinct layers → two connection pools +const UserRepoLive = UserRepo.Default.pipe(Layer.provide(Postgres.layer({ url: dbUrl }))) +const OrderRepoLive = OrderRepo.Default.pipe(Layer.provide(Postgres.layer({ url: dbUrl }))) +``` + +## Provide once + +Build `MainLive` at the composition root and provide it at the single entry point. Do not `Effect.provide` inside business logic — it rebuilds layers and hides what a function truly requires. The language service flags repeated provides as `multipleEffectProvide` / `strictEffectProvide`. diff --git a/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/testing.md b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/testing.md new file mode 100644 index 0000000..36a7c35 --- /dev/null +++ b/plugins/effect-kit/skills/engineering/effect-ts-specialist/references/testing.md @@ -0,0 +1,67 @@ +# Testing with `@effect/vitest` + +`@effect/vitest` runs Effects as tests with the test context wired in — including a **`TestClock` frozen at zero**, so time-dependent code is deterministic. Install `@effect/vitest` as a dev dependency and import `it` from it. + +## The test entry points + +```ts +import { it, expect } from "@effect/vitest" +import { Effect } from "effect" + +// it.effect — auto-provides TestContext + TestClock (time starts at 0, advances only when you say) +it.effect("adds users", () => + Effect.gen(function* () { + const users = yield* Users + const all = yield* users.getAll + expect(all).toHaveLength(0) + }).pipe(Effect.provide(Users.Default))) + +// it.live — real clock / real services (use when you actually need wall-clock or real delays) +it.live("hits the network", () => Effect.gen(function* () { /* ... */ })) + +// it.scoped — for effects that acquire scoped resources; the scope closes at test end +it.scoped("opens and closes a connection", () => Effect.gen(function* () { /* ... */ })) +``` + +Modifiers compose as in vitest: `it.effect.skip`, `it.effect.only`, `it.effect.fails` (asserts the effect fails). + +## Controlling time with `TestClock` + +```ts +import { it, expect } from "@effect/vitest" +import { Effect, TestClock, Fiber } from "effect" + +it.effect("times out after 5s without blocking the test", () => + Effect.gen(function* () { + const fiber = yield* Effect.fork(slowOp.pipe(Effect.timeout("5 seconds"))) + yield* TestClock.adjust("5 seconds") // virtual time jump — test runs instantly + const exit = yield* Fiber.join(fiber) + expect(exit._tag).toBe("Failure") + })) +``` + +`TestClock.adjust` advances virtual time, firing any scheduled effects (timeouts, `Effect.sleep`, `Schedule`) without real waiting. + +## Mocking services with layers + +Swap a real layer for a fake by providing a different `Layer` — no mocking framework: + +```ts +const FakeUsers = Layer.succeed(Users, { + getAll: Effect.succeed([{ id: "1", name: "Test" }]), +}) + +it.effect("uses the fake", () => + Effect.gen(function* () { + const users = yield* Users + expect(yield* users.getAll).toHaveLength(1) + }).pipe(Effect.provide(FakeUsers))) +``` + +Define a static `testLayer` next to a service's real `layer` for reuse. **Provide per-test** (inside each `it.effect`) for isolation, unless a resource is expensive enough to share via a suite-level layer. + +## Notes + +- Logging is suppressed by default under `it.effect`; re-enable with a `Logger` layer or switch to `it.live` to see logs. +- Assert on `Exit` (`Effect.runPromiseExit` / `Fiber.join`) when testing the failure channel — don't let a typed error throw. +- The test script should run `vitest` (not `bun test`); pin `@effect/vitest` to match your `effect` version. diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index ae974cd..9463a24 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -25,6 +25,16 @@ The repo hosts **multiple plugins** (`docks`, `session-relay`, …) under `plugi `ci.mjs` is **registry-driven**: it runs repo-wide checks **once** (workflow YAML, both marketplace catalogs, tree/guard, durable-anchors, idempotency, shellcheck over all plugins, scaffold), then a **capability-driven per-plugin gate** (`gatePlugin`) for each present plugin — a check fires only when its capability is declared, so a skills-only plugin and a skills+agents+selftest plugin share one code path. Flags: `-q` (quiet), `--list` (print the registry + presence), `--plugin ` (gate just that one; repo-wide checks still run). Versions are **per-plugin and independent** — `release.mjs` targets exactly one plugin via `--plugin` (default `docks`). +### Adding plugin N+1 (the whole checklist — no orchestrator edits) + +1. **Payload** at `plugins//` — `.claude-plugin/plugin.json` (+ `.codex-plugin/plugin.json` when it ships to Codex) and its `skills/`/`agents/`/`hooks/` dirs. +2. **One descriptor** appended to `PLUGINS` in `lib/plugins.mjs` — declare only the capabilities that exist (`agents`/`selftest`/`rust` take `null`, `extraJson` `[]` when absent); include the `install` snippet for release notes. +3. **Two catalog entries**: `.claude-plugin/marketplace.json` (name/source/version — version in lockstep with both manifests) and `.agents/plugins/marketplace.json` (local-source + policy block) for Codex. +4. **Optional context node** (`plugins//AGENTS.md` + one-line `CLAUDE.md`) when the plugin carries conventions of its own — `tree/guard` enforces the pair; the durable-anchors guard scans it. +5. Verify: `node scripts/ci.mjs --list` shows the plugin; full `node scripts/ci.mjs` green; release with `node scripts/release.mjs --plugin ` (tag `--v`). + +If a new plugin seems to need an edit to `ci.mjs`/`release.mjs`, the descriptor contract is being violated — extend the capability model in `lib/plugins.mjs` instead of special-casing the orchestrators. + ## Validators (orchestrated by ci.mjs) | Script | Purpose | Floor | diff --git a/scripts/lib/plugins.mjs b/scripts/lib/plugins.mjs index d893e8b..0c4bea9 100644 --- a/scripts/lib/plugins.mjs +++ b/scripts/lib/plugins.mjs @@ -64,6 +64,18 @@ export const PLUGINS = [ transformGuard: false, install: '/plugin marketplace update docks\n/plugin install session-relay@docks', }, + { + name: 'effect-kit', + root: 'plugins/effect-kit', + skills: 'plugins/effect-kit/skills', + agents: null, + codex: true, + selftest: null, + rust: null, + extraJson: [], + transformGuard: false, + install: '/plugin marketplace update docks\n/plugin install effect-kit@docks', + }, ]; // Shared catalogs (one entry per plugin, matched by name).