refactor(packages): extract utils package

This commit is contained in:
YeonGyu-Kim
2026-05-21 00:31:03 +09:00
parent c4fe747e15
commit a5c1d71001
52 changed files with 717 additions and 508 deletions
+2
View File
@@ -0,0 +1,2 @@
# Command: grep -rE "@opencode-ai|from [\"']opencode/|Bun\.|from [\"']bun:" --exclude='*.test.ts' --exclude='*.audit.test.ts' packages/utils/src/
# Result: no matches
+9
View File
@@ -0,0 +1,9 @@
12 files moved (write-file-atomically.ts deferred — see .omo/notepads/package-layering-refactor/decisions.md)
total 16
drwxr-xr-x@ 6 yeongyu staff 192 May 21 00:17 .
drwxr-xr-x@ 19 yeongyu staff 608 May 21 00:12 ..
drwxr-xr-x@ 5 yeongyu staff 160 May 21 00:17 node_modules
-rw-r--r--@ 1 yeongyu staff 487 May 21 00:12 package.json
drwxr-xr-x@ 25 yeongyu staff 800 May 21 00:13 src
-rw-r--r--@ 1 yeongyu staff 312 May 21 00:12 tsconfig.json
+12
View File
@@ -0,0 +1,12 @@
src/shared/deep-merge.ts: export { deepMerge, isPlainObject } from "@oh-my-opencode/utils"
src/shared/snake-case.ts: export { camelToSnake, objectToCamelCase, objectToSnakeCase, snakeToCamel, transformObjectKeys } from "@oh-my-opencode/utils"
src/shared/record-type-guard.ts: export { isRecord } from "@oh-my-opencode/utils"
src/shared/extract-semver.ts: export { extractSemverFromOutput } from "@oh-my-opencode/utils"
src/shared/frontmatter.ts: export { parseFrontmatter, type FrontmatterResult } from "@oh-my-opencode/utils"
src/shared/file-utils.ts: export { isMarkdownFile, isSymbolicLink, resolveSymlink, resolveSymlinkAsync } from "@oh-my-opencode/utils"
src/shared/contains-path.ts: export { containsPath, isWithinProject } from "@oh-my-opencode/utils"
src/shared/port-utils.ts: export {
src/shared/tool-name.ts: export { transformToolName } from "@oh-my-opencode/utils"
src/shared/replace-tool-args.ts: export { replaceToolArgs } from "@oh-my-opencode/utils"
src/shared/jsonc-parser.ts: export {
src/features/boulder-state/format-duration.ts: export { formatDurationHuman } from "@oh-my-opencode/utils"
+1
View File
@@ -0,0 +1 @@
Received: "# Debugging\n\nYou are a hypothesis-driven debugger. Two disciplines apply regardless of language, runtime, or whether you have source:\n\n1. **Runtime truth beats code reading.** Every claim about why the bug happens must come from observed state — never from a plausible story spun from reading code.\n2. **Leave no trace.** Debugging creates artifacts. Every artifact is journaled and removed before you call the task done.\n\nThe rest of this file is a map. **The knowledge is in `references/`.** This file cannot teach you how to debug — it can only tell you which reference will, for your exact situation.\n\n---\n\n# 🚨 READ THE REFERENCES. THIS IS NOT OPTIONAL.\n\n> **This skill is intentionally small.** Ninety percent of what you need to know lives in `references/`. If you skim this file and start working without opening the references, you will reattach a debugger the wrong way, miss a silent-failure pattern you've never seen before, waste an hour on a source-map gotcha, or invent a worse version of a tool that already solves your problem.\n>\n> **Every reference below is mandatory when its scenario applies.** \"I know this language\" is not an exemption. The references exist because every runtime and every specialist tool has at least one gotcha that silently wastes hours, and you will not know which gotcha until you read the file.\n>\n> **The gate rule**: before you run a command from a given reference's domain, you must have read that reference in this session. Re-reading across sessions is cheap. Guessing is expensive.\n\n---\n\n## Runtime Setup — MANDATORY READING BEFORE ATTACHING\n\nThe methodology is language-agnostic. The commands to launch, attach, breakpoint, and inspect are not. **Open the matching reference before Phase 0. Not during. Not after.**\n\n| Your runtime is… | Open this before attaching anything | Non-negotiable because… |\n|---|---|---|\n| Python (CPython, pytest, asyncio, Django, FastAPI) | 📖 **[references/runtimes/python.md](references/runtimes/python.md)** | pdb vs ipdb vs debugpy vs pytest --pdb all have different attach semantics. Async code needs special breakpoint handling. Wrappers like `poetry run` swallow flags. |\n| Node.js / tsx / ts-node / Bun / Deno (running source) | 📖 **[references/runtimes/node.md](references/runtimes/node.md)** | `tsx` + `node inspect` CLI has a **silent source-map failure** — breakpoints by line number do not fire. You will not notice unless you read this first. |\n| Rust (cargo, tokio, panics) | 📖 **[references/runtimes/rust.md](references/runtimes/rust.md)** | Release builds strip symbols. Tokio tasks need `tokio-console`. The borrow checker makes `dbg!` the faster tool most of the time. |\n| Go (goroutines, dlv, pprof, race) | 📖 **[references/runtimes/go.md](references/runtimes/go.md)** | Goroutine leaks and recovered panics are silent by default. `dlv` has a specific port convention. `go test -race` is the first thing to run, not the last. |\n| Native binary / stripped C/C++ / no source | 📖 **[references/runtimes/native-binary.md](references/runtimes/native-binary.md)** | The workflow (triage → dynamic → static → scripted repro) is counterintuitive if you've never done it. `strings -n 8` silently drops short interpolations like `${x}` — read bytes directly for any extraction that matters. macOS adds SIP / Mach-O / lldb specifics that don't apply on Linux. |\n| **Bundled-app binary** (Bun SEA, Node SEA, Deno compile, pkg, nexe, Electron, Tauri, PyInstaller) | 📖 **[references/runtimes/bundled-js-binary.md](references/runtimes/bundled-js-binary.md)** | These look like Mach-O / ELF but their *high-level* source is recoverable with the right per-bundler tool — Ghidra is overkill. Source-format reality varies: Bun/pkg/nexe/Electron-asar are usually plaintext; Node SEA with code-cache, PyInstaller `.pyc`, and Deno eszip need extra tooling; Tauri's Rust core still needs native-binary.md. Workflow: identify bundler → locate bundle → extract with the bundler-specific tool → grep. |\n\n...\n\n 7312 pass\n 1 skip\n 2 fail\n 24 snapshots, 16703 expect() calls\nRan 7315 tests across 744 files. [180.07s]
@@ -0,0 +1,10 @@
## [2026-05-20T15:12:12Z] Task 2 scope reduction
Dropped `write-file-atomically.ts` from utils extraction.
Reason: depends on omo-specific `tolerant-fsync` chain (classify-path-environment, fsync-skip-tracker, logger).
Decision deferred to a future task.
Net effect: utils package ships 12 files instead of 13.
## [2026-05-20T15:12:12Z] Task 2 jsonc-parser decoupling
Choice: Option A
Reason: Small call-site surface; keeps full jsonc-parser API together in the new package and avoids split ownership.
Call sites changed: src/plugin-config.ts, src/cli/config-manager/config-context.ts, src/cli/doctor/checks/config.ts, src/cli/doctor/checks/model-resolution-config.ts, src/cli/doctor/checks/team-mode.ts, src/cli/doctor/checks/tools-lsp.ts, src/shared/project-discovery-dirs.ts, packages/utils/src/jsonc-parser.test.ts, packages/utils/src/jsonc-parser.memoization.test.ts
@@ -0,0 +1,23 @@
## [2026-05-20T15:04:52Z] Task 1 baseline
- Total tests: 7315
- Pass: 7312, Fail: 2, Skip: 1
- Build exit code: 0 (dist/ size: 13M, 1456 files)
- Typecheck exit code: 0
- Packages: 15 package.json files under packages/, 3 private (ast-grep-mcp, rules-core, web)
- Anomalies observed: 2 pre-existing test failures in `src/features/opencode-skill-loader/skill-content.test.ts` — ambiguous short name resolution returns 2 resolved skills instead of 1 when "debugging" + "playwright" are queried together. This is a REAL baseline failure; do not fix as part of this refactor plan unless explicitly directed.
## [2026-05-20T15:12:12Z] Task 2 pre-flight
- `src/shared/deep-merge.ts`: pure, zero imports.
- `src/shared/snake-case.ts`: imports `./deep-merge` only (moved together) — pure after extraction.
- `src/shared/record-type-guard.ts`: pure, zero imports.
- `src/shared/extract-semver.ts`: pure, zero imports.
- `src/shared/frontmatter.ts`: imports `js-yaml` only.
- `src/shared/file-utils.ts`: imports `fs` only.
- `src/shared/contains-path.ts`: imports `fs` and `path` only.
- `src/shared/port-utils.ts`: imports `node:net` only.
- `src/shared/tool-name.ts`: pure, zero imports.
- `src/shared/replace-tool-args.ts`: pure, zero imports.
- `src/features/boulder-state/format-duration.ts`: pure, zero imports.
- `src/shared/jsonc-parser.ts`: coupled to plugin basenames; decoupled via parameterized `detectPluginConfigFile(dir, options)`.
- `src/shared/write-file-atomically.ts`: depends on omo-specific `./tolerant-fsync`; extraction deferred by scope decision (kept in-place).
+11
View File
@@ -26,6 +26,7 @@
"devDependencies": {
"@oh-my-opencode/ast-grep-mcp": "workspace:*",
"@oh-my-opencode/rules-core": "workspace:*",
"@oh-my-opencode/utils": "workspace:*",
"@types/js-yaml": "^4.0.9",
"@types/picomatch": "^4.0.3",
"@typescript/native-preview": "7.0.0-dev.20260518.1",
@@ -70,6 +71,14 @@
"picomatch": "^4.0.4",
},
},
"packages/utils": {
"name": "@oh-my-opencode/utils",
"version": "0.1.0",
"dependencies": {
"js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1",
},
},
},
"trustedDependencies": [
"@ast-grep/cli",
@@ -146,6 +155,8 @@
"@oh-my-opencode/rules-core": ["@oh-my-opencode/rules-core@workspace:packages/rules-core"],
"@oh-my-opencode/utils": ["@oh-my-opencode/utils@workspace:packages/utils"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.15.4", "", { "dependencies": { "@opencode-ai/sdk": "1.15.4", "effect": "4.0.0-beta.65", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.2.11", "@opentui/keymap": ">=0.2.11", "@opentui/solid": ">=0.2.11" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-5KAhUnks8GNlqRIax+3cs/ZT2UK74/MNdl4w846ysYdivb38fIm+X9R69ljQtRKyQY7rtga4JUQuARJMSExQqQ=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.15.4", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-55SBChNouj2XY9C4thO0w7SGJS3jD2DRBxzcrDpc5szgmJJ2t2Wu38uZh+TQMBLHA8YrTPDqgfnc7o5tx2qRPw=="],
+4 -2
View File
@@ -7,7 +7,8 @@
"type": "module",
"workspaces": [
"packages/rules-core",
"packages/ast-grep-mcp"
"packages/ast-grep-mcp",
"packages/utils"
],
"bin": {
"oh-my-opencode": "bin/oh-my-opencode.js",
@@ -42,7 +43,7 @@
"prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build",
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
"typecheck": "tsgo --noEmit && bun run typecheck:packages",
"typecheck:packages": "tsgo --noEmit -p packages/rules-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json",
"typecheck:packages": "tsgo --noEmit -p packages/rules-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json",
"typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
"test": "bun test",
"build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build"
@@ -88,6 +89,7 @@
"devDependencies": {
"@oh-my-opencode/ast-grep-mcp": "workspace:*",
"@oh-my-opencode/rules-core": "workspace:*",
"@oh-my-opencode/utils": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260518.1",
"@types/js-yaml": "^4.0.9",
"@types/picomatch": "^4.0.3",
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@oh-my-opencode/utils",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "Pure TypeScript shared utilities for oh-my-opencode.",
"exports": {
".": {
"types": "./index.d.ts",
"import": "./src/index.ts"
}
},
"types": "./index.d.ts",
"scripts": {
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test": "bun test src/*.test.ts"
},
"dependencies": {
"js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1"
}
}
+50
View File
@@ -0,0 +1,50 @@
import { existsSync, realpathSync } from "fs"
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
function findNearestExistingAncestor(resolvedPath: string): string {
let candidatePath = resolvedPath
while (!existsSync(candidatePath)) {
const parentPath = dirname(candidatePath)
if (parentPath === candidatePath) {
return candidatePath
}
candidatePath = parentPath
}
return candidatePath
}
function toCanonicalPath(pathToNormalize: string): string {
const resolvedPath = resolve(pathToNormalize)
if (existsSync(resolvedPath)) {
try {
return normalize(realpathSync.native(resolvedPath))
} catch {
return normalize(resolvedPath)
}
}
const nearestExistingAncestor = findNearestExistingAncestor(resolvedPath)
const canonicalAncestor = existsSync(nearestExistingAncestor)
? realpathSync.native(nearestExistingAncestor)
: nearestExistingAncestor
const relativePathFromAncestor = relative(nearestExistingAncestor, resolvedPath)
return normalize(join(canonicalAncestor, relativePathFromAncestor || basename(resolvedPath)))
}
export function containsPath(rootPath: string, candidatePath: string): boolean {
const canonicalRootPath = toCanonicalPath(rootPath)
const canonicalCandidatePath = toCanonicalPath(candidatePath)
const relativePath = relative(canonicalRootPath, canonicalCandidatePath)
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))
}
export function isWithinProject(candidatePath: string, projectRoot: string): boolean {
return containsPath(projectRoot, candidatePath)
}
+53
View File
@@ -0,0 +1,53 @@
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
const MAX_DEPTH = 50;
export function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
/**
* Deep merges two objects, with override values taking precedence.
* - Objects are recursively merged
* - Arrays are replaced (not concatenated)
* - undefined values in override do not overwrite base values
*
* @example
* deepMerge({ a: 1, b: { c: 2, d: 3 } }, { b: { c: 10 }, e: 5 })
* // => { a: 1, b: { c: 10, d: 3 }, e: 5 }
*/
export function deepMerge<T extends Record<string, unknown>>(base: T, override: Partial<T>, depth?: number): T;
export function deepMerge<T extends Record<string, unknown>>(base: T | undefined, override: T | undefined, depth?: number): T | undefined;
export function deepMerge<T extends Record<string, unknown>>(
base: T | undefined,
override: T | undefined,
depth = 0
): T | undefined {
if (!base && !override) return undefined;
if (!base) return override;
if (!override) return base;
if (depth > MAX_DEPTH) return override ?? base;
const result = { ...base } as Record<string, unknown>;
for (const key of Object.keys(override)) {
if (DANGEROUS_KEYS.has(key)) continue;
const baseValue = base[key];
const overrideValue = override[key];
if (overrideValue === undefined) continue;
if (isPlainObject(baseValue) && isPlainObject(overrideValue)) {
result[key] = deepMerge(baseValue, overrideValue, depth + 1);
} else {
result[key] = overrideValue;
}
}
return result as T;
}
+9
View File
@@ -0,0 +1,9 @@
export function extractSemverFromOutput(output: string): string | null {
const trimmed = output.trim()
if (!trimmed) return null
// The negative lookbehind `(?<![\d:])` prevents matching the milliseconds segment of timestamps
// like `00:24:25.202` that the Electron-based OpenCode binary leaks into stdout.
const semverPattern = /(?<![\d:])v?(\d+\.\d+\.\d+(?:[-+][\w.]+)*)/
const match = trimmed.match(semverPattern)
return match?.[1] ?? null
}
+34
View File
@@ -0,0 +1,34 @@
import { lstatSync, realpathSync } from "fs"
import { promises as fs } from "fs"
function normalizeDarwinRealpath(filePath: string): string {
return filePath.startsWith("/private/var/") ? filePath.slice("/private".length) : filePath
}
export function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile()
}
export function isSymbolicLink(filePath: string): boolean {
try {
return lstatSync(filePath, { throwIfNoEntry: false })?.isSymbolicLink() ?? false
} catch {
return false
}
}
export function resolveSymlink(filePath: string): string {
try {
return normalizeDarwinRealpath(realpathSync(filePath))
} catch {
return filePath
}
}
export async function resolveSymlinkAsync(filePath: string): Promise<string> {
try {
return normalizeDarwinRealpath(await fs.realpath(filePath))
} catch {
return filePath
}
}
+16
View File
@@ -0,0 +1,16 @@
export function formatDurationHuman(milliseconds: number): string {
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000))
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`
}
return `${seconds}s`
}
+31
View File
@@ -0,0 +1,31 @@
import yaml from "js-yaml"
export interface FrontmatterResult<T = Record<string, unknown>> {
data: T
body: string
hadFrontmatter: boolean
parseError: boolean
}
export function parseFrontmatter<T = Record<string, unknown>>(
content: string
): FrontmatterResult<T> {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n?---\r?\n([\s\S]*)$/
const match = content.match(frontmatterRegex)
if (!match) {
return { data: {} as T, body: content, hadFrontmatter: false, parseError: false }
}
const yamlContent = match[1]
const body = match[2]
try {
// Use JSON_SCHEMA for security - prevents code execution via YAML tags
const parsed = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA })
const data = (parsed ?? {}) as T
return { data, body, hadFrontmatter: true, parseError: false }
} catch {
return { data: {} as T, body, hadFrontmatter: true, parseError: true }
}
}
+12
View File
@@ -0,0 +1,12 @@
export * from "./deep-merge"
export * from "./snake-case"
export * from "./record-type-guard"
export * from "./extract-semver"
export * from "./frontmatter"
export * from "./file-utils"
export * from "./contains-path"
export * from "./port-utils"
export * from "./tool-name"
export * from "./replace-tool-args"
export * from "./format-duration"
export * from "./jsonc-parser"
@@ -2,6 +2,11 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import * as fs from "node:fs"
import { join } from "node:path"
const pluginConfigDetectionOptions = {
basenames: ["oh-my-openagent"],
legacyBasenames: ["oh-my-opencode"],
} as const
describe("detectPluginConfigFile memoization", () => {
const testDir = join(__dirname, ".test-detect-plugin-memoization")
@@ -15,14 +20,13 @@ describe("detectPluginConfigFile memoization", () => {
return String(filePath).endsWith("oh-my-openagent.jsonc")
})
const readdirSync = spyOn(fs, "readdirSync").mockImplementation(() => [])
spyOn(fs, "readFileSync").mockImplementation(() => "")
const parserModule = await import(`./jsonc-parser?memoization=${Date.now()}-${Math.random()}`)
// when
const firstResult = parserModule.detectPluginConfigFile(testDir)
const firstResult = parserModule.detectPluginConfigFile(testDir, pluginConfigDetectionOptions)
const callsAfterFirstResult = existsSync.mock.calls.length
const secondResult = parserModule.detectPluginConfigFile(testDir)
const secondResult = parserModule.detectPluginConfigFile(testDir, pluginConfigDetectionOptions)
// then
expect(firstResult).toEqual(secondResult)
@@ -36,16 +40,15 @@ describe("detectPluginConfigFile memoization", () => {
return String(filePath).endsWith("oh-my-openagent.jsonc")
})
const readdirSync = spyOn(fs, "readdirSync").mockImplementation(() => [])
spyOn(fs, "readFileSync").mockImplementation(() => "")
const parserModule = await import(`./jsonc-parser?memoization=${Date.now()}-${Math.random()}`)
parserModule.detectPluginConfigFile(testDir)
parserModule.detectPluginConfigFile(testDir, pluginConfigDetectionOptions)
parserModule.clearPluginConfigFileDetectionCache()
const callsAfterClear = existsSync.mock.calls.length
// when
parserModule.detectPluginConfigFile(testDir)
parserModule.detectPluginConfigFile(testDir, pluginConfigDetectionOptions)
// then
expect(existsSync.mock.calls.length).toBeGreaterThan(callsAfterClear)
@@ -3,6 +3,11 @@ import { clearPluginConfigFileDetectionCache, detectConfigFile, detectPluginConf
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
const pluginConfigDetectionOptions = {
basenames: ["oh-my-openagent"],
legacyBasenames: ["oh-my-opencode"],
} as const
describe("parseJsonc", () => {
test("parses plain JSON", () => {
// given
@@ -345,7 +350,7 @@ describe("detectPluginConfigFile", () => {
writeFileSync(join(testDir, "oh-my-opencode.jsonc"), "{}")
// when
const result = detectPluginConfigFile(testDir)
const result = detectPluginConfigFile(testDir, pluginConfigDetectionOptions)
// then
expect(result.format).toBe("jsonc")
@@ -361,7 +366,7 @@ describe("detectPluginConfigFile", () => {
writeFileSync(join(testDir, "oh-my-opencode.jsonc"), "{}")
// when
const result = detectPluginConfigFile(testDir)
const result = detectPluginConfigFile(testDir, pluginConfigDetectionOptions)
// then
expect(result.format).toBe("jsonc")
@@ -378,7 +383,7 @@ describe("detectPluginConfigFile", () => {
writeFileSync(join(testDir, "oh-my-opencode.json"), "{}")
// when
const result = detectPluginConfigFile(testDir)
const result = detectPluginConfigFile(testDir, pluginConfigDetectionOptions)
// then
expect(result.format).toBe("json")
@@ -394,7 +399,7 @@ describe("detectPluginConfigFile", () => {
if (!existsSync(emptyDir)) mkdirSync(emptyDir, { recursive: true })
// when
const result = detectPluginConfigFile(emptyDir)
const result = detectPluginConfigFile(emptyDir, pluginConfigDetectionOptions)
// then
expect(result.format).toBe("none")
@@ -410,7 +415,7 @@ describe("detectPluginConfigFile", () => {
writeFileSync(join(testDir, "oh-my-openagent.jsonc"), "{}")
// when
const result = detectPluginConfigFile(testDir)
const result = detectPluginConfigFile(testDir, pluginConfigDetectionOptions)
// then
expect(result.format).toBe("jsonc")
@@ -426,7 +431,7 @@ describe("detectPluginConfigFile", () => {
writeFileSync(join(testDir, "oh-my-openagent.jsonc"), "{}")
// when
const result = detectPluginConfigFile(testDir)
const result = detectPluginConfigFile(testDir, pluginConfigDetectionOptions)
// then
expect(result.format).toBe("jsonc")
+133
View File
@@ -0,0 +1,133 @@
import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { parse, ParseError, printParseErrorCode } from "jsonc-parser"
export interface JsoncParseResult<T> {
data: T | null
errors: Array<{ message: string; offset: number; length: number }>
}
type DetectPluginConfigResult = {
format: "json" | "jsonc" | "none"
path: string
legacyPath?: string
}
export interface DetectPluginConfigFileOptions {
readonly basenames: readonly [string, ...string[]]
readonly legacyBasenames?: readonly string[]
}
const pluginConfigFileDetectionCache = new Map<string, DetectPluginConfigResult>()
function getPluginConfigCacheKey(dir: string, options: DetectPluginConfigFileOptions): string {
const basenames = [...options.basenames].join(",")
const legacyBasenames = options.legacyBasenames ? [...options.legacyBasenames].join(",") : ""
return `${dir}::${basenames}::${legacyBasenames}`
}
function stripBom(content: string): string {
return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content
}
export function parseJsonc<T = unknown>(content: string): T {
// Strip UTF-8 BOM if present (Windows UTF-8 with BOM files)
content = content.replace(/^\uFEFF/, "")
const errors: ParseError[] = []
const result = parse(stripBom(content), errors, {
allowTrailingComma: true,
disallowComments: false,
}) as T
if (errors.length > 0) {
const errorMessages = errors
.map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`)
.join(", ")
throw new SyntaxError(`JSONC parse error: ${errorMessages}`)
}
return result
}
export function parseJsoncSafe<T = unknown>(content: string): JsoncParseResult<T> {
const errors: ParseError[] = []
const data = parse(stripBom(content), errors, {
allowTrailingComma: true,
disallowComments: false,
}) as T | null
return {
data: errors.length > 0 ? null : data,
errors: errors.map((e) => ({
message: printParseErrorCode(e.error),
offset: e.offset,
length: e.length,
})),
}
}
export function readJsoncFile<T = unknown>(filePath: string): T | null {
try {
const content = readFileSync(filePath, "utf-8")
return parseJsonc<T>(content)
} catch {
return null
}
}
export function detectConfigFile(basePath: string): {
format: "json" | "jsonc" | "none"
path: string
} {
const jsoncPath = `${basePath}.jsonc`
const jsonPath = `${basePath}.json`
if (existsSync(jsoncPath)) {
return { format: "jsonc", path: jsoncPath }
}
if (existsSync(jsonPath)) {
return { format: "json", path: jsonPath }
}
return { format: "none", path: jsonPath }
}
export function clearPluginConfigFileDetectionCache(): void {
pluginConfigFileDetectionCache.clear()
}
export function detectPluginConfigFile(
dir: string,
options: DetectPluginConfigFileOptions,
): DetectPluginConfigResult {
const cacheKey = getPluginConfigCacheKey(dir, options)
const cachedResult = pluginConfigFileDetectionCache.get(cacheKey)
if (cachedResult !== undefined) {
return cachedResult
}
const canonicalBasename = options.basenames[0]
const canonicalResult = detectConfigFile(join(dir, canonicalBasename))
const legacyResults = (options.legacyBasenames ?? []).map((legacyBasename) =>
detectConfigFile(join(dir, legacyBasename)),
)
const firstExistingLegacyResult = legacyResults.find((result) => result.format !== "none")
let detectionResult: DetectPluginConfigResult
if (canonicalResult.format !== "none") {
detectionResult = {
...canonicalResult,
legacyPath: firstExistingLegacyResult?.path,
}
} else if (firstExistingLegacyResult) {
detectionResult = firstExistingLegacyResult
} else {
detectionResult = { format: "none", path: join(dir, `${canonicalBasename}.json`) }
}
pluginConfigFileDetectionCache.set(cacheKey, detectionResult)
return detectionResult
}
+83
View File
@@ -0,0 +1,83 @@
import { createServer } from "node:net"
const DEFAULT_SERVER_PORT = 4096
const MAX_PORT_ATTEMPTS = 20
const PORT_CHECK_TIMEOUT_MS = 2000
export async function isPortAvailable(port: number, hostname: string = "127.0.0.1"): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const server = createServer()
let timeoutId: ReturnType<typeof setTimeout> | undefined
let resolved = false
const finish = (isAvailable: boolean): void => {
if (resolved) {
return
}
resolved = true
if (timeoutId) {
clearTimeout(timeoutId)
}
server.removeAllListeners("error")
server.removeAllListeners("listening")
resolve(isAvailable)
}
const closeThenFinish = (isAvailable: boolean): void => {
try {
server.close(() => finish(isAvailable))
} catch {
finish(isAvailable)
}
}
timeoutId = setTimeout(() => {
closeThenFinish(false)
}, PORT_CHECK_TIMEOUT_MS)
server.once("error", () => {
finish(false)
})
server.once("listening", () => {
closeThenFinish(true)
})
try {
server.listen(port, hostname)
} catch {
finish(false)
}
})
}
export async function findAvailablePort(
startPort: number = DEFAULT_SERVER_PORT,
hostname: string = "127.0.0.1"
): Promise<number> {
for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
const port = startPort + attempt
if (await isPortAvailable(port, hostname)) {
return port
}
}
throw new Error(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`)
}
export interface AutoPortResult {
port: number
wasAutoSelected: boolean
}
export async function getAvailableServerPort(
preferredPort: number = DEFAULT_SERVER_PORT,
hostname: string = "127.0.0.1"
): Promise<AutoPortResult> {
if (await isPortAvailable(preferredPort, hostname)) {
return { port: preferredPort, wasAutoSelected: false }
}
const port = await findAvailablePort(preferredPort + 1, hostname)
return { port, wasAutoSelected: true }
}
export { DEFAULT_SERVER_PORT }
+3
View File
@@ -0,0 +1,3 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
@@ -21,7 +21,7 @@ async function collectTsFiles(dir: string): Promise<string[]> {
return results
}
const HELPER_FILE = "shared/replace-tool-args.ts"
const HELPER_FILE = "src/replace-tool-args.ts"
// Matches direct mutations like `output.args.foo =` or `toolOutput.args.foo =`
// but excludes comparisons (===, !==, ==)
+16
View File
@@ -0,0 +1,16 @@
/**
* Safely replace tool arguments without mutating frozen objects.
*
* opencode >=1.14 may freeze `output.args` via Immer before plugin hooks run.
* Direct property assignment (`output.args.key = value`) or `Object.assign(output.args, patch)`
* throws `TypeError: Attempted to assign to readonly property` on a frozen object.
*
* This helper replaces `output.args` with a shallow clone containing the patch,
* which works regardless of whether the original args object is frozen.
*/
export function replaceToolArgs(
output: { args: Record<string, unknown> },
patch: Record<string, unknown>,
): void {
output.args = { ...output.args, ...patch }
}
+44
View File
@@ -0,0 +1,44 @@
import { isPlainObject } from "./deep-merge"
export function camelToSnake(str: string): string {
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
}
export function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
}
export function transformObjectKeys(
obj: Record<string, unknown>,
transformer: (key: string) => string,
deep: boolean = true
): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
const transformedKey = transformer(key)
if (deep && isPlainObject(value)) {
result[transformedKey] = transformObjectKeys(value, transformer, true)
} else if (deep && Array.isArray(value)) {
result[transformedKey] = value.map((item) =>
isPlainObject(item) ? transformObjectKeys(item, transformer, true) : item
)
} else {
result[transformedKey] = value
}
}
return result
}
export function objectToSnakeCase(
obj: Record<string, unknown>,
deep: boolean = true
): Record<string, unknown> {
return transformObjectKeys(obj, camelToSnake, deep)
}
export function objectToCamelCase(
obj: Record<string, unknown>,
deep: boolean = true
): Record<string, unknown> {
return transformObjectKeys(obj, snakeToCamel, deep)
}
+27
View File
@@ -0,0 +1,27 @@
const SPECIAL_TOOL_MAPPINGS: Record<string, string> = {
webfetch: "WebFetch",
websearch: "WebSearch",
todoread: "TodoRead",
todowrite: "TodoWrite",
}
function toPascalCase(str: string): string {
return str
.split(/[-_\s]+/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("")
}
export function transformToolName(toolName: string): string {
const trimmed = toolName.trim()
const lower = trimmed.toLowerCase()
if (lower in SPECIAL_TOOL_MAPPINGS) {
return SPECIAL_TOOL_MAPPINGS[lower]
}
if (trimmed.includes("-") || trimmed.includes("_")) {
return toPascalCase(trimmed)
}
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1)
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ESNext"],
"types": ["bun-types"]
},
"include": ["src/**/*"]
}
+5 -1
View File
@@ -1,4 +1,5 @@
import { getOpenCodeConfigPaths, detectPluginConfigFile } from "../../shared"
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "../../shared/plugin-identity"
import type {
OpenCodeBinaryType,
OpenCodeConfigPaths,
@@ -43,7 +44,10 @@ export function getConfigJsonc(): string {
export function getOmoConfigPath(): string {
const configDir = getConfigContext().paths.configDir
const detected = detectPluginConfigFile(configDir)
const detected = detectPluginConfigFile(configDir, {
basenames: [CONFIG_BASENAME],
legacyBasenames: [LEGACY_CONFIG_BASENAME],
})
if (detected.format !== "none") return detected.path
return getConfigContext().paths.omoConfig
}
+9 -2
View File
@@ -3,6 +3,7 @@ import { join } from "node:path"
import { OhMyOpenCodeConfigSchema } from "../../../config"
import { detectPluginConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared"
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "../../../shared/plugin-identity"
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
import type { CheckResult, DoctorIssue } from "../types"
import { loadAvailableModelsFromCache } from "./model-resolution-cache"
@@ -20,11 +21,17 @@ interface ConfigValidationResult {
}
function findConfigPath(): string | null {
const projectConfig = detectPluginConfigFile(PROJECT_CONFIG_DIR)
const projectConfig = detectPluginConfigFile(PROJECT_CONFIG_DIR, {
basenames: [CONFIG_BASENAME],
legacyBasenames: [LEGACY_CONFIG_BASENAME],
})
if (projectConfig.format !== "none") return projectConfig.path
const userConfigDir = getOpenCodeConfigDir({ binary: "opencode" })
const userConfig = detectPluginConfigFile(userConfigDir)
const userConfig = detectPluginConfigFile(userConfigDir, {
basenames: [CONFIG_BASENAME],
legacyBasenames: [LEGACY_CONFIG_BASENAME],
})
if (userConfig.format !== "none") return userConfig.path
return null
@@ -1,12 +1,16 @@
import { readFileSync } from "node:fs"
import { join } from "node:path"
import { detectPluginConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared"
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "../../../shared/plugin-identity"
import type { OmoConfig } from "./model-resolution-types"
const PROJECT_CONFIG_DIR = join(process.cwd(), ".opencode")
export function loadOmoConfig(): OmoConfig | null {
const projectDetected = detectPluginConfigFile(PROJECT_CONFIG_DIR)
const projectDetected = detectPluginConfigFile(PROJECT_CONFIG_DIR, {
basenames: [CONFIG_BASENAME],
legacyBasenames: [LEGACY_CONFIG_BASENAME],
})
if (projectDetected.format !== "none") {
try {
const content = readFileSync(projectDetected.path, "utf-8")
@@ -17,7 +21,10 @@ export function loadOmoConfig(): OmoConfig | null {
}
const userConfigDir = getOpenCodeConfigDir({ binary: "opencode" })
const userDetected = detectPluginConfigFile(userConfigDir)
const userDetected = detectPluginConfigFile(userConfigDir, {
basenames: [CONFIG_BASENAME],
legacyBasenames: [LEGACY_CONFIG_BASENAME],
})
if (userDetected.format !== "none") {
try {
const content = readFileSync(userDetected.path, "utf-8")
+9 -2
View File
@@ -6,6 +6,7 @@ import type { CheckResult } from "../types"
import { readFileSync, promises as fs } from "node:fs"
import path from "node:path"
import { detectPluginConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared"
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "../../../shared/plugin-identity"
export async function checkTeamMode(): Promise<CheckResult> {
const config = loadTeamModeConfig()
@@ -33,8 +34,14 @@ export async function checkTeamMode(): Promise<CheckResult> {
}
function loadTeamModeConfig() {
const projectConfig = detectPluginConfigFile(path.join(process.cwd(), ".opencode"))
const userConfig = detectPluginConfigFile(getOpenCodeConfigDir({ binary: "opencode" }))
const projectConfig = detectPluginConfigFile(path.join(process.cwd(), ".opencode"), {
basenames: [CONFIG_BASENAME],
legacyBasenames: [LEGACY_CONFIG_BASENAME],
})
const userConfig = detectPluginConfigFile(getOpenCodeConfigDir({ binary: "opencode" }), {
basenames: [CONFIG_BASENAME],
legacyBasenames: [LEGACY_CONFIG_BASENAME],
})
const configPath = projectConfig.format !== "none" ? projectConfig.path : userConfig.path
if (!configPath) return { team_mode: undefined }
try {
+5 -1
View File
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"
import { join } from "node:path"
import { createLspMcpConfig } from "../../../mcp/lsp"
import { detectPluginConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared"
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "../../../shared/plugin-identity"
type OmoConfigForDoctor = {
disabled_mcps?: string[]
@@ -13,7 +14,10 @@ type InstalledLspServersOptions = {
}
function readOmoConfig(configDirectory: string): OmoConfigForDoctor | null {
const detected = detectPluginConfigFile(configDirectory)
const detected = detectPluginConfigFile(configDirectory, {
basenames: [CONFIG_BASENAME],
legacyBasenames: [LEGACY_CONFIG_BASENAME],
})
if (detected.format === "none") {
return null
}
+1 -16
View File
@@ -1,16 +1 @@
export function formatDurationHuman(milliseconds: number): string {
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000))
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`
}
return `${seconds}s`
}
export { formatDurationHuman } from "@oh-my-opencode/utils"
+8 -2
View File
@@ -283,7 +283,10 @@ export function loadPluginConfig(
): OhMyOpenCodeConfig {
const userConfigDirs = [...getOpenCodeConfigDirs({ binary: "opencode" })].reverse()
const userConfigLayers = userConfigDirs.map((configDir) => {
const detected = detectPluginConfigFile(configDir)
const detected = detectPluginConfigFile(configDir, {
basenames: [CONFIG_BASENAME],
legacyBasenames: [LEGACY_CONFIG_BASENAME],
})
if (detected.legacyPath) {
log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", {
@@ -318,7 +321,10 @@ export function loadPluginConfig(
const canonicalAncestorPathsNearestFirst = ancestorConfigPathsNearestFirst.map(
(ancestorPath) => {
const opencodeDir = path.dirname(ancestorPath)
const ancestorDetected = detectPluginConfigFile(opencodeDir)
const ancestorDetected = detectPluginConfigFile(opencodeDir, {
basenames: [CONFIG_BASENAME],
legacyBasenames: [LEGACY_CONFIG_BASENAME],
})
if (ancestorDetected.legacyPath) {
log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", {
canonicalPath: ancestorDetected.path,
+1 -50
View File
@@ -1,50 +1 @@
import { existsSync, realpathSync } from "fs"
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
function findNearestExistingAncestor(resolvedPath: string): string {
let candidatePath = resolvedPath
while (!existsSync(candidatePath)) {
const parentPath = dirname(candidatePath)
if (parentPath === candidatePath) {
return candidatePath
}
candidatePath = parentPath
}
return candidatePath
}
function toCanonicalPath(pathToNormalize: string): string {
const resolvedPath = resolve(pathToNormalize)
if (existsSync(resolvedPath)) {
try {
return normalize(realpathSync.native(resolvedPath))
} catch {
return normalize(resolvedPath)
}
}
const nearestExistingAncestor = findNearestExistingAncestor(resolvedPath)
const canonicalAncestor = existsSync(nearestExistingAncestor)
? realpathSync.native(nearestExistingAncestor)
: nearestExistingAncestor
const relativePathFromAncestor = relative(nearestExistingAncestor, resolvedPath)
return normalize(join(canonicalAncestor, relativePathFromAncestor || basename(resolvedPath)))
}
export function containsPath(rootPath: string, candidatePath: string): boolean {
const canonicalRootPath = toCanonicalPath(rootPath)
const canonicalCandidatePath = toCanonicalPath(candidatePath)
const relativePath = relative(canonicalRootPath, canonicalCandidatePath)
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))
}
export function isWithinProject(candidatePath: string, projectRoot: string): boolean {
return containsPath(projectRoot, candidatePath)
}
export { containsPath, isWithinProject } from "@oh-my-opencode/utils"
+1 -53
View File
@@ -1,53 +1 @@
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
const MAX_DEPTH = 50;
export function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
/**
* Deep merges two objects, with override values taking precedence.
* - Objects are recursively merged
* - Arrays are replaced (not concatenated)
* - undefined values in override do not overwrite base values
*
* @example
* deepMerge({ a: 1, b: { c: 2, d: 3 } }, { b: { c: 10 }, e: 5 })
* // => { a: 1, b: { c: 10, d: 3 }, e: 5 }
*/
export function deepMerge<T extends Record<string, unknown>>(base: T, override: Partial<T>, depth?: number): T;
export function deepMerge<T extends Record<string, unknown>>(base: T | undefined, override: T | undefined, depth?: number): T | undefined;
export function deepMerge<T extends Record<string, unknown>>(
base: T | undefined,
override: T | undefined,
depth = 0
): T | undefined {
if (!base && !override) return undefined;
if (!base) return override;
if (!override) return base;
if (depth > MAX_DEPTH) return override ?? base;
const result = { ...base } as Record<string, unknown>;
for (const key of Object.keys(override)) {
if (DANGEROUS_KEYS.has(key)) continue;
const baseValue = base[key];
const overrideValue = override[key];
if (overrideValue === undefined) continue;
if (isPlainObject(baseValue) && isPlainObject(overrideValue)) {
result[key] = deepMerge(baseValue, overrideValue, depth + 1);
} else {
result[key] = overrideValue;
}
}
return result as T;
}
export { deepMerge, isPlainObject } from "@oh-my-opencode/utils"
+1 -9
View File
@@ -1,9 +1 @@
export function extractSemverFromOutput(output: string): string | null {
const trimmed = output.trim()
if (!trimmed) return null
// The negative lookbehind `(?<![\d:])` prevents matching the milliseconds segment of timestamps
// like `00:24:25.202` that the Electron-based OpenCode binary leaks into stdout.
const semverPattern = /(?<![\d:])v?(\d+\.\d+\.\d+(?:[-+][\w.]+)*)/
const match = trimmed.match(semverPattern)
return match?.[1] ?? null
}
export { extractSemverFromOutput } from "@oh-my-opencode/utils"
+1 -34
View File
@@ -1,34 +1 @@
import { lstatSync, realpathSync } from "fs"
import { promises as fs } from "fs"
function normalizeDarwinRealpath(filePath: string): string {
return filePath.startsWith("/private/var/") ? filePath.slice("/private".length) : filePath
}
export function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile()
}
export function isSymbolicLink(filePath: string): boolean {
try {
return lstatSync(filePath, { throwIfNoEntry: false })?.isSymbolicLink() ?? false
} catch {
return false
}
}
export function resolveSymlink(filePath: string): string {
try {
return normalizeDarwinRealpath(realpathSync(filePath))
} catch {
return filePath
}
}
export async function resolveSymlinkAsync(filePath: string): Promise<string> {
try {
return normalizeDarwinRealpath(await fs.realpath(filePath))
} catch {
return filePath
}
}
export { isMarkdownFile, isSymbolicLink, resolveSymlink, resolveSymlinkAsync } from "@oh-my-opencode/utils"
+1 -31
View File
@@ -1,31 +1 @@
import yaml from "js-yaml"
export interface FrontmatterResult<T = Record<string, unknown>> {
data: T
body: string
hadFrontmatter: boolean
parseError: boolean
}
export function parseFrontmatter<T = Record<string, unknown>>(
content: string
): FrontmatterResult<T> {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n?---\r?\n([\s\S]*)$/
const match = content.match(frontmatterRegex)
if (!match) {
return { data: {} as T, body: content, hadFrontmatter: false, parseError: false }
}
const yamlContent = match[1]
const body = match[2]
try {
// Use JSON_SCHEMA for security - prevents code execution via YAML tags
const parsed = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA })
const data = (parsed ?? {}) as T
return { data, body, hadFrontmatter: true, parseError: false }
} catch {
return { data: {} as T, body, hadFrontmatter: true, parseError: true }
}
}
export { parseFrontmatter, type FrontmatterResult } from "@oh-my-opencode/utils"
+10 -116
View File
@@ -1,116 +1,10 @@
import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { parse, ParseError, printParseErrorCode } from "jsonc-parser"
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./plugin-identity"
export interface JsoncParseResult<T> {
data: T | null
errors: Array<{ message: string; offset: number; length: number }>
}
type DetectPluginConfigResult = {
format: "json" | "jsonc" | "none"
path: string
legacyPath?: string
}
const pluginConfigFileDetectionCache = new Map<string, DetectPluginConfigResult>()
function stripBom(content: string): string {
return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content
}
export function parseJsonc<T = unknown>(content: string): T {
// Strip UTF-8 BOM if present (Windows UTF-8 with BOM files)
content = content.replace(/^\uFEFF/, "")
const errors: ParseError[] = []
const result = parse(stripBom(content), errors, {
allowTrailingComma: true,
disallowComments: false,
}) as T
if (errors.length > 0) {
const errorMessages = errors
.map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`)
.join(", ")
throw new SyntaxError(`JSONC parse error: ${errorMessages}`)
}
return result
}
export function parseJsoncSafe<T = unknown>(content: string): JsoncParseResult<T> {
const errors: ParseError[] = []
const data = parse(stripBom(content), errors, {
allowTrailingComma: true,
disallowComments: false,
}) as T | null
return {
data: errors.length > 0 ? null : data,
errors: errors.map((e) => ({
message: printParseErrorCode(e.error),
offset: e.offset,
length: e.length,
})),
}
}
export function readJsoncFile<T = unknown>(filePath: string): T | null {
try {
const content = readFileSync(filePath, "utf-8")
return parseJsonc<T>(content)
} catch {
return null
}
}
export function detectConfigFile(basePath: string): {
format: "json" | "jsonc" | "none"
path: string
} {
const jsoncPath = `${basePath}.jsonc`
const jsonPath = `${basePath}.json`
if (existsSync(jsoncPath)) {
return { format: "jsonc", path: jsoncPath }
}
if (existsSync(jsonPath)) {
return { format: "json", path: jsonPath }
}
return { format: "none", path: jsonPath }
}
export function clearPluginConfigFileDetectionCache(): void {
pluginConfigFileDetectionCache.clear()
}
export function detectPluginConfigFile(dir: string): DetectPluginConfigResult {
const cachedResult = pluginConfigFileDetectionCache.get(dir)
if (cachedResult !== undefined) {
return cachedResult
}
const canonicalResult = detectConfigFile(join(dir, CONFIG_BASENAME))
const legacyResult = detectConfigFile(join(dir, LEGACY_CONFIG_BASENAME))
let detectionResult: DetectPluginConfigResult
if (canonicalResult.format !== "none") {
detectionResult = {
...canonicalResult,
legacyPath: legacyResult.format !== "none" ? legacyResult.path : undefined,
}
} else if (legacyResult.format !== "none") {
detectionResult = legacyResult
} else {
detectionResult = { format: "none", path: join(dir, `${CONFIG_BASENAME}.json`) }
}
pluginConfigFileDetectionCache.set(dir, detectionResult)
return detectionResult
}
export {
clearPluginConfigFileDetectionCache,
detectConfigFile,
detectPluginConfigFile,
parseJsonc,
parseJsoncSafe,
readJsoncFile,
type DetectPluginConfigFileOptions,
type JsoncParseResult,
} from "@oh-my-opencode/utils"
+7 -83
View File
@@ -1,83 +1,7 @@
import { createServer } from "node:net"
const DEFAULT_SERVER_PORT = 4096
const MAX_PORT_ATTEMPTS = 20
const PORT_CHECK_TIMEOUT_MS = 2000
export async function isPortAvailable(port: number, hostname: string = "127.0.0.1"): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const server = createServer()
let timeoutId: ReturnType<typeof setTimeout> | undefined
let resolved = false
const finish = (isAvailable: boolean): void => {
if (resolved) {
return
}
resolved = true
if (timeoutId) {
clearTimeout(timeoutId)
}
server.removeAllListeners("error")
server.removeAllListeners("listening")
resolve(isAvailable)
}
const closeThenFinish = (isAvailable: boolean): void => {
try {
server.close(() => finish(isAvailable))
} catch {
finish(isAvailable)
}
}
timeoutId = setTimeout(() => {
closeThenFinish(false)
}, PORT_CHECK_TIMEOUT_MS)
server.once("error", () => {
finish(false)
})
server.once("listening", () => {
closeThenFinish(true)
})
try {
server.listen(port, hostname)
} catch {
finish(false)
}
})
}
export async function findAvailablePort(
startPort: number = DEFAULT_SERVER_PORT,
hostname: string = "127.0.0.1"
): Promise<number> {
for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
const port = startPort + attempt
if (await isPortAvailable(port, hostname)) {
return port
}
}
throw new Error(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`)
}
export interface AutoPortResult {
port: number
wasAutoSelected: boolean
}
export async function getAvailableServerPort(
preferredPort: number = DEFAULT_SERVER_PORT,
hostname: string = "127.0.0.1"
): Promise<AutoPortResult> {
if (await isPortAvailable(preferredPort, hostname)) {
return { port: preferredPort, wasAutoSelected: false }
}
const port = await findAvailablePort(preferredPort + 1, hostname)
return { port, wasAutoSelected: true }
}
export { DEFAULT_SERVER_PORT }
export {
DEFAULT_SERVER_PORT,
findAvailablePort,
getAvailableServerPort,
isPortAvailable,
type AutoPortResult,
} from "@oh-my-opencode/utils"
+5 -1
View File
@@ -3,6 +3,7 @@ import { existsSync, realpathSync } from "node:fs"
import { dirname, join, resolve } from "node:path"
import { detectPluginConfigFile } from "./jsonc-parser"
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./plugin-identity"
const worktreePathCache = new Map<string, string | undefined>()
@@ -129,7 +130,10 @@ export function findProjectOpencodePluginConfigFiles(
while (true) {
const opencodeDirectory = join(currentDirectory, ".opencode")
if (existsSync(opencodeDirectory)) {
const detected = detectPluginConfigFile(opencodeDirectory)
const detected = detectPluginConfigFile(opencodeDirectory, {
basenames: [CONFIG_BASENAME],
legacyBasenames: [LEGACY_CONFIG_BASENAME],
})
if (detected.format !== "none" && !seen.has(detected.path)) {
seen.add(detected.path)
paths.push(detected.path)
+1 -3
View File
@@ -1,3 +1 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
export { isRecord } from "@oh-my-opencode/utils"
+1 -16
View File
@@ -1,16 +1 @@
/**
* Safely replace tool arguments without mutating frozen objects.
*
* opencode >=1.14 may freeze `output.args` via Immer before plugin hooks run.
* Direct property assignment (`output.args.key = value`) or `Object.assign(output.args, patch)`
* throws `TypeError: Attempted to assign to readonly property` on a frozen object.
*
* This helper replaces `output.args` with a shallow clone containing the patch,
* which works regardless of whether the original args object is frozen.
*/
export function replaceToolArgs(
output: { args: Record<string, unknown> },
patch: Record<string, unknown>,
): void {
output.args = { ...output.args, ...patch }
}
export { replaceToolArgs } from "@oh-my-opencode/utils"
+1 -44
View File
@@ -1,44 +1 @@
import { isPlainObject } from "./deep-merge"
export function camelToSnake(str: string): string {
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
}
export function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
}
export function transformObjectKeys(
obj: Record<string, unknown>,
transformer: (key: string) => string,
deep: boolean = true
): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
const transformedKey = transformer(key)
if (deep && isPlainObject(value)) {
result[transformedKey] = transformObjectKeys(value, transformer, true)
} else if (deep && Array.isArray(value)) {
result[transformedKey] = value.map((item) =>
isPlainObject(item) ? transformObjectKeys(item, transformer, true) : item
)
} else {
result[transformedKey] = value
}
}
return result
}
export function objectToSnakeCase(
obj: Record<string, unknown>,
deep: boolean = true
): Record<string, unknown> {
return transformObjectKeys(obj, camelToSnake, deep)
}
export function objectToCamelCase(
obj: Record<string, unknown>,
deep: boolean = true
): Record<string, unknown> {
return transformObjectKeys(obj, snakeToCamel, deep)
}
export { camelToSnake, objectToCamelCase, objectToSnakeCase, snakeToCamel, transformObjectKeys } from "@oh-my-opencode/utils"
+1 -27
View File
@@ -1,27 +1 @@
const SPECIAL_TOOL_MAPPINGS: Record<string, string> = {
webfetch: "WebFetch",
websearch: "WebSearch",
todoread: "TodoRead",
todowrite: "TodoWrite",
}
function toPascalCase(str: string): string {
return str
.split(/[-_\s]+/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("")
}
export function transformToolName(toolName: string): string {
const trimmed = toolName.trim()
const lower = trimmed.toLowerCase()
if (lower in SPECIAL_TOOL_MAPPINGS) {
return SPECIAL_TOOL_MAPPINGS[lower]
}
if (trimmed.includes("-") || trimmed.includes("_")) {
return toPascalCase(trimmed)
}
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1)
}
export { transformToolName } from "@oh-my-opencode/utils"