effect-claudecode
Health Warn
- License — License: MIT
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 6 GitHub stars
Code Pass
- Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
Effect v4 bindings for Claude Code's plugin primitives — hooks, skills, settings, MCP servers, frontmatter
effect-claudecode
Write Claude Code plugins — hooks, skills, subagents, commands, MCP servers — with Effect v4.
Install
npm install effect-claudecode [email protected] @effect/[email protected]
bun add effect-claudecode [email protected] @effect/[email protected]
Before / After
A vanilla Claude Code hook that rewrites npm test to bun run test:
// hooks/rewrite-test.js — vanilla Node.js
let data = '';
process.stdin.on('data', (chunk) => (data += chunk));
process.stdin.on('end', () => {
const input = JSON.parse(data);
const command = input.tool_input?.command ?? '';
const output = command.startsWith('npm test')
? { continue: true, hookSpecificOutput: {
hookEventName: 'PreToolUse',
updatedInput: { command: command.replace('npm test', 'bun run test') },
permissionDecision: 'allow',
permissionDecisionReason: 'This workspace runs tests through Bun.'
}}
: { continue: true };
process.stdout.write(JSON.stringify(output));
process.exit(0);
});
The same hook with effect-claudecode:
import * as Effect from 'effect/Effect';
import * as Str from 'effect/String';
import { Hook } from 'effect-claudecode';
const hook = Hook.PreToolUse.onTool({
toolName: 'Bash',
handler: ({ tool }) =>
Effect.succeed(
Str.startsWith('npm test')(tool.command)
? Hook.PreToolUse.allowWithUpdatedInput(
{ command: Str.replace('npm test', 'bun run test')(tool.command) },
'This workspace runs tests through Bun.'
)
: Hook.PreToolUse.allow()
)
});
Hook.runMain(hook);
Typed tool payloads, decision constructors, correct exit-code semantics — no stdin plumbing, no manual JSON, no process.exit.
What You Can Build
Detect when Claude is stuck in a loop
Every Claude Code user has watched Claude read the same file three times in a row or re-run a failing command with the same arguments. This hook detects that and nudges Claude out of the loop.
Because hooks are ephemeral processes (each invocation is a fresh spawn), the action log is persisted to a JSON file keyed by session ID — a pattern that shows off Effect's FileSystem service and Schema for typed serialization:
import * as NodeFileSystem from '@effect/platform-node-shared/NodeFileSystem';
import * as Arr from 'effect/Array';
import * as Effect from 'effect/Effect';
import * as FileSystem from 'effect/FileSystem';
import * as Schema from 'effect/Schema';
import { Hook } from 'effect-claudecode';
const REPEAT_THRESHOLD = 3;
const ActionEntry = Schema.Struct({ tool: Schema.String, key: Schema.String });
const ActionLog = Schema.Struct({ entries: Schema.Array(ActionEntry) });
type ActionLog = typeof ActionLog.Type;
const actionKey = (input: Hook.PostToolUse.Input): string => {
const toolInput = input.tool_input;
if (input.tool_name === 'Bash') return String(toolInput['command'] ?? 'unknown');
if (input.tool_name === 'Read' || input.tool_name === 'Edit' || input.tool_name === 'Write')
return String(toolInput['file_path'] ?? 'unknown');
return input.tool_name;
};
const hook = Hook.PostToolUse.define({
handler: (input) =>
Effect.gen(function* () {
const sessionId = yield* Hook.sessionId;
const fs = yield* FileSystem.FileSystem;
const statePath = `/tmp/claude-loop-${sessionId}.json`;
const existing = yield* fs.readFileString(statePath).pipe(
Effect.flatMap((raw) =>
Schema.decodeUnknownEffect(Schema.fromJsonString(ActionLog))(raw)
),
Effect.orElseSucceed((): ActionLog => ({ entries: [] }))
);
const key = actionKey(input);
const updated: ActionLog = {
entries: [...existing.entries, { tool: input.tool_name, key }]
};
yield* Schema.encodeEffect(Schema.fromJsonString(ActionLog))(updated).pipe(
Effect.flatMap((json) => fs.writeFileString(statePath, json))
);
const repeats = Arr.filter(
updated.entries,
(e) => e.tool === input.tool_name && e.key === key
).length;
if (repeats >= REPEAT_THRESHOLD) {
return Hook.PostToolUse.addContext(
`You have run \`${input.tool_name}\` on \`${key}\` ${repeats} times this session.` +
' The result has not changed. Step back and consider a different approach' +
' — the fix likely is not in this file, or the command needs different arguments.'
);
}
return Hook.PostToolUse.passthrough();
}).pipe(Effect.provide(NodeFileSystem.layer))
});
Hook.runMain(hook);
Wire it into .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{ "hooks": [{ "type": "command", "command": "bun hooks/loop-detector.ts" }] }
]
}
}
Build a complete plugin in one script
Plugin.define assembles a full plugin — commands, agents, skills, output styles, hooks, and MCP config — as a single declarative value. Pipe it through validate → write → doctor and you have an installable plugin directory:
import * as Effect from 'effect/Effect';
import { ClaudeRuntime, Plugin } from 'effect-claudecode';
const plugin = Plugin.define({
manifest: {
name: 'review-toolkit',
version: '1.0.0',
description: 'Opinionated code review defaults — commands, agents, and loop detection.',
author: new Plugin.AuthorInfo({ name: 'Your Team' })
},
commands: [
Plugin.command({
name: 'review',
description: 'Review staged changes against project conventions',
body: '# /review\n\nReview the staged changes. Lead with concrete findings.\n'
}),
Plugin.command({
name: 'summarize',
description: 'Summarize recent work for a PR description',
body: '# /summarize\n\nRead the git log and diff. Produce a PR title, summary, and test plan.\n'
})
],
agents: [
Plugin.agent({
name: 'reviewer',
description: 'Autonomous code review agent',
body: '# Reviewer\n\nFocus on correctness: logic errors, race conditions, missing tests.\n'
})
],
skills: [
Plugin.skill({
name: 'effect-patterns',
description: 'Guide Claude toward idiomatic Effect v4 patterns',
body: '# Effect Patterns\n\nUse TaggedErrorClass for errors, Option for absence, Schema at boundaries.\n'
})
],
hooksConfig: {
PostToolUse: [{
hooks: [{ type: 'command', command: 'bun "${CLAUDE_PLUGIN_ROOT}"/hooks/loop-detector.ts' }]
}]
},
mcpConfig: {
mcpServers: {
'session-logs': { type: 'stdio', command: 'mcp-filesystem', args: ['--root', '/tmp'] }
}
}
});
const runtime = ClaudeRuntime.default();
await runtime.runPromise(
Plugin.validate(plugin).pipe(
Effect.flatMap(() => Plugin.write(plugin, 'artifacts/review-toolkit')),
Effect.flatMap(() => Plugin.doctor('artifacts/review-toolkit')),
Effect.tap((report) =>
Effect.logInfo('plugin materialized').pipe(
Effect.annotateLogs({ errors: report.errors.length, warnings: report.warnings.length })
)
)
)
);
await runtime.dispose();
artifacts/review-toolkit/
├── .claude-plugin/plugin.json
├── commands/review.md
├── commands/summarize.md
├── agents/reviewer.md
├── skills/effect-patterns/SKILL.md
├── hooks/hooks.json
└── .mcp.json
Stream session activity to external tools
A single binary handles multiple event types via Hook.dispatch, writing structured JSONL entries to a session log. Point any MCP filesystem server at the log directory and other agents can query what Claude has been doing in real-time:
import * as NodeFileSystem from '@effect/platform-node-shared/NodeFileSystem';
import * as Effect from 'effect/Effect';
import * as FileSystem from 'effect/FileSystem';
import { Hook } from 'effect-claudecode';
const appendEvent = (
sessionId: string, event: string, summary: string
): Effect.Effect<void, never, FileSystem.FileSystem> =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const entry = JSON.stringify({ ts: Date.now(), event, summary, session_id: sessionId });
yield* fs.writeFileString(`/tmp/claude-events-${sessionId}.jsonl`, entry + '\n', { flag: 'a' });
}).pipe(Effect.orElseSucceed(() => void 0));
Hook.dispatch({
SessionStart: Hook.SessionStart.define({
handler: (input) =>
appendEvent(input.session_id, 'SessionStart', `source=${input.source}`).pipe(
Effect.as(Hook.SessionStart.passthrough()), Effect.provide(NodeFileSystem.layer))
}),
PostToolUse: Hook.PostToolUse.define({
handler: (input) =>
appendEvent(input.session_id, 'PostToolUse', `tool=${input.tool_name}`).pipe(
Effect.as(Hook.PostToolUse.passthrough()), Effect.provide(NodeFileSystem.layer))
}),
Stop: Hook.Stop.define({
handler: (input) =>
appendEvent(input.session_id, 'Stop', 'turn ended').pipe(
Effect.as(Hook.Stop.allowStop()), Effect.provide(NodeFileSystem.layer))
}),
SessionEnd: Hook.SessionEnd.define({
handler: (input) =>
appendEvent(input.session_id, 'SessionEnd', `reason=${input.exit_reason}`).pipe(
Effect.as(Hook.SessionEnd.passthrough()), Effect.provide(NodeFileSystem.layer))
})
});
Wire all four events to the same binary, then add an MCP server that exposes the logs:
{
"mcpServers": {
"session-logs": { "type": "stdio", "command": "mcp-filesystem", "args": ["--root", "/tmp"] }
}
}
Runtime Choices
- Bun: write TypeScript hook files and point Claude Code at
bun ./hooks/my-hook.ts. - Node + TypeScript: keep the same
.tshook files, but point Claude Code attsx ./hooks/my-hook.ts. - Node + JavaScript: compile your hook scripts ahead of time and point Claude Code at
node ./hooks/my-hook.js.
The library itself does not require Bun. Bun is simply the nicest zero-friction path when you want to keep hook scripts as TypeScript files.
For non-hook programs, ClaudeRuntime.default() is the minimal preset providing platform services (FileSystem, Path) and Effect logging. For project-aware tooling, prefer ClaudeRuntime.project({ cwd }), which wires in the cached ClaudeProject service for settings, plugin lookups, and .mcp.json access. Use ClaudeRuntime.plugin({ cwd, pluginRoot }) when the script should treat a plugin directory as the source of truth.
Features
Hook runner & events
Hook.runMain(hook)— drop-in runner that reads stdin, decodes the schema, builds aHookContext, runs your handler, encodes the output, and exits with the right code- 30 event schemas — permission gates, prompt gates, lifecycle events, subagent events, elicitations, worktree events, display events, and more
- Decision constructors —
Hook.PreToolUse.deny('reason'),Hook.UserPromptSubmit.block('off-topic'),Hook.SessionStart.addContext('extra') HookContextservice —yield* Hook.sessionId,yield* Hook.cwd,yield* Hook.transcriptPathinside any handlerHook.dispatch({...})— handle multiple event types from a single binary- Typed tool adapters —
Hook.PreToolUse.onTool(...)/Hook.PostToolUse.onTool(...)for common tools including Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, Agent, AskUserQuestion, and ExitPlanMode HookBus— publish decoded hook events to a typed in-processStream
Plugin builder
Plugin.define({...})+Plugin.write(def, dir)— declarative plugin builder + materializer that produces a complete plugin directory treePlugin.scan(dir)/Plugin.load(dir)/Plugin.sync(def)— inspect, round-trip, and normalize existing plugin directoriesPlugin.validate+Plugin.doctor— pre-write validation and post-write diagnostics
Project & runtime
ClaudeRuntime.default()/.project({ cwd })/.plugin({ cwd, pluginRoot })— prewiredManagedRuntimepresets withFileSystem,Path, and optional cachedClaudeProjectstateClaudeProject.layer({ cwd })— cached project-scoped access to settings,.mcp.json, plugin directories, and named component lookupsSettings.load(cwd)— reads and merges user, project, local, optional CLI overlay, and file-based managedsettings.jsonscopes into one typedSettingsFile
Parsing & config
Frontmatter.parseSkillFile(path)and friends — one-step typed markdown loaders for skills, commands, subagents, and output stylesMcp.loadJson(path)— read.mcp.jsoninto a discriminatedstdio/http/streamable-http/ws/ deprecatedsseunion with currentoauthand header-helper fields
Testing
- Fixtures for every event with sensible defaults, override field-by-field
runHookWithMockStdin— end-to-end hook runner with captured stdout, stderr, exit codeexpectAllowDecision/expectDenyDecision/expectBlockDecision/expectAddContext— assertion helpers- Mock layers —
makeMockFileSystem,makeMockStdioLayer,makeMockHookContext
Hooks
Hook.runMain
Hook.runMain(hook) is the primary entry point. It is called at the top level of a hook script and:
- Collects all of stdin via
Stdio.stdin - Decodes the stdin JSON payload against the event's
Inputschema - Builds a
HookContext.Servicelayer from the decoded envelope - Runs the handler with the context layer provided
- Encodes the returned
Outputvalue back to JSON and writes it toStdio.stdout - Exits the process with the right code (
0success,1runner error,2input decode failure or handler-controlled event feedback,130SIGINT)
The runner internally provides NodeStdio.layer from @effect/platform-node-shared and installs a custom Runtime.Teardown for exit-code mapping. You never call process.exit, manually decode stdin, or hand-roll stdout/exit handling yourself.
Typed tool adapters
For common Claude Code tools, Hook.Tool and the onTool(...) helpers remove the repetitive tool_input['...'] / tool_response['...'] narrowing:
import * as Effect from 'effect/Effect';
import * as Str from 'effect/String';
import { Hook } from 'effect-claudecode';
const hook = Hook.PreToolUse.onTool({
toolName: 'Bash',
handler: ({ tool }) =>
Effect.succeed(
Str.startsWith('npm test')(tool.command)
? Hook.PreToolUse.allowWithUpdatedInput(
{
command: Str.replace('npm test', 'bun run test')(
tool.command
)
},
'This workspace runs tests through Bun.'
)
: Hook.PreToolUse.allow()
)
});
Hook.runMain(hook);
Currently the built-in typed adapters cover common tools such as Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, Agent, AskUserQuestion, and ExitPlanMode. The lower-level Hook.Tool.decodePreToolUse(...) / decodePostToolUse(...) helpers are also exported if you want the typed decoding without the onTool(...) wrapper.
Event definition
Each of the 30 event namespaces exposes the same shape. Using PreToolUse as the template:
import * as Effect from 'effect/Effect';
import * as Option from 'effect/Option';
import { Hook } from 'effect-claudecode';
// Hook.PreToolUse.Input — Schema.Class with envelope + event-specific fields:
// session_id, transcript_path, cwd, hook_event_name, permission_mode,
// tool_name, tool_input, tool_use_id
//
// Hook.PreToolUse.Output — universal + event-specific output fields:
// continue, stopReason, suppressOutput, systemMessage, terminalSequence,
// hookSpecificOutput
//
// Decision constructors:
// Hook.PreToolUse.allow(reason?)
// Hook.PreToolUse.deny(reason)
// Hook.PreToolUse.ask(reason?)
// Hook.PreToolUse.defer(reason?)
// Hook.PreToolUse.allowWithUpdatedInput(newInput, reason?)
const hook = Hook.PreToolUse.define({
handler: (input) =>
Effect.gen(function* () {
return input.tool_name === 'Bash'
? Hook.PreToolUse.deny('bash disabled')
: Hook.PreToolUse.allow();
})
});
Hook.runMain(hook);
.define(config) returns a HookDefinition<Input, Output> value. The handler can be any Effect whose requirement includes HookContext.Service — the runner provides the context, so your handler sees it as never at the call site.
Accessing the envelope via HookContext
Inside a handler, access the decoded envelope fields via top-level accessors:
import * as Effect from 'effect/Effect';
import { Hook } from 'effect-claudecode';
const hook = Hook.SessionStart.define({
handler: () =>
Effect.gen(function* () {
const sessionId = yield* Hook.sessionId;
const cwd = yield* Hook.cwd;
const transcriptPath = yield* Hook.transcriptPath;
const mode = yield* Hook.permissionMode; // Option.Option<string>
const event = yield* Hook.hookEventName;
return Hook.SessionStart.addContext(
`Session ${sessionId} started in ${cwd} (${event}, mode=${Option.getOrElse(mode, () => 'default')})`
);
})
});
Hook.runMain(hook);
The accessors are Effect<string, never, HookContext.Service> — they simply pull a field out of the service. For direct access to the whole interface, yield HookContext.Service instead.
The 30 events
| Event | Decision constructors | Notes |
|---|---|---|
PreToolUse |
allow(reason?), deny(reason), ask(reason?), defer(reason?), allowWithUpdatedInput(input, reason?) |
Permission gate for tool calls |
PostToolUse |
passthrough(), block(reason), addContext(text), replaceOutput(output, ctx?), replaceMcpOutput(output, ctx?) |
Transform tool output |
UserPromptSubmit |
allow(), block(reason), addContext(text), renameSession(title) |
Gate / augment user prompts |
UserPromptExpansion |
allow(), block(reason), addContext(text) |
Gate / augment slash-command expansion |
Notification |
passthrough(), addContext(text) |
Observe notifications; addContext emits systemMessage |
MessageDisplay |
passthrough(), display(text) |
Transform display-only assistant text |
Stop |
allowStop(), block(reason), addContext(text) |
Gate end-of-turn |
SubagentStop |
allowStop(), block(reason), addContext(text) |
Gate subagent end-of-turn |
SessionStart |
passthrough(), addContext(text), renameSession(title), watchPaths(paths), reloadSkills() |
Inject boot context |
SessionEnd |
passthrough() |
Side-effect only |
PreCompact |
passthrough(), block(reason) |
Gate compaction |
PostCompact |
passthrough() |
Side-effect only |
Setup |
passthrough(), addContext(text) |
Inject setup/maintenance context |
PermissionRequest |
allow(options?), deny(message) |
Respond to permission UI |
PermissionDenied |
accept(), retry() |
Follow-up on denials |
PostToolUseFailure |
passthrough(), addContext(text) |
Augment tool failure telemetry |
PostToolBatch |
passthrough(), block(reason), addContext(text) |
React to a parallel tool batch |
InstructionsLoaded |
passthrough() |
Observe instruction reload |
StopFailure |
passthrough() |
Observe failed stops |
CwdChanged |
passthrough() |
Observe working-dir changes |
FileChanged |
passthrough() |
Observe file changes |
ConfigChange |
allow(), block(reason) |
Gate config changes |
SubagentStart |
passthrough(), addContext(text) |
Inject subagent context |
TaskCreated |
allow(), block(reason) |
Gate task creation |
TaskCompleted |
allow(), block(reason) |
Gate task completion |
TeammateIdle |
allowIdle(), keepWorking(reason), stopTeammate(reason) |
Acknowledge or prevent idle state |
WorktreeCreate |
created(worktreePath) |
Report created worktree |
WorktreeRemove |
passthrough() |
Observe worktree removal |
Elicitation |
accept(content), decline(), cancel() |
Respond to elicitation |
ElicitationResult |
accept(content), decline(), cancel() |
Observe elicitation result |
Every namespace also exports Input and Output schema classes plus a define(config) factory. Events with event-specific output fields additionally export a HookSpecificOutput schema.
Hook.dispatch — multiple events in one binary
Plugins that handle several events from a single entry point can use Hook.dispatch instead of Hook.runMain. The runner peeks hook_event_name from stdin, routes to the matching definition, and succeeds silently if no handler is registered for the incoming event:
import * as Effect from 'effect/Effect';
import { Hook } from 'effect-claudecode';
Hook.dispatch({
PreToolUse: Hook.PreToolUse.define({
handler: () => Effect.succeed(Hook.PreToolUse.allow())
}),
PostToolUse: Hook.PostToolUse.define({
handler: () => Effect.succeed(Hook.PostToolUse.passthrough())
}),
SessionStart: Hook.SessionStart.define({
handler: () =>
Effect.succeed(Hook.SessionStart.addContext('session booted'))
})
});
Matchers
Claude Code matchers use match-all, exact/list, or regex semantics. Hook.matchTool mirrors those rules: * and "" match all, plain token strings like Bash|Read are exact lists, and strings containing other characters are JavaScript regexes. Hook.testTool is a one-shot equivalent:
import { Hook } from 'effect-claudecode';
const isBash = Hook.matchTool('Bash'); // exact match
const isMcp = Hook.matchTool('mcp__.*'); // regex fallback
const isEditOrWrite = Hook.matchTool('Edit|Write'); // exact list
isBash('Bash'); // true
isBash('Bash(git)'); // false — exact match
Hook.testTool(/^Read$/, 'Read'); // true
These helpers are optional — Claude Code filters hooks by matcher before spawning the process, so you rarely need them outside of multi-event dispatch scripts.
Transcript reading
Hook.readTranscript(path) is a FileSystem-backed reader for the JSONL conversation transcript. The handler needs a FileSystem layer at the call site:
import * as NodeFileSystem from '@effect/platform-node-shared/NodeFileSystem';
import * as Effect from 'effect/Effect';
import { Hook } from 'effect-claudecode';
const hook = Hook.Stop.define({
handler: () =>
Effect.gen(function* () {
const path = yield* Hook.transcriptPath;
const events = yield* Hook.readTranscript(path);
// events is ReadonlyArray<unknown> — decode further as needed
yield* Effect.logInfo(`transcript has ${events.length} entries`);
return Hook.Stop.allowStop();
}).pipe(Effect.provide(NodeFileSystem.layer))
});
Hook.runMain(hook);
Fails with TranscriptReadError { path, cause } on I/O failure.
Settings
Settings.load(cwd) reads the implemented Claude Code settings scopes in priority order (user → project → local → optional CLI overlay → file-based managed settings) and returns a merged SettingsFile:
import * as NodeFileSystem from '@effect/platform-node-shared/NodeFileSystem';
import * as NodePath from '@effect/platform-node-shared/NodePath';
import * as Console from 'effect/Console';
import * as Effect from 'effect/Effect';
import * as Layer from 'effect/Layer';
import { Settings } from 'effect-claudecode';
const program = Effect.gen(function* () {
const workspaceDir = process.cwd();
const settings = yield* Settings.load(workspaceDir);
yield* Console.log(settings.model); // e.g. "sonnet"
yield* Console.log(settings.hooks); // HooksSection
});
Effect.runPromise(
program.pipe(
Effect.provide(Layer.merge(NodeFileSystem.layer, NodePath.layer))
)
);
The loader requires FileSystem, Path, and Config services in its environment and fails with one of three tagged errors: SettingsReadError, SettingsParseError, or SettingsDecodeError.
Exported schemas
Settings.SettingsFile, Settings.PermissionsConfig, Settings.PermissionMode, Settings.SandboxConfig, Settings.StatusLineConfig, Settings.ApiKeyHelperConfig, Settings.AttributionConfig, Settings.McpServerEntry, Settings.Marketplace, Settings.GithubMarketplace, Settings.DirectoryMarketplace, Settings.HooksSection, Settings.HookMatcherGroup, Settings.HookEntry, Settings.CommandHookEntry, Settings.HttpHookEntry, Settings.McpToolHookEntry, Settings.PromptHookEntry, and Settings.AgentHookEntry.
Plugins
Plugin.define
Builds a PluginDefinition value from a declarative PluginConfig:
import { Plugin } from 'effect-claudecode';
const plugin = Plugin.define({
manifest: {
name: 'effect-review-kit',
version: '0.1.0',
description: 'Project-aware review defaults for Claude Code',
author: new Plugin.AuthorInfo({
name: 'Alice',
email: '[email protected]'
}),
keywords: ['effect', 'review', 'claude-code']
},
commands: [
Plugin.command({
name: 'review',
description: 'Review staged changes with project conventions',
body:
'# Review\n\nReview the staged changes. Lead with concrete findings, then call out regressions and missing tests.\n'
})
],
agents: [
Plugin.agent({
name: 'reviewer',
description: 'Investigate risky changes before they land',
body:
'# Reviewer\n\nFocus on bugs, behavioral regressions, and testing gaps before summarizing anything else.\n'
})
],
skills: [
Plugin.skill({
name: 'effect-first',
description: 'Keep implementations aligned with Effect v4 conventions',
body:
'# Effect-First\n\nPrefer typed errors, `Option` for absence, and `Schema` decoding at boundaries.\n'
})
],
outputStyles: [
Plugin.outputStyle({
name: 'concise-review',
description: 'Lead with findings and keep the summary tight',
body:
'# Concise Review\n\nStart with issues worth fixing. Keep supporting detail brief and specific.\n'
})
],
hooksConfig: {
/* same shape as the "hooks" section of .claude/settings.json */
},
mcpConfig: {
/* same shape as .mcp.json */
}
});
All component arrays are optional. Use Plugin.skill(...) for new slash-command-like capabilities; Plugin.command(...) remains available for legacy commands/ files. Plugin.agent(...) and Plugin.outputStyle(...) author the other markdown components without hand-writing YAML frontmatter strings. hooksConfig is typed as Settings.HooksSection, and mcpConfig is typed as Mcp.McpJsonFile.
Plugin.write
Materializes a PluginDefinition to disk at outputDir:
import * as Effect from 'effect/Effect';
import { ClaudeRuntime, Plugin } from 'effect-claudecode';
const outputDir = 'artifacts/effect-review-kit';
const runtime = ClaudeRuntime.default();
await runtime.runPromise(
Plugin.write(plugin, outputDir).pipe(
Effect.tap(() =>
Effect.logInfo('plugin written').pipe(
Effect.annotateLogs({ outputDir })
)
)
)
);
await runtime.dispose();
Default directory layout produced when you do not override manifest or entry paths:
outputDir/
├── .claude-plugin/plugin.json
├── commands/<name>.md (legacy; prefer skills/ for new command-like entries)
├── agents/<name>.md
├── skills/<name>/SKILL.md
├── output-styles/<name>.md
├── hooks/hooks.json (only if hooksConfig was provided)
└── .mcp.json (only if mcpConfig was provided)
Requires FileSystem and Path services. ClaudeRuntime.default() is the simplest way to provide them in one-shot build and maintenance scripts. Fails with PluginWriteError { path, cause }.
Plugin.scan / Plugin.load / Plugin.sync
Use the load helpers to introspect and normalize existing plugin directories:
import * as Effect from 'effect/Effect';
import { ClaudeRuntime, Plugin } from 'effect-claudecode';
const outputDir = 'artifacts/effect-review-kit';
const runtime = ClaudeRuntime.default();
const loaded = await runtime.runPromise(Plugin.load(outputDir));
const synced = Plugin.sync(loaded);
console.log(loaded.manifest.name);
console.log(synced.manifest.commands); // 'commands' when command files exist
await runtime.dispose();
Plugin.scan(dir)inspects the canonical component layout and infers a normalized manifest.Plugin.load(dir)parses the discovered command, agent, skill, and output-style files into a typedPluginDefinition.Plugin.sync(def)preserves explicit manifest layout fields and fills in the default pathsPlugin.writeuses when those fields are omitted.
Exported schemas
Plugin.PluginManifest, Plugin.AuthorInfo, Plugin.UserConfigEntry, Plugin.UserConfigRecord, Plugin.ChannelSpec, Plugin.ComponentPathSpec, Plugin.HooksSpec, Plugin.ServerConfigSpec, Plugin.MarketplaceFile, Plugin.MarketplacePluginEntry, Plugin.MarketplacePluginSourceSpec, Plugin.GithubPluginSource, Plugin.UrlPluginSource, Plugin.GitSubdirPluginSource, Plugin.NpmPluginSource, and deprecated Plugin.DirectoryPluginSource.
Frontmatter
Split YAML frontmatter from a markdown body and decode it, or render typed frontmatter back into markdown:
import * as Console from 'effect/Console';
import * as Effect from 'effect/Effect';
import { ClaudeRuntime, Frontmatter } from 'effect-claudecode';
const runtime = ClaudeRuntime.default();
await runtime.runPromise(
Effect.gen(function* () {
const skillPath = 'skills/effect-first/SKILL.md';
const parsed = yield* Frontmatter.parseSkillFile(skillPath);
yield* Console.log(parsed.frontmatter?.name ?? '(directory name)');
})
);
await runtime.dispose();
import { Frontmatter } from 'effect-claudecode';
const markdown = Frontmatter.renderSkill(
{
name: 'greet',
description: 'Say hello',
'allowed-tools': ['Read']
},
'# Greet\n\nSay hello to the user.\n'
);
If the source has no --- delimiters, parseFile returns { frontmatter: undefined, body: source } (no error). Malformed YAML between valid delimiters fails with FrontmatterParseError. I/O failures surface as FrontmatterReadError. Typed helpers like parseSkillFile additionally surface schema mismatches as FrontmatterDecodeError.
Markdown bodies are intentionally opaque pass-through strings. Claude Code resolves runtime substitutions such as $ARGUMENTS, $ARGUMENTS[0], $0, named $argument values, ${CLAUDE_SESSION_ID}, ${CLAUDE_EFFORT}, ${CLAUDE_SKILL_DIR}, @file, and !\command`` when the skill or command is invoked; this library preserves the body text and does not parse that grammar.
For in-memory sources use Frontmatter.parse(source, path) — same return type, no FileSystem requirement.
ClaudeProject
ClaudeRuntime.project({ cwd }) is the recommended way to consume ClaudeProject from local tooling. It wraps the common project-level loaders in explicit caches so repeated hook invocations or diagnostics can reuse parsed state until you decide to invalidate it:
import * as Effect from 'effect/Effect';
import * as Option from 'effect/Option';
import { ClaudeProject, ClaudeRuntime } from 'effect-claudecode';
const workspaceDir = process.cwd();
const runtime = ClaudeRuntime.project({ cwd: workspaceDir });
const summary = await runtime.runPromise(
Effect.gen(function* () {
const project = yield* ClaudeProject.project;
const settings = yield* project.settings;
const reviewSkill = yield* project.skill('review');
return {
model: settings.model,
hasReviewSkill: Option.isSome(reviewSkill)
};
})
);
await runtime.dispose();
The service exposes cached settings, optional cached mcp, cached plugin, name-based component lookups (skill, command, agent, outputStyle), and explicit invalidators under project.invalidate.*.
For advanced cases, manual layer composition is still available:
import { ClaudeProject, ClaudeRuntime } from 'effect-claudecode';
const workspaceDir = process.cwd();
const runtime = ClaudeRuntime.default({
layer: ClaudeProject.ClaudeProject.layer({ cwd: workspaceDir })
});
HookBus
HookBus is a typed in-process event bus over decoded hook inputs. It is useful for multi-event binaries, reactive local tooling, or any long-lived process that wants to build Stream pipelines over Claude Code events:
import * as Deferred from 'effect/Deferred';
import * as Effect from 'effect/Effect';
import * as Stream from 'effect/Stream';
import { Hook } from 'effect-claudecode';
const program = Effect.scoped(
Effect.gen(function* () {
const bus = yield* Hook.HookBus.Service;
const done = yield* Deferred.make<ReadonlyArray<string>>();
yield* bus
.stream('FileChanged')
.pipe(
Stream.map((event) => event.file_path),
Stream.take(2),
Stream.runCollect,
Effect.flatMap((paths) => Deferred.succeed(done, Array.from(paths))),
Effect.forkScoped
);
yield* Effect.yieldNow;
yield* bus.publish(
new Hook.FileChanged.Input({
session_id: 'session-1',
transcript_path: '/workspace/.claude/transcript.jsonl',
cwd: '/repo',
hook_event_name: 'FileChanged',
file_path: '/repo/a.ts',
event: 'change'
})
);
yield* bus.publish(
new Hook.FileChanged.Input({
session_id: 'session-1',
transcript_path: '/workspace/.claude/transcript.jsonl',
cwd: '/repo',
hook_event_name: 'FileChanged',
file_path: '/repo/b.ts',
event: 'change'
})
);
return yield* Deferred.await(done);
}).pipe(Effect.provide(Hook.HookBus.layer))
);
Exported schemas
| Schema | Purpose |
|---|---|
Frontmatter.SkillFrontmatter |
SKILL.md frontmatter — permissive Claude Code schema, preserving kebab-case keys such as disable-model-invocation, allowed-tools, disallowed-tools, and argument-hint |
Frontmatter.SubagentFrontmatter |
agents/*.md frontmatter — full user + plugin fields, including permissionMode, mcpServers, hooks, color, initialPrompt, and deprecated permissions |
Frontmatter.CommandFrontmatter |
Legacy commands/*.md frontmatter; supports the same skill-style fields |
Frontmatter.OutputStyleFrontmatter |
output-styles/*.md frontmatter, including plugin flags |
MCP
Mcp.loadJson(path) reads and decodes a .mcp.json file:
import * as NodeFileSystem from '@effect/platform-node-shared/NodeFileSystem';
import * as Console from 'effect/Console';
import * as Effect from 'effect/Effect';
import { Mcp } from 'effect-claudecode';
const program = Effect.gen(function* () {
const mcpPath = '.mcp.json';
const file = yield* Mcp.loadJson(mcpPath);
// file: McpJsonFile { mcpServers: Record<string, McpServerConfig> }
yield* Effect.forEach(Object.entries(file.mcpServers), ([name, server]) =>
Console.log(`${name}: ${server.type}`)
);
});
Effect.runPromise(program.pipe(Effect.provide(NodeFileSystem.layer)));
Fails with McpConfigError { path, cause } on read, parse, or decode failures.
Mcp.McpServerConfig is a union of current transports plus decode-only legacy fields:
| Transport | Class | Key fields |
|---|---|---|
omitted / "stdio" |
Mcp.StdioMcpServer |
command, args, env, timeout, alwaysLoad |
"http" / "streamable-http" |
Mcp.HttpMcpServer |
url, headers, headersHelper, oauth, timeout, alwaysLoad |
"ws" |
Mcp.WsMcpServer |
url, headers, headersHelper, timeout, alwaysLoad |
"sse" |
Mcp.SseMcpServer |
deprecated transport; same remote fields as HTTP except type |
timeout is a per-server tool-execution timeout in milliseconds; Claude Code ignores values below 1000 and falls back to MCP_TOOL_TIMEOUT. String fields are passed through opaquely so Claude Code can expand ${VAR} / ${VAR:-default} placeholders.
Legacy authorization, stdio cwd, and HTTP allowedEnvVars remain decodable for source compatibility, but Mcp.toClaudeCodeJson omits them when emitting current Claude Code config. Use oauth for OAuth and headers / headersHelper for bearer or API-key style authentication.
Errors
All tagged errors are re-exported at the top level of the barrel so consumers can catchTag against them directly:
import {
HookInputDecodeError,
HookHandlerError,
SettingsReadError,
PluginWriteError,
McpConfigError,
FrontmatterParseError
} from 'effect-claudecode';
| Error | Payload | Origin | Exit code |
|---|---|---|---|
HookStdinReadError |
{ cause } |
Runner | 1 |
HookInputDecodeError |
{ cause, phase: 'json' | 'schema' } |
Runner | 2 (Claude Code behavior is event-specific) |
HookHandlerError |
{ cause } |
Runner | 1 |
HookOutputEncodeError |
{ cause } |
Runner | 1 |
HookStdoutWriteError |
{ cause } |
Runner | 1 |
TranscriptReadError |
{ path, cause } |
Transcript | — |
SettingsReadError |
{ path, cause } |
Settings | — |
SettingsParseError |
{ path, cause } |
Settings | — |
SettingsDecodeError |
{ path, cause } |
Settings | — |
PluginWriteError |
{ path, cause } |
Plugin | — |
FrontmatterReadError |
{ path, cause } |
Frontmatter | — |
FrontmatterParseError |
{ path, cause } |
Frontmatter | — |
FrontmatterDecodeError |
{ path, cause } |
Frontmatter | — |
McpConfigError |
{ path, cause } |
MCP | — |
All error classes are declared with Schema.TaggedErrorClass and carry an effect-claudecode/ namespace on their identifier.
Exit-code semantics
The runner maps the final Exit<Output, RunnerError> to a process exit code via a custom Runtime.Teardown:
| Exit code | Meaning |
|---|---|
0 |
Success — handler produced an Output, it was encoded and written to stdout |
1 |
Non-blocking error — stdin read, handler failure, encode error, or stdout write failure |
2 |
Input decode failure, or a handler-authored controlled exit for events whose contract uses exit 2; Claude Code interprets this per event (block/deny for gate events, feedback-only or ignored for several observability events) |
130 |
SIGINT / fiber interruption |
Most handler-authored blocks (e.g. Hook.UserPromptSubmit.block('reason')) travel through the Output channel. A few Claude Code contracts require process-level feedback instead — for example TaskCreated.block(...), TaskCompleted.block(...), and TeammateIdle.keepWorking(...) use exit 2 with stderr. Input decode failures also exit 2 because the library cannot produce a valid output.
Testing
effect-claudecode ships a full test harness in the Testing namespace, designed for @effect/vitest:
import { describe, expect, it } from '@effect/vitest';
import * as Effect from 'effect/Effect';
import * as P from 'effect/Predicate';
import { Hook, Testing } from 'effect-claudecode';
describe('pre-bash-denylist', () => {
it.effect('denies rm -rf /', () =>
Effect.gen(function* () {
const hook = Hook.PreToolUse.define({
handler: (input) =>
Effect.gen(function* () {
const command = input.tool_input['command'];
return /rm\s+-rf\s+\//.test(
P.isString(command) ? command : ''
)
? Hook.PreToolUse.deny('destructive')
: Hook.PreToolUse.allow();
})
});
const result = yield* Testing.runHookWithMockStdin(
hook,
Testing.fixtures.PreToolUse({
tool_name: 'Bash',
tool_input: { command: 'rm -rf /' }
})
);
expect(result.exitCode).toBe(0);
Testing.expectDenyDecision(result.output, 'destructive');
})
);
});
runHookWithMockStdin
Runs a HookDefinition end-to-end through the runner pipeline, injecting stdin from a JSON string and capturing stdout, stderr, exit code, and any error tag:
const result: {
output: unknown; // parsed from stdout, or undefined
stdout: string; // raw
stderr: string; // raw
exitCode: number; // 0 | 1 | 2 | 130
errorTag: string | undefined; // e.g. 'HookInputDecodeError'
} = yield* Testing.runHookWithMockStdin(hook, stdinJson);
fixtures
One fixture builder per event, returning a wire-format JSON string with sensible defaults (session_id: 'test-session', cwd: '/workspace', transcript_path: '/workspace/.claude/transcript.jsonl', ...) that you can override field-by-field:
Testing.fixtures.PreToolUse({ tool_name: 'Bash', tool_input: { command: 'ls' } });
Testing.fixtures.UserPromptSubmit({ prompt: 'Hello, Claude' });
Testing.fixtures.SessionStart({ source: 'resume', model: 'claude-opus-4-6' });
Testing.fixtures.CwdChanged(); // no overrides needed — envelope fields have defaults
Fixtures exist for all 30 events. The return type is always string — a JSON blob you feed into runHookWithMockStdin or decode directly for schema round-trip tests.
Assertion helpers
Each helper asserts an Output against the corresponding decision shape and throws when it does not match:
Testing.expectAllowDecision(output, 'optional reason');
Testing.expectDenyDecision(output, 'reason');
Testing.expectAskDecision(output, 'optional reason');
Testing.expectBlockDecision(output, 'reason');
Testing.expectAddContext(output, 'optional context text');
expectBlockDecision matches both the PreToolUse-style hookSpecificOutput.permissionDecision: 'deny' and the UserPromptSubmit-style top-level decision: 'block', so one helper covers every event.
Mock layers
// Mock filesystem for settings / transcript / frontmatter tests
Effect.provide(
Testing.makeMockFileSystem({
'/a.txt': 'A',
'/b.txt': 'B'
})
);
// Or pass a ReadonlyMap
Effect.provide(Testing.makeMockFileSystem(new Map([['/x', 'X']])));
// Mock stdio for direct runner-level testing
Effect.provide(
Testing.makeMockStdioLayer({
stdinJson: '...',
stdoutBuffer: [],
stderrBuffer: []
})
);
makeMockFileSystem accepts either a plain record or a ReadonlyMap<string, string>. Reads return the mapped content, exists returns true/false, and missing paths surface a typed PlatformError (_tag: 'NotFound').
Mock context
Build a HookContext.Interface value directly for non-runner tests:
const context = Testing.makeMockHookContext({
sessionId: 'my-session',
cwd: '/workspace',
hookEventName: 'PreToolUse'
});
Examples
Complete runnable examples live in examples/:
examples/loop-detector.ts— Stateful PostToolUse hook that detects when Claude is stuck in a loop and injects context to break the cycleexamples/plugin-factory.ts— Build a complete plugin (commands, agents, skills, hooks, MCP) from a singlePlugin.definecallexamples/session-event-log.ts—Hook.dispatchmulti-event handler that writes structured JSONL session logs for MCP consumptionexamples/pre-bash-denylist.ts— PreToolUse hook that blocks destructive Bash commands withOption.match, hook context accessors, and structured logsexamples/session-start-inject-env.ts— SessionStart hook that injects session info viaEffect.genand theHook.sessionId/Hook.cwdaccessorsexamples/post-read-source-hint.ts— PostToolUse hook that redirects generated-file reads back to the source of truthexamples/plugin-define-complete.ts— FullPlugin.definepipeline with validation, write, and post-write diagnostics under a semantic output directoryexamples/project-runtime-summary.ts—ClaudeRuntime.project({ cwd })with concurrent cached lookups,Option.match, and structured logs
Development
bun install
bun run check # test + typecheck + build + npm pack --dry-run
bun run build # emit dist/ for the npm package
bun run test # vitest run — all tests
bun run typecheck # tsc --noEmit
bun run pack:dry-run # inspect the publish tarball contents
bunx vitest run test/Hook/Events/PreToolUse.test.ts # single file
bunx vitest run -t "denies rm -rf" # by test name pattern
License
MIT
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found