Compare commits

..

1 Commits

Author SHA1 Message Date
YeonGyu-Kim
55c8952668 fix(hashline-edit): update HASHLINE_OUTPUT_PATTERN separator from : to |
The colon separator was missed in PR #2079 which migrated all hashline
format separators from `:` to `|`. This constant is exported as public
API for external consumers.
2026-02-24 15:17:15 +09:00
25 changed files with 157 additions and 450 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.8.5",
"version": "3.8.4",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -74,13 +74,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.8.5",
"oh-my-opencode-darwin-x64": "3.8.5",
"oh-my-opencode-linux-arm64": "3.8.5",
"oh-my-opencode-linux-arm64-musl": "3.8.5",
"oh-my-opencode-linux-x64": "3.8.5",
"oh-my-opencode-linux-x64-musl": "3.8.5",
"oh-my-opencode-windows-x64": "3.8.5"
"oh-my-opencode-darwin-arm64": "3.8.4",
"oh-my-opencode-darwin-x64": "3.8.4",
"oh-my-opencode-linux-arm64": "3.8.4",
"oh-my-opencode-linux-arm64-musl": "3.8.4",
"oh-my-opencode-linux-x64": "3.8.4",
"oh-my-opencode-linux-x64-musl": "3.8.4",
"oh-my-opencode-windows-x64": "3.8.4"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.8.5",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.8.5",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
"version": "3.8.5",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.8.5",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.8.5",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.8.5",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.8.5",
"version": "3.8.4",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,59 +0,0 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { loadOpencodeGlobalCommands, loadOpencodeProjectCommands } from "./loader"
const testRoots: string[] = []
function createTempRoot(): string {
const root = join(tmpdir(), `command-loader-${Date.now()}-${Math.random().toString(16).slice(2)}`)
mkdirSync(root, { recursive: true })
testRoots.push(root)
return root
}
function writeCommand(dir: string, name: string): void {
mkdirSync(dir, { recursive: true })
writeFileSync(
join(dir, `${name}.md`),
"---\ndescription: command from test\n---\nUse this command"
)
}
afterEach(() => {
for (const root of testRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
delete process.env.OPENCODE_CONFIG_DIR
})
describe("claude-code-command-loader OpenCode paths", () => {
it("loads commands from global OpenCode commands directory", async () => {
// given
const root = createTempRoot()
const opencodeConfigDir = join(root, "config")
writeCommand(join(opencodeConfigDir, "commands"), "global-opencode")
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
// when
const commands = await loadOpencodeGlobalCommands()
// then
expect(commands["global-opencode"]).toBeDefined()
})
it("loads commands from project OpenCode commands directory", async () => {
// given
const root = createTempRoot()
writeCommand(join(root, ".opencode", "commands"), "project-opencode")
// when
const commands = await loadOpencodeProjectCommands(root)
// then
expect(commands["project-opencode"]).toBeDefined()
})
})

View File

@@ -122,13 +122,13 @@ export async function loadProjectCommands(directory?: string): Promise<Record<st
export async function loadOpencodeGlobalCommands(): Promise<Record<string, CommandDefinition>> {
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const opencodeCommandsDir = join(configDir, "commands")
const opencodeCommandsDir = join(configDir, "command")
const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode")
return commandsToRecord(commands)
}
export async function loadOpencodeProjectCommands(directory?: string): Promise<Record<string, CommandDefinition>> {
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "commands")
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "command")
const commands = await loadCommandsFromDir(opencodeProjectDir, "opencode-project")
return commandsToRecord(commands)
}

View File

