chore: import upstream snapshot with attribution
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* 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 {
|
||||
initStore,
|
||||
recordChange,
|
||||
clearChanges,
|
||||
} from "./lib/changed-files-store.js"
|
||||
import changedFilesTool from "../tools/changed-files.js"
|
||||
import dependencyAnalyzerTool from "../tools/dependency-analyzer.js"
|
||||
|
||||
/**
|
||||
* 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
|
||||
initStore(worktreePath)
|
||||
|
||||
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 } })
|
||||
|
||||
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)
|
||||
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) {
|
||||
recordChange(filePath, "modified")
|
||||
}
|
||||
if (input.tool === "write" && filePath) {
|
||||
const key = input.callID ?? `write-${++writeCounter}-${filePath}`
|
||||
const pending = pendingToolChanges.get(key)
|
||||
if (pending) {
|
||||
recordChange(pending.path, pending.type)
|
||||
pendingToolChanges.delete(key)
|
||||
} else {
|
||||
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()
|
||||
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"
|
||||
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.0.0",
|
||||
"- 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
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* ECC Plugins for OpenCode
|
||||
*
|
||||
* This module exports all ECC plugins for OpenCode integration.
|
||||
* Plugins provide hook-based automation that mirrors Claude Code's hook system
|
||||
* while taking advantage of OpenCode's more sophisticated 20+ event types.
|
||||
*/
|
||||
|
||||
export { ECCHooksPlugin, default } from "./ecc-hooks.js"
|
||||
|
||||
// Re-export for named imports
|
||||
export * from "./ecc-hooks.js"
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as path from "path"
|
||||
|
||||
export type ChangeType = "added" | "modified" | "deleted"
|
||||
|
||||
const changes = new Map<string, ChangeType>()
|
||||
let worktreeRoot = ""
|
||||
|
||||
export function initStore(worktree: string): void {
|
||||
worktreeRoot = worktree || process.cwd()
|
||||
}
|
||||
|
||||
function toRelative(p: string): string {
|
||||
if (!p) return ""
|
||||
const normalized = path.normalize(p)
|
||||
if (path.isAbsolute(normalized) && worktreeRoot) {
|
||||
const rel = path.relative(worktreeRoot, normalized)
|
||||
return rel.startsWith("..") ? normalized : rel
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function recordChange(filePath: string, type: ChangeType): void {
|
||||
const rel = toRelative(filePath)
|
||||
if (!rel) return
|
||||
changes.set(rel, type)
|
||||
}
|
||||
|
||||
export function getChanges(): Map<string, ChangeType> {
|
||||
return new Map(changes)
|
||||
}
|
||||
|
||||
export function clearChanges(): void {
|
||||
changes.clear()
|
||||
}
|
||||
|
||||
export type TreeNode = {
|
||||
name: string
|
||||
path: string
|
||||
changeType?: ChangeType
|
||||
children: TreeNode[]
|
||||
}
|
||||
|
||||
function addToTree(children: TreeNode[], segs: string[], fullPath: string, changeType: ChangeType): void {
|
||||
if (segs.length === 0) return
|
||||
const [head, ...rest] = segs
|
||||
let child = children.find((c) => c.name === head)
|
||||
|
||||
if (rest.length === 0) {
|
||||
if (child) {
|
||||
child.changeType = changeType
|
||||
child.path = fullPath
|
||||
} else {
|
||||
children.push({ name: head, path: fullPath, changeType, children: [] })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!child) {
|
||||
const dirPath = segs.slice(0, -rest.length).join(path.sep)
|
||||
child = { name: head, path: dirPath, children: [] }
|
||||
children.push(child)
|
||||
}
|
||||
addToTree(child.children, rest, fullPath, changeType)
|
||||
}
|
||||
|
||||
export function buildTree(filter?: ChangeType): TreeNode[] {
|
||||
const root: TreeNode[] = []
|
||||
for (const [relPath, changeType] of changes) {
|
||||
if (filter && changeType !== filter) continue
|
||||
const segs = relPath.split(path.sep).filter(Boolean)
|
||||
if (segs.length === 0) continue
|
||||
addToTree(root, segs, relPath, changeType)
|
||||
}
|
||||
|
||||
function sortNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
return [...nodes].sort((a, b) => {
|
||||
const aIsFile = a.changeType !== undefined
|
||||
const bIsFile = b.changeType !== undefined
|
||||
if (aIsFile !== bIsFile) return aIsFile ? 1 : -1
|
||||
return a.name.localeCompare(b.name)
|
||||
}).map((n) => ({ ...n, children: sortNodes(n.children) }))
|
||||
}
|
||||
return sortNodes(root)
|
||||
}
|
||||
|
||||
export function getChangedPaths(filter?: ChangeType): Array<{ path: string; changeType: ChangeType }> {
|
||||
const list: Array<{ path: string; changeType: ChangeType }> = []
|
||||
for (const [p, t] of changes) {
|
||||
if (filter && t !== filter) continue
|
||||
list.push({ path: p, changeType: t })
|
||||
}
|
||||
list.sort((a, b) => a.path.localeCompare(b.path))
|
||||
return list
|
||||
}
|
||||
|
||||
export function hasChanges(): boolean {
|
||||
return changes.size > 0
|
||||
}
|
||||
Reference in New Issue
Block a user