feat(debug): load user DAP adapter configs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user