mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-01 06:33:27 +08:00
feat(opencode): add changed-files tree with change indicators (#815)
* feat(opencode): add changed-files tree with change indicators * feat(opencode): address changed-files review feedback --------- Co-authored-by: Affaan Mustafa <me@affaanmustafa.com>
This commit is contained in:
@@ -74,6 +74,7 @@ export const metadata = {
|
||||
"format-code",
|
||||
"lint-check",
|
||||
"git-summary",
|
||||
"changed-files",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"write": true,
|
||||
"edit": true,
|
||||
"bash": true,
|
||||
"read": true
|
||||
"read": true,
|
||||
"changed-files": true
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
*/
|
||||
|
||||
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"
|
||||
|
||||
export const ECCHooksPlugin = async ({
|
||||
client,
|
||||
@@ -23,9 +31,25 @@ export const ECCHooksPlugin = async ({
|
||||
}: PluginInput) => {
|
||||
type HookProfile = "minimal" | "standard" | "strict"
|
||||
|
||||
// Track files edited in current session for console.log audit
|
||||
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)
|
||||
}
|
||||
|
||||
const pendingToolChanges = new Map<string, { path: string; type: "added" | "modified" }>()
|
||||
let writeCounter = 0
|
||||
|
||||
function getFilePath(args: Record<string, unknown> | 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 } })
|
||||
@@ -73,8 +97,8 @@ export const ECCHooksPlugin = async ({
|
||||
* Action: Runs prettier --write on the file
|
||||
*/
|
||||
"file.edited": async (event: { path: string }) => {
|
||||
// Track edited files for console.log audit
|
||||
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)$/)) {
|
||||
@@ -111,9 +135,24 @@ export const ECCHooksPlugin = async ({
|
||||
* Action: Runs tsc --noEmit to check for type errors
|
||||
*/
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; args?: { filePath?: string } },
|
||||
input: { tool: string; callID?: string; args?: { filePath?: string; file_path?: string; path?: string } },
|
||||
output: unknown
|
||||
) => {
|
||||
const filePath = getFilePath(input.args as Record<string, unknown>)
|
||||
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"]) &&
|
||||
@@ -152,8 +191,25 @@ export const ECCHooksPlugin = async ({
|
||||
* Action: Warns about potential security issues
|
||||
*/
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; args?: Record<string, unknown> }
|
||||
input: { tool: string; callID?: string; args?: Record<string, unknown> }
|
||||
) => {
|
||||
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") &&
|
||||
@@ -293,6 +349,8 @@ export const ECCHooksPlugin = async ({
|
||||
if (!hookEnabled("session:end-marker", ["minimal", "standard", "strict"])) return
|
||||
log("info", "[ECC] Session ended - cleaning up")
|
||||
editedFiles.clear()
|
||||
clearChanges()
|
||||
pendingToolChanges.clear()
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -303,6 +361,10 @@ export const ECCHooksPlugin = async ({
|
||||
* 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)
|
||||
}
|
||||
@@ -394,7 +456,7 @@ export const ECCHooksPlugin = async ({
|
||||
"",
|
||||
"## Active Plugin: Everything Claude Code v1.8.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",
|
||||
"- 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",
|
||||
@@ -449,6 +511,10 @@ export const ECCHooksPlugin = async ({
|
||||
// Everything else: let user decide
|
||||
return { approved: undefined }
|
||||
},
|
||||
|
||||
tool: {
|
||||
"changed-files": changedFilesTool,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
98
.opencode/plugins/lib/changed-files-store.ts
Normal file
98
.opencode/plugins/lib/changed-files-store.ts
Normal file
@@ -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
|
||||
}
|
||||
81
.opencode/tools/changed-files.ts
Normal file
81
.opencode/tools/changed-files.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import {
|
||||
buildTree,
|
||||
getChangedPaths,
|
||||
hasChanges,
|
||||
type ChangeType,
|
||||
type TreeNode,
|
||||
} from "../plugins/lib/changed-files-store.js"
|
||||
|
||||
const INDICATORS: Record<ChangeType, string> = {
|
||||
added: "+",
|
||||
modified: "~",
|
||||
deleted: "-",
|
||||
}
|
||||
|
||||
function renderTree(nodes: TreeNode[], indent: string): string {
|
||||
const lines: string[] = []
|
||||
for (const node of nodes) {
|
||||
const indicator = node.changeType ? ` (${INDICATORS[node.changeType]})` : ""
|
||||
const name = node.changeType ? `${node.name}${indicator}` : `${node.name}/`
|
||||
lines.push(`${indent}${name}`)
|
||||
if (node.children.length > 0) {
|
||||
lines.push(renderTree(node.children, `${indent} `))
|
||||
}
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export default tool({
|
||||
description:
|
||||
"List files changed by agents in this session as a navigable tree. Shows added (+), modified (~), and deleted (-) indicators. Use filter to show only specific change types. Returns paths for git diff.",
|
||||
args: {
|
||||
filter: tool.schema
|
||||
.enum(["all", "added", "modified", "deleted"])
|
||||
.optional()
|
||||
.describe("Filter by change type (default: all)"),
|
||||
format: tool.schema
|
||||
.enum(["tree", "json"])
|
||||
.optional()
|
||||
.describe("Output format: tree for terminal display, json for structured data (default: tree)"),
|
||||
},
|
||||
async execute(args, context) {
|
||||
const filter = args.filter === "all" || !args.filter ? undefined : (args.filter as ChangeType)
|
||||
const format = args.format ?? "tree"
|
||||
|
||||
if (!hasChanges()) {
|
||||
return JSON.stringify({ changed: false, message: "No files changed in this session" })
|
||||
}
|
||||
|
||||
const paths = getChangedPaths(filter)
|
||||
|
||||
if (format === "json") {
|
||||
return JSON.stringify(
|
||||
{
|
||||
changed: true,
|
||||
filter: filter ?? "all",
|
||||
files: paths.map((p) => ({ path: p.path, changeType: p.changeType })),
|
||||
diffCommands: paths
|
||||
.filter((p) => p.changeType !== "added")
|
||||
.map((p) => `git diff ${p.path}`),
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
|
||||
const tree = buildTree(filter)
|
||||
const treeStr = renderTree(tree, "")
|
||||
const diffHint = paths
|
||||
.filter((p) => p.changeType !== "added")
|
||||
.slice(0, 5)
|
||||
.map((p) => ` git diff ${p.path}`)
|
||||
.join("\n")
|
||||
|
||||
let output = `Changed files (${paths.length}):\n\n${treeStr}`
|
||||
if (diffHint) {
|
||||
output += `\n\nTo view diff for a file:\n${diffHint}`
|
||||
}
|
||||
return output
|
||||
},
|
||||
})
|
||||
@@ -11,3 +11,4 @@ export { default as securityAudit } from "./security-audit.js"
|
||||
export { default as formatCode } from "./format-code.js"
|
||||
export { default as lintCheck } from "./lint-check.js"
|
||||
export { default as gitSummary } from "./git-summary.js"
|
||||
export { default as changedFiles } from "./changed-files.js"
|
||||
|
||||
163
tests/lib/changed-files-store.test.js
Normal file
163
tests/lib/changed-files-store.test.js
Normal file
@@ -0,0 +1,163 @@
|
||||
const assert = require('assert')
|
||||
const path = require('path')
|
||||
const { pathToFileURL } = require('url')
|
||||
|
||||
const repoRoot = path.join(__dirname, '..', '..')
|
||||
const storePath = path.join(repoRoot, '.opencode', 'dist', 'plugins', 'lib', 'changed-files-store.js')
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn()
|
||||
console.log(` ✓ ${name}`)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`)
|
||||
console.log(` Error: ${err.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
|
||||
let store
|
||||
try {
|
||||
store = await import(pathToFileURL(storePath).href)
|
||||
} catch (err) {
|
||||
console.log('\n⚠ Skipping: build .opencode first (cd .opencode && npm run build)\n')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const { initStore, recordChange, buildTree, clearChanges, getChanges, getChangedPaths, hasChanges } = store
|
||||
const worktree = path.join(repoRoot, '.opencode')
|
||||
|
||||
console.log('\n=== Testing changed-files-store ===\n')
|
||||
|
||||
if (
|
||||
test('initStore and recordChange store relative path', () => {
|
||||
clearChanges()
|
||||
initStore(worktree)
|
||||
recordChange(path.join(worktree, 'src/foo.ts'), 'modified')
|
||||
const m = getChanges()
|
||||
assert.strictEqual(m.size, 1)
|
||||
assert.ok(m.has('src/foo.ts') || m.has(path.join('src', 'foo.ts')))
|
||||
assert.strictEqual(m.get(m.keys().next().value), 'modified')
|
||||
})
|
||||
)
|
||||
passed++
|
||||
else failed++
|
||||
|
||||
if (
|
||||
test('recordChange with relative path stores as-is when under worktree', () => {
|
||||
clearChanges()
|
||||
initStore(worktree)
|
||||
recordChange('plugins/ecc-hooks.ts', 'modified')
|
||||
const m = getChanges()
|
||||
assert.strictEqual(m.size, 1)
|
||||
const key = [...m.keys()][0]
|
||||
assert.ok(key.includes('ecc-hooks'))
|
||||
})
|
||||
)
|
||||
passed++
|
||||
else failed++
|
||||
|
||||
if (
|
||||
test('recordChange overwrites existing path with new type', () => {
|
||||
clearChanges()
|
||||
initStore(worktree)
|
||||
recordChange('a.ts', 'modified')
|
||||
recordChange('a.ts', 'added')
|
||||
const m = getChanges()
|
||||
assert.strictEqual(m.size, 1)
|
||||
assert.strictEqual(m.get([...m.keys()][0]), 'added')
|
||||
})
|
||||
)
|
||||
passed++
|
||||
else failed++
|
||||
|
||||
if (
|
||||
test('buildTree returns nested structure', () => {
|
||||
clearChanges()
|
||||
initStore(worktree)
|
||||
recordChange('src/a.ts', 'modified')
|
||||
recordChange('src/b.ts', 'added')
|
||||
recordChange('src/sub/c.ts', 'deleted')
|
||||
const tree = buildTree()
|
||||
assert.strictEqual(tree.length, 1)
|
||||
assert.strictEqual(tree[0].name, 'src')
|
||||
assert.strictEqual(tree[0].children.length, 3)
|
||||
const names = tree[0].children.map((n) => n.name).sort()
|
||||
assert.deepStrictEqual(names, ['a.ts', 'b.ts', 'sub'])
|
||||
})
|
||||
)
|
||||
passed++
|
||||
else failed++
|
||||
|
||||
if (
|
||||
test('buildTree filter restricts by change type', () => {
|
||||
clearChanges()
|
||||
initStore(worktree)
|
||||
recordChange('a.ts', 'added')
|
||||
recordChange('b.ts', 'modified')
|
||||
recordChange('c.ts', 'deleted')
|
||||
const added = buildTree('added')
|
||||
assert.strictEqual(added.length, 1)
|
||||
assert.strictEqual(added[0].changeType, 'added')
|
||||
const modified = buildTree('modified')
|
||||
assert.strictEqual(modified.length, 1)
|
||||
assert.strictEqual(modified[0].changeType, 'modified')
|
||||
})
|
||||
)
|
||||
passed++
|
||||
else failed++
|
||||
|
||||
if (
|
||||
test('getChangedPaths returns sorted list with filter', () => {
|
||||
clearChanges()
|
||||
initStore(worktree)
|
||||
recordChange('z.ts', 'modified')
|
||||
recordChange('a.ts', 'modified')
|
||||
const paths = getChangedPaths('modified')
|
||||
assert.strictEqual(paths.length, 2)
|
||||
assert.ok(paths[0].path <= paths[1].path)
|
||||
})
|
||||
)
|
||||
passed++
|
||||
else failed++
|
||||
|
||||
if (
|
||||
test('hasChanges reflects state', () => {
|
||||
clearChanges()
|
||||
initStore(worktree)
|
||||
assert.strictEqual(hasChanges(), false)
|
||||
recordChange('x.ts', 'modified')
|
||||
assert.strictEqual(hasChanges(), true)
|
||||
clearChanges()
|
||||
assert.strictEqual(hasChanges(), false)
|
||||
})
|
||||
)
|
||||
passed++
|
||||
else failed++
|
||||
|
||||
if (
|
||||
test('clearChanges clears all', () => {
|
||||
clearChanges()
|
||||
initStore(worktree)
|
||||
recordChange('a.ts', 'modified')
|
||||
recordChange('b.ts', 'added')
|
||||
clearChanges()
|
||||
assert.strictEqual(getChanges().size, 0)
|
||||
})
|
||||
)
|
||||
passed++
|
||||
else failed++
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`)
|
||||
process.exit(failed > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
runTests().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user