903 lines
45 KiB
TypeScript
903 lines
45 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { constants, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, accessSync } from "node:fs";
|
|
import { dirname, isAbsolute, join } from "node:path";
|
|
import { createRequire } from "node:module";
|
|
|
|
import { app } from "electron";
|
|
import { buildClaudeMcpGetCommand, buildClaudeMcpPreview, classifyClaudeMcpStatus, createOpenPetsHookSettingsPreview, doctorClaudeHooks, installClaudeHooks, mapAsarPathToUnpacked, uninstallClaudeHooks, type ClaudeCommandSpec, type ClaudeHookDoctorResult, type ClaudeMcpPreview, type OpenPetsCommandMode, type ParsedClaudeMcpEntry } from "@open-pets/claude";
|
|
import { buildCursorRulesPreview, classifyCursorMcpStatus, executeCursorMcpWrite, getCursorGlobalMcpPath, planCursorMcpInstall, planCursorMcpRemove, planCursorMcpReplace, readCursorMcpConfig, type CursorMcpStatusResult } from "@open-pets/cursor";
|
|
import { buildOpenPetsOnlyPreview, type RedactedPreview } from "@open-pets/cursor";
|
|
import { doctorOpenCodeGlobalSetup, getGlobalOpenCodeConfigDir, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@open-pets/opencode";
|
|
|
|
import { getAppStateSnapshot, updatePreferences, type InstalledPetState, type OpenPetsStateV1 } from "./app-state.js";
|
|
import { doctorClaudeOpenPetsMemory, installClaudeOpenPetsMemory, uninstallClaudeOpenPetsMemory, type ClaudeOpenPetsMemoryStatus } from "./claude-memory.js";
|
|
|
|
export type AgentSetupAction = "configure" | "replace" | "remove" | "install-memory" | "doctor-hooks" | "install-hooks" | "uninstall-hooks" | "opencode-install" | "opencode-remove" | "cursor-install" | "cursor-replace" | "cursor-remove";
|
|
export type JournalAction = "configure" | "update" | "replace" | "remove";
|
|
|
|
export interface AgentSetupPetOption {
|
|
readonly id: string;
|
|
readonly displayName: string;
|
|
readonly default: boolean;
|
|
}
|
|
|
|
export interface ClaudeCodeStatus {
|
|
readonly state: "detected" | "not_detected" | "configured" | "needs_setup" | "error";
|
|
readonly label: string;
|
|
readonly details: string;
|
|
readonly claudeCommand?: string;
|
|
readonly version?: string;
|
|
readonly mcpListWorks: boolean;
|
|
readonly openPetsEntry: ParsedClaudeMcpEntry;
|
|
readonly canConfigure: boolean;
|
|
readonly canReplace: boolean;
|
|
readonly canRemove: boolean;
|
|
}
|
|
|
|
export interface AgentSetupSnapshot {
|
|
readonly selectedPetId?: string;
|
|
readonly commandMode: OpenPetsCommandMode;
|
|
readonly localDevAvailable: boolean;
|
|
readonly petOptions: readonly AgentSetupPetOption[];
|
|
readonly preview: ClaudeMcpPreview;
|
|
readonly status: ClaudeCodeStatus;
|
|
readonly hookStatus: ClaudeHookDoctorResult;
|
|
readonly memoryStatus: ClaudeOpenPetsMemoryStatus;
|
|
readonly opencodeStatus: OpenCodeSetupStatus;
|
|
readonly opencodePreview: OpenCodeSetupPreview;
|
|
readonly cursorStatus: CursorSetupStatus;
|
|
readonly cursorPreview: CursorSetupPreview;
|
|
readonly commandPaths: AgentSetupCommandPaths;
|
|
readonly busy: boolean;
|
|
readonly lastAction?: AgentSetupActionResult;
|
|
}
|
|
|
|
export interface AgentSetupCommandPaths {
|
|
readonly claude: string;
|
|
readonly node: string;
|
|
readonly opencode: string;
|
|
}
|
|
|
|
export interface OpenCodeSetupStatus {
|
|
readonly state: "configured" | "needs_setup" | "not_detected" | "error";
|
|
readonly label: string;
|
|
readonly details: string;
|
|
readonly configDir: string;
|
|
readonly canInstall: boolean;
|
|
readonly canRemove: boolean;
|
|
}
|
|
|
|
export interface OpenCodeSetupPreview {
|
|
readonly global: true;
|
|
readonly configDir: string;
|
|
readonly configPath: string;
|
|
readonly cleanupConfigPaths: readonly string[];
|
|
readonly mcpCommand: readonly string[];
|
|
readonly plugin: readonly unknown[] | string;
|
|
readonly instructionPath: string;
|
|
readonly configPreview: Record<string, unknown>;
|
|
}
|
|
|
|
export interface CursorSetupStatus {
|
|
readonly state: "configured" | "needs_setup" | "not_detected" | "error" | "conflict" | "needs_update";
|
|
readonly label: string;
|
|
readonly details: string;
|
|
readonly configPath: string;
|
|
readonly canInstall: boolean;
|
|
readonly canReplace: boolean;
|
|
readonly canRemove: boolean;
|
|
}
|
|
|
|
export interface CursorSetupPreview {
|
|
readonly global: true;
|
|
readonly configPath: string;
|
|
readonly mcpEntry: RedactedPreview;
|
|
readonly rulesPath: string;
|
|
readonly rulesContent: string;
|
|
readonly commandMode: "published" | "local" | "bundled";
|
|
}
|
|
|
|
export interface AgentSetupActionResult {
|
|
readonly ok: boolean;
|
|
readonly action: AgentSetupAction;
|
|
readonly message: string;
|
|
readonly changed: boolean;
|
|
}
|
|
|
|
export interface AgentSetupJournalEntry {
|
|
readonly timestamp: string;
|
|
readonly action: JournalAction;
|
|
readonly selectedPetId?: string;
|
|
readonly command: readonly string[];
|
|
readonly previousStatus: string;
|
|
readonly success: boolean;
|
|
readonly message: string;
|
|
}
|
|
|
|
interface CommandResult {
|
|
readonly ok: boolean;
|
|
readonly timedOut: boolean;
|
|
readonly exitCode: number | null;
|
|
readonly stdout: string;
|
|
readonly stderr: string;
|
|
readonly error?: string;
|
|
}
|
|
|
|
const commandTimeoutMs = 6_000;
|
|
const maxOutputBytes = 16_384;
|
|
const require = createRequire(import.meta.url);
|
|
let operationRunning = false;
|
|
let lastAction: AgentSetupActionResult | undefined;
|
|
|
|
export async function getAgentSetupSnapshot(selectedPetId?: unknown, commandModeInput?: unknown): Promise<AgentSetupSnapshot> {
|
|
const petId = validateSelectedPetId(selectedPetId);
|
|
const commandMode = validateCommandMode(commandModeInput);
|
|
const preview = safeBuildClaudeMcpPreview(petId, commandMode);
|
|
const status = preview.error ? createBundledResourceErrorStatus(preview.error) : await detectClaudeCodeStatus(petId, commandMode);
|
|
const rawHookStatus = preview.error ? createHookErrorStatus(preview.error) : safeDoctorClaudeHooks(commandMode, petId);
|
|
const hookStatus = { ...rawHookStatus, settingsPath: formatUserPath(rawHookStatus.settingsPath) ?? rawHookStatus.settingsPath, backupPath: formatUserPath(rawHookStatus.backupPath) };
|
|
const rawMemoryStatus = doctorClaudeOpenPetsMemory(app.getPath("home"));
|
|
const memoryStatus = { ...rawMemoryStatus, claudeMdPath: formatUserPath(rawMemoryStatus.claudeMdPath) ?? rawMemoryStatus.claudeMdPath, openPetsMemoryPath: formatUserPath(rawMemoryStatus.openPetsMemoryPath) ?? rawMemoryStatus.openPetsMemoryPath };
|
|
const opencode = await getOpenCodeSetup(commandMode, petId);
|
|
const cursor = await getCursorSetup(commandMode, petId);
|
|
|
|
return {
|
|
selectedPetId: petId,
|
|
commandMode,
|
|
localDevAvailable: !app.isPackaged,
|
|
petOptions: getPetOptions(),
|
|
preview: preview.preview,
|
|
status,
|
|
hookStatus,
|
|
memoryStatus,
|
|
opencodeStatus: opencode.status,
|
|
opencodePreview: opencode.preview,
|
|
cursorStatus: cursor.status,
|
|
cursorPreview: cursor.preview,
|
|
commandPaths: getAgentSetupCommandPaths(),
|
|
busy: operationRunning,
|
|
lastAction,
|
|
};
|
|
}
|
|
|
|
export function updateAgentSetupCommandPaths(patch: unknown): AgentSetupCommandPaths {
|
|
if (!isRecord(patch)) throw new Error("Invalid command path settings.");
|
|
for (const key of Object.keys(patch)) {
|
|
if (key !== "claude" && key !== "node" && key !== "opencode") throw new Error("Invalid command path setting.");
|
|
}
|
|
const updates: Writable<Partial<OpenPetsStateV1["preferences"]>> = {};
|
|
if ("claude" in patch) updates.claudeCommandPath = normalizeOptionalCommandPath(patch.claude, "Claude");
|
|
if ("node" in patch) updates.nodeCommandPath = normalizeOptionalCommandPath(patch.node, "Node.js");
|
|
if ("opencode" in patch) updates.opencodeCommandPath = normalizeOptionalCommandPath(patch.opencode, "OpenCode");
|
|
updatePreferences(updates);
|
|
return getAgentSetupCommandPaths();
|
|
}
|
|
|
|
type Writable<T> = { -readonly [K in keyof T]: T[K] };
|
|
|
|
export async function runAgentSetupAction(action: AgentSetupAction, selectedPetId?: unknown, commandModeInput?: unknown): Promise<AgentSetupSnapshot> {
|
|
if (operationRunning) throw new Error("Another Claude setup operation is already running.");
|
|
const petId = validateSelectedPetId(selectedPetId);
|
|
const commandMode = validateCommandMode(commandModeInput);
|
|
operationRunning = true;
|
|
|
|
try {
|
|
lastAction = await runAction(action, petId, commandMode);
|
|
operationRunning = false;
|
|
return getAgentSetupSnapshot(petId, commandMode);
|
|
} finally {
|
|
operationRunning = false;
|
|
}
|
|
}
|
|
|
|
export function sanitizeAgentSetupOutput(value: string): string {
|
|
const home = app.isReady() ? app.getPath("home") : "";
|
|
return value
|
|
.replaceAll(home, "~")
|
|
.replace(/(?:[A-Za-z]:)?[\\/][^\s"']{2,}/g, "<path>")
|
|
.replace(/-----BEGIN [^-]+PRIVATE KEY-----[\s\S]*?-----END [^-]+PRIVATE KEY-----/gi, "<redacted-private-key>")
|
|
.replace(/bearer\s+[A-Za-z0-9._~+\/-]+=*/gi, "Bearer <redacted>")
|
|
.replace(/\b(api[_-]?key|secret|password|token)\s*[:=]\s*\S+/gi, "$1=<redacted>")
|
|
.slice(0, 500);
|
|
}
|
|
|
|
function safeBuildClaudeMcpPreview(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): { readonly preview: ClaudeMcpPreview; readonly error?: string } {
|
|
try {
|
|
return { preview: withPreferredClaudeCommand(buildClaudeMcpPreview(selectedPetId, commandMode, getPreferredNodeCommand())) };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Packaged OpenPets command resources are unavailable.";
|
|
return { preview: createErrorPreview(commandMode, message), error: message };
|
|
}
|
|
}
|
|
|
|
function safeDoctorClaudeHooks(commandMode: OpenPetsCommandMode, selectedPetId: string | undefined): ClaudeHookDoctorResult {
|
|
try {
|
|
return doctorClaudeHooks(undefined, commandMode, selectedPetId, getPreferredNodeCommand());
|
|
} catch (error) {
|
|
return createHookErrorStatus(error instanceof Error ? error.message : "Packaged OpenPets hook resources are unavailable.");
|
|
}
|
|
}
|
|
|
|
function createErrorPreview(commandMode: OpenPetsCommandMode, message: string): ClaudeMcpPreview {
|
|
const claude = getPreferredClaudeCommand();
|
|
return {
|
|
commandMode,
|
|
add: { command: claude, args: [] },
|
|
remove: { command: claude, args: ["mcp", "remove", "--scope", "user", "openpets"] },
|
|
mcpJson: { mcpServers: { openpets: { type: "stdio", command: "node", args: [] } } },
|
|
displayCommand: message,
|
|
};
|
|
}
|
|
|
|
function withPreferredClaudeCommand(preview: ClaudeMcpPreview): ClaudeMcpPreview {
|
|
const claude = getPreferredClaudeCommand();
|
|
if (claude === preview.add.command && claude === preview.remove.command) return preview;
|
|
return {
|
|
...preview,
|
|
add: { ...preview.add, command: claude },
|
|
remove: { ...preview.remove, command: claude },
|
|
displayCommand: preview.displayCommand.replace(/^claude(?=\s|$)/, quoteCommandForDisplay(claude)),
|
|
};
|
|
}
|
|
|
|
function createBundledResourceErrorStatus(message: string): ClaudeCodeStatus {
|
|
return createStatus("error", "Packaged commands unavailable", message, undefined, { ok: false, timedOut: false, exitCode: null, stdout: "", stderr: "", error: message }, { present: false, source: "none", verified: false, matchesExpected: false });
|
|
}
|
|
|
|
function createHookErrorStatus(message: string): ClaudeHookDoctorResult {
|
|
return { status: "error", settingsPath: "~/.claude/settings.json", exists: false, valid: false, message, preview: {}, asyncSupported: false };
|
|
}
|
|
|
|
async function runAction(action: AgentSetupAction, selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<AgentSetupActionResult> {
|
|
if (action === "opencode-install") return installOpenCodeGlobal(selectedPetId, commandMode);
|
|
if (action === "opencode-remove") return removeOpenCodeGlobal();
|
|
if (action === "cursor-install") return installCursorGlobal(selectedPetId, commandMode);
|
|
if (action === "cursor-replace") return replaceCursorGlobal(selectedPetId, commandMode);
|
|
if (action === "cursor-remove") return removeCursorGlobal();
|
|
if (action === "doctor-hooks") {
|
|
const doctor = safeDoctorClaudeHooks(commandMode, selectedPetId);
|
|
writeActionJournal({ action: "update", selectedPetId, command: createHookJournalCommand("doctor-hooks", selectedPetId), previousStatus: doctor.status, success: doctor.status !== "error", message: doctor.message });
|
|
return { ok: doctor.status !== "error", action, message: doctor.message, changed: false };
|
|
}
|
|
if (action === "uninstall-hooks") {
|
|
let result;
|
|
try {
|
|
result = uninstallClaudeHooks(undefined, commandMode);
|
|
} catch (error) {
|
|
return { ok: false, action, message: error instanceof Error ? error.message : "OpenPets hook uninstall failed.", changed: false };
|
|
}
|
|
const message = result.changed ? `Uninstalled OpenPets Claude hooks. Backup: ${formatUserPath(result.backupPath) ?? "not needed"}` : result.message;
|
|
writeActionJournal({ action: "remove", selectedPetId, command: ["open-pets-claude", "uninstall-hooks"], previousStatus: result.status, success: result.status !== "error", message });
|
|
return { ok: result.status !== "error", action, message, changed: result.changed };
|
|
}
|
|
if (action === "install-memory") {
|
|
const result = safeInstallClaudeMemory();
|
|
return { ok: result.ok, action, message: result.ok ? result.message : `Claude instructions were not updated: ${result.message}`, changed: result.ok && result.message.startsWith("Added") };
|
|
}
|
|
if (action === "remove") {
|
|
return runRemove(createErrorPreview(commandMode, ""), selectedPetId, "Unknown", action);
|
|
}
|
|
if (commandMode === "bundled") {
|
|
const node = await runCommand({ command: getPreferredNodeCommand(), args: ["--version"] });
|
|
if (!node.ok) return { ok: false, action, message: `Node.js is required for packaged OpenPets commands. Open Claude configuration, set the Node.js command path, then try again. ${summarizeCommandResult(node)}`, changed: false };
|
|
}
|
|
const previewResult = safeBuildClaudeMcpPreview(selectedPetId, commandMode);
|
|
if (previewResult.error) return { ok: false, action, message: previewResult.error, changed: false };
|
|
|
|
if (action === "install-hooks") {
|
|
let result;
|
|
try {
|
|
result = installClaudeHooks(undefined, commandMode, selectedPetId, getPreferredNodeCommand());
|
|
} catch (error) {
|
|
return { ok: false, action, message: error instanceof Error ? error.message : "OpenPets hook install failed.", changed: false };
|
|
}
|
|
const message = result.changed ? `Installed OpenPets Claude hooks. Backup: ${formatUserPath(result.backupPath) ?? "not needed"}` : result.message;
|
|
writeActionJournal({ action: "update", selectedPetId, command: createHookJournalCommand("install-hooks", selectedPetId), previousStatus: result.status, success: result.status !== "error", message });
|
|
return { ok: result.status !== "error", action, message, changed: result.changed };
|
|
}
|
|
const detection = await detectClaudeCodeStatus(selectedPetId, commandMode);
|
|
const previousStatus = detection.label;
|
|
const preview = previewResult.preview;
|
|
|
|
if (detection.state === "not_detected") {
|
|
const result = { ok: false, action, message: "Claude Code was not found. Install Claude Code or use Copy command to configure manually.", changed: false };
|
|
writeActionJournal({ action: journalActionFor(action), selectedPetId, command: [preview.add.command, ...preview.add.args], previousStatus, success: false, message: result.message });
|
|
return result;
|
|
}
|
|
|
|
if (action === "configure") {
|
|
if (detection.openPetsEntry.present && detection.openPetsEntry.verified && detection.openPetsEntry.matchesExpected) {
|
|
const memoryResult = safeInstallClaudeMemory();
|
|
const message = `OpenPets MCP is already configured for Claude Code.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`;
|
|
return { ok: true, action, message, changed: memoryResult.ok && memoryResult.message.startsWith("Added") };
|
|
}
|
|
if (detection.openPetsEntry.present) {
|
|
return { ok: false, action, message: "Claude already has an openpets MCP entry. OpenPets will keep it as installed; use Replace only if you want to recreate it with the recommended command.", changed: false };
|
|
}
|
|
return runAdd(preview, selectedPetId, previousStatus, action);
|
|
}
|
|
|
|
if (!detection.openPetsEntry.present) {
|
|
return runAdd(preview, selectedPetId, previousStatus, action);
|
|
}
|
|
|
|
const removed = await runRemove(preview, selectedPetId, previousStatus, action);
|
|
if (!removed.ok) return removed;
|
|
const added = await runAdd(preview, selectedPetId, previousStatus, action);
|
|
if (!added.ok) {
|
|
return {
|
|
ok: false,
|
|
action,
|
|
message: `${added.message} The previous openpets entry was removed; use this command to restore the intended entry: ${preview.displayCommand}`,
|
|
changed: true,
|
|
};
|
|
}
|
|
return { ok: true, action, message: `Replaced Claude Code OpenPets MCP entry.${summarizeMemoryMessages(removed.message, added.message)}`, changed: true };
|
|
}
|
|
|
|
async function getOpenCodeSetup(commandMode: OpenPetsCommandMode, selectedPetId: string | undefined): Promise<{ readonly status: OpenCodeSetupStatus; readonly preview: OpenCodeSetupPreview }> {
|
|
const configDir = getGlobalOpenCodeConfigDir(process.env, app.getPath("home"), process.platform);
|
|
const petId = selectedPetId || undefined;
|
|
const cliVersion = getCliPackageVersion();
|
|
const pluginVersion = getOpenCodePackageVersion();
|
|
const cliEntryPath = commandMode === "published" ? undefined : getDesktopCliEntryPath(commandMode);
|
|
const prepared = safePrepareOpenCode(configDir, petId, cliVersion, pluginVersion, commandMode, cliEntryPath);
|
|
const detected = await runCommand({ command: getPreferredOpenCodeCommand(), args: ["--version"] });
|
|
const globalState = doctorOpenCodeGlobalSetup(configDir);
|
|
const configured = globalState.status === "installed";
|
|
return {
|
|
status: {
|
|
state: globalState.status === "error" || globalState.status === "custom" || globalState.status === "conflict" ? "error" : configured ? "configured" : detected.ok ? "needs_setup" : "not_detected",
|
|
label: configured ? "Installed" : globalState.status === "custom" || globalState.status === "conflict" ? "Needs attention" : detected.ok ? "Ready" : "Not detected",
|
|
details: globalState.status === "custom" || globalState.status === "conflict" || globalState.status === "error" ? globalState.message : configured ? globalState.message : detected.ok ? "OpenCode was detected. Desktop setup writes global OpenCode config." : getPreferredOpenCodeCommand() === (process.platform === "win32" ? "opencode.cmd" : "opencode") ? "OpenCode was not found on PATH. You can still preview setup, but OpenCode must be installed to use it." : "OpenCode did not run from the saved command path. You can still preview setup, but OpenCode must be installed to use it.",
|
|
configDir: formatUserPath(configDir) ?? configDir,
|
|
canInstall: prepared.ok && !configured,
|
|
canRemove: configured,
|
|
},
|
|
preview: {
|
|
global: true,
|
|
configDir: formatUserPath(configDir) ?? configDir,
|
|
configPath: prepared.ok ? (formatUserPath(prepared.configPath) ?? prepared.configPath) : "",
|
|
cleanupConfigPaths: prepared.ok ? prepared.cleanupConfigPaths.map((path) => formatUserPath(path) ?? path) : [],
|
|
mcpCommand: prepared.ok ? prepared.command : [],
|
|
plugin: prepared.ok ? prepared.plugin : (petId ? [`@open-pets/opencode@${pluginVersion}`, { pet: petId }] : `@open-pets/opencode@${pluginVersion}`),
|
|
instructionPath: prepared.ok ? (formatUserPath(prepared.instructionPath) ?? prepared.instructionPath) : "",
|
|
configPreview: prepared.ok ? prepared.configPreview : {},
|
|
},
|
|
};
|
|
}
|
|
|
|
async function getCursorSetup(commandMode: OpenPetsCommandMode, selectedPetId: string | undefined): Promise<{ readonly status: CursorSetupStatus; readonly preview: CursorSetupPreview }> {
|
|
const homeDir = app.getPath("home");
|
|
const configPath = getCursorGlobalMcpPath(homeDir);
|
|
const petId = selectedPetId || undefined;
|
|
const mcpVersion = getMcpPackageVersion();
|
|
|
|
const configResult = readCursorMcpConfig(configPath);
|
|
const statusResult = classifyCursorMcpStatus(configResult, configPath, { mcpVersion, petId, commandMode: "published" });
|
|
|
|
const state = mapCursorStatusToState(statusResult.status);
|
|
const label = mapCursorStatusToLabel(statusResult.status);
|
|
const details = statusResult.message;
|
|
|
|
return {
|
|
status: {
|
|
state,
|
|
label,
|
|
details,
|
|
configPath: formatUserPath(configPath) ?? configPath,
|
|
canInstall: statusResult.canInstall,
|
|
canReplace: statusResult.canReplace,
|
|
canRemove: statusResult.canRemove,
|
|
},
|
|
preview: {
|
|
global: true,
|
|
configPath: formatUserPath(configPath) ?? configPath,
|
|
mcpEntry: buildOpenPetsOnlyPreview({ mcpVersion, petId, commandMode: "published" }),
|
|
rulesPath: ".cursor/rules/openpets.mdc",
|
|
rulesContent: buildCursorRulesPreview(),
|
|
commandMode: "published",
|
|
},
|
|
};
|
|
}
|
|
|
|
function mapCursorStatusToState(status: CursorMcpStatusResult["status"]): CursorSetupStatus["state"] {
|
|
switch (status) {
|
|
case "installed":
|
|
return "configured";
|
|
case "missing":
|
|
return "needs_setup";
|
|
case "needs-update":
|
|
return "needs_update";
|
|
case "conflict":
|
|
return "conflict";
|
|
case "invalid":
|
|
case "error":
|
|
return "error";
|
|
default:
|
|
return "error";
|
|
}
|
|
}
|
|
|
|
function mapCursorStatusToLabel(status: CursorMcpStatusResult["status"]): string {
|
|
switch (status) {
|
|
case "installed":
|
|
return "Configured";
|
|
case "missing":
|
|
return "Not configured";
|
|
case "needs-update":
|
|
return "Needs update";
|
|
case "conflict":
|
|
return "Conflict";
|
|
case "invalid":
|
|
case "error":
|
|
return "Config error";
|
|
default:
|
|
return "Checking";
|
|
}
|
|
}
|
|
|
|
function getAgentSetupCommandPaths(): AgentSetupCommandPaths {
|
|
const preferences = getAppStateSnapshot().preferences;
|
|
return {
|
|
claude: preferences.claudeCommandPath ?? "",
|
|
node: preferences.nodeCommandPath ?? "",
|
|
opencode: preferences.opencodeCommandPath ?? "",
|
|
};
|
|
}
|
|
|
|
function getPreferredClaudeCommand(): string {
|
|
return getAppStateSnapshot().preferences.claudeCommandPath || "claude";
|
|
}
|
|
|
|
function getPreferredNodeCommand(): string {
|
|
return getAppStateSnapshot().preferences.nodeCommandPath || "node";
|
|
}
|
|
|
|
function getPreferredOpenCodeCommand(): string {
|
|
return getAppStateSnapshot().preferences.opencodeCommandPath || (process.platform === "win32" ? "opencode.cmd" : "opencode");
|
|
}
|
|
|
|
function normalizeOptionalCommandPath(value: unknown, label: string): string | undefined {
|
|
if (value === undefined || value === null) return undefined;
|
|
if (typeof value !== "string") throw new Error(`${label} command path must be text.`);
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return undefined;
|
|
if (trimmed.length > 4096 || /[\r\n\0]/.test(trimmed)) throw new Error(`${label} command path is invalid.`);
|
|
if (!isAbsolute(trimmed)) throw new Error(`${label} command path must be a full absolute path.`);
|
|
if (process.platform === "win32" && /[&|<>^%!]/.test(trimmed)) throw new Error(`${label} command path contains unsupported shell characters.`);
|
|
try {
|
|
const stat = statSync(trimmed);
|
|
if (!stat.isFile()) throw new Error();
|
|
if (process.platform !== "win32") accessSync(trimmed, constants.X_OK);
|
|
} catch {
|
|
throw new Error(`${label} command path must point to an existing executable file.`);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function quoteCommandForDisplay(command: string): string {
|
|
return /\s/.test(command) ? JSON.stringify(command) : command;
|
|
}
|
|
|
|
function safePrepareOpenCode(configDir: string, selectedPetId: string | undefined, cliVersion: string, pluginVersion: string, commandMode: OpenPetsCommandMode, cliEntryPath: string | undefined): { readonly ok: true; readonly command: readonly string[]; readonly configPath: string; readonly cleanupConfigPaths: readonly string[]; readonly instructionPath: string; readonly plugin: readonly unknown[] | string; readonly configPreview: Record<string, unknown> } | { readonly ok: false; readonly message: string } {
|
|
try {
|
|
const prepared = prepareOpenCodeGlobalSetup({ configDir, petId: selectedPetId || undefined, cliVersion, pluginVersion, commandMode, cliEntryPath });
|
|
const parsed = parseOpenCodeConfig(prepared.configWrite.content);
|
|
if (!parsed.ok) return { ok: false, message: parsed.message };
|
|
const config = parsed.value as { mcp?: { openpets?: { command?: readonly string[] } }; plugin?: readonly unknown[] };
|
|
const plugin = Array.isArray(config.plugin) ? config.plugin[config.plugin.length - 1] : undefined;
|
|
return { ok: true, command: config.mcp?.openpets?.command ?? [], configPath: prepared.configPath, cleanupConfigPaths: prepared.cleanupConfigWrites.map((write) => write.targetPath), instructionPath: prepared.instructionPath, plugin: plugin === undefined ? [] : (plugin as readonly unknown[] | string), configPreview: parsed.value };
|
|
} catch (error) {
|
|
return { ok: false, message: error instanceof Error ? error.message : "OpenCode setup preview failed." };
|
|
}
|
|
}
|
|
|
|
async function installOpenCodeGlobal(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<AgentSetupActionResult> {
|
|
if (commandMode === "bundled") {
|
|
const node = await runCommand({ command: getPreferredNodeCommand(), args: ["--version"] });
|
|
if (!node.ok) return { ok: false, action: "opencode-install", message: `Node.js is required for packaged OpenPets commands. Open OpenCode configuration, set the Node.js command path, then try again. ${summarizeCommandResult(node)}`, changed: false };
|
|
}
|
|
try {
|
|
const configDir = getGlobalOpenCodeConfigDir(process.env, app.getPath("home"), process.platform);
|
|
const prepared = prepareOpenCodeGlobalSetup({ configDir, petId: selectedPetId || undefined, cliVersion: getCliPackageVersion(), pluginVersion: getOpenCodePackageVersion(), commandMode, cliEntryPath: commandMode === "published" ? undefined : getDesktopCliEntryPath(commandMode) });
|
|
writePreparedOpenCodeGlobalSetup(prepared);
|
|
return { ok: true, action: "opencode-install", message: `Installed global OpenCode OpenPets setup. Config: ${formatUserPath(prepared.configPath) ?? prepared.configPath}. Instructions: ${formatUserPath(prepared.instructionPath) ?? prepared.instructionPath}.`, changed: true };
|
|
} catch (error) {
|
|
return { ok: false, action: "opencode-install", message: error instanceof Error ? error.message : "OpenCode setup failed.", changed: false };
|
|
}
|
|
}
|
|
|
|
async function removeOpenCodeGlobal(): Promise<AgentSetupActionResult> {
|
|
try {
|
|
const configDir = getGlobalOpenCodeConfigDir(process.env, app.getPath("home"), process.platform);
|
|
const prepared = prepareOpenCodeGlobalRemove(configDir);
|
|
writePreparedOpenCodeGlobalRemove(prepared);
|
|
return { ok: true, action: "opencode-remove", message: prepared.configWrites.length > 0 ? "Removed global OpenCode OpenPets setup." : "Global OpenCode OpenPets setup was already absent.", changed: prepared.configWrites.length > 0 };
|
|
} catch (error) {
|
|
return { ok: false, action: "opencode-remove", message: error instanceof Error ? error.message : "OpenCode removal failed.", changed: false };
|
|
}
|
|
}
|
|
|
|
async function installCursorGlobal(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<AgentSetupActionResult> {
|
|
void commandMode;
|
|
try {
|
|
const homeDir = app.getPath("home");
|
|
const configPath = getCursorGlobalMcpPath(homeDir);
|
|
const mcpVersion = getMcpPackageVersion();
|
|
const plan = planCursorMcpInstall(configPath, { mcpVersion, petId: selectedPetId || undefined, commandMode: "published" });
|
|
if ("ok" in plan && !plan.ok) {
|
|
return { ok: false, action: "cursor-install", message: plan.message, changed: false };
|
|
}
|
|
if ("targetPath" in plan) {
|
|
executeCursorMcpWrite(plan);
|
|
const backupMsg = plan.backupPath ? ` Backup: ${formatUserPath(plan.backupPath) ?? plan.backupPath}.` : "";
|
|
return { ok: true, action: "cursor-install", message: `Installed Cursor OpenPets MCP config at ${formatUserPath(configPath) ?? configPath}.${backupMsg} Cursor may need to be restarted or reloaded.`, changed: true };
|
|
}
|
|
return { ok: false, action: "cursor-install", message: "Failed to plan Cursor MCP install.", changed: false };
|
|
} catch (error) {
|
|
return { ok: false, action: "cursor-install", message: error instanceof Error ? error.message : "Cursor MCP install failed.", changed: false };
|
|
}
|
|
}
|
|
|
|
async function replaceCursorGlobal(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<AgentSetupActionResult> {
|
|
void commandMode;
|
|
try {
|
|
const homeDir = app.getPath("home");
|
|
const configPath = getCursorGlobalMcpPath(homeDir);
|
|
const mcpVersion = getMcpPackageVersion();
|
|
const plan = planCursorMcpReplace(configPath, { mcpVersion, petId: selectedPetId || undefined, commandMode: "published" });
|
|
if ("ok" in plan && !plan.ok) {
|
|
return { ok: false, action: "cursor-replace", message: plan.message, changed: false };
|
|
}
|
|
if ("targetPath" in plan) {
|
|
executeCursorMcpWrite(plan);
|
|
const backupMsg = plan.backupPath ? ` Backup: ${formatUserPath(plan.backupPath) ?? plan.backupPath}.` : "";
|
|
return { ok: true, action: "cursor-replace", message: `Replaced Cursor OpenPets MCP config at ${formatUserPath(configPath) ?? configPath}.${backupMsg} Cursor may need to be restarted or reloaded.`, changed: true };
|
|
}
|
|
return { ok: false, action: "cursor-replace", message: "Failed to plan Cursor MCP replace.", changed: false };
|
|
} catch (error) {
|
|
return { ok: false, action: "cursor-replace", message: error instanceof Error ? error.message : "Cursor MCP replace failed.", changed: false };
|
|
}
|
|
}
|
|
|
|
async function removeCursorGlobal(): Promise<AgentSetupActionResult> {
|
|
try {
|
|
const homeDir = app.getPath("home");
|
|
const configPath = getCursorGlobalMcpPath(homeDir);
|
|
const plan = planCursorMcpRemove(configPath);
|
|
if ("ok" in plan && !plan.ok) {
|
|
return { ok: false, action: "cursor-remove", message: plan.message, changed: false };
|
|
}
|
|
if ("targetPath" in plan) {
|
|
executeCursorMcpWrite(plan);
|
|
return { ok: true, action: "cursor-remove", message: `Removed Cursor OpenPets MCP config at ${formatUserPath(configPath) ?? configPath}. Cursor may need to be restarted or reloaded.`, changed: true };
|
|
}
|
|
return { ok: false, action: "cursor-remove", message: "Failed to plan Cursor MCP remove.", changed: false };
|
|
} catch (error) {
|
|
return { ok: false, action: "cursor-remove", message: error instanceof Error ? error.message : "Cursor MCP remove failed.", changed: false };
|
|
}
|
|
}
|
|
|
|
function getDesktopCliEntryPath(commandMode: OpenPetsCommandMode): string {
|
|
const path = require.resolve("@open-pets/cli");
|
|
return commandMode === "bundled" ? mapAsarPathToUnpacked(path) : path;
|
|
}
|
|
|
|
function getCliPackageVersion(): string {
|
|
return getWorkspacePackageVersion("@open-pets/cli");
|
|
}
|
|
|
|
function getOpenCodePackageVersion(): string {
|
|
return getWorkspacePackageVersion("@open-pets/opencode");
|
|
}
|
|
|
|
function getWorkspacePackageVersion(packageName: string): string {
|
|
try {
|
|
const entryPath = require.resolve(packageName);
|
|
const packageJsonPath = join(dirname(dirname(entryPath)), "package.json");
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { readonly version?: unknown };
|
|
return typeof packageJson.version === "string" && packageJson.version ? packageJson.version : "0.0.0";
|
|
} catch {
|
|
return "0.0.0";
|
|
}
|
|
}
|
|
|
|
function getMcpPackageVersion(): string {
|
|
return getWorkspacePackageVersion("@open-pets/mcp");
|
|
}
|
|
|
|
function summarizeMemoryMessages(...messages: readonly string[]): string {
|
|
const memoryMessages = messages.flatMap((message) => message.match(/Claude (?:OpenPets )?instructions[^.]*\./g) ?? []);
|
|
return memoryMessages.length > 0 ? ` ${memoryMessages.join(" ")}` : "";
|
|
}
|
|
|
|
function createHookJournalCommand(command: "doctor-hooks" | "install-hooks", selectedPetId: string | undefined): readonly string[] {
|
|
return selectedPetId ? ["open-pets-claude", command, "--pet", selectedPetId] : ["open-pets-claude", command];
|
|
}
|
|
|
|
async function runAdd(preview: ClaudeMcpPreview, selectedPetId: string | undefined, previousStatus: string, action: AgentSetupAction): Promise<AgentSetupActionResult> {
|
|
const result = await runClaudeCommand(preview.add);
|
|
const memoryResult = result.ok ? safeInstallClaudeMemory() : { ok: false as const, message: "" };
|
|
const message = result.ok
|
|
? `Configured Claude Code OpenPets MCP entry.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`
|
|
: `Claude MCP add failed: ${summarizeCommandResult(result)}`;
|
|
writeActionJournal({ action: journalActionFor(action), selectedPetId, command: [preview.add.command, ...preview.add.args], previousStatus, success: result.ok, message });
|
|
return { ok: result.ok, action, message, changed: result.ok };
|
|
}
|
|
|
|
async function runRemove(preview: ClaudeMcpPreview, selectedPetId: string | undefined, previousStatus: string, action: AgentSetupAction): Promise<AgentSetupActionResult> {
|
|
const result = await runClaudeCommand(preview.remove);
|
|
const memoryResult = result.ok ? safeUninstallClaudeMemory() : { ok: false as const, message: "" };
|
|
const message = result.ok
|
|
? `Removed Claude Code OpenPets MCP entry.${memoryResult.ok ? ` ${memoryResult.message}` : ` Claude instructions were not updated: ${memoryResult.message}`}`
|
|
: `Claude MCP remove failed: ${summarizeCommandResult(result)}`;
|
|
writeActionJournal({ action: journalActionFor(action), selectedPetId, command: [preview.remove.command, ...preview.remove.args], previousStatus, success: result.ok, message });
|
|
return { ok: result.ok, action, message, changed: result.ok };
|
|
}
|
|
|
|
function safeInstallClaudeMemory(): { readonly ok: true; readonly message: string } | { readonly ok: false; readonly message: string } {
|
|
try {
|
|
const result = installClaudeOpenPetsMemory(app.getPath("home"));
|
|
return { ok: true, message: result.changed ? "Added Claude OpenPets instructions." : "Claude OpenPets instructions already present." };
|
|
} catch (error) {
|
|
return { ok: false, message: error instanceof Error ? error.message : "Unknown error." };
|
|
}
|
|
}
|
|
|
|
function safeUninstallClaudeMemory(): { readonly ok: true; readonly message: string } | { readonly ok: false; readonly message: string } {
|
|
try {
|
|
const result = uninstallClaudeOpenPetsMemory(app.getPath("home"));
|
|
return { ok: true, message: result.changed ? "Removed Claude OpenPets instructions." : "Claude OpenPets instructions were already absent." };
|
|
} catch (error) {
|
|
return { ok: false, message: error instanceof Error ? error.message : "Unknown error." };
|
|
}
|
|
}
|
|
|
|
async function detectClaudeCodeStatus(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise<ClaudeCodeStatus> {
|
|
if (commandMode === "bundled") {
|
|
const node = await runCommand({ command: getPreferredNodeCommand(), args: ["--version"] });
|
|
if (!node.ok) return createStatus("error", "Node required", `Node.js is required for packaged OpenPets commands. Open Claude configuration, expand Advanced detection, set the Node.js command path, then try again. ${summarizeCommandResult(node)}`, undefined, node, { present: false, source: "none", verified: false, matchesExpected: false });
|
|
}
|
|
|
|
const version = await runClaudeCommand({ command: "claude", args: ["--version"] });
|
|
if (!version.ok) {
|
|
const hasOverride = getPreferredClaudeCommand() !== "claude";
|
|
return createStatus("not_detected", "Not detected", `${hasOverride ? "Claude Code did not run from the saved command path" : "Claude Code was not found or did not run"}: ${summarizeCommandResult(version)}`, undefined, version, { present: false, source: "none", verified: false, matchesExpected: false });
|
|
}
|
|
|
|
const list = await runClaudeCommandWithTimeoutRetry({ command: "claude", args: ["mcp", "list"] });
|
|
if (!list.ok) {
|
|
return createStatus("error", "Error / needs attention", `Claude Code was detected, but MCP status failed: ${summarizeCommandResult(list)}`, sanitizeAgentSetupOutput(version.stdout || version.stderr), list, { present: false, source: "none", verified: false, matchesExpected: false });
|
|
}
|
|
|
|
const listed = classifyClaudeMcpStatus(list.stdout, undefined, selectedPetId, commandMode, getPreferredNodeCommand());
|
|
let entry = listed;
|
|
if (listed.present) {
|
|
const get = await runClaudeCommand(buildClaudeMcpGetCommand());
|
|
if (get.ok) entry = classifyClaudeMcpStatus(list.stdout, get.stdout, selectedPetId, commandMode, getPreferredNodeCommand());
|
|
}
|
|
|
|
if (!entry.present) return createStatus("needs_setup", "Needs setup", "Claude Code is detected, but OpenPets MCP is not configured.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
|
|
if (entry.verified && entry.matchesExpected) return createStatus("configured", "Configured", "Claude Code has the expected OpenPets MCP entry.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
|
|
if (entry.verified) return createStatus("configured", "Installed — custom", "Claude Code has an openpets MCP entry with a custom command. OpenPets will leave it alone unless you choose Replace with recommended.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
|
|
return createStatus("configured", "Installed — unverified", "Claude Code lists an openpets MCP entry, but command details were not available. OpenPets will leave it alone unless you choose Replace with recommended.", sanitizeAgentSetupOutput(version.stdout || version.stderr), list, entry);
|
|
}
|
|
|
|
async function runClaudeCommandWithTimeoutRetry(spec: ClaudeCommandSpec): Promise<CommandResult> {
|
|
const first = await runClaudeCommand(spec);
|
|
if (!first.timedOut) return first;
|
|
await delay(250);
|
|
const second = await runClaudeCommand(spec);
|
|
return second.ok ? second : first;
|
|
}
|
|
|
|
function createStatus(state: ClaudeCodeStatus["state"], label: string, details: string, version: string | undefined, listResult: CommandResult, entry: ParsedClaudeMcpEntry): ClaudeCodeStatus {
|
|
return {
|
|
state,
|
|
label,
|
|
details,
|
|
claudeCommand: "claude",
|
|
version,
|
|
mcpListWorks: listResult.ok,
|
|
openPetsEntry: entry,
|
|
canConfigure: state === "needs_setup",
|
|
canReplace: entry.present && !(entry.verified && entry.matchesExpected),
|
|
canRemove: entry.present,
|
|
};
|
|
}
|
|
|
|
function validateSelectedPetId(value: unknown): string | undefined {
|
|
if (value === undefined || value === null || value === "") return undefined;
|
|
if (typeof value !== "string") throw new Error("Invalid selected pet id.");
|
|
const pet = getAppStateSnapshot().pets.installed.find((candidate) => candidate.id === value);
|
|
if (!pet || pet.broken) throw new Error("Selected pet is not installed or is broken.");
|
|
return pet.id;
|
|
}
|
|
|
|
function validateCommandMode(value: unknown): OpenPetsCommandMode {
|
|
if (app.isPackaged) return "bundled";
|
|
return value === "local" ? "local" : "published";
|
|
}
|
|
|
|
function getPetOptions(): readonly AgentSetupPetOption[] {
|
|
const state = getAppStateSnapshot();
|
|
return state.pets.installed.filter(isUsablePet).map((pet) => ({ id: pet.id, displayName: pet.displayName, default: pet.id === state.preferences.defaultPetId }));
|
|
}
|
|
|
|
function isUsablePet(pet: InstalledPetState): boolean {
|
|
return pet.installed && !pet.broken && !pet.builtIn;
|
|
}
|
|
|
|
async function runClaudeCommand(spec: ClaudeCommandSpec): Promise<CommandResult> {
|
|
for (const command of getClaudeCommandCandidates(spec.command)) {
|
|
const result = await runCommand({ command, args: spec.args });
|
|
if (result.ok || !isCommandNotFound(result)) return result;
|
|
}
|
|
return { ok: false, timedOut: false, exitCode: null, stdout: "", stderr: "", error: "Claude command was not found." };
|
|
}
|
|
|
|
function runCommand(spec: ClaudeCommandSpec): Promise<CommandResult> {
|
|
return new Promise((resolve) => {
|
|
const command = process.platform === "win32" && spec.command.toLowerCase().endsWith(".cmd") ? "cmd.exe" : spec.command;
|
|
const args = process.platform === "win32" && spec.command.toLowerCase().endsWith(".cmd") ? ["/d", "/s", "/c", spec.command, ...spec.args] : spec.args;
|
|
let child;
|
|
try {
|
|
child = spawn(command, args, { cwd: app.getPath("home"), env: createCommandEnv(), windowsHide: true, shell: false });
|
|
} catch (error) {
|
|
resolve({ ok: false, timedOut: false, exitCode: null, stdout: "", stderr: "", error: error instanceof Error ? error.message : "Command failed to start." });
|
|
return;
|
|
}
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let settled = false;
|
|
const timer = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
child.kill();
|
|
resolve({ ok: false, timedOut: true, exitCode: null, stdout: sanitizeAgentSetupOutput(stdout), stderr: sanitizeAgentSetupOutput(stderr), error: "Command timed out." });
|
|
}, commandTimeoutMs);
|
|
|
|
child.stdout?.on("data", (chunk: Buffer) => { stdout = appendBounded(stdout, chunk.toString("utf8")); });
|
|
child.stderr?.on("data", (chunk: Buffer) => { stderr = appendBounded(stderr, chunk.toString("utf8")); });
|
|
child.on("error", (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve({ ok: false, timedOut: false, exitCode: null, stdout: sanitizeAgentSetupOutput(stdout), stderr: sanitizeAgentSetupOutput(stderr), error: error.message });
|
|
});
|
|
child.on("close", (code) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve({ ok: code === 0, timedOut: false, exitCode: code, stdout: sanitizeAgentSetupOutput(stdout), stderr: sanitizeAgentSetupOutput(stderr), error: undefined });
|
|
});
|
|
});
|
|
}
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function getClaudeCommandCandidates(command: string): readonly string[] {
|
|
if (command !== "claude") return [command];
|
|
const preferred = getPreferredClaudeCommand();
|
|
if (preferred !== "claude") return [preferred];
|
|
if (process.platform === "win32") return ["claude", "claude.cmd"];
|
|
return ["claude"];
|
|
}
|
|
|
|
function createCommandEnv(): NodeJS.ProcessEnv {
|
|
const separator = process.platform === "win32" ? ";" : ":";
|
|
const existingPath = process.env.PATH ?? "";
|
|
return { ...process.env, PATH: dedupePathEntries([existingPath, ...getExtraCommandPaths()], separator).join(separator) };
|
|
}
|
|
|
|
function getExtraCommandPaths(): readonly string[] {
|
|
if (process.platform === "win32") return [];
|
|
const home = app.getPath("home");
|
|
const env = process.env;
|
|
return filterExistingPaths([
|
|
"/opt/homebrew/bin",
|
|
"/opt/homebrew/sbin",
|
|
"/usr/local/bin",
|
|
"/usr/local/sbin",
|
|
"/usr/bin",
|
|
"/bin",
|
|
"/usr/sbin",
|
|
"/sbin",
|
|
join(home, "bin"),
|
|
join(home, ".local", "bin"),
|
|
join(home, ".opencode", "bin"),
|
|
join(env.VOLTA_HOME || join(home, ".volta"), "bin"),
|
|
join(env.BUN_INSTALL || join(home, ".bun"), "bin"),
|
|
join(env.MISE_DATA_DIR || join(home, ".local", "share", "mise"), "shims"),
|
|
join(env.ASDF_DATA_DIR || join(home, ".asdf"), "shims"),
|
|
env.PNPM_HOME,
|
|
join(home, ".local", "share", "pnpm"),
|
|
join(home, "Library", "pnpm"),
|
|
join(env.NVM_DIR || join(home, ".nvm"), "current", "bin"),
|
|
]);
|
|
}
|
|
|
|
function filterExistingPaths(paths: readonly (string | undefined)[]): readonly string[] {
|
|
return paths.filter((path): path is string => Boolean(path && existsSync(path)));
|
|
}
|
|
|
|
function dedupePathEntries(paths: readonly string[], separator: string): readonly string[] {
|
|
const seen = new Set<string>();
|
|
const entries: string[] = [];
|
|
for (const path of paths.flatMap((value) => value.split(separator)).filter(Boolean)) {
|
|
if (seen.has(path)) continue;
|
|
seen.add(path);
|
|
entries.push(path);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function isCommandNotFound(result: CommandResult): boolean {
|
|
return Boolean(result.error && /ENOENT|not found/i.test(result.error));
|
|
}
|
|
|
|
function summarizeCommandResult(result: CommandResult): string {
|
|
if (result.timedOut) return "command timed out.";
|
|
const output = sanitizeAgentSetupOutput(result.stderr || result.stdout || result.error || `exit code ${result.exitCode ?? "unknown"}`);
|
|
return output || "command failed.";
|
|
}
|
|
|
|
function formatUserPath(path: string | undefined): string | undefined {
|
|
if (!path) return undefined;
|
|
return path.replace(app.getPath("home"), "~");
|
|
}
|
|
|
|
function appendBounded(existing: string, next: string): string {
|
|
const combined = existing + next;
|
|
return combined.length > maxOutputBytes ? combined.slice(combined.length - maxOutputBytes) : combined;
|
|
}
|
|
|
|
function writeActionJournal(entry: Omit<AgentSetupJournalEntry, "timestamp"> & { readonly timestamp?: string }): void {
|
|
try {
|
|
const path = getJournalPath();
|
|
const entries = readActionJournal().concat({ ...entry, command: entry.command.map((part) => formatUserPath(part) ?? part), message: sanitizeAgentSetupOutput(entry.message), timestamp: entry.timestamp || new Date().toISOString() }).slice(-20);
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
const tempPath = `${path}.${process.pid}.tmp`;
|
|
writeFileSync(tempPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8");
|
|
renameSync(tempPath, path);
|
|
} catch (error) {
|
|
console.error("Failed to write OpenPets agent setup action journal.", error);
|
|
}
|
|
}
|
|
|
|
function readActionJournal(): AgentSetupJournalEntry[] {
|
|
const path = getJournalPath();
|
|
if (!existsSync(path)) return [];
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
return Array.isArray(parsed) ? parsed.filter(isJournalEntry).slice(-20) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function getJournalPath(): string {
|
|
return join(app.getPath("userData"), "agent-setup-actions.json");
|
|
}
|
|
|
|
function isJournalEntry(value: unknown): value is AgentSetupJournalEntry {
|
|
return typeof value === "object" && value !== null && typeof (value as { timestamp?: unknown }).timestamp === "string";
|
|
}
|
|
|
|
function journalActionFor(action: AgentSetupAction): JournalAction {
|
|
if (action === "replace") return "replace";
|
|
if (action === "remove") return "remove";
|
|
return "configure";
|
|
}
|
|
|
|
export const agentSetupInternalsForChecks = {
|
|
sanitizeAgentSetupOutput,
|
|
createOpenPetsHookSettingsPreview,
|
|
};
|