Compare commits

...

18 Commits

Author SHA1 Message Date
YeonGyu-Kim
6ba1d675b9 fix(installer): improve Windows compatibility for shell detection and paths
Closes #461
2026-02-24 21:42:04 +09:00
github-actions[bot]
55b9ad60d8 release: v3.8.5 2026-02-24 09:45:36 +00:00
YeonGyu-Kim
e997e0071c Merge pull request #2088 from minpeter/feat/hashline-edit-error-hints
fix(hashline-edit): improve error messages for invalid LINE#ID references
2026-02-24 18:36:04 +09:00
YeonGyu-Kim
b8257dc59c fix(hashline-edit): tolerate >>> prefix and spaces around # in line refs 2026-02-24 18:21:05 +09:00
YeonGyu-Kim
365d863e3a fix(hashline-edit): use instanceof for hash mismatch error detection 2026-02-24 18:21:05 +09:00
YeonGyu-Kim
1785313f3b fix(hashline-read-enhancer): skip hashifying OpenCode-truncated lines 2026-02-24 18:21:05 +09:00
YeonGyu-Kim
ac962d62ab fix(hashline-edit): add same-line operation precedence ordering 2026-02-24 18:21:05 +09:00
YeonGyu-Kim
d61c0f8cb5 fix(hashline-read-enhancer): guard against overwriting error output with success message 2026-02-24 17:52:04 +09:00
YeonGyu-Kim
a567cd0d68 fix(hashline-edit): address Oracle review feedback
- Extract WRITE_SUCCESS_MARKER constant to couple guard and output string
- Remove double blank line after parseLineRefWithHint
- Add comment clarifying normalized equals ref.trim() in error paths
2026-02-24 17:41:30 +09:00
YeonGyu-Kim
55ad4297d4 fix(hashline-edit): widen non-numeric prefix detection and remove duplicate try-catch
- Replace regex /^([A-Za-z_]+)#.../ with indexOf-based prefix check to catch
  line-ref#VK and line.ref#VK style inputs that were previously giving generic errors
- Extract parseLineRefWithHint helper to eliminate duplicated try-catch in
  validateLineRef and validateLineRefs
- Restore idempotency guard in appendWriteHashlineOutput using new output format
- Add tests for LINE42 extraction, line-ref hint, line.ref hint, and guard behavior

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-24 17:32:44 +09:00
minpeter
c6a69899d8 fix(hashline-read-enhancer): simplify write tool output to line count summary
Replace full hashlined file content in write tool response with a simple
'File written successfully. N lines written.' summary to reduce context
bloat.
2026-02-24 16:00:23 +09:00
minpeter
2aeb96c3f6 fix(hashline-edit): improve error messages for invalid LINE#ID references
- Detect non-numeric prefixes (e.g., "LINE#HK", "POS#VK") and explain
  that the prefix must be an actual line number, not literal text
- Add suggestLineForHash() that reverse-looks up a hash in file lines
  to suggest the correct reference (e.g., Did you mean "1#HK"?)
- Unify error message format from "LINE#ID" to "{line_number}#{hash_id}"
  matching the tool description convention