@@ -1,63 +0,0 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { executeSlashCommand } from "./executor"
const testRoots: string[] = []
function createTempRoot(): string {
const root = join(tmpdir(), `auto-slash-executor-${Date.now()}-${Math.random().toString(16).slice(2)}`)
mkdirSync(root, { recursive: true })
testRoots.push(root)
return root
}
function writeCommand(dir: string, name: string): void {
mkdirSync(dir, { recursive: true })
writeFileSync(
join(dir, `${name}.md`),
"---\ndescription: command from test\n---\nRun from OpenCode command directory"
)
}
afterEach(() => {
for (const root of testRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
delete process.env.OPENCODE_CONFIG_DIR
})
describe("auto-slash-command executor OpenCode paths", () => {
it("resolves commands from OpenCode global and project plural directories", async () => {
// given
const root = createTempRoot()
const opencodeConfigDir = join(root, "config")
writeCommand(join(opencodeConfigDir, "commands"), "global-cmd")
writeCommand(join(root, ".opencode", "commands"), "project-cmd")
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
const originalCwd = process.cwd()
process.chdir(root)
try {
// when
const globalResult = await executeSlashCommand(
{ command: "global-cmd", args: "", raw: "/global-cmd" },
{ skills: [] }
)
const projectResult = await executeSlashCommand(
{ command: "project-cmd", args: "", raw: "/project-cmd" },
{ skills: [] }
)
// then
expect(globalResult.success).toBe(true)
expect(projectResult.success).toBe(true)
} finally {
process.chdir(originalCwd)
}
})
})

View File

@@ -105,8 +105,8 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandIn
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const opencodeGlobalDir = join(configDir, "commands")
const opencodeProjectDir = join(process.cwd(), ".opencode", "commands")
const opencodeGlobalDir = join(configDir, "command")
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")

View File

@@ -1,7 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { computeLineHash } from "../../tools/hashline-edit/hash-computation"
const WRITE_SUCCESS_MARKER = "File written successfully."
import { toHashlineContent } from "../../tools/hashline-edit/diff-utils"
interface HashlineReadEnhancerConfig {
hashline_edit?: { enabled: boolean }
@@ -13,7 +12,6 @@ const CONTENT_OPEN_TAG = "<content>"
const CONTENT_CLOSE_TAG = "</content>"
const FILE_OPEN_TAG = "<file>"
const FILE_CLOSE_TAG = "</file>"
const OPENCODE_LINE_TRUNCATION_SUFFIX = "... (line truncated to 2000 chars)"
function isReadTool(toolName: string): boolean {
return toolName.toLowerCase() === "read"
@@ -57,9 +55,6 @@ function transformLine(line: string): string {
if (!parsed) {
return line
}
if (parsed.content.endsWith(OPENCODE_LINE_TRUNCATION_SUFFIX)) {
return line
}
const hash = computeLineHash(parsed.lineNumber, parsed.content)
return `${parsed.lineNumber}#${hash}|${parsed.content}`
}
@@ -142,12 +137,7 @@ function extractFilePath(metadata: unknown): string | undefined {
}
async function appendWriteHashlineOutput(output: { output: string; metadata: unknown }): Promise<void> {
if (output.output.startsWith(WRITE_SUCCESS_MARKER)) {
return
}
const outputLower = output.output.toLowerCase()
if (outputLower.startsWith("error") || outputLower.includes("failed")) {
if (output.output.includes("Updated file (LINE#ID|content):")) {
return
}
@@ -162,8 +152,8 @@ async function appendWriteHashlineOutput(output: { output: string; metadata: unk
}
const content = await file.text()
const lineCount = content === "" ? 0 : content.split("\n").length
output.output = `${WRITE_SUCCESS_MARKER} ${lineCount} lines written.`
const hashlined = toHashlineContent(content)
output.output = `${output.output}\n\nUpdated file (LINE#ID|content):\n${hashlined}`
}
export function createHashlineReadEnhancerHook(

View File

@@ -84,33 +84,6 @@ describe("hashline-read-enhancer", () => {
expect(lines[7]).toBe("</content>")
})
it("keeps OpenCode-truncated lines unhashed while hashifying normal lines", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const input = { tool: "read", sessionID: "s", callID: "c" }
const truncatedLine = `${"x".repeat(60)}... (line truncated to 2000 chars)`
const output = {
title: "demo.ts",
output: [
"<path>/tmp/demo.ts</path>",
"<type>file</type>",
"<content>",
`1: ${truncatedLine}`,
"2: normal line",
"</content>",
].join("\n"),
metadata: {},
}
//#when
await hook["tool.execute.after"](input, output)
//#then
const lines = output.output.split("\n")
expect(lines[3]).toBe(`1: ${truncatedLine}`)
expect(lines[4]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|normal line$/)
})
it("hashifies plain read output without content tags", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
@@ -191,7 +164,7 @@ describe("hashline-read-enhancer", () => {
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
})
it("appends simple summary for write tool instead of full hashlined content", async () => {
it("appends LINE#ID output for write tool using metadata filepath", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-write-"))
@@ -208,55 +181,9 @@ describe("hashline-read-enhancer", () => {
await hook["tool.execute.after"](input, output)
//#then
expect(output.output).toContain("File written successfully.")
expect(output.output).toContain("2 lines written.")
expect(output.output).not.toContain("Updated file (LINE#ID|content):")
expect(output.output).not.toContain("const x = 1")
fs.rmSync(tempDir, { recursive: true, force: true })
})
it("does not re-process write output that already contains the success marker", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-idem-"))
const filePath = path.join(tempDir, "demo.ts")
fs.writeFileSync(filePath, "a\nb\nc\nd\ne")
const input = { tool: "write", sessionID: "s", callID: "c" }
const output = {
title: "write",
output: "File written successfully. 99 lines written.",
metadata: { filepath: filePath },
}
//#when
await hook["tool.execute.after"](input, output)
//#then — guard should prevent re-reading the file and updating the count
expect(output.output).toBe("File written successfully. 99 lines written.")
fs.rmSync(tempDir, { recursive: true, force: true })
})
it("does not overwrite write tool error output with success message", async () => {
//#given — write tool failed, but stale file exists from previous write
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-err-"))
const filePath = path.join(tempDir, "demo.ts")
fs.writeFileSync(filePath, "const x = 1")
const input = { tool: "write", sessionID: "s", callID: "c" }
const output = {
title: "write",
output: "Error: EACCES: permission denied, open '" + filePath + "'",
metadata: { filepath: filePath },
}
//#when
await hook["tool.execute.after"](input, output)
//#then — error output must be preserved, not overwritten with success message
expect(output.output).toContain("Error: EACCES")
expect(output.output).not.toContain("File written successfully.")
expect(output.output).toContain("Updated file (LINE#ID|content):")
expect(output.output).toMatch(/1#[ZPMQVRWSNKTXJBYH]{2}\|const x = 1/)
expect(output.output).toMatch(/2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2/)
fs.rmSync(tempDir, { recursive: true, force: true })
})

View File

@@ -8,3 +8,4 @@ export const HASHLINE_DICT = Array.from({ length: 256 }, (_, i) => {
export const HASHLINE_REF_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})$/
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{2})\|(.*)$/
export const HASHLINE_LEGACY_REF_PATTERN = /^([0-9]+):([0-9a-fA-F]{2,})$/

View File

@@ -80,7 +80,7 @@ export function applyInsertAfter(
const result = [...lines]
const newLines = stripInsertAnchorEcho(lines[line - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`append (anchored) requires non-empty text for ${anchor}`)
throw new Error(`insert_after requires non-empty text for ${anchor}`)
}
result.splice(line, 0, ...newLines)
return result
@@ -97,12 +97,38 @@ export function applyInsertBefore(
const result = [...lines]
const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`prepend (anchored) requires non-empty text for ${anchor}`)
throw new Error(`insert_before requires non-empty text for ${anchor}`)
}
result.splice(line - 1, 0, ...newLines)
return result
}
export function applyInsertBetween(
lines: string[],
afterAnchor: string,
beforeAnchor: string,
text: string | string[],
options?: EditApplyOptions
): string[] {
if (shouldValidate(options)) {
validateLineRef(lines, afterAnchor)
validateLineRef(lines, beforeAnchor)
}
const { line: afterLine } = parseLineRef(afterAnchor)
const { line: beforeLine } = parseLineRef(beforeAnchor)
if (beforeLine <= afterLine) {
throw new Error(`insert_between requires after_line (${afterLine}) < before_line (${beforeLine})`)
}
const result = [...lines]
const newLines = stripInsertBoundaryEcho(lines[afterLine - 1], lines[beforeLine - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`insert_between requires non-empty text for ${afterAnchor}..${beforeAnchor}`)
}
result.splice(beforeLine - 1, 0, ...newLines)
return result
}
export function applyAppend(lines: string[], text: string | string[]): string[] {
const normalized = toNewLines(text)
if (normalized.length === 0) {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test"
import { applyHashlineEdits } from "./edit-operations"
import { applyAppend, applyInsertAfter, applyPrepend, applyReplaceLines, applySetLine } from "./edit-operation-primitives"
import { applyHashlineEdits, applyInsertAfter, applyReplaceLines, applySetLine } from "./edit-operations"
import { applyAppend, applyInsertBetween, applyPrepend } from "./edit-operation-primitives"
import { computeLineHash } from "./hash-computation"
import type { HashlineEdit } from "./types"
@@ -56,6 +56,16 @@ describe("hashline edit operations", () => {
expect(result).toEqual("line 1\nbefore 2\nline 2\nline 3")
})
it("applies insert_between with dual anchors", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
//#when
const result = applyInsertBetween(lines, anchorFor(lines, 1), anchorFor(lines, 2), ["between"]).join("\n")
//#then
expect(result).toEqual("line 1\nbetween\nline 2\nline 3")
})
it("throws when insert_after receives empty text array", () => {
//#given
@@ -75,6 +85,13 @@ describe("hashline edit operations", () => {
).toThrow(/non-empty/i)
})
it("throws when insert_between receives empty text array", () => {
//#given
const lines = ["line 1", "line 2"]
//#when / #then
expect(() => applyInsertBetween(lines, anchorFor(lines, 1), anchorFor(lines, 2), [])).toThrow(/non-empty/i)
})
it("applies mixed edits in one pass", () => {
//#given
@@ -92,22 +109,6 @@ describe("hashline edit operations", () => {
expect(result).toEqual("line 1\ninserted\nline 2\nmodified")
})
it("applies replace before prepend when both target same line", () => {
//#given
const content = "line 1\nline 2\nline 3"
const lines = content.split("\n")
const edits: HashlineEdit[] = [
{ op: "prepend", pos: anchorFor(lines, 2), lines: "before line 2" },
{ op: "replace", pos: anchorFor(lines, 2), lines: "modified line 2" },
]
//#when
const result = applyHashlineEdits(content, edits)
//#then
expect(result).toEqual("line 1\nbefore line 2\nmodified line 2\nline 3")
})
it("deduplicates identical insert edits in one pass", () => {
//#given
const content = "line 1\nline 2"
@@ -214,6 +215,15 @@ describe("hashline edit operations", () => {
expect(result).toEqual(["before", "new 1", "new 2", "after"])
})
it("throws when insert_between payload contains only boundary echoes", () => {
//#given
const lines = ["line 1", "line 2", "line 3"]
//#when / #then
expect(() => applyInsertBetween(lines, anchorFor(lines, 1), anchorFor(lines, 2), ["line 1", "line 2"])).toThrow(
/non-empty/i
)
})
it("restores indentation for first replace_lines entry", () => {
//#given

View File

@@ -27,13 +27,7 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
}
const dedupeResult = dedupeEdits(edits)
const EDIT_PRECEDENCE: Record<string, number> = { replace: 0, append: 1, prepend: 2 }
const sortedEdits = [...dedupeResult.edits].sort((a, b) => {
const lineA = getEditLineNumber(a)
const lineB = getEditLineNumber(b)
if (lineB !== lineA) return lineB - lineA
return (EDIT_PRECEDENCE[a.op] ?? 3) - (EDIT_PRECEDENCE[b.op] ?? 3)
})
const sortedEdits = [...dedupeResult.edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a))
let noopEdits = 0
@@ -93,3 +87,10 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
export function applyHashlineEdits(content: string, edits: HashlineEdit[]): string {
return applyHashlineEditsWithReport(content, edits).content
}
export {
applySetLine,
applyReplaceLines,
applyInsertAfter,
applyInsertBefore,
} from "./edit-operation-primitives"

View File

@@ -5,7 +5,6 @@ import { countLineDiffs, generateUnifiedDiff } from "./diff-utils"
import { canonicalizeFileText, restoreFileText } from "./file-text-canonicalization"
import { normalizeHashlineEdits, type RawHashlineEdit } from "./normalize-edits"
import type { HashlineEdit } from "./types"
import { HashlineMismatchError } from "./validation"
interface HashlineEditArgs {
filePath: string
@@ -159,7 +158,7 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
return `Updated ${effectivePath}`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (error instanceof HashlineMismatchError) {
if (message.toLowerCase().includes("hash")) {
return `Error: hash mismatch - ${message}\nTip: reuse LINE#ID entries from the latest read/edit output, or batch related edits in one call.`
}
return `Error: ${message}`

View File

@@ -16,5 +16,9 @@ export type {
export { NIBBLE_STR, HASHLINE_DICT, HASHLINE_REF_PATTERN, HASHLINE_OUTPUT_PATTERN } from "./constants"
export {
applyHashlineEdits,
applyInsertAfter,
applyInsertBefore,
applyReplaceLines,
applySetLine,
} from "./edit-operations"
export { createHashlineEditTool } from "./tools"

View File

@@ -103,25 +103,6 @@ describe("createHashlineEditTool", () => {
expect(result).toContain(">>>")
})
it("does not classify invalid pos format as hash mismatch", async () => {
//#given
const filePath = path.join(tempDir, "invalid-format.txt")
fs.writeFileSync(filePath, "line1\nline2")
//#when
const result = await tool.execute(
{
filePath,
edits: [{ op: "replace", pos: "42", lines: "updated" }],
},
createMockContext(),
)
//#then
expect(result).toContain("Error")
expect(result.toLowerCase()).not.toContain("hash mismatch")
})
it("preserves literal backslash-n and supports string[] payload", async () => {
//#given
const filePath = path.join(tempDir, "test.txt")

View File

@@ -19,49 +19,7 @@ describe("parseLineRef", () => {
const ref = "42:VK"
//#when / #then
expect(() => parseLineRef(ref)).toThrow("{line_number}#{hash_id}")
})
it("gives specific hint when literal text is used instead of line number", () => {
//#given — model sends "LINE#HK" instead of "1#HK"
const ref = "LINE#HK"
//#when / #then — error should mention that LINE is not a valid number
expect(() => parseLineRef(ref)).toThrow(/not a line number/i)
})
it("gives specific hint for other non-numeric prefixes like POS#VK", () => {
//#given
const ref = "POS#VK"
//#when / #then
expect(() => parseLineRef(ref)).toThrow(/not a line number/i)
})
it("extracts valid line number from mixed prefix like LINE42 without throwing", () => {
//#given — normalizeLineRef extracts 42#VK from LINE42#VK
const ref = "LINE42#VK"
//#when / #then — should parse successfully as line 42
const result = parseLineRef(ref)
expect(result.line).toBe(42)
expect(result.hash).toBe("VK")
})
it("gives specific hint when hyphenated prefix like line-ref is used", () => {
//#given
const ref = "line-ref#VK"
//#when / #then
expect(() => parseLineRef(ref)).toThrow(/not a line number/i)
})
it("gives specific hint when prefix contains a period like line.ref", () => {
//#given
const ref = "line.ref#VK"
//#when / #then
expect(() => parseLineRef(ref)).toThrow(/not a line number/i)
expect(() => parseLineRef(ref)).toThrow("LINE#ID")
})
it("accepts refs copied with markers and trailing content", () => {
@@ -74,28 +32,6 @@ describe("parseLineRef", () => {
//#then
expect(result).toEqual({ line: 42, hash: "VK" })
})
it("accepts refs copied with >>> marker only", () => {
//#given
const ref = ">>> 42#VK"
//#when
const result = parseLineRef(ref)
//#then
expect(result).toEqual({ line: 42, hash: "VK" })
})
it("accepts refs with spaces around hash separator", () => {
//#given
const ref = "42 # VK"
//#when
const result = parseLineRef(ref)
//#then
expect(result).toEqual({ line: 42, hash: "VK" })
})
})
describe("validateLineRef", () => {
@@ -124,13 +60,47 @@ describe("validateLineRef", () => {
expect(() => validateLineRefs(lines, ["2#ZZ"]))
.toThrow(/>>>\s+2#[ZPMQVRWSNKTXJBYH]{2}\|two/)
})
})
it("suggests correct line number when hash matches a file line", () => {
//#given — model sends LINE#XX where XX is the actual hash for line 1
const lines = ["function hello() {", " return 42", "}"]
const hash = computeLineHash(1, lines[0])
describe("legacy LINE:HEX backward compatibility", () => {
it("parses legacy LINE:HEX ref", () => {
//#given
const ref = "42:ab"
//#when / #then — error should suggest the correct reference
expect(() => validateLineRefs(lines, [`LINE#${hash}`])).toThrow(new RegExp(`1#${hash}`))
//#when
const result = parseLineRef(ref)
//#then
expect(result).toEqual({ line: 42, hash: "ab" })
})
it("parses legacy LINE:HEX ref with uppercase hex", () => {
//#given
const ref = "10:FF"
//#when
const result = parseLineRef(ref)
//#then
expect(result).toEqual({ line: 10, hash: "FF" })
})
it("legacy ref fails validation with hash mismatch, not parse error", () => {
//#given
const lines = ["function hello() {"]
//#when / #then
expect(() => validateLineRef(lines, "1:ab")).toThrow(/>>>\s+1#[ZPMQVRWSNKTXJBYH]{2}\|/)
})
it("extracts legacy ref from content with markers", () => {
//#given
const ref = ">>> 42:ab|const x = 1"
//#when
const result = parseLineRef(ref)
//#then
expect(result).toEqual({ line: 42, hash: "ab" })
})
})

View File

@@ -1,5 +1,5 @@
import { computeLineHash } from "./hash-computation"
import { HASHLINE_REF_PATTERN } from "./constants"
import { HASHLINE_REF_PATTERN, HASHLINE_LEGACY_REF_PATTERN } from "./constants"
export interface LineRef {
line: number
@@ -13,26 +13,23 @@ interface HashMismatch {
const MISMATCH_CONTEXT = 2
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2}|[0-9]+:[0-9a-fA-F]{2,})/
function normalizeLineRef(ref: string): string {
const originalTrimmed = ref.trim()
let trimmed = originalTrimmed
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
trimmed = trimmed.replace(/\s*#\s*/, "#")
trimmed = trimmed.replace(/\|.*$/, "")
trimmed = trimmed.trim()
const trimmed = ref.trim()
if (HASHLINE_REF_PATTERN.test(trimmed)) {
return trimmed
}
if (HASHLINE_LEGACY_REF_PATTERN.test(trimmed)) {
return trimmed
}
const extracted = trimmed.match(LINE_REF_EXTRACT_PATTERN)
if (extracted) {
return extracted[1]
}
return originalTrimmed
return trimmed
}
export function parseLineRef(ref: string): LineRef {
@@ -44,25 +41,20 @@ export function parseLineRef(ref: string): LineRef {
hash: match[2],
}
}
// normalized equals ref.trim() in all error paths — extraction only succeeds for valid refs
const hashIdx = normalized.indexOf('#')
if (hashIdx > 0) {
const prefix = normalized.slice(0, hashIdx)
const suffix = normalized.slice(hashIdx + 1)
if (!/^\d+$/.test(prefix) && /^[ZPMQVRWSNKTXJBYH]{2}$/.test(suffix)) {
throw new Error(
`Invalid line reference: "${ref}". "${prefix}" is not a line number. ` +
`Use the actual line number from the read output.`
)
const legacyMatch = normalized.match(HASHLINE_LEGACY_REF_PATTERN)
if (legacyMatch) {
return {
line: Number.parseInt(legacyMatch[1], 10),
hash: legacyMatch[2],
}
}
throw new Error(
`Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"`
`Invalid line reference format: "${ref}". Expected format: "LINE#ID" (e.g., "42#VK")`
)
}
export function validateLineRef(lines: string[], ref: string): void {
const { line, hash } = parseLineRefWithHint(ref, lines)
const { line, hash } = parseLineRef(ref)
if (line < 1 || line > lines.length) {
throw new Error(
@@ -110,7 +102,7 @@ export class HashlineMismatchError extends Error {
const output: string[] = []
output.push(
`${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since last read. ` +
"Use updated {line_number}#{hash_id} references below (>>> marks changed lines)."
"Use updated LINE#ID references below (>>> marks changed lines)."
)
output.push("")
@@ -135,34 +127,11 @@ export class HashlineMismatchError extends Error {
}
}
function suggestLineForHash(ref: string, lines: string[]): string | null {
const hashMatch = ref.trim().match(/#([ZPMQVRWSNKTXJBYH]{2})$/)
if (!hashMatch) return null
const hash = hashMatch[1]
for (let i = 0; i < lines.length; i++) {
if (computeLineHash(i + 1, lines[i]) === hash) {
return `Did you mean "${i + 1}#${hash}"?`
}
}
return null
}
function parseLineRefWithHint(ref: string, lines: string[]): LineRef {
try {
return parseLineRef(ref)
} catch (parseError) {
const hint = suggestLineForHash(ref, lines)
if (hint && parseError instanceof Error) {
throw new Error(`${parseError.message} ${hint}`)
}
throw parseError
}
}
export function validateLineRefs(lines: string[], refs: string[]): void {
const mismatches: HashMismatch[] = []
for (const ref of refs) {
const { line, hash } = parseLineRefWithHint(ref, lines)
const { line, hash } = parseLineRef(ref)
if (line < 1 || line > lines.length) {
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)

View File

@@ -52,8 +52,8 @@ export function discoverCommandsSync(directory?: string): CommandInfo[] {
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const projectCommandsDir = join(directory ?? process.cwd(), ".claude", "commands")
const opencodeGlobalDir = join(configDir, "commands")
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "commands")
const opencodeGlobalDir = join(configDir, "command")
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "command")
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")

View File

@@ -1,27 +1,6 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { describe, expect, it } from "bun:test"
import * as slashcommand from "./index"
const testRoots: string[] = []
function createTempRoot(): string {
const root = join(tmpdir(), `slashcommand-discovery-${Date.now()}-${Math.random().toString(16).slice(2)}`)
mkdirSync(root, { recursive: true })
testRoots.push(root)
return root
}
afterEach(() => {
for (const root of testRoots.splice(0)) {
rmSync(root, { recursive: true, force: true })
}
delete process.env.OPENCODE_CONFIG_DIR
})
describe("slashcommand module exports", () => {
it("exports discovery API only", () => {
// given
@@ -35,32 +14,4 @@ describe("slashcommand module exports", () => {
expect(exportNames).not.toContain("createSlashcommandTool")
expect(exportNames).not.toContain("slashcommand")
})
it("discovers commands from OpenCode plural command directories", () => {
// given
const root = createTempRoot()
const opencodeConfigDir = join(root, "config")
const globalCommandsDir = join(opencodeConfigDir, "commands")
const projectCommandsDir = join(root, ".opencode", "commands")
mkdirSync(globalCommandsDir, { recursive: true })
mkdirSync(projectCommandsDir, { recursive: true })
writeFileSync(
join(globalCommandsDir, "global-cmd.md"),
"---\ndescription: global command\n---\nGlobal command body"
)
writeFileSync(
join(projectCommandsDir, "project-cmd.md"),
"---\ndescription: project command\n---\nProject command body"
)
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
// when
const commands = slashcommand.discoverCommandsSync(root)
// then
expect(commands.some((cmd) => cmd.name === "global-cmd" && cmd.scope === "opencode")).toBe(true)
expect(commands.some((cmd) => cmd.name === "project-cmd" && cmd.scope === "opencode-project")).toBe(true)
})
})