diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..dd27e20 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "effect-ts-skills", + "interface": { + "displayName": "Effect TS Skills" + }, + "plugins": [ + { + "name": "effect-ts-skills", + "source": { + "source": "local", + "path": "./plugins/effect-ts-skills" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa3321a --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# effect-ts-skills + +Reusable Effect-TS skills and compliance tooling for [Codex](https://github.com/openai/codex). + +## Skills + +| Skill | Path | Description | +|-------|------|-------------| +| [effect-ts-guide](skills/effect-ts-guide/SKILL.md) | `skills/effect-ts-guide` | Effect-TS guidance for architecture, typed errors, Layers, boundary validation, resource safety, compliance checks, and editor tooling. | + +## Standalone Skill Installation + +Use this path when you only want the `effect-ts-guide` skill in your local Codex setup. + +```bash +python3 ~/.codex/skills/.system/skill-installer/scripts/install-skill-from-github.py \ + --repo ProverCoderAI/effect-ts-skills \ + --path skills/effect-ts-guide +``` + +### Standalone Skill Update / Reinstall + +The skill installer does not overwrite an existing installation. +To update to the latest version, remove the previous copy first: + +```bash +rm -rf ~/.codex/skills/effect-ts-guide + +python3 ~/.codex/skills/.system/skill-installer/scripts/install-skill-from-github.py \ + --repo ProverCoderAI/effect-ts-skills \ + --path skills/effect-ts-guide +``` + +### Verify + +After installation, the skill entry point should exist at: + +``` +~/.codex/skills/effect-ts-guide/SKILL.md +``` + +Start a new Codex thread after installing or updating so the skill list is refreshed. + +## Plugin Installation + +Use this path when you want Codex to install the plugin bundle through a marketplace. + +```bash +codex plugin marketplace add ProverCoderAI/effect-ts-skills +codex plugin add effect-ts-skills@effect-ts-skills +``` + +For local development from a checkout: + +```bash +codex plugin marketplace add . +codex plugin add effect-ts-skills@effect-ts-skills +``` + +The marketplace entry points at `plugins/effect-ts-skills`, a generated plugin wrapper synchronized from the root manifest and skills directory by `corepack pnpm run sync:plugin-wrapper`. + +After installation, start a new Codex thread and invoke the skill explicitly with `$effect-ts-guide` or ask for an Effect-TS implementation/review task. + +## Development + +This repository is a [pnpm workspace](https://pnpm.io/workspaces). + +```bash +corepack pnpm install +corepack pnpm run sync:distribution +corepack pnpm run check +``` + +### Structure + +``` +skills/effect-ts-guide/ # Publishable skill (SKILL.md + bundled assets) +plugins/effect-ts-skills/ # Marketplace plugin wrapper generated from root files +packages/effect-ts-check/ # Reusable Effect-TS compliance CLI +tools/ # Repo-level validation scripts +``` + +## License + +ISC diff --git a/package.json b/package.json index 88f10b8..ccec141 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,13 @@ "description": "Reusable Effect-TS skills and compliance tooling", "packageManager": "pnpm@10.32.1", "scripts": { - "check": "pnpm run lint && pnpm run test && pnpm --filter @prover-coder-ai/effect-ts-check run check && pnpm run validate:distribution && pnpm run check:skill", - "check:skill": "pnpm run sync:skill-asset && bash skills/effect-ts-guide/scripts/run-effect-ts-check.sh .", - "lint": "pnpm --filter @prover-coder-ai/effect-ts-check run lint", + "check": "corepack pnpm run lint && corepack pnpm run test && corepack pnpm --filter @prover-coder-ai/effect-ts-check run check && corepack pnpm run validate:distribution && corepack pnpm run check:skill", + "check:skill": "bash skills/effect-ts-guide/scripts/run-effect-ts-check.sh packages/effect-ts-check/src", + "lint": "corepack pnpm --filter @prover-coder-ai/effect-ts-check run lint", + "sync:distribution": "corepack pnpm run sync:skill-asset && corepack pnpm run sync:plugin-wrapper", "sync:skill-asset": "bash skills/effect-ts-guide/scripts/refresh-effect-ts-check-asset.sh", - "test": "pnpm --filter @prover-coder-ai/effect-ts-check run test", + "sync:plugin-wrapper": "node tools/sync-plugin-wrapper.mjs", + "test": "corepack pnpm --filter @prover-coder-ai/effect-ts-check run test", "validate:distribution": "node tools/validate-distribution.mjs" } } diff --git a/packages/effect-ts-check/src/base.mjs b/packages/effect-ts-check/src/base.mjs index 8b08734..f48c064 100644 --- a/packages/effect-ts-check/src/base.mjs +++ b/packages/effect-ts-check/src/base.mjs @@ -2,7 +2,7 @@ import tseslint from "typescript-eslint"; import { effectSyntaxRestrictions } from "./rules/index.mjs"; -export const effectFileGlobs = Object.freeze(["**/*.{js,mjs,cjs,ts,tsx}"]); +export const effectFileGlobs = Object.freeze(["**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}"]); export const effectIgnoreGlobs = Object.freeze([ "tests/**", "**/tests/**", diff --git a/packages/effect-ts-check/src/rules/syntax.mjs b/packages/effect-ts-check/src/rules/syntax.mjs index 215d0ad..eba8f22 100644 --- a/packages/effect-ts-check/src/rules/syntax.mjs +++ b/packages/effect-ts-check/src/rules/syntax.mjs @@ -54,18 +54,6 @@ export const effectStrictSyntaxRestrictions = Object.freeze([ selector: "CallExpression[callee.property.name='catchAll']", message: "Avoid catchAll that swallows typed errors; map or rethrow explicitly.", }, - { - selector: "CallExpression[callee.property.name='runSync']", - message: "Use Effect.runSync only at shell boundaries.", - }, - { - selector: "CallExpression[callee.property.name='runSyncExit']", - message: "Use Effect.runSyncExit only at shell boundaries.", - }, - { - selector: "CallExpression[callee.property.name='runPromise']", - message: "Use Effect.runPromise only at shell boundaries.", - }, { selector: "TSAsExpression", message: "Avoid casts in product code; keep them in one axioms boundary if needed.", diff --git a/packages/effect-ts-check/src/run.mjs b/packages/effect-ts-check/src/run.mjs index 26a599d..bf2d876 100644 --- a/packages/effect-ts-check/src/run.mjs +++ b/packages/effect-ts-check/src/run.mjs @@ -81,7 +81,7 @@ export function printUsage() { "", "Profiles:", " minimal Default fast effect compliance check.", - " strict Adds import/type/runtime policy checks.", + " strict Adds import/type/host API policy checks.", "", ].join("\n"), ); diff --git a/packages/effect-ts-check/src/strict.mjs b/packages/effect-ts-check/src/strict.mjs index a3e97f0..db79c24 100644 --- a/packages/effect-ts-check/src/strict.mjs +++ b/packages/effect-ts-check/src/strict.mjs @@ -6,6 +6,7 @@ import { effectRestrictedImportPatterns, effectRestrictedImports, effectStrictSyntaxRestrictions, + effectSyntaxRestrictions, effectTypeRules, } from "./rules/index.mjs"; @@ -32,7 +33,11 @@ export const strict = [ patterns: effectRestrictedImportPatterns, }, ], - "no-restricted-syntax": ["error", ...effectStrictSyntaxRestrictions], + "no-restricted-syntax": [ + "error", + ...effectSyntaxRestrictions, + ...effectStrictSyntaxRestrictions, + ], ...effectTypeRules, }, }, diff --git a/packages/effect-ts-check/tests/cli.test.mjs b/packages/effect-ts-check/tests/cli.test.mjs index 3e42ec3..de90233 100644 --- a/packages/effect-ts-check/tests/cli.test.mjs +++ b/packages/effect-ts-check/tests/cli.test.mjs @@ -80,6 +80,25 @@ test("cli supports strict profile", () => { assert.match(result.stdout, /no-restricted-imports|@typescript-eslint\/no-explicit-any|no-console/) }) +test("cli strict profile keeps minimal syntax checks", () => { + const fixture = writeTempFixture( + "fail.ts", + "async function demo() {\n await Promise.resolve(1)\n}\n\ndemo()\n" + ) + const result = spawnSync( + process.execPath, + [cliPath, "--profile", "strict", fixture.path], + { + cwd: packageDir, + encoding: "utf8" + } + ) + fixture.cleanup() + + assert.notEqual(result.status, 0) + assert.match(result.stdout, /no-restricted-syntax/) +}) + test("cli rejects unknown profiles", () => { const result = spawnSync(process.execPath, [cliPath, "--profile", "weird"], { cwd: packageDir, diff --git a/packages/effect-ts-check/tests/configs.test.mjs b/packages/effect-ts-check/tests/configs.test.mjs index 1c95690..7110540 100644 --- a/packages/effect-ts-check/tests/configs.test.mjs +++ b/packages/effect-ts-check/tests/configs.test.mjs @@ -39,10 +39,62 @@ test("strict adds import and type policy", async () => { assert.ok(ruleIds.includes("no-console")) }) +test("strict retains minimal syntax policy", async () => { + const [result] = await lintSnippet( + strict, + "async function demo() { await Promise.resolve(1) }", + "demo.ts" + ) + + assert.ok(result.messages.some((message) => message.ruleId === "no-restricted-syntax")) +}) + test("strict includes additional effect-eslint preset layers", () => { assert.ok(strict.length > minimal.length) }) +test("minimal leaves import and type policy to strict", async () => { + const [result] = await lintSnippet( + minimal, + ['import fs from "node:fs"', "const value: any = 1", "console.log(fs, value)"].join("\n"), + "demo.ts" + ) + + const ruleIds = result.messages.map((message) => message.ruleId) + + assert.ok(!ruleIds.includes("no-restricted-imports")) + assert.ok(!ruleIds.includes("@typescript-eslint/no-explicit-any")) +}) + +test("minimal applies to common module extensions", async () => { + for (const filePath of ["demo.jsx", "demo.mts", "demo.cts"]) { + const [result] = await lintSnippet( + minimal, + "async function demo() { await Promise.resolve(1) }", + filePath + ) + + assert.ok( + result.messages.some((message) => message.ruleId === "no-restricted-syntax"), + `${filePath} should be checked` + ) + } +}) + +test("strict applies to common module extensions", async () => { + for (const filePath of ["demo.jsx", "demo.mts", "demo.cts"]) { + const [result] = await lintSnippet( + strict, + "async function demo() { await Promise.resolve(1); console.log(1) }", + filePath + ) + const ruleIds = result.messages.map((message) => message.ruleId) + + assert.ok(ruleIds.includes("no-restricted-syntax"), `${filePath} should keep syntax checks`) + assert.ok(ruleIds.includes("no-console"), `${filePath} should keep strict checks`) + } +}) + test("minimal ignores nested tests and fixtures", async () => { const [testResult] = await lintSnippet( minimal, diff --git a/plugins/effect-ts-skills/.codex-plugin/plugin.json b/plugins/effect-ts-skills/.codex-plugin/plugin.json new file mode 100644 index 0000000..26e0c02 --- /dev/null +++ b/plugins/effect-ts-skills/.codex-plugin/plugin.json @@ -0,0 +1,38 @@ +{ + "name": "effect-ts-skills", + "version": "0.1.0", + "description": "Effect-TS skill and bundled compliance tooling for Codex.", + "author": { + "name": "ProverCoderAI", + "url": "https://github.com/ProverCoderAI" + }, + "homepage": "https://github.com/ProverCoderAI/effect-ts-skills", + "repository": "https://github.com/ProverCoderAI/effect-ts-skills", + "license": "ISC", + "keywords": [ + "effect", + "effect-ts", + "typescript", + "eslint", + "skills", + "codex" + ], + "skills": "./skills/", + "interface": { + "displayName": "Effect TS Skills", + "shortDescription": "Effect-TS skill and compliance checks", + "longDescription": "Installable Codex plugin that bundles the effect-ts-guide skill together with the effect-ts-check compliance CLI.", + "developerName": "ProverCoderAI", + "category": "Developer Tools", + "capabilities": [ + "Read", + "Write" + ], + "websiteURL": "https://github.com/ProverCoderAI/effect-ts-skills", + "defaultPrompt": [ + "Review this repo for Effect-TS compliance.", + "Wire Effect editor tooling for this project.", + "Run the bundled Effect-TS skill on this codebase." + ] + } +} diff --git a/plugins/effect-ts-skills/skills/effect-ts-guide/SKILL.md b/plugins/effect-ts-skills/skills/effect-ts-guide/SKILL.md new file mode 100644 index 0000000..7df5087 --- /dev/null +++ b/plugins/effect-ts-skills/skills/effect-ts-guide/SKILL.md @@ -0,0 +1,67 @@ +--- +name: effect-ts-guide +description: Effect-TS guidance for architecture, typed errors, Layers, boundary validation, resource safety, compliance checks, and editor tooling. Use when a task is explicitly about reviewing or implementing Effect or @effect/* application and library code, refactoring code to Effect, validating Effect-style conventions, or wiring Effect editor/language-service setup. Do not use for generic package publishing or plugin scaffolding tasks unless the code under review is the Effect code itself. +--- + +# Effect TS Guide + +## Workflow + +1. Confirm the codebase or request is actually Effect-oriented. If it is not, stop and do not force this skill. +2. Run the quick compliance check first: + +```bash +SKILL_DIR= +bash "$SKILL_DIR/scripts/run-effect-ts-check.sh" . +``` + +If the repository is mostly tooling, docs, or test fixtures for the checker itself, scope the command to the relevant Effect source directories instead of blindly linting the whole workspace. + +3. For stricter product-code policy checks, run: + +```bash +bash "$SKILL_DIR/scripts/run-effect-ts-check.sh" --profile strict +``` + +4. Fix the violations that are machine-detectable. +5. Apply the manual rules from the references for architecture and style decisions. +6. Re-run the relevant check before finishing. + +For editor integration tasks, treat `effect-ts-check` as the reusable CLI/compliance package and `@effect/language-service` plus VSCode settings/extensions as a separate setup concern. + +The skill is intentionally self-contained. Resolve `scripts/run-effect-ts-check.sh` relative to the skill directory so the same instructions work for standalone installs, repo-local skills, and plugin-contributed skills without hardcoding `~/.codex`. + +## What The Check Covers + +The compliance check is for fast, repeatable signals: + +- direct `async/await` +- raw `Promise` usage +- `try/catch` in product code +- `switch` +- `require` +- common JavaScript and TypeScript module extensions, including `.jsx`, `.mts`, and `.cts` + +The `strict` profile also layers in unsafe host import checks, obvious typing-policy violations such as `any` and `ts-ignore`, unsupported casts, direct `fetch`, `catchAll`, and the official `@effect/eslint-plugin` preset for Effect-aware lint behavior. + +The wrapper uses `npx` with a bundled `effect-ts-check` tarball. It can run from a standalone skill install, but npm registry access or an npm cache is required for package dependencies. + +## What Still Needs Judgment + +The check does not replace architectural reasoning. Apply manual rules for: + +- CORE vs SHELL boundaries +- typed error design +- Layer and dependency injection shape +- boundary decoding with `@effect/schema` +- resource safety with `Effect.acquireRelease` and `Effect.scoped` +- exhaustive handling of unions +- whether `Effect.runPromise`, `Effect.runSync`, and similar runtime execution calls are isolated to shell entrypoints + +## References + +- [Best Practices](references/best-practices.md) +- [Platform Map](references/platform-map.md) +- [Lint Checks](references/lint-checks.md) +- [Manual Writing Rules](references/manual-writing-rules.md) +- [Editor Tooling](references/editor-tooling.md) diff --git a/plugins/effect-ts-skills/skills/effect-ts-guide/agents/openai.yaml b/plugins/effect-ts-skills/skills/effect-ts-guide/agents/openai.yaml new file mode 100644 index 0000000..322db24 --- /dev/null +++ b/plugins/effect-ts-skills/skills/effect-ts-guide/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Effect TS Guide" + short_description: "Effect-TS guidance, checks, and editor setup" + default_prompt: "Use $effect-ts-guide to review or implement Effect-TS code; run the bundled checker from the skill directory first." + +policy: + allow_implicit_invocation: true diff --git a/plugins/effect-ts-skills/skills/effect-ts-guide/assets/effect-ts-check/prover-coder-ai-effect-ts-check-0.1.0.tgz b/plugins/effect-ts-skills/skills/effect-ts-guide/assets/effect-ts-check/prover-coder-ai-effect-ts-check-0.1.0.tgz new file mode 100644 index 0000000..fd06654 Binary files /dev/null and b/plugins/effect-ts-skills/skills/effect-ts-guide/assets/effect-ts-check/prover-coder-ai-effect-ts-check-0.1.0.tgz differ diff --git a/plugins/effect-ts-skills/skills/effect-ts-guide/references/best-practices.md b/plugins/effect-ts-skills/skills/effect-ts-guide/references/best-practices.md new file mode 100644 index 0000000..e03573b --- /dev/null +++ b/plugins/effect-ts-skills/skills/effect-ts-guide/references/best-practices.md @@ -0,0 +1,48 @@ +# Best Practices + +## Core Principles + +- Keep core logic pure. +- Keep IO in a thin shell. +- Model errors explicitly with tagged unions. +- Prefer immutable data and total functions. + +## Composition + +- Use `pipe`, `Effect.flatMap`, `Effect.map`, or `Effect.gen` for sequential flows. +- Use `Match.exhaustive` for union handling. +- Use `Effect.try` and `Effect.tryPromise` only at boundaries. + +## Dependency Injection + +- Define services with `Context.Tag`. +- Provide live layers at runtime. +- Provide test layers in tests. + +## Boundary Validation + +- Accept `unknown` only at the boundary. +- Decode with `@effect/schema`. +- Pass validated values into core logic. + +## Resource Safety + +- Use `Effect.acquireRelease` for resources. +- Use `Effect.scoped` for controlled lifetimes. + +## Platform Usage + +- Prefer `@effect/platform` services over host APIs. +- Use `HttpClient`, `FileSystem`, `Path`, `Command`, and `PlatformLogger` where appropriate. + +## Testing + +- Write tests as Effects. +- Use test layers and mocks. +- Add property-based tests when a rule should hold for many inputs. + +## Editor Setup + +- Use `@effect/language-service` in `tsconfig.base.json` for editor diagnostics and Effect-aware suggestions. +- Recommend the Effect VSCode extension alongside ESLint, but treat it as authoring support rather than a runtime or CI dependency. +- Keep the editor language service separate from the reusable CLI compliance package. diff --git a/plugins/effect-ts-skills/skills/effect-ts-guide/references/editor-tooling.md b/plugins/effect-ts-skills/skills/effect-ts-guide/references/editor-tooling.md new file mode 100644 index 0000000..17e2dfd --- /dev/null +++ b/plugins/effect-ts-skills/skills/effect-ts-guide/references/editor-tooling.md @@ -0,0 +1,23 @@ +# Editor Tooling + +## Scope + +- `@prover-coder-ai/effect-ts-check` is the reusable CLI/compliance package. +- `scripts/run-effect-ts-check.sh` is the self-contained skill entrypoint for running that package before npm publish. +- `@effect/language-service` is the editor-facing TypeScript plugin for Effect-aware diagnostics and suggestions. +- VSCode integration is a separate setup layer, not part of the CLI package. + +## What To Mention + +- Add `@effect/language-service` to `tsconfig.base.json` `compilerOptions.plugins`. +- Recommend the Effect VSCode extension `effectful-tech.effect-vscode` when the repo is used interactively in the editor. +- Keep `dbaeumer.vscode-eslint` alongside the Effect extension so editor lint feedback matches the CLI package. +- Keep ESLint and language-service responsibilities separate: + - ESLint/CLI catches repeatable policy violations. + - The language service improves editor diagnostics and suggestions. + +## Practical Guidance + +- If a task is about code quality enforcement in CI, use the CLI/package docs. +- If a task is about authoring experience, completion, or diagnostics in the editor, use the editor tooling docs. +- Do not describe the language service as a replacement for the CLI check. diff --git a/plugins/effect-ts-skills/skills/effect-ts-guide/references/lint-checks.md b/plugins/effect-ts-skills/skills/effect-ts-guide/references/lint-checks.md new file mode 100644 index 0000000..e686a36 --- /dev/null +++ b/plugins/effect-ts-skills/skills/effect-ts-guide/references/lint-checks.md @@ -0,0 +1,52 @@ +# Lint Checks + +## Quick Command + +Run this first: + +```bash +SKILL_DIR= +bash "$SKILL_DIR/scripts/run-effect-ts-check.sh" . +``` + +Resolve `scripts/run-effect-ts-check.sh` relative to the skill directory. The wrapper resolves the bundled `effect-ts-check` tarball without assuming a specific install location such as `~/.codex`. + +If the repository is mostly tooling, docs, or test fixtures for the checker itself, run the command only against the relevant Effect source paths instead of `.`. + +The wrapper installs the bundled tarball through `npx --package`, so npm registry access or a warm npm cache is required for the package dependencies. + +## Minimal Profile + +The minimal profile should catch the high-signal Effect violations: + +- `async` functions and `await` +- raw `Promise` construction and `Promise.*` +- `try/catch` +- `switch` +- `require` +- common JavaScript and TypeScript module extensions, including `.jsx`, `.mts`, and `.cts` + +## Strict Profile + +The strict profile should add deeper policy checks: + +- the official `@effect/eslint-plugin` preset +- direct host imports that bypass Effect platform services +- obvious unsafe typing policy violations +- safer handling around casts and `unknown` +- direct `fetch` and other host API restrictions +- eslint comment hygiene + +Runtime execution boundaries such as `Effect.runPromise` and CORE/SHELL import direction still need manual review because the correct answer depends on the repository's entrypoint layout. + +## Editor Tooling Boundary + +- `effect-ts-check` is the reusable command-line compliance layer. +- `@effect/language-service` and VSCode settings belong to the editor experience layer. +- Do not expect the CLI to provide completion or hover behavior; that comes from the language service. + +## How To Use Results + +- Fix machine-detectable violations first. +- Treat remaining issues as architecture or design decisions. +- Do not use the lint output as a substitute for boundary modeling or type design. diff --git a/plugins/effect-ts-skills/skills/effect-ts-guide/references/manual-writing-rules.md b/plugins/effect-ts-skills/skills/effect-ts-guide/references/manual-writing-rules.md new file mode 100644 index 0000000..09b6568 --- /dev/null +++ b/plugins/effect-ts-skills/skills/effect-ts-guide/references/manual-writing-rules.md @@ -0,0 +1,39 @@ +# Manual Writing Rules + +## Applicability Gate + +Only apply this skill when the task is explicitly Effect-related. + +## Architecture Rules + +- CORE must not depend on SHELL. +- Effects belong in the shell or boundary layer. +- Every external input should be decoded before entering core. +- Every failure that matters should be typed. + +## Code Style Rules + +- Prefer `Effect.gen` for readable effect composition. +- Prefer `Match.exhaustive` over `switch`. +- Avoid `async/await` in product logic. +- Avoid `try/catch` except at boundaries where you immediately convert to typed errors. +- Avoid raw `Promise` chains. +- Keep `Effect.runPromise`, `Effect.runSync`, and similar runtime execution calls in shell entrypoints. + +## Review Rules + +- If the code compiles but violates the architecture, call that out. +- Separate machine-checkable issues from design issues. +- If the one-command check passes, still inspect boundaries, error types, and resource lifetimes. + +## Response Rules + +- Be concrete about what should change. +- Prefer minimal diffs. +- Explain why a change preserves purity, typing, or boundary safety. + +## Editor Integration Rules + +- When the user asks about Effect editor support, mention both `@effect/language-service` and the VSCode extension. +- Keep the explanation split between reusable compliance tooling and editor authoring setup. +- If a repo already has CLI checks, do not imply the language service replaces them. diff --git a/plugins/effect-ts-skills/skills/effect-ts-guide/references/platform-map.md b/plugins/effect-ts-skills/skills/effect-ts-guide/references/platform-map.md new file mode 100644 index 0000000..685bd55 --- /dev/null +++ b/plugins/effect-ts-skills/skills/effect-ts-guide/references/platform-map.md @@ -0,0 +1,18 @@ +# Platform Map + +`@effect/platform` replaces host APIs with typed services and Layers. + +## Common Mappings + +- `HttpClient` replaces `fetch`, `undici`, and `axios` +- `FileSystem` replaces `fs` and `fs.promises` +- `Path` replaces `node:path` +- `Command` replaces `child_process` +- `Runtime` replaces direct `process` handling +- `PlatformLogger` replaces `console` for structured logging + +## Guidance + +- Prefer Effect platform services over host APIs. +- Use host APIs only when no Effect replacement is needed and the boundary is already isolated. +- Editor tooling such as `@effect/language-service` is not a runtime replacement for host APIs; it only improves authoring feedback. diff --git a/skills/effect-ts-guide/SKILL.md b/skills/effect-ts-guide/SKILL.md index 4cf2279..7df5087 100644 --- a/skills/effect-ts-guide/SKILL.md +++ b/skills/effect-ts-guide/SKILL.md @@ -11,14 +11,21 @@ description: Effect-TS guidance for architecture, typed errors, Layers, boundary 2. Run the quick compliance check first: ```bash -bash scripts/run-effect-ts-check.sh . +SKILL_DIR= +bash "$SKILL_DIR/scripts/run-effect-ts-check.sh" . ``` If the repository is mostly tooling, docs, or test fixtures for the checker itself, scope the command to the relevant Effect source directories instead of blindly linting the whole workspace. -3. Fix the violations that are machine-detectable. -4. Apply the manual rules from the references for architecture and style decisions. -5. Re-run the check before finishing. +3. For stricter product-code policy checks, run: + +```bash +bash "$SKILL_DIR/scripts/run-effect-ts-check.sh" --profile strict +``` + +4. Fix the violations that are machine-detectable. +5. Apply the manual rules from the references for architecture and style decisions. +6. Re-run the relevant check before finishing. For editor integration tasks, treat `effect-ts-check` as the reusable CLI/compliance package and `@effect/language-service` plus VSCode settings/extensions as a separate setup concern. @@ -33,10 +40,11 @@ The compliance check is for fast, repeatable signals: - `try/catch` in product code - `switch` - `require` -- unsafe host imports where Effect platform services should be used -- obvious policy violations such as `any`, `ts-ignore`, or unsupported casts in strict areas +- common JavaScript and TypeScript module extensions, including `.jsx`, `.mts`, and `.cts` + +The `strict` profile also layers in unsafe host import checks, obvious typing-policy violations such as `any` and `ts-ignore`, unsupported casts, direct `fetch`, `catchAll`, and the official `@effect/eslint-plugin` preset for Effect-aware lint behavior. -The `strict` profile also layers in the official `@effect/eslint-plugin` preset for Effect-aware lint behavior. +The wrapper uses `npx` with a bundled `effect-ts-check` tarball. It can run from a standalone skill install, but npm registry access or an npm cache is required for package dependencies. ## What Still Needs Judgment @@ -48,6 +56,7 @@ The check does not replace architectural reasoning. Apply manual rules for: - boundary decoding with `@effect/schema` - resource safety with `Effect.acquireRelease` and `Effect.scoped` - exhaustive handling of unions +- whether `Effect.runPromise`, `Effect.runSync`, and similar runtime execution calls are isolated to shell entrypoints ## References diff --git a/skills/effect-ts-guide/agents/openai.yaml b/skills/effect-ts-guide/agents/openai.yaml index daa91a0..322db24 100644 --- a/skills/effect-ts-guide/agents/openai.yaml +++ b/skills/effect-ts-guide/agents/openai.yaml @@ -1,7 +1,7 @@ interface: display_name: "Effect TS Guide" short_description: "Effect-TS guidance, checks, and editor setup" - default_prompt: "Use $effect-ts-guide for Effect-TS implementation, review, or editor setup tasks, start with `bash scripts/run-effect-ts-check.sh .` for Effect code compliance, and narrow the scope when the repository is primarily tooling or fixtures." + default_prompt: "Use $effect-ts-guide to review or implement Effect-TS code; run the bundled checker from the skill directory first." policy: allow_implicit_invocation: true diff --git a/skills/effect-ts-guide/assets/effect-ts-check/prover-coder-ai-effect-ts-check-0.1.0.tgz b/skills/effect-ts-guide/assets/effect-ts-check/prover-coder-ai-effect-ts-check-0.1.0.tgz index b7074a1..fd06654 100644 Binary files a/skills/effect-ts-guide/assets/effect-ts-check/prover-coder-ai-effect-ts-check-0.1.0.tgz and b/skills/effect-ts-guide/assets/effect-ts-check/prover-coder-ai-effect-ts-check-0.1.0.tgz differ diff --git a/skills/effect-ts-guide/references/lint-checks.md b/skills/effect-ts-guide/references/lint-checks.md index c2b32e4..e686a36 100644 --- a/skills/effect-ts-guide/references/lint-checks.md +++ b/skills/effect-ts-guide/references/lint-checks.md @@ -5,13 +5,16 @@ Run this first: ```bash -bash scripts/run-effect-ts-check.sh . +SKILL_DIR= +bash "$SKILL_DIR/scripts/run-effect-ts-check.sh" . ``` Resolve `scripts/run-effect-ts-check.sh` relative to the skill directory. The wrapper resolves the bundled `effect-ts-check` tarball without assuming a specific install location such as `~/.codex`. If the repository is mostly tooling, docs, or test fixtures for the checker itself, run the command only against the relevant Effect source paths instead of `.`. +The wrapper installs the bundled tarball through `npx --package`, so npm registry access or a warm npm cache is required for the package dependencies. + ## Minimal Profile The minimal profile should catch the high-signal Effect violations: @@ -21,20 +24,21 @@ The minimal profile should catch the high-signal Effect violations: - `try/catch` - `switch` - `require` -- direct host imports that bypass Effect platform services -- obvious unsafe typing policy violations +- common JavaScript and TypeScript module extensions, including `.jsx`, `.mts`, and `.cts` ## Strict Profile The strict profile should add deeper policy checks: - the official `@effect/eslint-plugin` preset -- shell-only boundaries for runtime execution -- no direct CORE imports from SHELL +- direct host imports that bypass Effect platform services +- obvious unsafe typing policy violations - safer handling around casts and `unknown` -- stricter host API restrictions +- direct `fetch` and other host API restrictions - eslint comment hygiene +Runtime execution boundaries such as `Effect.runPromise` and CORE/SHELL import direction still need manual review because the correct answer depends on the repository's entrypoint layout. + ## Editor Tooling Boundary - `effect-ts-check` is the reusable command-line compliance layer. diff --git a/skills/effect-ts-guide/references/manual-writing-rules.md b/skills/effect-ts-guide/references/manual-writing-rules.md index 2d6ccc6..09b6568 100644 --- a/skills/effect-ts-guide/references/manual-writing-rules.md +++ b/skills/effect-ts-guide/references/manual-writing-rules.md @@ -18,6 +18,7 @@ Only apply this skill when the task is explicitly Effect-related. - Avoid `async/await` in product logic. - Avoid `try/catch` except at boundaries where you immediately convert to typed errors. - Avoid raw `Promise` chains. +- Keep `Effect.runPromise`, `Effect.runSync`, and similar runtime execution calls in shell entrypoints. ## Review Rules diff --git a/skills/effect-ts-guide/scripts/refresh-effect-ts-check-asset.sh b/skills/effect-ts-guide/scripts/refresh-effect-ts-check-asset.sh index d077117..b9c6ee0 100755 --- a/skills/effect-ts-guide/scripts/refresh-effect-ts-check-asset.sh +++ b/skills/effect-ts-guide/scripts/refresh-effect-ts-check-asset.sh @@ -5,8 +5,24 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" SKILL_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" REPO_ROOT="$(cd -- "$SKILL_DIR/../.." && pwd)" ASSET_DIR="$SKILL_DIR/assets/effect-ts-check" +TMP_DIR="$(mktemp -d)" + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +corepack pnpm --dir "$REPO_ROOT/packages/effect-ts-check" pack --pack-destination "$TMP_DIR" + +shopt -s nullglob +TARBALLS=("$TMP_DIR"/*.tgz) +shopt -u nullglob + +if [[ "${#TARBALLS[@]}" -ne 1 ]]; then + printf 'Expected exactly one packed effect-ts-check tarball, found %s\n' "${#TARBALLS[@]}" >&2 + exit 1 +fi mkdir -p "$ASSET_DIR" rm -f "$ASSET_DIR"/*.tgz - -pnpm --dir "$REPO_ROOT/packages/effect-ts-check" pack --pack-destination "$ASSET_DIR" +mv "${TARBALLS[0]}" "$ASSET_DIR/" diff --git a/tools/sync-plugin-wrapper.mjs b/tools/sync-plugin-wrapper.mjs new file mode 100644 index 0000000..3d899c5 --- /dev/null +++ b/tools/sync-plugin-wrapper.mjs @@ -0,0 +1,15 @@ +import { cpSync, mkdirSync, rmSync } from "node:fs" +import { join, resolve } from "node:path" + +const repoRoot = resolve(new URL("..", import.meta.url).pathname) +const wrapperRoot = join(repoRoot, "plugins", "effect-ts-skills") + +rmSync(wrapperRoot, { recursive: true, force: true }) +mkdirSync(wrapperRoot, { recursive: true }) + +cpSync(join(repoRoot, ".codex-plugin"), join(wrapperRoot, ".codex-plugin"), { + recursive: true, +}) +cpSync(join(repoRoot, "skills"), join(wrapperRoot, "skills"), { + recursive: true, +}) diff --git a/tools/validate-distribution.mjs b/tools/validate-distribution.mjs index 61d6a63..27954e4 100644 --- a/tools/validate-distribution.mjs +++ b/tools/validate-distribution.mjs @@ -1,10 +1,24 @@ -import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs" +import { + existsSync, + lstatSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs" +import { spawnSync } from "node:child_process" import { join, resolve } from "node:path" +import { tmpdir } from "node:os" const repoRoot = resolve(new URL("..", import.meta.url).pathname) const pluginManifestPath = join(repoRoot, ".codex-plugin", "plugin.json") +const marketplacePath = join(repoRoot, ".agents", "plugins", "marketplace.json") const skillDir = join(repoRoot, "skills", "effect-ts-guide") const skillEntryPath = join(skillDir, "SKILL.md") +const skillAgentPath = join(skillDir, "agents", "openai.yaml") +const skillLintChecksPath = join(skillDir, "references", "lint-checks.md") const skillScriptPath = join(skillDir, "scripts", "run-effect-ts-check.sh") const skillAssetPath = join( skillDir, @@ -13,6 +27,25 @@ const skillAssetPath = join( "prover-coder-ai-effect-ts-check-0.1.0.tgz", ) const repoLocalSkillPath = join(repoRoot, ".agents", "skills", "effect-ts-guide") +const repoLocalPluginPath = join(repoRoot, "plugins", "effect-ts-skills") +const packageDir = join(repoRoot, "packages", "effect-ts-check") +const packageSrcDir = join(packageDir, "src") + +const pluginAllowedFields = new Set([ + "id", + "name", + "version", + "description", + "skills", + "apps", + "mcpServers", + "interface", + "author", + "homepage", + "repository", + "license", + "keywords", +]) function fail(message) { process.stderr.write(`${message}\n`) @@ -25,9 +58,23 @@ function assertPathExists(path, description) { } } +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")) +} + +function assertNoUnknownPluginFields(manifest) { + for (const field of Object.keys(manifest)) { + if (!pluginAllowedFields.has(field)) { + fail(`Unsupported plugin manifest field: ${field}`) + } + } +} + function assertPluginManifest() { assertPathExists(pluginManifestPath, "Plugin manifest") - const manifest = JSON.parse(readFileSync(pluginManifestPath, "utf8")) + const manifest = readJson(pluginManifestPath) + + assertNoUnknownPluginFields(manifest) if (manifest.name !== "effect-ts-skills") { fail(`Unexpected plugin name: ${manifest.name}`) @@ -36,6 +83,45 @@ function assertPluginManifest() { if (manifest.skills !== "./skills/") { fail(`Unexpected plugin skills path: ${manifest.skills}`) } + + if (!Array.isArray(manifest.interface?.defaultPrompt)) { + fail("Plugin manifest must define interface.defaultPrompt") + } +} + +function assertMarketplace() { + assertPathExists(marketplacePath, "Repo plugin marketplace") + const marketplace = readJson(marketplacePath) + + if (marketplace.name !== "effect-ts-skills") { + fail(`Unexpected marketplace name: ${marketplace.name}`) + } + + const plugin = marketplace.plugins?.find((entry) => entry.name === "effect-ts-skills") + + if (!plugin) { + fail("Marketplace must include effect-ts-skills plugin entry") + return + } + + if ( + plugin.source?.source !== "local" || + plugin.source?.path !== "./plugins/effect-ts-skills" + ) { + fail("Marketplace effect-ts-skills source must point at plugins/effect-ts-skills") + } + + if (plugin.policy?.installation !== "AVAILABLE") { + fail("Marketplace effect-ts-skills policy.installation must be AVAILABLE") + } + + if (plugin.policy?.authentication !== "ON_INSTALL") { + fail("Marketplace effect-ts-skills policy.authentication must be ON_INSTALL") + } + + if (plugin.category !== "Developer Tools") { + fail("Marketplace effect-ts-skills category must be Developer Tools") + } } function assertRepoLocalSkillLink() { @@ -53,12 +139,249 @@ function assertRepoLocalSkillLink() { } } +function assertRepoLocalPluginWrapper() { + assertPathExists(repoLocalPluginPath, "Repo-local plugin entry") + + const stats = lstatSync(repoLocalPluginPath) + + if (!stats.isDirectory()) { + fail(`Repo-local plugin entry must be a directory: ${repoLocalPluginPath}`) + return + } +} + +function assertSkillAgentMetadata() { + assertPathExists(skillAgentPath, "Skill OpenAI metadata") + const agentMetadata = readFileSync(skillAgentPath, "utf8") + + if (!agentMetadata.includes("display_name:")) { + fail("Skill OpenAI metadata must define interface.display_name") + } + + if (!agentMetadata.includes("short_description:")) { + fail("Skill OpenAI metadata must define interface.short_description") + } + + if (!agentMetadata.includes("$effect-ts-guide")) { + fail("Skill OpenAI metadata default prompt must mention $effect-ts-guide") + } +} + +function sectionBetween(contents, start, end) { + const startIndex = contents.indexOf(start) + const endIndex = contents.indexOf(end) + + if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) { + fail(`Unable to find documentation section ${start}`) + return "" + } + + return contents.slice(startIndex, endIndex) +} + +function assertLintCheckDocs() { + assertPathExists(skillLintChecksPath, "Skill lint checks reference") + const contents = readFileSync(skillLintChecksPath, "utf8") + const minimalSection = sectionBetween(contents, "## Minimal Profile", "## Strict Profile") + const strictSection = sectionBetween(contents, "## Strict Profile", "## Editor Tooling Boundary") + + for (const expected of [ + "`async`", + "`Promise`", + "`try/catch`", + "`switch`", + "`require`", + "`.jsx`", + "`.mts`", + "`.cts`", + ]) { + if (!minimalSection.includes(expected)) { + fail(`Minimal profile docs must mention ${expected}`) + } + } + + for (const unexpected of ["direct host imports", "unsafe typing"]) { + if (minimalSection.includes(unexpected)) { + fail(`Minimal profile docs must not mention ${unexpected}`) + } + } + + for (const expected of [ + "direct host imports", + "unsafe typing", + "casts", + "`unknown`", + "`fetch`", + "host API restrictions", + ]) { + if (!strictSection.includes(expected)) { + fail(`Strict profile docs must mention ${expected}`) + } + } + + if (!contents.includes("Runtime execution boundaries")) { + fail("Lint docs must call runtime execution boundaries manual review") + } + + if (!contents.includes("CORE/SHELL import direction")) { + fail("Lint docs must call CORE/SHELL import direction manual review") + } +} + +function listFiles(root) { + const entries = [] + + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name) + + if (entry.isDirectory()) { + for (const child of listFiles(path)) { + entries.push(join(entry.name, child)) + } + continue + } + + if (entry.isFile()) { + entries.push(entry.name) + } + } + + return entries.sort() +} + +function assertTreeMatches(sourceRoot, copyRoot, description) { + const sourceFiles = listFiles(sourceRoot) + const copyFiles = listFiles(copyRoot) + + if (sourceFiles.join("\n") !== copyFiles.join("\n")) { + fail(`${description} file list is out of sync`) + return + } + + for (const file of sourceFiles) { + const source = readFileSync(join(sourceRoot, file)) + const copy = readFileSync(join(copyRoot, file)) + + if (!source.equals(copy)) { + fail(`${description} is out of sync for ${file}`) + } + } +} + +function stableJson(value) { + if (Array.isArray(value)) { + return value.map(stableJson) + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, stableJson(entry)]), + ) + } + + return value +} + +function assertPackageManifestSync(extractedPackage) { + const source = stableJson(readJson(join(packageDir, "package.json"))) + const bundled = stableJson(readJson(join(extractedPackage, "package.json"))) + + if (JSON.stringify(source) !== JSON.stringify(bundled)) { + fail("Bundled effect-ts-check package.json is out of sync") + } +} + +function assertPluginWrapperSync() { + assertTreeMatches( + join(repoRoot, ".codex-plugin"), + join(repoLocalPluginPath, ".codex-plugin"), + "Plugin wrapper manifest", + ) + assertTreeMatches( + join(repoRoot, "skills"), + join(repoLocalPluginPath, "skills"), + "Plugin wrapper skills", + ) +} + +function assertTarballSync() { + const tmpRoot = mkdtempSync(join(tmpdir(), "effect-ts-check-asset-")) + + try { + const result = spawnSync("tar", ["-xzf", skillAssetPath, "-C", tmpRoot], { + encoding: "utf8", + }) + + if (result.status !== 0) { + fail(result.stderr.trim() || "Unable to extract bundled effect-ts-check asset") + return + } + + const extractedPackage = join(tmpRoot, "package") + assertPackageManifestSync(extractedPackage) + + const packageFiles = listFiles(packageSrcDir) + const tarballFiles = listFiles(join(extractedPackage, "src")) + + if (packageFiles.join("\n") !== tarballFiles.join("\n")) { + fail("Bundled effect-ts-check asset src file list is out of sync") + return + } + + for (const file of packageFiles) { + const source = readFileSync(join(packageSrcDir, file), "utf8") + const bundled = readFileSync(join(extractedPackage, "src", file), "utf8") + + if (source !== bundled) { + fail(`Bundled effect-ts-check asset is out of sync for src/${file}`) + } + } + } finally { + rmSync(tmpRoot, { recursive: true, force: true }) + } +} + +function assertBundledWrapperRuns() { + const tmpRoot = mkdtempSync(join(repoRoot, ".tmp-effect-check-")) + const fixture = join(tmpRoot, "fail.mts") + + try { + writeFileSync( + fixture, + "async function demo() {\n await Promise.resolve(1)\n}\n\ndemo()\n", + ) + const result = spawnSync("bash", [skillScriptPath, fixture], { + cwd: repoRoot, + encoding: "utf8", + }) + + if (result.status !== 1) { + fail(`Bundled wrapper should reject fixture, got exit ${result.status}`) + } + + if (!result.stdout.includes("no-restricted-syntax")) { + fail("Bundled wrapper output should include no-restricted-syntax") + } + } finally { + rmSync(tmpRoot, { recursive: true, force: true }) + } +} + function main() { assertPluginManifest() + assertMarketplace() assertPathExists(skillEntryPath, "Skill entrypoint") assertPathExists(skillScriptPath, "Bundled skill wrapper") assertPathExists(skillAssetPath, "Bundled effect-ts-check asset") assertRepoLocalSkillLink() + assertRepoLocalPluginWrapper() + assertPluginWrapperSync() + assertSkillAgentMetadata() + assertLintCheckDocs() + assertTarballSync() + assertBundledWrapperRuns() } main()