- Add 3 tests covering non-numeric prefix detection and hash suggestion
2026-02-24 16:00:23 +09:00
YeonGyu-Kim
5fd65f2935 Merge pull request #2086 from code-yeongyu/refactor/hashline-legacy-cleanup
refactor(hashline-edit): clean up legacy code and dead exports
2026-02-24 15:44:32 +09:00
YeonGyu-Kim
b03aae57f3 fix: remove accidentally committed node_modules symlink 2026-02-24 15:39:31 +09:00
YeonGyu-Kim
8c3a0ca2fe refactor(hashline-edit): rename legacy operation names in error messages
Update error messages to match current op schema:
- insert_after → append (anchored)
- insert_before → prepend (anchored)
2026-02-24 15:33:48 +09:00
YeonGyu-Kim
9a2e0f1add refactor(hashline-edit): remove unnecessary barrel re-exports of internal primitives
applySetLine, applyReplaceLines, applyInsertAfter, applyInsertBefore
were re-exported from both edit-operations.ts and index.ts but have no
external consumers — they are only used internally within the module.
Only applyHashlineEdits (the public API) remains exported.
2026-02-24 15:33:17 +09:00
YeonGyu-Kim
d28ebd10c1 refactor(hashline-edit): remove HASHLINE_LEGACY_REF_PATTERN and legacy ref compat
Remove the old LINE:HEX (e.g. "42:ab") reference format support. All
refs now use LINE#ID format exclusively (e.g. "42#VK"). Also fixes
HASHLINE_OUTPUT_PATTERN to use | separator (was missed in PR #2079).
2026-02-24 15:32:24 +09:00
YeonGyu-Kim
fb92babee7 refactor(hashline-edit): remove dead applyInsertBetween function
This function is no longer called from edit-operations.ts after the
op/pos/end/lines schema refactor in PR #2079. Remove the function
definition and its 3 dedicated test cases.
2026-02-24 15:31:43 +09:00
29 changed files with 507 additions and 169 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.8.4",
"version": "3.8.5",
"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.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"
"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"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.8.4",
"version": "3.8.5",
"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.4",
"version": "3.8.5",
"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.4",
"version": "3.8.5",
"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.4",
"version": "3.8.5",
"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.4",
"version": "3.8.5",
"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.4",
"version": "3.8.5",
"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.4",
"version": "3.8.5",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -0,0 +1,47 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import { initConfigContext, resetConfigContext } from "./config-context"
import { runBunInstallWithDetails } from "./bun-install"
describe("bun-install", () => {
let originalPlatform: NodeJS.Platform
beforeEach(() => {
originalPlatform = process.platform
resetConfigContext()
initConfigContext("opencode", null)
})
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform })
resetConfigContext()
})
test("#given Windows with bun.exe on PATH #when runBunInstallWithDetails is called #then uses bun.exe", async () => {
Object.defineProperty(process, "platform", { value: "win32" })
const whichSpy = spyOn(Bun, "which")
.mockImplementation((binary: string) => {
if (binary === "bun.exe") {
return "C:\\Tools\\bun.exe"
}
return null
})
const spawnSpy = spyOn(Bun, "spawn").mockReturnValue({
exited: Promise.resolve(0),
exitCode: 0,
kill: () => {},
} as unknown as ReturnType<typeof Bun.spawn>)
try {
const result = await runBunInstallWithDetails()
expect(result.success).toBe(true)
expect(spawnSpy).toHaveBeenCalledTimes(1)
expect(spawnSpy.mock.calls[0]?.[0]).toEqual(["C:\\Tools\\bun.exe", "install"])
} finally {
spawnSpy.mockRestore()
whichSpy.mockRestore()
}
})
})

View File

