mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-20 16:47:59 +02:00
Root package.json declared no main or exports, so OpenCode npm plugin resolution (import.meta.resolve) failed and the plugin was silently skipped (#3127). The .opencode TypeScript sources imported siblings with .js specifiers that only exist after compilation, so the home install, which loads the .ts files in place, crashed the tool registry with ERR_MODULE_NOT_FOUND (#3112). Declare main/types/exports on the root package pointing at the compiled plugin entry, switch the sources to .ts specifiers, and enable allowImportingTsExtensions with rewriteRelativeImportExtensions so the emitted dist keeps working .js specifiers. Add smoke tests that build the package, resolve and import the entry by name from a temp install, and verify every in-place relative import resolves. Fixes #3127 Fixes #3112
625 lines
21 KiB
TypeScript
625 lines
21 KiB
TypeScript
/**
|
|
* ECC Plugin Hooks for OpenCode
|
|
*
|
|
* This plugin translates Claude Code hooks to OpenCode's plugin system.
|
|
* OpenCode's plugin system is MORE sophisticated than Claude Code with 20+ events
|
|
* compared to Claude Code's 3 phases (PreToolUse, PostToolUse, Stop).
|
|
*
|
|
* Hook Event Mapping:
|
|
* - PreToolUse → tool.execute.before
|
|
* - PostToolUse → tool.execute.after
|
|
* - Stop → session.idle / session.status
|
|
* - SessionStart → session.created
|
|
* - SessionEnd → session.deleted
|
|
*/
|
|
|
|
import type { PluginInput } from "@opencode-ai/plugin"
|
|
import * as fs from "fs"
|
|
import * as path from "path"
|
|
import changedFilesTool from "../tools/changed-files.ts"
|
|
import dependencyAnalyzerTool from "../tools/dependency-analyzer.ts"
|
|
|
|
/**
|
|
* Type definitions for better type safety
|
|
*/
|
|
interface ToolArgs {
|
|
filePath?: string
|
|
file_path?: string
|
|
path?: string
|
|
command?: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
interface ToolInput {
|
|
tool: string
|
|
callID?: string
|
|
args?: ToolArgs
|
|
}
|
|
|
|
interface PermissionEvent {
|
|
tool: string
|
|
args: unknown
|
|
}
|
|
|
|
interface FileEvent {
|
|
path: string
|
|
type?: string
|
|
}
|
|
|
|
interface TodoEvent {
|
|
todos: Array<{ text: string; done: boolean }>
|
|
}
|
|
|
|
/**
|
|
* Read ECC version from package.json
|
|
* Falls back to a default if package.json cannot be read
|
|
*/
|
|
function getECCVersion(): string {
|
|
try {
|
|
const packageJsonPath = path.resolve(__dirname, "../../package.json")
|
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"))
|
|
return packageJson.version || "2.0.0"
|
|
} catch {
|
|
return "2.0.0"
|
|
}
|
|
}
|
|
|
|
type ECCHooksPluginFn = (input: PluginInput) => Promise<Record<string, unknown>>
|
|
|
|
export const ECCHooksPlugin: ECCHooksPluginFn = async ({
|
|
client,
|
|
$,
|
|
directory,
|
|
worktree,
|
|
}: PluginInput) => {
|
|
type HookProfile = "minimal" | "standard" | "strict"
|
|
|
|
const worktreePath = worktree || directory
|
|
|
|
const editedFiles = new Set<string>()
|
|
|
|
function resolvePath(p: string): string {
|
|
if (path.isAbsolute(p)) return p
|
|
return path.join(worktreePath, p)
|
|
}
|
|
|
|
function hasProjectFile(relativePath: string): boolean {
|
|
try {
|
|
return fs.statSync(resolvePath(relativePath)).isFile()
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
const pendingToolChanges = new Map<string, { path: string; type: "added" | "modified" }>()
|
|
let writeCounter = 0
|
|
|
|
function getFilePath(args: ToolArgs | undefined): string | null {
|
|
if (!args) return null
|
|
const p = (args.filePath ?? args.file_path ?? args.path) as string | undefined
|
|
return typeof p === "string" && p.trim() ? p : null
|
|
}
|
|
|
|
// Helper to call the SDK's log API with correct signature
|
|
const log = (level: "debug" | "info" | "warn" | "error", message: string) =>
|
|
client.app.log({ body: { service: "ecc", level, message } })
|
|
|
|
// Loaded lazily (instead of via a top-level import) so that a missing or
|
|
// partially-installed `~/.opencode/plugins/lib` directory (e.g. an
|
|
// interrupted or partial ECC install on Termux/Android) only disables
|
|
// changed-files tracking, rather than throwing during module evaluation.
|
|
// This plugin is OpenCode's startup entry point, so a static import
|
|
// failure here previously crashed the whole plugin -- and with it, the
|
|
// entire OpenCode session -- before any hooks could load (see #2530).
|
|
let changedFilesStore: typeof import("./lib/changed-files-store.ts") | undefined
|
|
try {
|
|
const store = await import("./lib/changed-files-store.ts")
|
|
store.initStore(worktreePath)
|
|
changedFilesStore = store
|
|
} catch {
|
|
// Best-effort diagnostic only: deferred via .then() (rather than
|
|
// Promise.resolve(log(...))) so that even a *synchronous* throw inside
|
|
// log() -- not just an async rejection -- is caught here instead of
|
|
// escaping this catch block. The raw loader error is intentionally not
|
|
// included in the message since it can contain absolute filesystem
|
|
// paths; this whole block exists to guarantee startup resilience even
|
|
// when things go wrong.
|
|
Promise.resolve()
|
|
.then(() =>
|
|
log(
|
|
"warn",
|
|
"[ECC] changed-files tracking disabled: could not load the changed-files store. " +
|
|
"Run `ecc repair --target opencode` to restore the missing files. Other ECC hooks are unaffected."
|
|
)
|
|
)
|
|
.catch(() => {})
|
|
}
|
|
|
|
const normalizeProfile = (value: string | undefined): HookProfile => {
|
|
if (value === "minimal" || value === "strict") return value
|
|
return "standard"
|
|
}
|
|
|
|
const currentProfile = normalizeProfile(process.env.ECC_HOOK_PROFILE)
|
|
const disabledHooks = new Set(
|
|
(process.env.ECC_DISABLED_HOOKS || "")
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
)
|
|
|
|
const profileOrder: Record<HookProfile, number> = {
|
|
minimal: 0,
|
|
standard: 1,
|
|
strict: 2,
|
|
}
|
|
|
|
const profileAllowed = (required: HookProfile | HookProfile[]): boolean => {
|
|
if (Array.isArray(required)) {
|
|
return required.some((entry) => profileOrder[currentProfile] >= profileOrder[entry])
|
|
}
|
|
return profileOrder[currentProfile] >= profileOrder[required]
|
|
}
|
|
|
|
const hookEnabled = (
|
|
hookId: string,
|
|
requiredProfile: HookProfile | HookProfile[] = "standard"
|
|
): boolean => {
|
|
if (disabledHooks.has(hookId)) return false
|
|
return profileAllowed(requiredProfile)
|
|
}
|
|
|
|
return {
|
|
/**
|
|
* Prettier Auto-Format Hook
|
|
* Equivalent to Claude Code PostToolUse hook for prettier
|
|
*
|
|
* Triggers: After any JS/TS/JSX/TSX file is edited
|
|
* Action: Runs prettier --write on the file
|
|
*/
|
|
"file.edited": async (event: { path: string }) => {
|
|
editedFiles.add(event.path)
|
|
changedFilesStore?.recordChange(event.path, "modified")
|
|
|
|
// Auto-format JS/TS files
|
|
if (hookEnabled("post:edit:format", ["strict"]) && event.path.match(/\.(ts|tsx|js|jsx)$/)) {
|
|
try {
|
|
await $`prettier --write ${event.path} 2>/dev/null`
|
|
log("info", `[ECC] Formatted: ${event.path}`)
|
|
} catch (error: unknown) {
|
|
// Prettier not installed or failed - log but continue
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
log("debug", `[ECC] Prettier formatting failed for ${event.path}: ${errorMessage}`)
|
|
}
|
|
}
|
|
|
|
// Console.log warning check
|
|
if (hookEnabled("post:edit:console-warn", ["standard", "strict"]) && event.path.match(/\.(ts|tsx|js|jsx)$/)) {
|
|
try {
|
|
const result = await $`grep -n "console\\.log" ${event.path} 2>/dev/null`.text()
|
|
if (result.trim()) {
|
|
const lines = result.trim().split("\n").length
|
|
log(
|
|
"warn",
|
|
`[ECC] console.log found in ${event.path} (${lines} occurrence${lines > 1 ? "s" : ""})`
|
|
)
|
|
}
|
|
} catch {
|
|
// No console.log found (grep returns non-zero) - this is good
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* TypeScript Check Hook
|
|
* Equivalent to Claude Code PostToolUse hook for tsc
|
|
*
|
|
* Triggers: After edit tool completes on .ts/.tsx files
|
|
* Action: Runs tsc --noEmit to check for type errors
|
|
*/
|
|
"tool.execute.after": async (
|
|
input: ToolInput,
|
|
output: unknown
|
|
) => {
|
|
const filePath = getFilePath(input.args)
|
|
if (input.tool === "edit" && filePath) {
|
|
changedFilesStore?.recordChange(filePath, "modified")
|
|
}
|
|
if (input.tool === "write" && filePath) {
|
|
const key = input.callID ?? `write-${++writeCounter}-${filePath}`
|
|
const pending = pendingToolChanges.get(key)
|
|
if (pending) {
|
|
changedFilesStore?.recordChange(pending.path, pending.type)
|
|
pendingToolChanges.delete(key)
|
|
} else {
|
|
changedFilesStore?.recordChange(filePath, "modified")
|
|
}
|
|
}
|
|
|
|
// Check if a TypeScript file was edited
|
|
if (
|
|
hookEnabled("post:edit:typecheck", ["strict"]) &&
|
|
input.tool === "edit" &&
|
|
input.args?.filePath?.match(/\.tsx?$/)
|
|
) {
|
|
try {
|
|
await $`npx tsc --noEmit 2>&1`
|
|
log("info", "[ECC] TypeScript check passed")
|
|
} catch (error: unknown) {
|
|
const err = error as { stdout?: string }
|
|
log("warn", "[ECC] TypeScript errors detected:")
|
|
if (err.stdout) {
|
|
// Log first few errors
|
|
const errors = err.stdout.split("\n").slice(0, 5)
|
|
errors.forEach((line: string) => log("warn", ` ${line}`))
|
|
}
|
|
}
|
|
}
|
|
|
|
// PR creation logging
|
|
if (
|
|
hookEnabled("post:bash:pr-created", ["standard", "strict"]) &&
|
|
input.tool === "bash" &&
|
|
input.args?.toString().includes("gh pr create")
|
|
) {
|
|
log("info", "[ECC] PR created - check GitHub Actions status")
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Pre-Tool Security Check
|
|
* Equivalent to Claude Code PreToolUse hook
|
|
*
|
|
* Triggers: Before tool execution
|
|
* Action: Warns about potential security issues
|
|
*/
|
|
"tool.execute.before": async (
|
|
input: ToolInput
|
|
) => {
|
|
if (input.tool === "write") {
|
|
const filePath = getFilePath(input.args)
|
|
if (filePath) {
|
|
const absPath = resolvePath(filePath)
|
|
let type: "added" | "modified" = "modified"
|
|
try {
|
|
if (typeof fs.existsSync === "function") {
|
|
type = fs.existsSync(absPath) ? "modified" : "added"
|
|
}
|
|
} catch {
|
|
type = "modified"
|
|
}
|
|
const key = input.callID ?? `write-${++writeCounter}-${filePath}`
|
|
pendingToolChanges.set(key, { path: filePath, type })
|
|
}
|
|
}
|
|
|
|
// Git push review reminder
|
|
if (
|
|
hookEnabled("pre:bash:git-push-reminder", "strict") &&
|
|
input.tool === "bash" &&
|
|
input.args?.toString().includes("git push")
|
|
) {
|
|
log(
|
|
"info",
|
|
"[ECC] Remember to review changes before pushing: git diff origin/main...HEAD"
|
|
)
|
|
}
|
|
|
|
// Block creation of unnecessary documentation files
|
|
if (
|
|
hookEnabled("pre:write:doc-file-warning", ["standard", "strict"]) &&
|
|
input.tool === "write" &&
|
|
input.args?.filePath &&
|
|
typeof input.args.filePath === "string"
|
|
) {
|
|
const filePath = input.args.filePath
|
|
if (
|
|
filePath.match(/\.(md|txt)$/i) &&
|
|
!filePath.includes("README") &&
|
|
!filePath.includes("CHANGELOG") &&
|
|
!filePath.includes("LICENSE") &&
|
|
!filePath.includes("CONTRIBUTING")
|
|
) {
|
|
log(
|
|
"warn",
|
|
`[ECC] Creating ${filePath} - consider if this documentation is necessary`
|
|
)
|
|
}
|
|
}
|
|
|
|
// Long-running command reminder
|
|
if (hookEnabled("pre:bash:tmux-reminder", "strict") && input.tool === "bash") {
|
|
const cmd = String(input.args?.command || input.args || "")
|
|
if (
|
|
cmd.match(/^(npm|pnpm|yarn|bun)\s+(install|build|test|run)/) ||
|
|
cmd.match(/^cargo\s+(build|test|run)/) ||
|
|
cmd.match(/^go\s+(build|test|run)/)
|
|
) {
|
|
log(
|
|
"info",
|
|
"[ECC] Long-running command detected - consider using background execution"
|
|
)
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Session Created Hook
|
|
* Equivalent to Claude Code SessionStart hook
|
|
*
|
|
* Triggers: When a new session starts
|
|
* Action: Loads context and displays welcome message
|
|
*/
|
|
"session.created": async () => {
|
|
if (!hookEnabled("session:start", ["minimal", "standard", "strict"])) return
|
|
|
|
log("info", `[ECC] Session started - profile=${currentProfile}`)
|
|
|
|
// Check for project-specific context files
|
|
if (hasProjectFile("CLAUDE.md")) {
|
|
log("info", "[ECC] Found CLAUDE.md - loading project context")
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Session Idle Hook
|
|
* Equivalent to Claude Code Stop hook
|
|
*
|
|
* Triggers: When session becomes idle (task completed)
|
|
* Action: Runs console.log audit on all edited files
|
|
*/
|
|
"session.idle": async () => {
|
|
if (!hookEnabled("stop:check-console-log", ["minimal", "standard", "strict"])) return
|
|
if (editedFiles.size === 0) return
|
|
|
|
log("info", "[ECC] Session idle - running console.log audit")
|
|
|
|
let totalConsoleLogCount = 0
|
|
const filesWithConsoleLogs: string[] = []
|
|
|
|
for (const file of editedFiles) {
|
|
if (!file.match(/\.(ts|tsx|js|jsx)$/)) continue
|
|
|
|
try {
|
|
const result = await $`grep -c "console\\.log" ${file} 2>/dev/null`.text()
|
|
const count = parseInt(result.trim(), 10)
|
|
if (count > 0) {
|
|
totalConsoleLogCount += count
|
|
filesWithConsoleLogs.push(file)
|
|
}
|
|
} catch {
|
|
// No console.log found
|
|
}
|
|
}
|
|
|
|
if (totalConsoleLogCount > 0) {
|
|
log(
|
|
"warn",
|
|
`[ECC] Audit: ${totalConsoleLogCount} console.log statement(s) in ${filesWithConsoleLogs.length} file(s)`
|
|
)
|
|
filesWithConsoleLogs.forEach((f) =>
|
|
log("warn", ` - ${f}`)
|
|
)
|
|
log("warn", "[ECC] Remove console.log statements before committing")
|
|
} else {
|
|
log("info", "[ECC] Audit passed: No console.log statements found")
|
|
}
|
|
|
|
// Desktop notification (cross-platform)
|
|
try {
|
|
if (process.platform === "darwin") {
|
|
// macOS
|
|
await $`osascript -e 'display notification "Task completed!" with title "OpenCode ECC"' 2>/dev/null`
|
|
} else if (process.platform === "win32") {
|
|
// Windows - PowerShell notification
|
|
await $`powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show('Task completed!', 'OpenCode ECC', 'OK', 'Information')" 2>/dev/null`
|
|
} else if (process.platform === "linux") {
|
|
// Linux - notify-send (requires libnotify)
|
|
await $`notify-send "OpenCode ECC" "Task completed!" 2>/dev/null`
|
|
}
|
|
} catch (error: unknown) {
|
|
// Notification not supported or failed - log but continue
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
log("debug", `[ECC] Desktop notification failed: ${errorMessage}`)
|
|
}
|
|
|
|
// Clear tracked files for next task
|
|
editedFiles.clear()
|
|
},
|
|
|
|
/**
|
|
* Session Deleted Hook
|
|
* Equivalent to Claude Code SessionEnd hook
|
|
*
|
|
* Triggers: When session ends
|
|
* Action: Final cleanup and state saving
|
|
*/
|
|
"session.deleted": async () => {
|
|
if (!hookEnabled("session:end-marker", ["minimal", "standard", "strict"])) return
|
|
log("info", "[ECC] Session ended - cleaning up")
|
|
editedFiles.clear()
|
|
changedFilesStore?.clearChanges()
|
|
pendingToolChanges.clear()
|
|
},
|
|
|
|
/**
|
|
* File Watcher Hook
|
|
* OpenCode-only feature
|
|
*
|
|
* Triggers: When file system changes are detected
|
|
* Action: Updates tracking
|
|
*/
|
|
"file.watcher.updated": async (event: { path: string; type: string }) => {
|
|
let changeType: "added" | "modified" | "deleted" = "modified"
|
|
if (event.type === "create" || event.type === "add") changeType = "added"
|
|
else if (event.type === "delete" || event.type === "remove") changeType = "deleted"
|
|
changedFilesStore?.recordChange(event.path, changeType)
|
|
if (event.type === "change" && event.path.match(/\.(ts|tsx|js|jsx)$/)) {
|
|
editedFiles.add(event.path)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Todo Updated Hook
|
|
* OpenCode-only feature
|
|
*
|
|
* Triggers: When todo list is updated
|
|
* Action: Logs progress
|
|
*/
|
|
"todo.updated": async (event: { todos: Array<{ text: string; done: boolean }> }) => {
|
|
const completed = event.todos.filter((t) => t.done).length
|
|
const total = event.todos.length
|
|
if (total > 0) {
|
|
log("info", `[ECC] Progress: ${completed}/${total} tasks completed`)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Shell Environment Hook
|
|
* OpenCode-specific: Inject environment variables into shell commands
|
|
*
|
|
* Triggers: Before shell command execution
|
|
* Action: Sets PROJECT_ROOT, PACKAGE_MANAGER, DETECTED_LANGUAGES, ECC_VERSION
|
|
*/
|
|
"shell.env": async () => {
|
|
const env: Record<string, string> = {
|
|
ECC_VERSION: getECCVersion(),
|
|
ECC_PLUGIN: "true",
|
|
ECC_HOOK_PROFILE: currentProfile,
|
|
ECC_DISABLED_HOOKS: process.env.ECC_DISABLED_HOOKS || "",
|
|
PROJECT_ROOT: worktreePath,
|
|
}
|
|
|
|
// Detect package manager
|
|
const lockfiles: Record<string, string> = {
|
|
"bun.lockb": "bun",
|
|
"pnpm-lock.yaml": "pnpm",
|
|
"yarn.lock": "yarn",
|
|
"package-lock.json": "npm",
|
|
}
|
|
for (const [lockfile, pm] of Object.entries(lockfiles)) {
|
|
if (hasProjectFile(lockfile)) {
|
|
env.PACKAGE_MANAGER = pm
|
|
break
|
|
}
|
|
}
|
|
|
|
// Detect languages
|
|
const langDetectors: Record<string, string> = {
|
|
"tsconfig.json": "typescript",
|
|
"go.mod": "go",
|
|
"pyproject.toml": "python",
|
|
"Cargo.toml": "rust",
|
|
"Package.swift": "swift",
|
|
}
|
|
const detected: string[] = []
|
|
for (const [file, lang] of Object.entries(langDetectors)) {
|
|
if (hasProjectFile(file)) {
|
|
detected.push(lang)
|
|
}
|
|
}
|
|
if (detected.length > 0) {
|
|
env.DETECTED_LANGUAGES = detected.join(",")
|
|
env.PRIMARY_LANGUAGE = detected[0]
|
|
}
|
|
|
|
return env
|
|
},
|
|
|
|
/**
|
|
* Session Compacting Hook
|
|
* OpenCode-specific: Control context compaction behavior
|
|
*
|
|
* Triggers: Before context compaction
|
|
* Action: Push ECC context block and custom compaction prompt
|
|
*/
|
|
"experimental.session.compacting": async () => {
|
|
const contextBlock = [
|
|
"# ECC Context (preserve across compaction)",
|
|
"",
|
|
"## Active Plugin: ECC v2.2.1",
|
|
"- Hooks: file.edited, tool.execute.before/after, session.created/idle/deleted, shell.env, compacting, permission.ask",
|
|
"- Tools: run-tests, check-coverage, security-audit, format-code, lint-check, git-summary, changed-files",
|
|
"- Agents: 13 specialized (planner, architect, tdd-guide, code-reviewer, security-reviewer, build-error-resolver, e2e-runner, refactor-cleaner, doc-updater, go-reviewer, go-build-resolver, database-reviewer, python-reviewer)",
|
|
"",
|
|
"## Key Principles",
|
|
"- TDD: write tests first, 80%+ coverage",
|
|
"- Immutability: never mutate, always return new copies",
|
|
"- Security: validate inputs, no hardcoded secrets",
|
|
"",
|
|
]
|
|
|
|
// Include recently edited files
|
|
if (editedFiles.size > 0) {
|
|
contextBlock.push("## Recently Edited Files")
|
|
for (const f of editedFiles) {
|
|
contextBlock.push(`- ${f}`)
|
|
}
|
|
contextBlock.push("")
|
|
}
|
|
|
|
return {
|
|
context: contextBlock.join("\n"),
|
|
compaction_prompt: "Focus on preserving: 1) Current task status and progress, 2) Key decisions made, 3) Files created/modified, 4) Remaining work items, 5) Any security concerns flagged. Discard: verbose tool outputs, intermediate exploration, redundant file listings.",
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Permission Auto-Approve Hook
|
|
* OpenCode-specific: Auto-approve safe operations
|
|
*
|
|
* Triggers: When permission is requested
|
|
* Action: Auto-approve reads, formatters, and test commands; log all for audit
|
|
*/
|
|
"permission.ask": async (event: PermissionEvent) => {
|
|
log("info", `[ECC] Permission requested for: ${event.tool}`)
|
|
|
|
try {
|
|
// Handle both string args and object args with command property
|
|
let cmd: string
|
|
if (typeof event.args === "string") {
|
|
cmd = event.args
|
|
} else if (event.args && typeof event.args === "object") {
|
|
cmd = String((event.args as Record<string, unknown>).command || "")
|
|
} else {
|
|
cmd = String(event.args || "")
|
|
}
|
|
|
|
// Auto-approve: read/search tools
|
|
if (["read", "glob", "grep", "search", "list"].includes(event.tool)) {
|
|
log("debug", `[ECC] Auto-approved read-only tool: ${event.tool}`)
|
|
return { approved: true, reason: "Read-only operation" }
|
|
}
|
|
|
|
// Auto-approve: formatters
|
|
if (event.tool === "bash" && /^(npx )?(@biomejs\/biome|prettier|black|gofmt|rustfmt|swift-format)/.test(cmd)) {
|
|
log("debug", `[ECC] Auto-approved formatter: ${cmd}`)
|
|
return { approved: true, reason: "Formatter execution" }
|
|
}
|
|
|
|
// Auto-approve: test execution
|
|
if (event.tool === "bash" && /^(npm test|npx vitest|npx jest|pytest|go test|cargo test)/.test(cmd)) {
|
|
log("debug", `[ECC] Auto-approved test execution: ${cmd}`)
|
|
return { approved: true, reason: "Test execution" }
|
|
}
|
|
|
|
// Everything else: let user decide
|
|
log("debug", `[ECC] Permission requires user approval: ${event.tool}`)
|
|
return { approved: undefined }
|
|
} catch (error: unknown) {
|
|
// Error in permission handling - log and deny for safety
|
|
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
log("error", `[ECC] Permission handling error for ${event.tool}: ${errorMessage}`)
|
|
return { approved: false, reason: `Error: ${errorMessage}` }
|
|
}
|
|
},
|
|
|
|
tool: {
|
|
"changed-files": changedFilesTool,
|
|
"dependency-analyzer": dependencyAnalyzerTool,
|
|
},
|
|
}
|
|
}
|
|
|
|
export default ECCHooksPlugin
|