Make provider diagnostics easier to share (#1611)
* Show PATH matches in provider diagnostics * Add copyable provider command probes * Fix provider diagnostic path reporting * Clean up command probe diagnostics
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
|
import * as Clipboard from "expo-clipboard";
|
||||||
|
import { AlertTriangle, Copy, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -20,6 +21,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
@@ -248,6 +250,7 @@ function DiagnosticSubSheet({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
const toast = useToast();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -288,31 +291,62 @@ function DiagnosticSubSheet({
|
|||||||
void fetchDiagnostic();
|
void fetchDiagnostic();
|
||||||
}, [fetchDiagnostic]);
|
}, [fetchDiagnostic]);
|
||||||
|
|
||||||
|
const copyButtonStyle = useCallback(
|
||||||
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||||
|
sheetStyles.iconButton,
|
||||||
|
(Boolean(hovered) || pressed) && Boolean(diagnostic) && sheetStyles.iconButtonHovered,
|
||||||
|
diagnostic ? null : sheetStyles.disabled,
|
||||||
|
],
|
||||||
|
[diagnostic],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopyPress = useCallback(() => {
|
||||||
|
if (!diagnostic) return;
|
||||||
|
void Clipboard.setStringAsync(diagnostic)
|
||||||
|
.then(() => toast.copied(t("settings.providers.diagnostic.copyLabel")))
|
||||||
|
.catch(() => toast.error(t("settings.providers.diagnostic.copyFailed")));
|
||||||
|
}, [diagnostic, t, toast]);
|
||||||
|
|
||||||
const header = useMemo<SheetHeader>(
|
const header = useMemo<SheetHeader>(
|
||||||
() => ({
|
() => ({
|
||||||
title: t("settings.providers.diagnostic.title"),
|
title: t("settings.providers.diagnostic.title"),
|
||||||
actions: (
|
actions: (
|
||||||
<Pressable
|
<View style={sheetStyles.headerActions}>
|
||||||
onPress={handleRefreshPress}
|
<Pressable
|
||||||
disabled={loading}
|
onPress={handleCopyPress}
|
||||||
hitSlop={8}
|
disabled={!diagnostic}
|
||||||
style={refreshButtonStyle}
|
hitSlop={8}
|
||||||
accessibilityRole="button"
|
style={copyButtonStyle}
|
||||||
accessibilityLabel={
|
accessibilityRole="button"
|
||||||
loading
|
accessibilityLabel={t("settings.providers.diagnostic.copyAccessibility")}
|
||||||
? t("settings.providers.diagnostic.refreshingAccessibility")
|
>
|
||||||
: t("settings.providers.diagnostic.refreshAccessibility")
|
<Copy size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
}
|
</Pressable>
|
||||||
>
|
<Pressable
|
||||||
{loading ? (
|
onPress={handleRefreshPress}
|
||||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
disabled={loading}
|
||||||
) : (
|
hitSlop={8}
|
||||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
style={refreshButtonStyle}
|
||||||
)}
|
accessibilityRole="button"
|
||||||
</Pressable>
|
accessibilityLabel={
|
||||||
|
loading
|
||||||
|
? t("settings.providers.diagnostic.refreshingAccessibility")
|
||||||
|
: t("settings.providers.diagnostic.refreshAccessibility")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
copyButtonStyle,
|
||||||
|
diagnostic,
|
||||||
|
handleCopyPress,
|
||||||
handleRefreshPress,
|
handleRefreshPress,
|
||||||
loading,
|
loading,
|
||||||
refreshButtonStyle,
|
refreshButtonStyle,
|
||||||
@@ -733,6 +767,11 @@ const sheetStyles = StyleSheet.create((theme) => ({
|
|||||||
iconButtonHovered: {
|
iconButtonHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
|
headerActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
disabled: {
|
disabled: {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1778,6 +1778,9 @@ export const ar: TranslationResources = {
|
|||||||
button: "التشخيص",
|
button: "التشخيص",
|
||||||
refresh: "ينعش",
|
refresh: "ينعش",
|
||||||
refreshing: "منعش...",
|
refreshing: "منعش...",
|
||||||
|
copyLabel: "التشخيص",
|
||||||
|
copyAccessibility: "نسخ التشخيص",
|
||||||
|
copyFailed: "فشل نسخ التشخيص",
|
||||||
refreshAccessibility: "تحديث التشخيص",
|
refreshAccessibility: "تحديث التشخيص",
|
||||||
refreshingAccessibility: "تحديث التشخيص",
|
refreshingAccessibility: "تحديث التشخيص",
|
||||||
running: "تشغيل التشخيص...",
|
running: "تشغيل التشخيص...",
|
||||||
|
|||||||
@@ -1786,6 +1786,9 @@ export const en = {
|
|||||||
button: "Diagnostic",
|
button: "Diagnostic",
|
||||||
refresh: "Refresh",
|
refresh: "Refresh",
|
||||||
refreshing: "Refreshing...",
|
refreshing: "Refreshing...",
|
||||||
|
copyLabel: "diagnostic",
|
||||||
|
copyAccessibility: "Copy diagnostic",
|
||||||
|
copyFailed: "Failed to copy diagnostic",
|
||||||
refreshAccessibility: "Refresh diagnostic",
|
refreshAccessibility: "Refresh diagnostic",
|
||||||
refreshingAccessibility: "Refreshing diagnostic",
|
refreshingAccessibility: "Refreshing diagnostic",
|
||||||
running: "Running diagnostic...",
|
running: "Running diagnostic...",
|
||||||
|
|||||||
@@ -1819,6 +1819,9 @@ export const es: TranslationResources = {
|
|||||||
button: "Diagnóstico",
|
button: "Diagnóstico",
|
||||||
refresh: "Refrescar",
|
refresh: "Refrescar",
|
||||||
refreshing: "Refrescante...",
|
refreshing: "Refrescante...",
|
||||||
|
copyLabel: "diagnóstico",
|
||||||
|
copyAccessibility: "Copiar diagnóstico",
|
||||||
|
copyFailed: "No se pudo copiar el diagnóstico",
|
||||||
refreshAccessibility: "Actualizar diagnóstico",
|
refreshAccessibility: "Actualizar diagnóstico",
|
||||||
refreshingAccessibility: "Diagnóstico refrescante",
|
refreshingAccessibility: "Diagnóstico refrescante",
|
||||||
running: "Ejecutando diagnóstico...",
|
running: "Ejecutando diagnóstico...",
|
||||||
|
|||||||
@@ -1824,6 +1824,9 @@ export const fr: TranslationResources = {
|
|||||||
button: "Diagnostique",
|
button: "Diagnostique",
|
||||||
refresh: "Rafraîchir",
|
refresh: "Rafraîchir",
|
||||||
refreshing: "Rafraîchissant...",
|
refreshing: "Rafraîchissant...",
|
||||||
|
copyLabel: "diagnostic",
|
||||||
|
copyAccessibility: "Copier le diagnostic",
|
||||||
|
copyFailed: "Échec de la copie du diagnostic",
|
||||||
refreshAccessibility: "Actualiser le diagnostic",
|
refreshAccessibility: "Actualiser le diagnostic",
|
||||||
refreshingAccessibility: "Diagnostic rafraîchissant",
|
refreshingAccessibility: "Diagnostic rafraîchissant",
|
||||||
running: "Exécution du diagnostic...",
|
running: "Exécution du diagnostic...",
|
||||||
|
|||||||
@@ -1810,6 +1810,9 @@ export const ru: TranslationResources = {
|
|||||||
button: "Диагностика",
|
button: "Диагностика",
|
||||||
refresh: "Обновить",
|
refresh: "Обновить",
|
||||||
refreshing: "Освежающий...",
|
refreshing: "Освежающий...",
|
||||||
|
copyLabel: "диагностика",
|
||||||
|
copyAccessibility: "Скопировать диагностику",
|
||||||
|
copyFailed: "Не удалось скопировать диагностику",
|
||||||
refreshAccessibility: "Обновить диагностику",
|
refreshAccessibility: "Обновить диагностику",
|
||||||
refreshingAccessibility: "Обновление диагностики",
|
refreshingAccessibility: "Обновление диагностики",
|
||||||
running: "Запускаю диагностику...",
|
running: "Запускаю диагностику...",
|
||||||
|
|||||||
@@ -1755,6 +1755,9 @@ export const zhCN: TranslationResources = {
|
|||||||
button: "诊断",
|
button: "诊断",
|
||||||
refresh: "刷新",
|
refresh: "刷新",
|
||||||
refreshing: "正在刷新...",
|
refreshing: "正在刷新...",
|
||||||
|
copyLabel: "诊断",
|
||||||
|
copyAccessibility: "复制诊断",
|
||||||
|
copyFailed: "复制诊断失败",
|
||||||
refreshAccessibility: "刷新诊断",
|
refreshAccessibility: "刷新诊断",
|
||||||
refreshingAccessibility: "正在刷新诊断",
|
refreshingAccessibility: "正在刷新诊断",
|
||||||
running: "正在运行诊断...",
|
running: "正在运行诊断...",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
|||||||
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
|
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
|
||||||
import {
|
import {
|
||||||
buildBinaryDiagnosticRows,
|
buildBinaryDiagnosticRows,
|
||||||
|
buildCommandResolutionDiagnosticRows,
|
||||||
formatDiagnosticStatus,
|
formatDiagnosticStatus,
|
||||||
formatProviderDiagnostic,
|
formatProviderDiagnostic,
|
||||||
formatProviderDiagnosticError,
|
formatProviderDiagnosticError,
|
||||||
@@ -1494,6 +1495,9 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
diagnostic: formatProviderDiagnostic("Claude Code", [
|
diagnostic: formatProviderDiagnostic("Claude Code", [
|
||||||
|
...(await buildCommandResolutionDiagnosticRows(launch, {
|
||||||
|
knownBinaryNames: ["claude"],
|
||||||
|
})),
|
||||||
...(await buildBinaryDiagnosticRows(launch, availability)),
|
...(await buildBinaryDiagnosticRows(launch, availability)),
|
||||||
...(auth ? [{ label: "Auth", value: auth }] : []),
|
...(auth ? [{ label: "Auth", value: auth }] : []),
|
||||||
{ label: "Models", value: modelsValue },
|
{ label: "Models", value: modelsValue },
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ import {
|
|||||||
formatProviderDiagnostic,
|
formatProviderDiagnostic,
|
||||||
formatProviderDiagnosticError,
|
formatProviderDiagnosticError,
|
||||||
buildBinaryDiagnosticRows,
|
buildBinaryDiagnosticRows,
|
||||||
|
buildCommandResolutionDiagnosticRows,
|
||||||
resolveBinaryVersion,
|
resolveBinaryVersion,
|
||||||
toDiagnosticErrorMessage,
|
toDiagnosticErrorMessage,
|
||||||
} from "./diagnostic-utils.js";
|
} from "./diagnostic-utils.js";
|
||||||
@@ -5606,6 +5607,9 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
const availability = await checkCodexLaunchAvailable(launch);
|
const availability = await checkCodexLaunchAvailable(launch);
|
||||||
const available = availability.available;
|
const available = availability.available;
|
||||||
const entries: Array<{ label: string; value: string }> = [
|
const entries: Array<{ label: string; value: string }> = [
|
||||||
|
...(await buildCommandResolutionDiagnosticRows(launch, {
|
||||||
|
knownBinaryNames: ["codex"],
|
||||||
|
})),
|
||||||
...(await buildBinaryDiagnosticRows(launch, availability)),
|
...(await buildBinaryDiagnosticRows(launch, availability)),
|
||||||
];
|
];
|
||||||
let status = formatDiagnosticStatus(available);
|
let status = formatDiagnosticStatus(available);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
formatProviderDiagnostic,
|
formatProviderDiagnostic,
|
||||||
formatProviderDiagnosticError,
|
formatProviderDiagnosticError,
|
||||||
buildBinaryDiagnosticRows,
|
buildBinaryDiagnosticRows,
|
||||||
|
buildCommandResolutionDiagnosticRows,
|
||||||
toDiagnosticErrorMessage,
|
toDiagnosticErrorMessage,
|
||||||
} from "./diagnostic-utils.js";
|
} from "./diagnostic-utils.js";
|
||||||
|
|
||||||
@@ -127,6 +128,9 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
diagnostic: formatProviderDiagnostic("Copilot", [
|
diagnostic: formatProviderDiagnostic("Copilot", [
|
||||||
|
...(await buildCommandResolutionDiagnosticRows(launch, {
|
||||||
|
knownBinaryNames: ["copilot"],
|
||||||
|
})),
|
||||||
...(await buildBinaryDiagnosticRows(launch, availability)),
|
...(await buildBinaryDiagnosticRows(launch, availability)),
|
||||||
{ label: "Models", value: modelsValue },
|
{ label: "Models", value: modelsValue },
|
||||||
{ label: "Status", value: status },
|
{ label: "Status", value: status },
|
||||||
|
|||||||
@@ -1,6 +1,131 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { toDiagnosticErrorMessage } from "./diagnostic-utils.js";
|
import {
|
||||||
|
buildCommandResolutionDiagnosticRows,
|
||||||
|
toDiagnosticErrorMessage,
|
||||||
|
} from "./diagnostic-utils.js";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (tempDirs.length > 0) {
|
||||||
|
const dir = tempDirs.pop();
|
||||||
|
if (dir) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeTempDir(): string {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), "paseo-diagnostic-path-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeExecutable(dir: string, name: string): string {
|
||||||
|
const filePath = path.join(dir, name);
|
||||||
|
writeFileSync(filePath, "#!/bin/sh\nexit 0\n");
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
chmodSync(filePath, 0o755);
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildCommandResolutionDiagnosticRows", () => {
|
||||||
|
test("reports daemon PATH matches for known binary names", async () => {
|
||||||
|
const binDir = makeTempDir();
|
||||||
|
const binaryPath = writeExecutable(binDir, "claude");
|
||||||
|
|
||||||
|
const rows = await buildCommandResolutionDiagnosticRows(
|
||||||
|
{ command: "claude", args: [], source: "default" },
|
||||||
|
{
|
||||||
|
knownBinaryNames: ["claude"],
|
||||||
|
pathValue: binDir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows).toContainEqual({ label: "Command source", value: "default" });
|
||||||
|
expect(rows).toContainEqual({ label: "Configured command", value: "claude" });
|
||||||
|
expect(rows).toContainEqual({
|
||||||
|
label: "Daemon PATH",
|
||||||
|
value: expect.stringContaining(binDir),
|
||||||
|
});
|
||||||
|
expect(rows).toContainEqual({
|
||||||
|
label: "PATH matches",
|
||||||
|
value: expect.stringContaining(binaryPath),
|
||||||
|
});
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
expect(rows.some((row) => row.label === "where.exe claude")).toBe(true);
|
||||||
|
expect(rows.some((row) => row.label === "powershell Get-Command -All claude")).toBe(true);
|
||||||
|
} else {
|
||||||
|
expect(rows.some((row) => row.label === "which -a claude")).toBe(true);
|
||||||
|
expect(rows.some((row) => row.label.endsWith(" -lc type -a claude"))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports none when the daemon PATH has no matching executable", async () => {
|
||||||
|
const binDir = makeTempDir();
|
||||||
|
|
||||||
|
const rows = await buildCommandResolutionDiagnosticRows(
|
||||||
|
{ command: "claude", args: [], source: "default" },
|
||||||
|
{ knownBinaryNames: ["claude"], includeCommandProbes: false, pathValue: binDir },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows).toContainEqual({ label: "PATH matches", value: "none" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("truncates very long daemon PATH values", async () => {
|
||||||
|
const rows = await buildCommandResolutionDiagnosticRows(
|
||||||
|
{ command: "claude", args: [], source: "default" },
|
||||||
|
{
|
||||||
|
knownBinaryNames: ["claude"],
|
||||||
|
includeCommandProbes: false,
|
||||||
|
pathValue: "x".repeat(5000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows).toContainEqual({
|
||||||
|
label: "Daemon PATH",
|
||||||
|
value: expect.stringContaining("(truncated)"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matches Windows PATHEXT executable names", async () => {
|
||||||
|
const binDir = makeTempDir();
|
||||||
|
const binaryPath = writeExecutable(binDir, "claude.EXE");
|
||||||
|
|
||||||
|
const rows = await buildCommandResolutionDiagnosticRows(
|
||||||
|
{ command: "claude", args: [], source: "default" },
|
||||||
|
{
|
||||||
|
knownBinaryNames: ["claude"],
|
||||||
|
includeCommandProbes: false,
|
||||||
|
pathext: ".EXE;.CMD",
|
||||||
|
pathValue: binDir,
|
||||||
|
platform: "win32",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows).toContainEqual({
|
||||||
|
label: "PATH matches",
|
||||||
|
value: expect.stringContaining(binaryPath),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not treat absolute configured commands as PATH binary names", async () => {
|
||||||
|
const rows = await buildCommandResolutionDiagnosticRows(
|
||||||
|
{ command: "/Users/mn/.local/bin/claude", args: [], source: "override" },
|
||||||
|
{
|
||||||
|
knownBinaryNames: ["/Users/mn/.local/bin/claude"],
|
||||||
|
includeCommandProbes: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rows).toContainEqual({ label: "PATH matches", value: "not checked" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("toDiagnosticErrorMessage", () => {
|
describe("toDiagnosticErrorMessage", () => {
|
||||||
test("returns message for plain Error", () => {
|
test("returns message for plain Error", () => {
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { constants } from "node:fs";
|
||||||
|
import { access, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createProviderEnvSpec,
|
createProviderEnvSpec,
|
||||||
type ProviderLaunchAvailability,
|
type ProviderLaunchAvailability,
|
||||||
@@ -154,6 +158,232 @@ export interface BinaryDiagnosticRowsOptions {
|
|||||||
versionCommand?: BinaryDiagnosticVersionCommand;
|
versionCommand?: BinaryDiagnosticVersionCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommandResolutionDiagnosticRowsOptions {
|
||||||
|
knownBinaryNames: readonly string[];
|
||||||
|
includeCommandProbes?: boolean;
|
||||||
|
pathValue?: string;
|
||||||
|
pathext?: string;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
shell?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMAND_PROBE_TIMEOUT_MS = 3_000;
|
||||||
|
const COMMAND_PROBE_MAX_BUFFER = 32 * 1024;
|
||||||
|
|
||||||
|
function resolvePlatform(options?: CommandResolutionDiagnosticRowsOptions): NodeJS.Platform {
|
||||||
|
return options?.platform ?? process.platform;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePathValue(options?: CommandResolutionDiagnosticRowsOptions): string {
|
||||||
|
return options?.pathValue ?? process.env["PATH"] ?? process.env["Path"] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveShellValue(options?: CommandResolutionDiagnosticRowsOptions): string {
|
||||||
|
if (options?.shell) {
|
||||||
|
return options.shell;
|
||||||
|
}
|
||||||
|
if (resolvePlatform(options) === "win32") {
|
||||||
|
return process.env["ComSpec"] ?? "cmd.exe";
|
||||||
|
}
|
||||||
|
return process.env["SHELL"] ?? "/bin/sh";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isExecutableFile(filePath: string, platform: NodeJS.Platform): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const candidate = await stat(filePath);
|
||||||
|
if (!candidate.isFile()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (platform === "win32") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await access(filePath, constants.X_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSearchableNames(binaryNames: readonly string[]): string[] {
|
||||||
|
return binaryNames.filter(
|
||||||
|
(binaryName) =>
|
||||||
|
binaryName.trim().length > 0 && !binaryName.includes("/") && !binaryName.includes("\\"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveWindowsPathExt(options: CommandResolutionDiagnosticRowsOptions): string[] {
|
||||||
|
const value = options.pathext ?? process.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD";
|
||||||
|
return value
|
||||||
|
.split(";")
|
||||||
|
.map((extension) => extension.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBinaryCandidateNames(
|
||||||
|
binaryName: string,
|
||||||
|
options: CommandResolutionDiagnosticRowsOptions,
|
||||||
|
): string[] {
|
||||||
|
if (resolvePlatform(options) !== "win32" || path.win32.extname(binaryName)) {
|
||||||
|
return [binaryName];
|
||||||
|
}
|
||||||
|
return [binaryName, ...resolveWindowsPathExt(options).map((extension) => binaryName + extension)];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function formatPathMatches(options: CommandResolutionDiagnosticRowsOptions): Promise<string> {
|
||||||
|
const binaryNames = options.knownBinaryNames;
|
||||||
|
const searchableNames = resolveSearchableNames(binaryNames);
|
||||||
|
|
||||||
|
if (searchableNames.length === 0) {
|
||||||
|
return "not checked";
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathDelimiter = resolvePlatform(options) === "win32" ? ";" : path.delimiter;
|
||||||
|
const pathEntries = resolvePathValue(options).split(pathDelimiter).filter(Boolean);
|
||||||
|
const matches: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const platform = resolvePlatform(options);
|
||||||
|
|
||||||
|
for (const directory of pathEntries) {
|
||||||
|
for (const binaryName of searchableNames) {
|
||||||
|
for (const candidateName of resolveBinaryCandidateNames(binaryName, options)) {
|
||||||
|
const candidate = path.join(directory, candidateName);
|
||||||
|
if (seen.has(candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(candidate);
|
||||||
|
if (await isExecutableFile(candidate, platform)) {
|
||||||
|
matches.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches.length > 0 ? matches.join("\n ") : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function shellToken(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommandProbeOutput(stdout: string, stderr: string): string {
|
||||||
|
const sections: string[] = [];
|
||||||
|
const trimmedStdout = truncateForDiagnostic(stdout);
|
||||||
|
const trimmedStderr = truncateForDiagnostic(stderr);
|
||||||
|
if (trimmedStdout.length > 0) {
|
||||||
|
sections.push(trimmedStdout);
|
||||||
|
}
|
||||||
|
if (trimmedStderr.length > 0) {
|
||||||
|
sections.push(`stderr: ${trimmedStderr}`);
|
||||||
|
}
|
||||||
|
return sections.length > 0 ? sections.join("\n") : "(no output)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommandProbeError(error: unknown): string {
|
||||||
|
return toDiagnosticErrorMessage(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommandProbe(command: string, args: string[]): Promise<string> {
|
||||||
|
try {
|
||||||
|
const { stdout, stderr } = await execCommand(command, args, {
|
||||||
|
timeout: COMMAND_PROBE_TIMEOUT_MS,
|
||||||
|
killSignal: "SIGKILL",
|
||||||
|
maxBuffer: COMMAND_PROBE_MAX_BUFFER,
|
||||||
|
});
|
||||||
|
return formatCommandProbeOutput(stdout, stderr);
|
||||||
|
} catch (error) {
|
||||||
|
return formatCommandProbeError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPosixCommandProbeRows(binaryName: string): Promise<DiagnosticEntry[]> {
|
||||||
|
const shell = resolveShellValue();
|
||||||
|
const typeCommand = `type -a ${shellToken(binaryName)}`;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: `which -a ${binaryName}`,
|
||||||
|
value: await runCommandProbe("/usr/bin/which", ["-a", binaryName]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `${path.basename(shell)} -lc type -a ${binaryName}`,
|
||||||
|
value: await runCommandProbe(shell, ["-lc", typeCommand]),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildWindowsCommandProbeRows(binaryName: string): Promise<DiagnosticEntry[]> {
|
||||||
|
const powershellCommand = [
|
||||||
|
"$ErrorActionPreference = 'Continue';",
|
||||||
|
`Get-Command -All ${JSON.stringify(binaryName)} |`,
|
||||||
|
"Select-Object CommandType,Source,Name,Definition |",
|
||||||
|
"Format-List",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: `where.exe ${binaryName}`,
|
||||||
|
value: await runCommandProbe("where.exe", [binaryName]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: `powershell Get-Command -All ${binaryName}`,
|
||||||
|
value: await runCommandProbe("powershell.exe", [
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-Command",
|
||||||
|
powershellCommand,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildCommandProbeRows(binaryNames: readonly string[]): Promise<DiagnosticEntry[]> {
|
||||||
|
const searchableNames = resolveSearchableNames(binaryNames);
|
||||||
|
if (searchableNames.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: DiagnosticEntry[] = [];
|
||||||
|
for (const binaryName of searchableNames) {
|
||||||
|
rows.push(
|
||||||
|
...(process.platform === "win32"
|
||||||
|
? await buildWindowsCommandProbeRows(binaryName)
|
||||||
|
: await buildPosixCommandProbeRows(binaryName)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildCommandResolutionDiagnosticRows(
|
||||||
|
launch: ResolvedProviderLaunch,
|
||||||
|
options: CommandResolutionDiagnosticRowsOptions,
|
||||||
|
): Promise<DiagnosticEntry[]> {
|
||||||
|
const includeCommandProbes = options.includeCommandProbes ?? true;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Command source",
|
||||||
|
value: launch.source,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Configured command",
|
||||||
|
value: [launch.command, ...launch.args].join(" "),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Daemon PATH",
|
||||||
|
value: truncateForDiagnostic(resolvePathValue(options)) || "(empty)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Daemon shell",
|
||||||
|
value: resolveShellValue(options),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "PATH matches",
|
||||||
|
value: await formatPathMatches(options),
|
||||||
|
},
|
||||||
|
...(includeCommandProbes ? await buildCommandProbeRows(options.knownBinaryNames) : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveCommandVersion(invocation: BinaryDiagnosticVersionCommand): Promise<string> {
|
async function resolveCommandVersion(invocation: BinaryDiagnosticVersionCommand): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const { stdout, stderr } = await execCommand(invocation.command, invocation.args, {
|
const { stdout, stderr } = await execCommand(invocation.command, invocation.args, {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import {
|
|||||||
formatProviderDiagnostic,
|
formatProviderDiagnostic,
|
||||||
formatProviderDiagnosticError,
|
formatProviderDiagnosticError,
|
||||||
buildBinaryDiagnosticRows,
|
buildBinaryDiagnosticRows,
|
||||||
|
buildCommandResolutionDiagnosticRows,
|
||||||
toDiagnosticErrorMessage,
|
toDiagnosticErrorMessage,
|
||||||
} from "./diagnostic-utils.js";
|
} from "./diagnostic-utils.js";
|
||||||
import { runProviderTurn } from "./provider-runner.js";
|
import { runProviderTurn } from "./provider-runner.js";
|
||||||
@@ -1609,6 +1610,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
diagnostic: formatProviderDiagnostic("OpenCode", [
|
diagnostic: formatProviderDiagnostic("OpenCode", [
|
||||||
|
...(await buildCommandResolutionDiagnosticRows(launch, {
|
||||||
|
knownBinaryNames: ["opencode"],
|
||||||
|
})),
|
||||||
...(await buildBinaryDiagnosticRows(launch, availability)),
|
...(await buildBinaryDiagnosticRows(launch, availability)),
|
||||||
{ label: "Server", value: serverStatus },
|
{ label: "Server", value: serverStatus },
|
||||||
{ label: "Auth", value: authValue },
|
{ label: "Auth", value: authValue },
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
|||||||
import { composeSystemPromptParts } from "../../system-prompt.js";
|
import { composeSystemPromptParts } from "../../system-prompt.js";
|
||||||
import {
|
import {
|
||||||
buildBinaryDiagnosticRows,
|
buildBinaryDiagnosticRows,
|
||||||
|
buildCommandResolutionDiagnosticRows,
|
||||||
formatDiagnosticStatus,
|
formatDiagnosticStatus,
|
||||||
formatProviderDiagnostic,
|
formatProviderDiagnostic,
|
||||||
formatProviderDiagnosticError,
|
formatProviderDiagnosticError,
|
||||||
@@ -2062,6 +2063,9 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
diagnostic: formatProviderDiagnostic("Pi", [
|
diagnostic: formatProviderDiagnostic("Pi", [
|
||||||
|
...(await buildCommandResolutionDiagnosticRows(launch, {
|
||||||
|
knownBinaryNames: [launch.command],
|
||||||
|
})),
|
||||||
...(await buildBinaryDiagnosticRows(launch, availability)),
|
...(await buildBinaryDiagnosticRows(launch, availability)),
|
||||||
{ label: "Configured providers", value: configuredProvidersValue },
|
{ label: "Configured providers", value: configuredProvidersValue },
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user