feat(debug): load user DAP adapter configs

This commit is contained in:
DanZAIO
2026-06-19 02:14:28 -03:00
parent 73065ea431
commit c381a25a2f
4 changed files with 334 additions and 7 deletions
+38
View File
@@ -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.
+3
View File
@@ -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
+161 -7
View File
@@ -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<string, unknown>;
}
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<string, DapAdapterConfig> {
const adapters: Record<string, DapAdapterConfig> = {};
for (const [name, config] of Object.entries(DEFAULTS)) {
@@ -45,12 +81,125 @@ function getDefaults(): Record<string, DapAdapterConfig> {
const DEFAULT_ADAPTERS = getDefaults();
export function getAdapterConfigs(): Record<string, DapAdapterConfig> {
return { ...DEFAULT_ADAPTERS };
function mergeAdapters(
base: Record<string, DapAdapterConfig>,
overrides: Record<string, unknown>,
): Record<string, DapAdapterConfig> {
const merged: Record<string, DapAdapterConfig> = { ...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<string, DapAdapterConfig> {
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<string, DapAdapterConfig> {
return cwd ? loadAdapterConfigs(cwd) : { ...DEFAULT_ADAPTERS };
}
function resolveAdapterFromConfig(
adapterName: string,
configs: Record<string, DapAdapterConfig>,
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);
}
@@ -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<string> {
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"), "<project />\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");
});
});