@@ -9,6 +9,14 @@ export interface BunInstallResult {
error?: string
}
function resolveBunCommand(): string {
if (process.platform === "win32") {
return Bun.which("bun.exe") ?? Bun.which("bun") ?? "bun.exe"
}
return Bun.which("bun") ?? "bun"
}
export async function runBunInstall(): Promise<boolean> {
const result = await runBunInstallWithDetails()
return result.success
@@ -16,7 +24,8 @@ export async function runBunInstall(): Promise<boolean> {
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
try {
const proc = Bun.spawn(["bun", "install"], {
const bunCommand = resolveBunCommand()
const proc = Bun.spawn([bunCommand, "install"], {
cwd: getConfigDir(),
stdout: "inherit",
stderr: "inherit",
@@ -39,7 +48,7 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
return {
success: false,
timedOut: true,
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ${getConfigDir()} && bun i`,
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually in ${getConfigDir()}: bun install`,
}
}
@@ -55,7 +64,7 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
const message = err instanceof Error ? err.message : String(err)
return {
success: false,
error: `bun install failed: ${message}. Is bun installed? Try: curl -fsSL https://bun.sh/install | bash`,
error: `bun install failed: ${message}. Ensure Bun is installed and available in PATH: https://bun.sh/docs/installation`,
}
}
}

View File

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

View File

@@ -84,6 +84,33 @@ 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 } })
@@ -164,7 +191,7 @@ describe("hashline-read-enhancer", () => {
expect(lines[2]).toMatch(/^2#[ZPMQVRWSNKTXJBYH]{2}\|const y = 2$/)
})
it("appends LINE#ID output for write tool using metadata filepath", async () => {
it("appends simple summary for write tool instead of full hashlined content", async () => {
//#given
const hook = createHashlineReadEnhancerHook(mockCtx(), { hashline_edit: { enabled: true } })
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "hashline-write-"))
@@ -181,9 +208,55 @@ describe("hashline-read-enhancer", () => {
await hook["tool.execute.after"](input, output)
//#then
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/)
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.")
fs.rmSync(tempDir, { recursive: true, force: true })
})

View File

@@ -0,0 +1,39 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { findBashPath } from "./shell-path"
describe("shell-path", () => {
let originalPlatform: NodeJS.Platform
let originalComspec: string | undefined
beforeEach(() => {
originalPlatform = process.platform
originalComspec = process.env.COMSPEC
})
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform })
if (originalComspec !== undefined) {
process.env.COMSPEC = originalComspec
return
}
delete process.env.COMSPEC
})
test("#given Windows platform with COMSPEC #when findBashPath is called #then returns COMSPEC path", () => {
Object.defineProperty(process, "platform", { value: "win32" })
process.env.COMSPEC = "C:\\Windows\\System32\\cmd.exe"
const result = findBashPath()
expect(result).toBe("C:\\Windows\\System32\\cmd.exe")
})
test("#given Windows platform without COMSPEC #when findBashPath is called #then returns default cmd path", () => {
Object.defineProperty(process, "platform", { value: "win32" })
delete process.env.COMSPEC
const result = findBashPath()
expect(result).toBe("C:\\Windows\\System32\\cmd.exe")
})
})

View File

@@ -2,6 +2,7 @@ import { existsSync } from "node:fs"
const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"]
const DEFAULT_BASH_PATHS = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
const DEFAULT_WINDOWS_CMD_PATH = "C:\\Windows\\System32\\cmd.exe"
function findShellPath(
defaultPaths: string[],
@@ -19,9 +20,17 @@ function findShellPath(
}
export function findZshPath(customZshPath?: string): string | null {
if (process.platform === "win32") {
return process.env.COMSPEC?.trim() || DEFAULT_WINDOWS_CMD_PATH
}
return findShellPath(DEFAULT_ZSH_PATHS, customZshPath)
}
export function findBashPath(): string | null {
if (process.platform === "win32") {
return process.env.COMSPEC?.trim() || DEFAULT_WINDOWS_CMD_PATH
}
return findShellPath(DEFAULT_BASH_PATHS)
}

View File

@@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { homedir } from "node:os"
import { join } from "node:path"
import { getCacheDir, getDataDir } from "./data-path"
describe("data-path", () => {
let originalPlatform: NodeJS.Platform
let originalEnv: Record<string, string | undefined>
beforeEach(() => {
originalPlatform = process.platform
originalEnv = {
LOCALAPPDATA: process.env.LOCALAPPDATA,
APPDATA: process.env.APPDATA,
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
}
})
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform })
for (const [key, value] of Object.entries(originalEnv)) {
if (value !== undefined) {
process.env[key] = value
} else {
delete process.env[key]
}
}
})
test("#given Windows with LOCALAPPDATA #when getDataDir is called #then returns LOCALAPPDATA", () => {
Object.defineProperty(process, "platform", { value: "win32" })
process.env.LOCALAPPDATA = "C:\\Users\\TestUser\\AppData\\Local"
const result = getDataDir()
expect(result).toBe("C:\\Users\\TestUser\\AppData\\Local")
})
test("#given Windows without LOCALAPPDATA #when getDataDir is called #then falls back to AppData Local", () => {
Object.defineProperty(process, "platform", { value: "win32" })
delete process.env.LOCALAPPDATA
const result = getDataDir()
expect(result).toBe(join(homedir(), "AppData", "Local"))
})
test("#given Windows with LOCALAPPDATA #when getCacheDir is called #then returns Local cache path", () => {
Object.defineProperty(process, "platform", { value: "win32" })
process.env.LOCALAPPDATA = "C:\\Users\\TestUser\\AppData\\Local"
const result = getCacheDir()
expect(result).toBe(join("C:\\Users\\TestUser\\AppData\\Local", "cache"))
})
})

