Skip to content

Commit e34d844

Browse files
committed
feat: add OpenCode plugin support
Add --opencode flag to 'openwolf init' for registering OpenCode plugin instead of Claude Code hooks. - Create OpenCode plugin directory with separated modules: - types.ts: interfaces for SessionState, FileRead, FileWrite, etc. - fs.ts: file system helpers (readJSON, writeJSON, etc.) - anatomy.ts: anatomy.md parsing and serialization - session.ts: session start handling - pre-read.ts: repeated read warnings, anatomy lookups - pre-write.ts: cerebrum check, buglog lookup - post-read.ts: token estimation - post-write.ts: anatomy update, memory append, bug detection - stop.ts: session summary, ledger update - index.ts: main plugin entry point - Map OpenCode hooks to OpenWolf infrastructure - Add opencode-md-snippet.md template - Update shared.ts with projectDir param + wolfDirExists() - Exclude plugin directory from tsconfig build Refactors monolithic 1000-line file into 10 focused modules. Closes #6
1 parent bd69835 commit e34d844

15 files changed

Lines changed: 1159 additions & 35 deletions

File tree

src/cli/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function createProgram(): Command {
3131
program
3232
.command("init")
3333
.description("Initialize .wolf/ in current project")
34+
.option("--opencode", "Register OpenCode plugin instead of Claude Code hooks")
3435
.action(initCommand);
3536

3637
program

src/cli/init.ts

Lines changed: 118 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,11 @@ const HOOK_SETTINGS = {
118118
},
119119
};
120120

