From c381a25a2f29ead4268a5c8639543d082672cb55 Mon Sep 17 00:00:00 2001 From: DanZAIO <213864024+danzaio@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:14:28 -0300 Subject: [PATCH] feat(debug): load user DAP adapter configs --- docs/tools/debug.md | 38 ++++ packages/coding-agent/CHANGELOG.md | 3 + packages/coding-agent/src/dap/config.ts | 168 +++++++++++++++++- .../test/debug/dap-config.test.ts | 132 ++++++++++++++ 4 files changed, 334 insertions(+), 7 deletions(-) create mode 100644 packages/coding-agent/test/debug/dap-config.test.ts diff --git a/docs/tools/debug.md b/docs/tools/debug.md index 5eeab83be..ebb874f2c 100644 --- a/docs/tools/debug.md +++ b/docs/tools/debug.md @@ -155,6 +155,44 @@ Side-channel artifacts outside the model tool result: - **Adapter selection** - `launch`: explicit `adapter` wins; otherwise `selectLaunchAdapter()` ranks available adapters by extension match, root-marker match, then native-debugger preference (`gdb`, `lldb-dap`) for extensionless binaries. - `attach`: explicit `adapter` wins; otherwise remote `port` prefers `debugpy`, then native debuggers, then first available adapter. +- **Custom adapter config** + - Debug adapters can be added or overridden with `dap.json`, `.dap.json`, `dap.yaml`, `.dap.yaml`, `dap.yml`, or `.dap.yml`. + - Search order mirrors LSP config: project root, project config dirs (`.omp/`, `.pi/`, `.claude/`), user config dirs, plugin roots, then home-root fallback. Files are merged from lowest to highest priority. + - Config shape may be either `{ "adapters": { ... } }` or a top-level adapter map. + - Adapter fields: + - `command`: executable name or path. Required. + - `args`: adapter argv. + - `languages`: display/filter metadata. + - `fileTypes`: file extensions or filenames used for launch auto-selection. + - `rootMarkers`: files/directories used to rank adapters for a project. + - `launchDefaults`: default DAP launch arguments merged before the selected program/cwd/args. + - `attachDefaults`: default DAP attach arguments merged before pid/port/host/cwd. + - `connectMode`: `"stdio"` (default) or `"socket"`. + - `acceptsDirectoryProgram`: set `true` for adapters such as `dlv` that can launch a package/project directory. + +Example `.omp/dap.json`: + +```json +{ + "adapters": { + "custom-jvm": { + "command": "kotlin-debug-adapter", + "args": ["--stdio"], + "languages": ["java", "kotlin"], + "fileTypes": [".java", ".kt", ".kts"], + "rootMarkers": ["pom.xml", "build.gradle", "build.gradle.kts"], + "launchDefaults": { + "request": "launch", + "projectRoot": "." + }, + "attachDefaults": { + "request": "attach", + "host": "127.0.0.1" + } + } + } +} +``` - **Transport** - stdio adapters: direct `stdin`/`stdout` framing. - socket adapters: Unix domain socket on Linux; TCP callback on macOS/other. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a13b2b201..64a922b99 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -114,6 +114,9 @@ - Snapcompact compaction summaries now reach the model as ordered history blocks instead of one lead-in text block plus appended images: plain text at the oldest edge, an imaged middle, then plain text at the newest edge. This matches the new text-first snapcompact archive layout and preserves chronological order in the provider prompt. - Fixed `/dump` output repeating the tool inventory twice when `inlineToolDescriptors` is enabled. - Unified TUI border corners on the rounded style: tool-result frames, overlays, code fences, debug frames, and the interactive bash box now draw rounded corners (`╭╮╰╯`) to match the editor and message cards, instead of mixing rounded boxes with sharp (`┌┐└┘`) ones. `boxRound` now carries the sharp tee/cross junction glyphs (no rounded variant exists), so dividers still honor `boxSharp.tee*`/`cross` theme overrides. Markdown tables intentionally keep the fully sharp `boxSharp` set; its corner tokens now affect tables only. +### Added + +- Added project/user/plugin `dap.json` and `dap.yaml` support for defining or overriding debugger adapters used by the `debug` tool. ([#2999](https://github.com/can1357/oh-my-pi/issues/2999)) ## [16.0.11] - 2026-06-19 diff --git a/packages/coding-agent/src/dap/config.ts b/packages/coding-agent/src/dap/config.ts index 31f870b91..8827c2ac1 100644 --- a/packages/coding-agent/src/dap/config.ts +++ b/packages/coding-agent/src/dap/config.ts @@ -1,11 +1,38 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; -import { isRecord } from "@oh-my-pi/pi-utils"; +import { isRecord, logger, pathIsWithin } from "@oh-my-pi/pi-utils"; +import { YAML } from "bun"; +import { getConfigDirPaths } from "../config"; +import { type ClaudePluginRoot, getPreloadedPluginRoots } from "../discovery/helpers"; import { hasRootMarkers, resolveCommand } from "../lsp/config"; import DEFAULTS from "./defaults.json" with { type: "json" }; import type { DapAdapterConfig, DapResolvedAdapter } from "./types"; const EXTENSIONLESS_DEBUGGER_ORDER = ["gdb", "lldb-dap"] as const; +interface NormalizedConfig { + adapters: Record; +} + +interface ConfigSource { + read(): NormalizedConfig | null; +} + +function parseConfigContent(content: string, filePath: string): unknown { + const extension = path.extname(filePath).toLowerCase(); + if (extension === ".yaml" || extension === ".yml") { + return YAML.parse(content) as unknown; + } + return JSON.parse(content) as unknown; +} + +function normalizeConfig(value: unknown): NormalizedConfig | null { + if (!isRecord(value)) return null; + if (isRecord(value.adapters)) return { adapters: value.adapters }; + return { adapters: value }; +} + function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); @@ -32,6 +59,15 @@ function normalizeAdapterConfig(config: unknown): DapAdapterConfig | null { }; } +function readConfigFile(filePath: string): NormalizedConfig | null { + try { + const content = fs.readFileSync(filePath, "utf-8"); + return normalizeConfig(parseConfigContent(content, filePath)); + } catch { + return null; + } +} + function getDefaults(): Record { const adapters: Record = {}; for (const [name, config] of Object.entries(DEFAULTS)) { @@ -45,12 +81,125 @@ function getDefaults(): Record { const DEFAULT_ADAPTERS = getDefaults(); -export function getAdapterConfigs(): Record { - return { ...DEFAULT_ADAPTERS }; +function mergeAdapters( + base: Record, + overrides: Record, +): Record { + const merged: Record = { ...base }; + for (const [name, config] of Object.entries(overrides)) { + const candidate = isRecord(merged[name]) && isRecord(config) ? { ...merged[name], ...config } : config; + const normalized = normalizeAdapterConfig(candidate); + if (normalized) { + merged[name] = normalized; + } else if (merged[name]) { + logger.warn("Ignoring invalid DAP adapter override (keeping previous config).", { name }); + } else { + logger.warn("Ignoring invalid DAP adapter config.", { name }); + } + } + return merged; } -export function resolveAdapter(adapterName: string, cwd: string): DapResolvedAdapter | null { - const config = DEFAULT_ADAPTERS[adapterName]; +function fileConfigSource(filePath: string): ConfigSource { + return { + read: () => readConfigFile(filePath), + }; +} + +function readMarketplaceDapConfig(root: ClaudePluginRoot): NormalizedConfig | null { + const catalogPaths = [ + path.resolve(root.path, "..", "..", "marketplace.json"), + path.resolve(root.path, "..", "..", ".claude-plugin", "marketplace.json"), + ]; + + for (const catalogPath of catalogPaths) { + try { + const catalog = JSON.parse(fs.readFileSync(catalogPath, "utf-8")) as unknown; + if (!isRecord(catalog) || !Array.isArray(catalog.plugins)) continue; + + for (const plugin of catalog.plugins) { + if (!isRecord(plugin) || plugin.name !== root.plugin) continue; + + const dapAdapters = plugin.dapAdapters; + if (typeof dapAdapters === "string") { + const configPath = path.resolve(root.path, dapAdapters); + if (!pathIsWithin(root.path, configPath)) return null; + return readConfigFile(configPath); + } + if (isRecord(dapAdapters)) { + return normalizeConfig({ adapters: dapAdapters }); + } + return null; + } + } catch {} + } + + return null; +} + +function marketplaceConfigSource(root: ClaudePluginRoot): ConfigSource { + return { + read: () => readMarketplaceDapConfig(root), + }; +} + +function getConfigSources(cwd: string): ConfigSource[] { + const filenames = ["dap.json", ".dap.json", "dap.yaml", ".dap.yaml", "dap.yml", ".dap.yml"]; + const sources: ConfigSource[] = []; + + for (const filename of filenames) { + sources.push(fileConfigSource(path.join(cwd, filename))); + } + + const projectDirs = getConfigDirPaths("", { user: false, project: true, cwd }); + for (const dir of projectDirs) { + for (const filename of filenames) { + sources.push(fileConfigSource(path.join(dir, filename))); + } + } + + const userDirs = getConfigDirPaths("", { user: true, project: false }); + for (const dir of userDirs) { + for (const filename of filenames) { + sources.push(fileConfigSource(path.join(dir, filename))); + } + } + + const pluginRoots = getPreloadedPluginRoots(); + for (const root of pluginRoots) { + for (const filename of filenames) { + sources.push(fileConfigSource(path.join(root.path, filename))); + } + sources.push(marketplaceConfigSource(root)); + } + + for (const filename of filenames) { + sources.push(fileConfigSource(path.join(os.homedir(), filename))); + } + + return sources; +} + +function loadAdapterConfigs(cwd: string): Record { + let adapters = { ...DEFAULT_ADAPTERS }; + for (const source of getConfigSources(cwd).reverse()) { + const parsed = source.read(); + if (!parsed) continue; + adapters = mergeAdapters(adapters, parsed.adapters); + } + return adapters; +} + +export function getAdapterConfigs(cwd?: string): Record { + return cwd ? loadAdapterConfigs(cwd) : { ...DEFAULT_ADAPTERS }; +} + +function resolveAdapterFromConfig( + adapterName: string, + configs: Record, + cwd: string, +): DapResolvedAdapter | null { + const config = configs[adapterName]; if (!config) return null; const resolvedCommand = resolveCommand(config.command, cwd); if (!resolvedCommand) return null; @@ -69,9 +218,14 @@ export function resolveAdapter(adapterName: string, cwd: string): DapResolvedAda }; } +export function resolveAdapter(adapterName: string, cwd: string): DapResolvedAdapter | null { + return resolveAdapterFromConfig(adapterName, getAdapterConfigs(cwd), cwd); +} + export function getAvailableAdapters(cwd: string): DapResolvedAdapter[] { - return Object.keys(DEFAULT_ADAPTERS) - .map(name => resolveAdapter(name, cwd)) + const configs = getAdapterConfigs(cwd); + return Object.keys(configs) + .map(name => resolveAdapterFromConfig(name, configs, cwd)) .filter((adapter): adapter is DapResolvedAdapter => adapter !== null); } diff --git a/packages/coding-agent/test/debug/dap-config.test.ts b/packages/coding-agent/test/debug/dap-config.test.ts new file mode 100644 index 000000000..579ee5384 --- /dev/null +++ b/packages/coding-agent/test/debug/dap-config.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getAdapterConfigs, resolveAdapter, selectLaunchAdapter } from "@oh-my-pi/pi-coding-agent/dap/config"; + +const tempDirs: string[] = []; + +async function makeTempDir(prefix: string): Promise { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(cwd); + return cwd; +} + +afterEach(async () => { + vi.restoreAllMocks(); + await Promise.all(tempDirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("DAP adapter configuration", () => { + it("loads a custom adapter from dap.json and selects it by file extension", async () => { + const cwd = await makeTempDir("omp-dap-config-json-"); + await fs.writeFile(path.join(cwd, "pom.xml"), "\n"); + await fs.mkdir(path.join(cwd, "src"), { recursive: true }); + await fs.writeFile(path.join(cwd, "src", "Main.java"), "class Main {}\n"); + await fs.writeFile( + path.join(cwd, "dap.json"), + JSON.stringify({ + adapters: { + "custom-jvm": { + command: "bun", + args: ["run", "debug-adapter"], + languages: ["java", "kotlin"], + fileTypes: [".java", ".kt"], + rootMarkers: ["pom.xml", "build.gradle.kts"], + launchDefaults: { request: "launch", mainClass: "" }, + attachDefaults: { request: "attach", host: "127.0.0.1" }, + }, + }, + }), + ); + + const adapter = resolveAdapter("custom-jvm", cwd); + expect(adapter?.name).toBe("custom-jvm"); + expect(adapter?.command).toBe("bun"); + expect(adapter?.args).toEqual(["run", "debug-adapter"]); + expect(adapter?.languages).toEqual(["java", "kotlin"]); + expect(adapter?.fileTypes).toEqual([".java", ".kt"]); + expect(adapter?.launchDefaults).toEqual({ request: "launch", mainClass: "" }); + expect(adapter?.attachDefaults).toEqual({ request: "attach", host: "127.0.0.1" }); + + const selected = selectLaunchAdapter(path.join("src", "Main.java"), cwd); + expect(selected?.name).toBe("custom-jvm"); + }); + + it("merges partial user overrides over built-in adapters", async () => { + const cwd = await makeTempDir("omp-dap-config-override-"); + await fs.writeFile(path.join(cwd, "script.py"), "print('hi')\n"); + await fs.writeFile( + path.join(cwd, "dap.json"), + JSON.stringify({ + adapters: { + debugpy: { + args: ["-m", "debugpy.adapter", "--log-dir", ".debugpy-logs"], + launchDefaults: { request: "launch", justMyCode: false }, + }, + }, + }), + ); + + const config = getAdapterConfigs(cwd).debugpy; + expect(config.command).toBe("python"); + expect(config.args).toEqual(["-m", "debugpy.adapter", "--log-dir", ".debugpy-logs"]); + expect(config.fileTypes).toContain(".py"); + expect(config.launchDefaults).toMatchObject({ request: "launch", justMyCode: false }); + }); + + it("loads adapter config from project config directories and YAML", async () => { + const cwd = await makeTempDir("omp-dap-config-yaml-"); + await fs.mkdir(path.join(cwd, ".omp"), { recursive: true }); + await fs.writeFile(path.join(cwd, "build.gradle.kts"), "plugins {}\n"); + await fs.writeFile(path.join(cwd, "Main.kt"), "fun main() {}\n"); + await fs.writeFile( + path.join(cwd, ".omp", "dap.yaml"), + [ + "adapters:", + " yaml-kotlin:", + " command: bun", + " args:", + " - run", + " - kotlin-debug-adapter", + " languages:", + " - kotlin", + " fileTypes:", + " - .kt", + " rootMarkers:", + " - build.gradle.kts", + " launchDefaults:", + " request: launch", + " projectRoot: .", + "", + ].join("\n"), + ); + + const selected = selectLaunchAdapter("Main.kt", cwd); + expect(selected?.name).toBe("yaml-kotlin"); + expect(selected?.launchDefaults).toEqual({ request: "launch", projectRoot: "." }); + }); + + it("ignores invalid custom adapters without discarding valid configs", async () => { + const cwd = await makeTempDir("omp-dap-config-invalid-"); + await fs.writeFile( + path.join(cwd, "dap.json"), + JSON.stringify({ + adapters: { + "missing-command": { + fileTypes: [".bad"], + }, + valid: { + command: "bun", + fileTypes: [".ok"], + rootMarkers: ["."], + }, + }, + }), + ); + + const config = getAdapterConfigs(cwd); + expect(config["missing-command"]).toBeUndefined(); + expect(config.valid?.command).toBe("bun"); + }); +});