View File

@@ -10,6 +10,10 @@ import * as os from "node:os"
* including Windows, so we match that behavior exactly.
*/
export function getDataDir(): string {
if (process.platform === "win32") {
return process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local")
}
return process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share")
}
@@ -27,6 +31,11 @@ export function getOpenCodeStorageDir(): string {
* - All platforms: XDG_CACHE_HOME or ~/.cache
*/
export function getCacheDir(): string {
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local")
return path.join(localAppData, "cache")
}
return process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), ".cache")
}

View File

@@ -179,7 +179,7 @@ describe("opencode-config-dir", () => {
expect(result).toBe(join(homedir(), ".config", "opencode"))
})
test("returns ~/.config/opencode on Windows by default", () => {
test("returns %APPDATA%/opencode on Windows by default", () => {
// given opencode CLI binary detected, platform is Windows
Object.defineProperty(process, "platform", { value: "win32" })
delete process.env.APPDATA
@@ -188,8 +188,8 @@ describe("opencode-config-dir", () => {
// when getOpenCodeConfigDir is called with binary="opencode"
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200", checkExisting: false })
// then returns ~/.config/opencode (cross-platform default)
expect(result).toBe(join(homedir(), ".config", "opencode"))
// then returns %APPDATA%/opencode
expect(result).toBe(join(homedir(), "AppData", "Roaming", "opencode"))
})
})

View File

@@ -42,29 +42,32 @@ function getTauriConfigDir(identifier: string): string {
}
}
function getCliConfigDir(): string {
function getCliConfigDir(checkExisting = true): string {
const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim()
if (envConfigDir) {
return resolve(envConfigDir)
}
if (process.platform === "win32") {
const crossPlatformDir = join(homedir(), ".config", "opencode")
const crossPlatformConfig = join(crossPlatformDir, "opencode.json")
if (existsSync(crossPlatformConfig)) {
return crossPlatformDir
}
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming")
const appdataDir = join(appData, "opencode")
const crossPlatformDir = join(homedir(), ".config", "opencode")
if (!checkExisting) {
return appdataDir
}
const appdataConfig = join(appdataDir, "opencode.json")
const crossPlatformConfig = join(crossPlatformDir, "opencode.json")
if (existsSync(appdataConfig)) {
return appdataDir
}
return crossPlatformDir
if (existsSync(crossPlatformConfig)) {
return crossPlatformDir
}
return appdataDir
}
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
@@ -75,14 +78,14 @@ export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string
const { binary, version, checkExisting = true } = options
if (binary === "opencode") {
return getCliConfigDir()
return getCliConfigDir(checkExisting)
}
const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER
const tauriDir = getTauriConfigDir(identifier)
if (checkExisting) {
const legacyDir = getCliConfigDir()
const legacyDir = getCliConfigDir(true)
const legacyConfig = join(legacyDir, "opencode.json")
const legacyConfigC = join(legacyDir, "opencode.jsonc")

View File

@@ -10,6 +10,7 @@ describe("shell-env", () => {
originalEnv = {
SHELL: process.env.SHELL,
PSModulePath: process.env.PSModulePath,
COMSPEC: process.env.COMSPEC,
}
})
@@ -57,6 +58,7 @@ describe("shell-env", () => {
test("#given Windows platform without PSModulePath #when detectShellType is called #then returns cmd", () => {
delete process.env.PSModulePath
delete process.env.SHELL
delete process.env.COMSPEC
Object.defineProperty(process, "platform", { value: "win32" })
const result = detectShellType()
@@ -77,12 +79,35 @@ describe("shell-env", () => {
test("#given PSModulePath takes priority over SHELL #when both are set #then returns powershell", () => {
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
process.env.SHELL = "/bin/bash"
process.env.COMSPEC = "C:\\Windows\\System32\\cmd.exe"
Object.defineProperty(process, "platform", { value: "win32" })
const result = detectShellType()
expect(result).toBe("powershell")
})
test("#given Windows COMSPEC points to powershell #when detectShellType is called #then returns powershell", () => {
delete process.env.PSModulePath
delete process.env.SHELL
process.env.COMSPEC = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
Object.defineProperty(process, "platform", { value: "win32" })
const result = detectShellType()
expect(result).toBe("powershell")
})
test("#given Windows COMSPEC points to bash executable #when detectShellType is called #then returns unix", () => {
delete process.env.PSModulePath
delete process.env.SHELL
process.env.COMSPEC = "C:\\Program Files\\Git\\bin\\bash.exe"
Object.defineProperty(process, "platform", { value: "win32" })
const result = detectShellType()
expect(result).toBe("unix")
})
})
describe("shellEscape", () => {

View File

@@ -13,11 +13,29 @@ export function detectShellType(): ShellType {
return "powershell"
}
if (process.platform === "win32") {
const comspec = process.env.COMSPEC ?? process.env.ComSpec
const normalizedComspec = comspec?.toLowerCase()
if (normalizedComspec?.includes("powershell") || normalizedComspec?.includes("pwsh")) {
return "powershell"
}
if (normalizedComspec?.includes("bash") || normalizedComspec?.includes("zsh")) {
return "unix"
}
if (process.env.SHELL) {
return "unix"
}
return "cmd"
}
if (process.env.SHELL) {
return "unix"
}
return process.platform === "win32" ? "cmd" : "unix"
return "unix"
}
/**

View File

@@ -7,5 +7,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,})$/
export const HASHLINE_OUTPUT_PATTERN = /^([0-9]+)#([ZPMQVRWSNKTXJBYH]{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(`insert_after requires non-empty text for ${anchor}`)
throw new Error(`append (anchored) requires non-empty text for ${anchor}`)
}
result.splice(line, 0, ...newLines)
return result
@@ -97,38 +97,12 @@ export function applyInsertBefore(
const result = [...lines]
const newLines = stripInsertBeforeEcho(lines[line - 1], toNewLines(text))
if (newLines.length === 0) {
throw new Error(`insert_before requires non-empty text for ${anchor}`)
throw new Error(`prepend (anchored) 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, applyInsertAfter, applyReplaceLines, applySetLine } from "./edit-operations"
import { applyAppend, applyInsertBetween, applyPrepend } from "./edit-operation-primitives"
import { applyHashlineEdits } from "./edit-operations"
import { applyAppend, applyInsertAfter, applyPrepend, applyReplaceLines, applySetLine } from "./edit-operation-primitives"
import { computeLineHash } from "./hash-computation"
import type { HashlineEdit } from "./types"
@@ -56,16 +56,6 @@ 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
@@ -85,13 +75,6 @@ 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
@@ -109,6 +92,22 @@ 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"
@@ -215,15 +214,6 @@ 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,7 +27,13 @@ export function applyHashlineEditsWithReport(content: string, edits: HashlineEdi
}
const dedupeResult = dedupeEdits(edits)
const sortedEdits = [...dedupeResult.edits].sort((a, b) => getEditLineNumber(b) - getEditLineNumber(a))
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)
})
let noopEdits = 0
@@ -87,10 +93,3 @@ 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,6 +5,7 @@ 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
@@ -158,7 +159,7 @@ export async function executeHashlineEditTool(args: HashlineEditArgs, context: T
return `Updated ${effectivePath}`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (message.toLowerCase().includes("hash")) {
if (error instanceof HashlineMismatchError) {
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,9 +16,5 @@ 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,6 +103,25 @@ 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,7 +19,49 @@ describe("parseLineRef", () => {
const ref = "42:VK"
//#when / #then
expect(() => parseLineRef(ref)).toThrow("LINE#ID")
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)
})
it("accepts refs copied with markers and trailing content", () => {
@@ -32,6 +74,28 @@ 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", () => {
@@ -60,47 +124,13 @@ describe("validateLineRef", () => {
expect(() => validateLineRefs(lines, ["2#ZZ"]))
.toThrow(/>>>\s+2#[ZPMQVRWSNKTXJBYH]{2}\|two/)
})
})
describe("legacy LINE:HEX backward compatibility", () => {
it("parses legacy LINE:HEX ref", () => {
//#given
const ref = "42:ab"
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])
//#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" })
//#when / #then — error should suggest the correct reference
expect(() => validateLineRefs(lines, [`LINE#${hash}`])).toThrow(new RegExp(`1#${hash}`))
})
})

View File

@@ -1,5 +1,5 @@
import { computeLineHash } from "./hash-computation"
import { HASHLINE_REF_PATTERN, HASHLINE_LEGACY_REF_PATTERN } from "./constants"
import { HASHLINE_REF_PATTERN } from "./constants"
export interface LineRef {
line: number
@@ -13,23 +13,26 @@ interface HashMismatch {
const MISMATCH_CONTEXT = 2
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2}|[0-9]+:[0-9a-fA-F]{2,})/
const LINE_REF_EXTRACT_PATTERN = /([0-9]+#[ZPMQVRWSNKTXJBYH]{2})/
function normalizeLineRef(ref: string): string {
const trimmed = ref.trim()
const originalTrimmed = ref.trim()
let trimmed = originalTrimmed
trimmed = trimmed.replace(/^(?:>>>|[+-])\s*/, "")
trimmed = trimmed.replace(/\s*#\s*/, "#")
trimmed = trimmed.replace(/\|.*$/, "")
trimmed = trimmed.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 trimmed
return originalTrimmed
}
export function parseLineRef(ref: string): LineRef {
@@ -41,20 +44,25 @@ export function parseLineRef(ref: string): LineRef {
hash: match[2],
}
}
const legacyMatch = normalized.match(HASHLINE_LEGACY_REF_PATTERN)
if (legacyMatch) {
return {
line: Number.parseInt(legacyMatch[1], 10),
hash: legacyMatch[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.`
)
}
}
throw new Error(
`Invalid line reference format: "${ref}". Expected format: "LINE#ID" (e.g., "42#VK")`
`Invalid line reference format: "${ref}". Expected format: "{line_number}#{hash_id}"`
)
}
export function validateLineRef(lines: string[], ref: string): void {
const { line, hash } = parseLineRef(ref)
const { line, hash } = parseLineRefWithHint(ref, lines)
if (line < 1 || line > lines.length) {
throw new Error(
@@ -102,7 +110,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#ID references below (>>> marks changed lines)."
"Use updated {line_number}#{hash_id} references below (>>> marks changed lines)."
)
output.push("")
@@ -127,11 +135,34 @@ 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 } = parseLineRef(ref)
const { line, hash } = parseLineRefWithHint(ref, lines)
if (line < 1 || line > lines.length) {
throw new Error(`Line number ${line} out of bounds (file has ${lines.length} lines)`)