diff --git a/apps/desktop/preload.cjs b/apps/desktop/preload.cjs index 28fe0f8e..9406d166 100644 --- a/apps/desktop/preload.cjs +++ b/apps/desktop/preload.cjs @@ -175,6 +175,7 @@ async function renderAgentSetup(selectedPetId, commandMode) { const result = requireElement("claude-action-result"); const devMode = requireInput("claude-dev-mode"); const claudeCommandPath = requireInput("claude-command-path"); + const nodeCommandPath = requireInput("node-command-path"); const hookStatus = requireElement("claude-hooks-status"); const hookDetails = requireElement("claude-hooks-details"); const hookPreview = requireElement("claude-hooks-preview"); @@ -191,6 +192,7 @@ async function renderAgentSetup(selectedPetId, commandMode) { devMode.checked = snapshot.commandMode === "local"; devMode.disabled = !snapshot.localDevAvailable; claudeCommandPath.value = snapshot.commandPaths.claude || ""; + nodeCommandPath.value = snapshot.commandPaths.node || ""; commandPreview.textContent = snapshot.preview.displayCommand; jsonPreview.textContent = JSON.stringify(snapshot.preview.mcpJson, null, 2); warning.textContent = createClaudeSetupWarning(snapshot); @@ -203,6 +205,7 @@ async function renderAgentSetup(selectedPetId, commandMode) { memoryStatus.className = `agent-status-pill ${memoryStatusClassFor(snapshot.memoryStatus.status)}`; memoryDetails.textContent = `${snapshot.memoryStatus.message} Files: ${snapshot.memoryStatus.claudeMdPath}, ${snapshot.memoryStatus.openPetsMemoryPath}`; updateClaudeIntegrationCard(snapshot); + updateClaudeCommandPathHelp(snapshot); updateOpenCodeIntegration(snapshot, selected); select.onchange = () => { void renderAgentSetup(select.value, getCommandMode()); }; @@ -212,6 +215,7 @@ async function renderAgentSetup(selectedPetId, commandMode) { bindIntegrationHubButtons(snapshot, select); bindAgentSetupButton("claude-refresh", () => renderAgentSetup(select.value, getCommandMode()), snapshot.busy, "Refreshing…"); bindAgentSetupButton("claude-command-path-save", () => saveAgentCommandPath("claude", claudeCommandPath.value, select.value, getCommandMode()), snapshot.busy, "Saving…"); + bindAgentSetupButton("node-command-path-save", () => saveAgentCommandPath("node", nodeCommandPath.value, select.value, getCommandMode()), snapshot.busy, "Saving…"); bindAgentSetupButton("claude-copy-command", async () => copyText(snapshot.preview.displayCommand), false); bindAgentSetupButton("claude-configure", () => runAgentAction("configure", select.value, getCommandMode()), snapshot.busy || !snapshot.status.canConfigure, "Installing…"); bindAgentSetupButton("claude-replace", () => runAgentAction("replace", select.value, getCommandMode()), snapshot.busy || !snapshot.status.canReplace, "Replacing…"); @@ -249,10 +253,13 @@ function updateOpenCodeIntegration(snapshot, selected) { if (title) title.textContent = opencode.state === "configured" ? "OpenCode global setup installed" : "Global setup available"; const details = document.getElementById("opencode-details"); if (details) details.textContent = opencode.details; + updateOpenCodeCommandPathHelp(opencode); const select = document.getElementById("opencode-pet-select"); if (select instanceof HTMLSelectElement) renderPetSelect(select, snapshot, selected); const opencodeCommandPath = document.getElementById("opencode-command-path"); if (opencodeCommandPath instanceof HTMLInputElement) opencodeCommandPath.value = snapshot.commandPaths.opencode || ""; + const opencodeNodeCommandPath = document.getElementById("opencode-node-command-path"); + if (opencodeNodeCommandPath instanceof HTMLInputElement) opencodeNodeCommandPath.value = snapshot.commandPaths.node || ""; const paths = document.getElementById("opencode-paths"); if (paths) { const cleanup = Array.isArray(preview.cleanupConfigPaths) && preview.cleanupConfigPaths.length > 0 ? `. Cleanup: ${preview.cleanupConfigPaths.join(", ")}` : ""; @@ -266,6 +273,7 @@ function updateOpenCodeIntegration(snapshot, selected) { bindAgentSetupButton("opencode-remove", () => runAgentAction("opencode-remove", select instanceof HTMLSelectElement ? select.value : selected, getCommandMode()), snapshot.busy || !opencode.canRemove, "Removing…"); bindAgentSetupButton("opencode-refresh", () => renderAgentSetup(select instanceof HTMLSelectElement ? select.value : selected, getCommandMode()), snapshot.busy, "Refreshing…"); bindAgentSetupButton("opencode-command-path-save", () => saveAgentCommandPath("opencode", opencodeCommandPath instanceof HTMLInputElement ? opencodeCommandPath.value : "", select instanceof HTMLSelectElement ? select.value : selected, getCommandMode()), snapshot.busy, "Saving…"); + bindAgentSetupButton("opencode-node-command-path-save", () => saveAgentCommandPath("node", opencodeNodeCommandPath instanceof HTMLInputElement ? opencodeNodeCommandPath.value : "", select instanceof HTMLSelectElement ? select.value : selected, getCommandMode()), snapshot.busy, "Saving…"); bindAgentSetupButton("opencode-copy-config", async () => copyText(requireElement("opencode-json-preview").textContent || "", "opencode-action-result", "Copied OpenCode config preview."), false); if (select instanceof HTMLSelectElement) select.onchange = () => { void renderAgentSetup(select.value, getCommandMode()); }; } @@ -304,6 +312,25 @@ function updateClaudeIntegrationCard(snapshot) { } } +function updateClaudeCommandPathHelp(snapshot) { + const needsNode = snapshot.status.label === "Node required" || /Node\.js is required|set the Node\.js command path/i.test(snapshot.status.details || ""); + const details = document.querySelector("#claude-detail-view .agent-command-paths"); + const card = document.querySelector("#claude-detail-view .connection-card"); + if (details instanceof HTMLElement) { + details.classList.toggle("needs-command-path", needsNode); + } + if (card instanceof HTMLElement) card.classList.toggle("needs-command-path", needsNode); + if (needsNode) renderError("Node.js was not found. Open Claude configuration → Advanced detection, set the Node.js command path, then retry."); +} + +function updateOpenCodeCommandPathHelp(opencode) { + const needsNode = /Node\.js is required|set the Node\.js command path/i.test(opencode.details || ""); + const paths = document.querySelector("#opencode-detail-view .agent-command-paths"); + const card = document.querySelector("#opencode-detail-view .connection-card"); + if (paths instanceof HTMLElement) paths.classList.toggle("needs-command-path", needsNode); + if (card instanceof HTMLElement) card.classList.toggle("needs-command-path", needsNode); +} + function cardStatusClassFor(state) { if (state === "not_detected" || state === "error") return "error"; return statusClassFor(state); @@ -431,12 +458,13 @@ function memoryStatusClassFor(status) { } function decorateAgentSetupButtons() { - for (const id of ["claude-configure", "claude-refresh", "claude-command-path-save", "claude-copy-command", "claude-replace", "claude-remove", "claude-memory-install", "claude-hooks-doctor", "claude-hooks-install", "claude-hooks-uninstall", "opencode-install", "opencode-remove", "opencode-refresh", "opencode-command-path-save", "opencode-copy-config"]) { + for (const id of ["claude-configure", "claude-refresh", "claude-command-path-save", "node-command-path-save", "claude-copy-command", "claude-replace", "claude-remove", "claude-memory-install", "claude-hooks-doctor", "claude-hooks-install", "claude-hooks-uninstall", "opencode-install", "opencode-remove", "opencode-refresh", "opencode-command-path-save", "opencode-node-command-path-save", "opencode-copy-config"]) { delete requireButton(id).dataset.loading; } setIconButtonContent(requireButton("claude-configure"), "plug", "Install integration"); setIconButtonContent(requireButton("claude-refresh"), "refresh", "Refresh"); setIconButtonContent(requireButton("claude-command-path-save"), "check", "Save path"); + setIconButtonContent(requireButton("node-command-path-save"), "check", "Save path"); setIconButtonContent(requireButton("claude-copy-command"), "copy", "Copy command"); setIconButtonContent(requireButton("claude-replace"), "repeat", "Replace configuration"); requireButton("claude-replace").className = "agent-action primary"; @@ -449,6 +477,7 @@ function decorateAgentSetupButtons() { setIconButtonContent(requireButton("opencode-remove"), "trash", "Remove global setup"); setIconButtonContent(requireButton("opencode-refresh"), "refresh", "Refresh"); setIconButtonContent(requireButton("opencode-command-path-save"), "check", "Save path"); + setIconButtonContent(requireButton("opencode-node-command-path-save"), "check", "Save path"); setIconButtonContent(requireButton("opencode-copy-config"), "copy", "Copy config preview"); } @@ -546,6 +575,7 @@ function setAgentSetupControlsBusy(busy) { "claude-memory-install", "claude-refresh", "claude-command-path-save", + "node-command-path-save", "claude-copy-command", "claude-hooks-doctor", "claude-hooks-install", @@ -554,6 +584,7 @@ function setAgentSetupControlsBusy(busy) { "opencode-remove", "opencode-refresh", "opencode-command-path-save", + "opencode-node-command-path-save", "opencode-copy-config", ]; if (busy) { @@ -595,10 +626,10 @@ async function runAgentAction(action, selectedPetId, commandMode) { } async function saveAgentCommandPath(kind, path, selectedPetId, commandMode) { - const patch = kind === "claude" ? { claude: path } : { opencode: path }; + const patch = kind === "claude" ? { claude: path } : kind === "node" ? { node: path } : { opencode: path }; await agentSetupApi.updateCommandPaths(patch); await renderAgentSetup(selectedPetId || "", commandMode); - const result = document.getElementById(kind === "claude" ? "claude-action-result" : "opencode-action-result"); + const result = document.getElementById(kind === "opencode" ? "opencode-action-result" : "claude-action-result"); if (result) result.textContent = path.trim() ? "Saved command path. Refreshed detection using the saved path." : "Cleared command path. Refreshed automatic detection."; } @@ -1301,6 +1332,7 @@ function renderError(message) { const error = document.querySelector("[data-error]"); if (error) { error.textContent = message; + error.title = message; } } @@ -1396,6 +1428,7 @@ function isAgentSetupSnapshot(value) { && typeof value.memoryStatus.claudeMdPath === "string" && typeof value.memoryStatus.openPetsMemoryPath === "string" && typeof value.commandPaths.claude === "string" + && typeof value.commandPaths.node === "string" && typeof value.commandPaths.opencode === "string"; } diff --git a/apps/desktop/src/agent-setup.ts b/apps/desktop/src/agent-setup.ts index b7d68c70..de8c3a20 100644 --- a/apps/desktop/src/agent-setup.ts +++ b/apps/desktop/src/agent-setup.ts @@ -50,6 +50,7 @@ export interface AgentSetupSnapshot { export interface AgentSetupCommandPaths { readonly claude: string; + readonly node: string; readonly opencode: string; } @@ -136,10 +137,11 @@ export async function getAgentSetupSnapshot(selectedPetId?: unknown, commandMode 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 !== "opencode") throw new Error("Invalid command path setting."); + if (key !== "claude" && key !== "node" && key !== "opencode") throw new Error("Invalid command path setting."); } const updates: Writable> = {}; 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(); @@ -175,7 +177,7 @@ export function sanitizeAgentSetupOutput(value: string): string { function safeBuildClaudeMcpPreview(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): { readonly preview: ClaudeMcpPreview; readonly error?: string } { try { - return { preview: withPreferredClaudeCommand(buildClaudeMcpPreview(selectedPetId, commandMode)) }; + 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 }; @@ -184,7 +186,7 @@ function safeBuildClaudeMcpPreview(selectedPetId: string | undefined, commandMod function safeDoctorClaudeHooks(commandMode: OpenPetsCommandMode, selectedPetId: string | undefined): ClaudeHookDoctorResult { try { - return doctorClaudeHooks(undefined, commandMode, selectedPetId); + return doctorClaudeHooks(undefined, commandMode, selectedPetId, getPreferredNodeCommand()); } catch (error) { return createHookErrorStatus(error instanceof Error ? error.message : "Packaged OpenPets hook resources are unavailable."); } @@ -247,8 +249,8 @@ async function runAction(action: AgentSetupAction, selectedPetId: string | undef return runRemove(createErrorPreview(commandMode, ""), selectedPetId, "Unknown", action); } if (commandMode === "bundled") { - const node = await runCommand({ command: "node", args: ["--version"] }); - if (!node.ok) return { ok: false, action, message: `Packaged OpenPets Claude commands require node on Claude's PATH: ${summarizeCommandResult(node)}`, changed: false }; + 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 }; @@ -256,7 +258,7 @@ async function runAction(action: AgentSetupAction, selectedPetId: string | undef if (action === "install-hooks") { let result; try { - result = installClaudeHooks(undefined, commandMode, selectedPetId); + result = installClaudeHooks(undefined, commandMode, selectedPetId, getPreferredNodeCommand()); } catch (error) { return { ok: false, action, message: error instanceof Error ? error.message : "OpenPets hook install failed.", changed: false }; } @@ -340,6 +342,7 @@ function getAgentSetupCommandPaths(): AgentSetupCommandPaths { const preferences = getAppStateSnapshot().preferences; return { claude: preferences.claudeCommandPath ?? "", + node: preferences.nodeCommandPath ?? "", opencode: preferences.opencodeCommandPath ?? "", }; } @@ -348,6 +351,10 @@ 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"); } @@ -389,8 +396,8 @@ function safePrepareOpenCode(configDir: string, selectedPetId: string | undefine async function installOpenCodeGlobal(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise { if (commandMode === "bundled") { - const node = await runCommand({ command: "node", args: ["--version"] }); - if (!node.ok) return { ok: false, action: "opencode-install", message: `Packaged OpenPets OpenCode setup requires node on OpenCode's PATH: ${summarizeCommandResult(node)}`, changed: false }; + 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); @@ -486,8 +493,8 @@ function safeUninstallClaudeMemory(): { readonly ok: true; readonly message: str async function detectClaudeCodeStatus(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): Promise { if (commandMode === "bundled") { - const node = await runCommand({ command: "node", args: ["--version"] }); - if (!node.ok) return createStatus("error", "Node required", `Packaged OpenPets Claude commands require node on Claude's PATH: ${summarizeCommandResult(node)}`, undefined, node, { present: false, source: "none", verified: false, matchesExpected: false }); + 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"] }); @@ -501,11 +508,11 @@ async function detectClaudeCodeStatus(selectedPetId: string | undefined, command 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); + 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); + 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); diff --git a/apps/desktop/src/app-state.ts b/apps/desktop/src/app-state.ts index a776e1c3..3b1e0830 100644 --- a/apps/desktop/src/app-state.ts +++ b/apps/desktop/src/app-state.ts @@ -37,6 +37,7 @@ export interface OpenPetsStateV1 { readonly petScale: number; readonly onboardingCompleted: boolean; readonly claudeCommandPath?: string; + readonly nodeCommandPath?: string; readonly opencodeCommandPath?: string; }; readonly pets: { @@ -292,6 +293,7 @@ function normalizePreferences(value: Partial): O petScale: normalizePetScale(value.petScale), onboardingCompleted: normalizeOnboardingCompleted(value), claudeCommandPath: normalizeCommandPath(value.claudeCommandPath), + nodeCommandPath: normalizeCommandPath(value.nodeCommandPath), opencodeCommandPath: normalizeCommandPath(value.opencodeCommandPath), }; } @@ -357,6 +359,7 @@ function createDefaultState(): OpenPetsStateV1 { petScale: defaultPetScale, onboardingCompleted: false, claudeCommandPath: undefined, + nodeCommandPath: undefined, opencodeCommandPath: undefined, }, pets: { diff --git a/apps/desktop/src/windows.ts b/apps/desktop/src/windows.ts index cb1ec3e1..4f7e70ea 100644 --- a/apps/desktop/src/windows.ts +++ b/apps/desktop/src/windows.ts @@ -594,11 +594,12 @@ function createAgentSetupHtml(definition: TaskWindowDefinition): string { -
- Advanced detectionClaude command path -

If Claude is not detected, paste the full path to the Claude executable or command shim. Leave blank for automatic PATH detection.

-
-
+
+
ConfigurationCommand paths
+

If Claude or Node.js is not detected from the app, paste the full executable path. Leave blank for automatic PATH detection.

+ + +