Files

437 lines
12 KiB
JavaScript
Raw Permalink Normal View History

/**
* Trellis Context Manager
*
* Unified context management for OpenCode plugins.
* Handles detection of oh-my-opencode, .claude/hooks/, and other edge cases.
*
* Usage:
* import { TrellisContext } from "./trellis-context.js"
* const ctx = new TrellisContext(directory)
* if (ctx.shouldSkipHook("session-start")) return
*/
import { existsSync, readFileSync, appendFileSync, readdirSync } from "fs"
import { join } from "path"
import { homedir, platform } from "os"
import { execSync } from "child_process"
// Python command: Windows uses 'python', macOS/Linux use 'python3'
const PYTHON_CMD = platform() === "win32" ? "python" : "python3"
// Debug logging
const DEBUG_LOG = "/tmp/trellis-plugin-debug.log"
function debugLog(prefix, ...args) {
const timestamp = new Date().toISOString()
const msg = `[${timestamp}] [${prefix}] ${args.map(a => typeof a === "object" ? JSON.stringify(a) : a).join(" ")}\n`
try {
appendFileSync(DEBUG_LOG, msg)
} catch {
// ignore
}
}
/**
* Trellis Context Manager
*
* Centralized logic for:
* - Detecting oh-my-opencode installation
* - Checking .claude/hooks/ presence
* - Determining which plugin should handle each hook
*/
export class TrellisContext {
constructor(directory) {
this.directory = directory
this._omoInstalled = null
this._omoHooksEnabled = null
this._claudeHooksPresent = {}
debugLog("context", "TrellisContext initialized", { directory })
}
// ============================================================
// oh-my-opencode Detection
// ============================================================
/**
* Check if oh-my-opencode is installed
*
* Detection order:
* 1. Check if oh-my-opencode.json exists (most reliable)
* 2. Fallback: check opencode.json plugin list
*/
isOmoInstalled() {
if (this._omoInstalled !== null) {
return this._omoInstalled
}
try {
const configDir = join(homedir(), ".config", "opencode")
// Method 1: Check oh-my-opencode.json existence (omo-specific config)
const omoConfigPath = join(configDir, "oh-my-opencode.json")
if (existsSync(omoConfigPath)) {
this._omoInstalled = true
debugLog("context", "omo installed: oh-my-opencode.json exists")
return true
}
// Method 2: Fallback to plugin list check
const configPath = join(configDir, "opencode.json")
if (!existsSync(configPath)) {
this._omoInstalled = false
debugLog("context", "omo not installed: no config files")
return false
}
const content = readFileSync(configPath, "utf-8")
const config = JSON.parse(content)
const plugins = config.plugin || []
this._omoInstalled = plugins.some(p =>
typeof p === "string" && p.toLowerCase().includes("oh-my-opencode")
)
debugLog("context", "omo installed (plugin list):", this._omoInstalled)
return this._omoInstalled
} catch (e) {
debugLog("context", "omo detection error:", e.message)
this._omoInstalled = false
return false
}
}
/**
* Check if omo's claude_code.hooks is enabled
* Reads oh-my-opencode.json or defaults to true
*/
isOmoHooksEnabled() {
if (this._omoHooksEnabled !== null) {
return this._omoHooksEnabled
}
if (!this.isOmoInstalled()) {
this._omoHooksEnabled = false
return false
}
try {
// Check global config
const globalConfig = join(homedir(), ".config", "opencode", "oh-my-opencode.json")
if (existsSync(globalConfig)) {
const content = readFileSync(globalConfig, "utf-8")
const config = JSON.parse(content)
if (config.claude_code?.hooks === false) {
this._omoHooksEnabled = false
debugLog("context", "omo hooks disabled in global config")
return false
}
}
// Check project config
const projectConfig = join(this.directory, "oh-my-opencode.json")
if (existsSync(projectConfig)) {
const content = readFileSync(projectConfig, "utf-8")
const config = JSON.parse(content)
if (config.claude_code?.hooks === false) {
this._omoHooksEnabled = false
debugLog("context", "omo hooks disabled in project config")
return false
}
}
// Default: enabled
this._omoHooksEnabled = true
debugLog("context", "omo hooks enabled (default)")
return true
} catch (e) {
debugLog("context", "omo hooks detection error:", e.message)
this._omoHooksEnabled = true // Default to enabled
return true
}
}
// ============================================================
// .claude/hooks/ Detection
// ============================================================
/**
* Check if a specific .claude/hooks/ file exists
*/
hasClaudeHook(hookName) {
if (hookName in this._claudeHooksPresent) {
return this._claudeHooksPresent[hookName]
}
const hookPath = join(this.directory, ".claude", "hooks", `${hookName}.py`)
const exists = existsSync(hookPath)
this._claudeHooksPresent[hookName] = exists
debugLog("context", `claude hook ${hookName}:`, exists)
return exists
}
// ============================================================
// Trellis Project Detection
// ============================================================
/**
* Check if this is a Trellis-managed project
*/
isTrellisProject() {
return existsSync(join(this.directory, ".trellis"))
}
/**
* Get current task directory from .trellis/.current-task
*/
getCurrentTask() {
try {
const currentTaskPath = join(this.directory, ".trellis", ".current-task")
if (!existsSync(currentTaskPath)) {
return null
}
return readFileSync(currentTaskPath, "utf-8").trim()
} catch {
return null
}
}
// ============================================================
// Hook Decision Logic
// ============================================================
/**
* Determine if our plugin should skip this hook
* (because omo will handle it via .claude/hooks/)
*
* @param {string} hookName - Hook name without extension (e.g., "session-start")
* @returns {boolean} - true if we should skip, false if we should handle
*/
shouldSkipHook(hookName) {
// Not a Trellis project? Skip.
if (!this.isTrellisProject()) {
debugLog("context", `shouldSkipHook(${hookName}): skip - not Trellis project`)
return true
}
// omo not installed? We handle it.
if (!this.isOmoInstalled()) {
debugLog("context", `shouldSkipHook(${hookName}): handle - omo not installed`)
return false
}
// omo installed but hooks disabled? We handle it.
if (!this.isOmoHooksEnabled()) {
debugLog("context", `shouldSkipHook(${hookName}): handle - omo hooks disabled`)
return false
}
// omo installed + hooks enabled + .claude/hooks/ exists? Skip (omo handles).
if (this.hasClaudeHook(hookName)) {
debugLog("context", `shouldSkipHook(${hookName}): skip - omo will handle via .claude/hooks/`)
return true
}
// omo installed but no .claude/hooks/ file? We handle it.
debugLog("context", `shouldSkipHook(${hookName}): handle - no .claude/hooks/ file`)
return false
}
// ============================================================
// File Reading Utilities
// ============================================================
/**
* Read a file, return null on error
*/
readFile(filePath) {
try {
if (existsSync(filePath)) {
return readFileSync(filePath, "utf-8")
}
} catch {
// Ignore read errors
}
return null
}
/**
* Read a file relative to project directory
*/
readProjectFile(relativePath) {
return this.readFile(join(this.directory, relativePath))
}
/**
* Run a Python script and return output
*/
runScript(scriptPath, cwd = null) {
try {
const result = execSync(`${PYTHON_CMD} "${scriptPath}"`, {
cwd: cwd || this.directory,
timeout: 10000,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"]
})
return result || ""
} catch {
return ""
}
}
// ============================================================
// JSONL Reading
// ============================================================
/**
* Read all .md files in a directory
* @param {string} dirPath - Directory path relative to project root
* @param {number} maxFiles - Max files to read (prevent huge directories)
* @returns {Array<{path: string, content: string}>}
*/
readDirectoryMdFiles(dirPath, maxFiles = 20) {
const results = []
const fullPath = join(this.directory, dirPath)
if (!existsSync(fullPath)) {
return results
}
try {
const files = readdirSync(fullPath)
.filter(f => f.endsWith(".md"))
.sort()
.slice(0, maxFiles)
for (const filename of files) {
const filePath = join(dirPath, filename)
const content = this.readProjectFile(filePath)
if (content) {
results.push({ path: filePath, content })
}
}
} catch {
// Ignore directory read errors
}
return results
}
/**
* Read a JSONL file and load referenced files/directories
* Supports:
* {"file": "path/to/file.md", "reason": "..."}
* {"file": "path/to/dir/", "type": "directory", "reason": "..."}
*/
readJsonlWithFiles(jsonlPath) {
const results = []
const content = this.readFile(jsonlPath)
if (!content) return results
for (const line of content.split("\n")) {
if (!line.trim()) continue
try {
const item = JSON.parse(line)
const file = item.file || item.path
const entryType = item.type || "file"
if (!file) continue
if (entryType === "directory") {
// Read all .md files in directory
const dirEntries = this.readDirectoryMdFiles(file)
results.push(...dirEntries)
} else {
// Read single file
const fullPath = join(this.directory, file)
const fileContent = this.readFile(fullPath)
if (fileContent) {
results.push({ path: file, content: fileContent })
}
}
} catch {
// Ignore parse errors for individual lines
}
}
return results
}
/**
* Build context string from file entries
*/
buildContextFromEntries(entries) {
return entries.map(e => `=== ${e.path} ===\n${e.content}`).join("\n\n")
}
}
// ============================================================
// Context Collector (for synthetic message injection)
// ============================================================
/**
* Simple context collector for cross-hook communication
* Similar to oh-my-opencode's contextCollector
*/
class ContextCollector {
constructor() {
this.pending = new Map()
this.processed = new Set()
}
/**
* Store context for a session
*/
store(sessionID, content) {
this.pending.set(sessionID, {
content,
timestamp: Date.now()
})
debugLog("collector", "stored context for session:", sessionID, "length:", content.length)
}
/**
* Check if session has pending context
*/
hasPending(sessionID) {
return this.pending.has(sessionID)
}
/**
* Get and consume pending context
*/
consume(sessionID) {
const pending = this.pending.get(sessionID)
this.pending.delete(sessionID)
return pending
}
/**
* Mark session as processed (for first-message-only injection)
*/
markProcessed(sessionID) {
this.processed.add(sessionID)
}
/**
* Check if session was already processed
*/
isProcessed(sessionID) {
return this.processed.has(sessionID)
}
/**
* Clear session state
*/
clear(sessionID) {
this.pending.delete(sessionID)
this.processed.delete(sessionID)
}
}
// Singleton instance
export const contextCollector = new ContextCollector()
// Export debug log for plugins
export { debugLog }