121-
export async function initCommand(): Promise<void> {
121+
export interface InitOptions {
122+
opencode?: boolean;
123+
}
124+
125+
export async function initCommand(opts: InitOptions = {}): Promise<void> {
122126
// Check Node.js version
123127
const nodeVersion = parseInt(process.version.slice(1), 10);
124128
if (nodeVersion < 20) {
@@ -182,35 +186,40 @@ export async function initCommand(): Promise<void> {
182186
// --- Hook scripts: always update (bug fixes, new features) ---
183187
copyHookScripts(wolfDir);
184188

185-
// --- Claude settings: replace OpenWolf hooks (upgrade old paths) ---
186-
const claudeDir = path.join(projectRoot, ".claude");
187-
ensureDir(claudeDir);
188-
189-
const settingsPath = path.join(claudeDir, "settings.json");
190-
if (fs.existsSync(settingsPath)) {
191-
const existing = readJSON<Record<string, unknown>>(settingsPath, {});
192-
const merged = replaceOpenWolfHooks(existing, HOOK_SETTINGS);
193-
writeJSON(settingsPath, merged);
189+
if (opts.opencode) {
190+
// --- OpenCode plugin ---
191+
initOpenCode(projectRoot, actualTemplatesDir);
194192
} else {
195-
writeJSON(settingsPath, HOOK_SETTINGS);
196-
}
193+
// --- Claude settings: replace OpenWolf hooks (upgrade old paths) ---
194+
const claudeDir = path.join(projectRoot, ".claude");
195+
ensureDir(claudeDir);
196+
197+
const settingsPath = path.join(claudeDir, "settings.json");
198+
if (fs.existsSync(settingsPath)) {
199+
const existing = readJSON<Record<string, unknown>>(settingsPath, {});
200+
const merged = replaceOpenWolfHooks(existing, HOOK_SETTINGS);
201+
writeJSON(settingsPath, merged);
202+
} else {
203+
writeJSON(settingsPath, HOOK_SETTINGS);
204+
}
197205

198-
// --- Claude rules: always update ---
199-
const rulesDir = path.join(claudeDir, "rules");
200-
ensureDir(rulesDir);
201-
const rulesContent = readTemplateContent("claude-rules-openwolf.md", actualTemplatesDir);
202-
writeText(path.join(rulesDir, "openwolf.md"), rulesContent);
203-
204-
// --- CLAUDE.md: add snippet if missing ---
205-
const claudeMdPath = path.join(projectRoot, "CLAUDE.md");
206-
const snippetContent = readTemplateContent("claude-md-snippet.md", actualTemplatesDir);
207-
if (fs.existsSync(claudeMdPath)) {
208-
const existing = readText(claudeMdPath);
209-
if (!existing.includes("OpenWolf")) {
210-
writeText(claudeMdPath, snippetContent + "\n\n" + existing);
206+
// --- Claude rules: always update ---
207+
const rulesDir = path.join(claudeDir, "rules");
208+
ensureDir(rulesDir);
209+
const rulesContent = readTemplateContent("claude-rules-openwolf.md", actualTemplatesDir);
210+
writeText(path.join(rulesDir, "openwolf.md"), rulesContent);
211+
212+
// --- CLAUDE.md: add snippet if missing ---
213+
const claudeMdPath = path.join(projectRoot, "CLAUDE.md");
214+
const snippetContent = readTemplateContent("claude-md-snippet.md", actualTemplatesDir);
215+
if (fs.existsSync(claudeMdPath)) {
216+
const existing = readText(claudeMdPath);
217+
if (!existing.includes("OpenWolf")) {
218+
writeText(claudeMdPath, snippetContent + "\n\n" + existing);
219+
}
220+
} else {
221+
writeText(claudeMdPath, snippetContent);
211222
}
212-
} else {
213-
writeText(claudeMdPath, snippetContent);
214223
}
215224

216225
// --- Anatomy scan: only on fresh init ---
@@ -277,14 +286,23 @@ export async function initCommand(): Promise<void> {
277286
} else {
278287
console.log(` ✓ OpenWolf v${version} initialized`);
279288
console.log(` ✓ .wolf/ created with ${createdCount} files`);
280-
console.log(` ✓ Claude Code hooks registered (6 hooks)`);
281-
console.log(` ✓ CLAUDE.md updated`);
282-
console.log(` ✓ .claude/rules/openwolf.md created`);
289+
if (opts.opencode) {
290+
console.log(` ✓ OpenCode plugin registered (.opencode/plugin/openwolf.ts)`);
291+
console.log(` ✓ opencode.md updated`);
292+
} else {
293+
console.log(` ✓ Claude Code hooks registered (6 hooks)`);
294+
console.log(` ✓ CLAUDE.md updated`);
295+
console.log(` ✓ .claude/rules/openwolf.md created`);
296+
}
283297
console.log(` ✓ Anatomy scan: ${fileCount} files indexed`);
284298
}
285299
console.log(` ✓ Daemon: ${daemonStatus}`);
286300
console.log("");
287-
console.log(" You're ready. Just use 'claude' as normal — OpenWolf is watching.");
301+
if (opts.opencode) {
302+
console.log(" You're ready. Just use 'opencode' as normal — OpenWolf is watching.");
303+
} else {
304+
console.log(" You're ready. Just use 'claude' as normal — OpenWolf is watching.");
305+
}
288306
console.log("");
289307
}
290308

@@ -324,6 +342,7 @@ function readTemplateContent(filename: string, templatesDir: string): string {
324342
function getEmbeddedTemplate(filename: string): string {
325343
const templates: Record<string, string> = {
326344
"claude-md-snippet.md": `# OpenWolf\n\n@.wolf/OPENWOLF.md\n\nThis project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md every session. Check .wolf/cerebrum.md before generating code. Check .wolf/anatomy.md before reading files.`,
345+
"opencode-md-snippet.md": `# OpenWolf\n\n@.wolf/OPENWOLF.md\n\nThis project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md every session. Check .wolf/cerebrum.md before generating code. Check .wolf/anatomy.md before reading files.`,
327346
"claude-rules-openwolf.md": `---\ndescription: OpenWolf protocol enforcement — active on all files\nglobs: **/*\n---\n\n- Check .wolf/anatomy.md before reading any project file\n- Check .wolf/cerebrum.md Do-Not-Repeat list before generating code\n- After writing or editing files, update .wolf/anatomy.md and append to .wolf/memory.md\n- After receiving a user correction, update .wolf/cerebrum.md immediately (Preferences, Learnings, or Do-Not-Repeat)\n- LEARN from every interaction: if you discover a convention, user preference, or project pattern, add it to .wolf/cerebrum.md. Low threshold — when in doubt, log it.\n- BEFORE fixing any bug or error: read .wolf/buglog.json for known fixes\n- AFTER fixing any bug, error, failed test, failed build, or user-reported problem: ALWAYS log to .wolf/buglog.json with error_message, root_cause, fix, and tags\n- If you edit a file more than twice in a session, that likely indicates a bug — log it to .wolf/buglog.json\n- When the user asks to check/evaluate UI design: run \`openwolf designqc\` to capture screenshots, then read them from .wolf/designqc-captures/\n- When the user asks to change/pick/migrate UI framework: read .wolf/reframe-frameworks.md, ask decision questions, recommend a framework, then execute with the framework's prompt`,
328347
};
329348
return templates[filename] ?? "";
@@ -467,6 +486,74 @@ function copyHookScripts(wolfDir: string): void {
467486
fs.writeFileSync(hooksPkgPath, JSON.stringify({ type: "module" }, null, 2) + "\n", "utf-8");
468487
}
469488

489+
/**
490+
* Initialize OpenCode plugin for OpenWolf.
491+
* Creates .opencode/plugin/openwolf.ts and injects snippet into opencode.md.
492+
*/
493+
function initOpenCode(projectRoot: string, templatesDir: string): void {
494+
// Create .opencode/plugin/ directory
495+
const pluginDir = path.join(projectRoot, ".opencode", "plugin");
496+
ensureDir(pluginDir);
497+
498+
// Copy the plugin template directory
499+
const pluginSrcDir = path.join(templatesDir, "opencode-plugin");
500+
const pluginDestDir = path.join(pluginDir, "openwolf");
501+
502+
if (fs.existsSync(pluginSrcDir)) {
503+
copyPluginDirectory(pluginSrcDir, pluginDestDir);
504+
} else {
505+
// Fallback: write embedded version
506+
const pluginDest = path.join(pluginDir, "openwolf.ts");
507+
fs.writeFileSync(pluginDest, getEmbeddedOpenCodePlugin(), "utf-8");
508+
}
509+
510+
// Inject snippet into opencode.md (OpenCode's equivalent of CLAUDE.md)
511+
const opencodeMdPath = path.join(projectRoot, "opencode.md");
512+
const snippetContent = readTemplateContent("opencode-md-snippet.md", templatesDir);
513+
if (fs.existsSync(opencodeMdPath)) {
514+
const existing = readText(opencodeMdPath);
515+
if (!existing.includes("OpenWolf")) {
516+
writeText(opencodeMdPath, snippetContent + "\n\n" + existing);
517+
}
518+
} else {
519+
writeText(opencodeMdPath, snippetContent);
520+
}
521+
}
522+
523+
function copyPluginDirectory(srcDir: string, destDir: string): void {
524+
ensureDir(destDir);
525+
const files = fs.readdirSync(srcDir);
526+
for (const file of files) {
527+
const srcPath = path.join(srcDir, file);
528+
const destPath = path.join(destDir, file);
529+
if (fs.statSync(srcPath).isDirectory()) {
530+
copyPluginDirectory(srcPath, destPath);
531+
} else {
532+
fs.copyFileSync(srcPath, destPath);
533+
}
534+
}
535+
}
536+
537+
function getEmbeddedOpenCodePlugin(): string {
538+
return `import type { Plugin } from "@opencode-ai/plugin"
539+
// OpenWolf plugin — auto-generated by openwolf init
540+
// For the full implementation, see the openwolf source repository.
541+
export const OpenWolf: Plugin = async ({ directory }) => {
542+
return {
543+
event: async ({ event }) => {
544+
// Session lifecycle handled by .wolf/ infrastructure
545+
},
546+
"tool.execute.before": async (_input, _output) => {},
547+
"tool.execute.after": async (_input, _output) => {},
548+
stop: async (_input) => {},
549+
"experimental.chat.system.transform": async (_input, output) => {
550+
output.system.push("This project uses OpenWolf. Read .wolf/OPENWOLF.md for protocol instructions.")
551+
},
552+
}
553+
}
554+
`;
555+
}
556+
470557
/**
471558
* Replace all OpenWolf hook entries in settings.json with the current version.
472559
* Removes old-style relative-path hooks and inserts the new $CLAUDE_PROJECT_DIR hooks.

src/hooks/shared.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import * as fs from "node:fs";
22
import * as path from "node:path";
33
import * as crypto from "node:crypto";
44

5-
export function getWolfDir(): string {
5+
export function getWolfDir(projectDir?: string): string {
66
// Prefer CLAUDE_PROJECT_DIR so hooks work even if CWD changes during a session
7-
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
8-
return path.join(projectDir, ".wolf");
7+
const dir = projectDir || process.env.CLAUDE_PROJECT_DIR || process.cwd();
8+
return path.join(dir, ".wolf");
99
}
1010

1111
/**
@@ -19,6 +19,14 @@ export function ensureWolfDir(): void {
1919
}
2020
}
2121

22+
/**
23+
* Check if .wolf/ directory exists without exiting.
24+
* Used by the OpenCode plugin to skip processing in non-OpenWolf projects.
25+
*/
26+
export function wolfDirExists(): boolean {
27+
return fs.existsSync(getWolfDir());
28+
}
29+
2230
export function readJSON<T = unknown>(filePath: string, fallback: T): T {
2331
try {
2432
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# OpenWolf
2+
3+
@.wolf/OPENWOLF.md
4+
5+
This project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md every session. Check .wolf/cerebrum.md before generating code. Check .wolf/anatomy.md before reading files.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import * as fs from "node:fs"
2+
import * as path from "node:path"
3+
import type { AnatomyEntry } from "./types.js"
4+
5+
export function parseAnatomy(content: string): Map<string, AnatomyEntry[]> {
6+
const sections = new Map<string, AnatomyEntry[]>()
7+
let currentSection = ""
8+
for (const line of content.split("\n")) {
9+
const sm = line.match(/^## (.+)/)
10+
if (sm) {
11+
currentSection = sm[1].trim()
12+
if (!sections.has(currentSection)) sections.set(currentSection, [])
13+
continue
14+
}
15+
if (!currentSection) continue
16+
const em = line.match(/^- `([^`]+)`(?:\s+\s+(.+?))?\s*\(~(\d+)\s+tok\)$/)
17+
if (em) {
18+
sections.get(currentSection)!.push({
19+
file: em[1],
20+
description: em[2] || "",
21+
tokens: parseInt(em[3], 10),
22+
})
23+
}
24+
}
25+
return sections
26+
}
27+
28+
export function serializeAnatomy(
29+
sections: Map<string, AnatomyEntry[]>,
30+
metadata: { lastScanned: string; fileCount: number; hits: number; misses: number }
31+
): string {
32+
const lines: string[] = [
33+
"# anatomy.md",
34+
"",
35+
`> Auto-maintained by OpenWolf. Last scanned: ${metadata.lastScanned}`,
36+
`> Files: ${metadata.fileCount} tracked | Anatomy hits: ${metadata.hits} | Misses: ${metadata.misses}`,
37+
"",
38+
]
39+
const keys = [...sections.keys()].sort()
40+
for (const key of keys) {
41+
lines.push(`## ${key}`)
42+
lines.push("")
43+
const entries = sections.get(key)!.sort((a, b) => a.file.localeCompare(b.file))
44+
for (const e of entries) {
45+
const desc = e.description ? ` — ${e.description}` : ""
46+
lines.push(`- \`${e.file}\`${desc} (~${e.tokens} tok)`)
47+
}
48+
lines.push("")
49+
}
50+
return lines.join("\n")
51+
}
52+
53+
export function extractDescription(filePath: string): string {
54+
const MAX_DESC = 150
55+
const basename = path.basename(filePath)
56+
const ext = path.extname(basename).toLowerCase()
57+
const known: Record<string, string> = {
58+
"package.json": "Node.js package manifest",
59+
"tsconfig.json": "TypeScript configuration",
60+
".gitignore": "Git ignore rules",
61+
"README.md": "Project documentation",
62+
}
63+
if (known[basename]) return known[basename]
64+
65+
let content: string
66+
try {
67+
const fd = fs.openSync(filePath, "r")
68+
const buf = Buffer.alloc(12288)
69+
const n = fs.readSync(fd, buf, 0, 12288, 0)
70+
fs.closeSync(fd)
71+
content = buf.subarray(0, n).toString("utf-8")
72+
} catch {
73+
return ""
74+
}
75+
if (!content.trim()) return ""
76+
77+
const cap = (s: string) => s.length <= MAX_DESC ? s : s.slice(0, MAX_DESC - 3) + "..."
78+
79+
if (ext === ".md" || ext === ".mdx") {
80+
const m = content.match(/^#{1,2}\s+(.+)$/m)
81+
if (m) return cap(m[1].trim())
82+
}
83+
84+
if (ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx") {
85+
if (basename === "page.tsx" || basename === "page.js") return "Next.js page component"
86+
if (basename === "layout.tsx" || basename === "layout.js") return "Next.js layout"
87+
const exports = (content.match(/export\s+(?:async\s+)?(?:function|class|const|interface|type|enum)\s+(\w+)/g) || [])
88+
.map(e => e.match(/(\w+)$/)?.[1]).filter(Boolean) as string[]
89+
if (exports.length > 0 && exports.length <= 5) return `Exports ${exports.join(", ")}`
90+
if (exports.length > 5) return cap(`Exports ${exports.slice(0, 4).join(", ")} + ${exports.length - 4} more`)
91+
}
92+
93+
const declM = content.match(/(?:function|class|const|interface|type|enum)\s+(\w+)/)
94+
if (declM) return `Declares ${declM[1]}`
95+
return ""
96+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import * as fs from "node:fs"
2+
import * as path from "node:path"
3+
import * as crypto from "node:crypto"
4+
5+
export function getWolfDir(directory: string): string {
6+
return path.join(directory, ".wolf")
7+
}
8+
9+
export function wolfDirExists(directory: string): boolean {
10+
return fs.existsSync(getWolfDir(directory))
11+
}
12+
13+
export function readJSON<T>(filePath: string, fallback: T): T {
14+
try {
15+
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T
16+
} catch {
17+
return fallback
18+
}
19+
}
20+
21+
export function writeJSON(filePath: string, data: unknown): void {
22+
const dir = path.dirname(filePath)
23+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
24+
const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp"
25+
try {
26+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8")
27+
fs.renameSync(tmp, filePath)
28+
} catch {
29+
try { fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8") } catch {}
30+
try { fs.unlinkSync(tmp) } catch {}
31+
}
32+
}
33+
34+
export function readMarkdown(filePath: string): string {
35+
try {
36+
return fs.readFileSync(filePath, "utf-8")
37+
} catch {
38+
return ""
39+
}
40+
}
41+
42+
export function appendMarkdown(filePath: string, line: string): void {
43+
const dir = path.dirname(filePath)
44+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
45+
fs.appendFileSync(filePath, line, "utf-8")
46+
}
47+
48+
export function timeShort(): string {
49+
const d = new Date()
50+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
51+
}
52+
53+
export function timestamp(): string {
54+
return new Date().toISOString()
55+
}
56+
57+
export function normalizePath(p: string): string {
58+
return p.replace(/\\/g, "/")
59+
}
60+
61+
export function estimateTokens(text: string, type: "code" | "prose" | "mixed" = "mixed"): number {
62+
const ratio = type === "code" ? 3.5 : type === "prose" ? 4.0 : 3.75
63+
return Math.ceil(text.length / ratio)
64+
}

0 commit comments

Comments
 (0)