commit 33b0c22068bf8601cf1763668700dcdbf856fb35 Author: Alvin Unreal Date: Sun May 10 13:30:48 2026 +0200 Releasing version 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a0d1faf8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +v1 +web +node_modules +dist +dist-electron +apps/desktop/dist-electron +*.tsbuildinfo +.DS_Store diff --git a/.ignore b/.ignore new file mode 100644 index 00000000..41f205b2 --- /dev/null +++ b/.ignore @@ -0,0 +1,2 @@ +!v1 +!web diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..950628f1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OpenPets + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..f2b507f4 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# OpenPets 2.0 + +OpenPets 2.0 is a tray-first desktop companion app for coding agents. + +This repository contains the fresh v2 workspace. The `v1/` folder is reference material only, and the existing `web/` app remains outside the v2 workspace. + +## Development baseline + +- Node.js 20+ +- pnpm 11+ +- TypeScript +- No Bun runtime requirement for v2 + +## Install + +```bash +pnpm install +``` + +## Checks + +```bash +pnpm check +pnpm typecheck +pnpm build +``` + +## Desktop development + +```bash +pnpm --filter @open-pets/desktop dev +``` + +Phase 01 desktop behavior is tray/menu-bar first. It should not open a dashboard window on startup. + +## Workspace layout + +```text +apps/desktop Electron desktop app shell +packages/client @open-pets/client, local IPC client +packages/mcp @open-pets/mcp, MCP stdio server +packages/claude @open-pets/claude, Claude integration package +packages/opencode @open-pets/opencode, OpenCode config/plugin integration +packages/agent-events shared safe agent event speech helpers +packages/cli @open-pets/cli, explicit user-run CLI commands +packages/pet-format @open-pets/pet-format, pet/catalog format types +``` + +## Agent integrations + +- Claude Code: configure from Desktop Integrations or with `openpets configure --agent claude --pet `. +- OpenCode project-local setup: use `openpets configure --agent opencode --pet ` from a project. This writes `.opencode/opencode.jsonc` when no OpenCode config exists, adds `.opencode/openpets.md`, and configures OpenPets MCP plus the OpenCode plugin for that project. +- OpenCode desktop setup: use Desktop Integrations → OpenCode for global setup. This writes OpenCode's global config directory only after explicit user action, adds a global `openpets.md` instruction file, and can be removed from the same screen. The MCP command uses bundled desktop CLI resources in packaged builds; the OpenCode plugin spec remains the published/version-pinned `@open-pets/opencode` package, so OpenCode may need npm/network access unless that package is already cached/installed. + +OpenPets MCP tools remain `openpets_status`, `openpets_say`, and `openpets_react`. Speech must stay short and must not include code, logs, secrets, URLs, or file paths. + +To reconfigure, rerun the CLI command for a project or use Replace/Install in Desktop Integrations for global OpenCode setup. To remove global desktop OpenCode setup, use the OpenCode Remove action; unrelated OpenCode config and user text outside OpenPets managed markers are preserved. + +The v2 workspace intentionally includes only: + +```text +apps/* +packages/* +``` + +It does not include `v1/**` or `web/**`. diff --git a/apps/desktop/assets/app-icon.icns b/apps/desktop/assets/app-icon.icns new file mode 100644 index 00000000..13a3da70 Binary files /dev/null and b/apps/desktop/assets/app-icon.icns differ diff --git a/apps/desktop/assets/app-icon.ico b/apps/desktop/assets/app-icon.ico new file mode 100644 index 00000000..7a4490dc Binary files /dev/null and b/apps/desktop/assets/app-icon.ico differ diff --git a/apps/desktop/assets/default-pet-spritesheet.webp b/apps/desktop/assets/default-pet-spritesheet.webp new file mode 100644 index 00000000..5391a3e5 Binary files /dev/null and b/apps/desktop/assets/default-pet-spritesheet.webp differ diff --git a/apps/desktop/assets/default-pet-thumbnail.png b/apps/desktop/assets/default-pet-thumbnail.png new file mode 100644 index 00000000..3ad39a25 Binary files /dev/null and b/apps/desktop/assets/default-pet-thumbnail.png differ diff --git a/apps/desktop/assets/integrations/claude.svg b/apps/desktop/assets/integrations/claude.svg new file mode 100644 index 00000000..ed1293c4 --- /dev/null +++ b/apps/desktop/assets/integrations/claude.svg @@ -0,0 +1 @@ +Claude Code \ No newline at end of file diff --git a/apps/desktop/assets/integrations/cursor.svg b/apps/desktop/assets/integrations/cursor.svg new file mode 100644 index 00000000..ba7c2557 --- /dev/null +++ b/apps/desktop/assets/integrations/cursor.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/assets/integrations/opencode.svg b/apps/desktop/assets/integrations/opencode.svg new file mode 100644 index 00000000..9c4cb89e --- /dev/null +++ b/apps/desktop/assets/integrations/opencode.svg @@ -0,0 +1 @@ +opencode \ No newline at end of file diff --git a/apps/desktop/assets/integrations/vscode.svg b/apps/desktop/assets/integrations/vscode.svg new file mode 100644 index 00000000..8ebf2fc1 --- /dev/null +++ b/apps/desktop/assets/integrations/vscode.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/desktop/assets/integrations/windsurf.svg b/apps/desktop/assets/integrations/windsurf.svg new file mode 100644 index 00000000..895544c5 --- /dev/null +++ b/apps/desktop/assets/integrations/windsurf.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/assets/integrations/zed.svg b/apps/desktop/assets/integrations/zed.svg new file mode 100644 index 00000000..0df50abb --- /dev/null +++ b/apps/desktop/assets/integrations/zed.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop/assets/onboarding-logo.webp b/apps/desktop/assets/onboarding-logo.webp new file mode 100644 index 00000000..e5a6c7df Binary files /dev/null and b/apps/desktop/assets/onboarding-logo.webp differ diff --git a/apps/desktop/assets/onboarding-pets.webp b/apps/desktop/assets/onboarding-pets.webp new file mode 100644 index 00000000..cc4bcee4 Binary files /dev/null and b/apps/desktop/assets/onboarding-pets.webp differ diff --git a/apps/desktop/assets/tray-icon.png b/apps/desktop/assets/tray-icon.png new file mode 100644 index 00000000..59807b0d Binary files /dev/null and b/apps/desktop/assets/tray-icon.png differ diff --git a/apps/desktop/catalog.v2.fixture.json b/apps/desktop/catalog.v2.fixture.json new file mode 100644 index 00000000..e5d15db0 --- /dev/null +++ b/apps/desktop/catalog.v2.fixture.json @@ -0,0 +1,41 @@ +{ + "version": 2, + "generatedAt": "2026-05-05T08:55:53.722Z", + "pets": [ + { + "id": "snoopy", + "displayName": "Snoopy", + "description": "A tiny black-and-white beagle with a red collar for calm coding sessions.", + "preview": "https://openpets.dev/pets/snoopy-23e05847/spritesheet.webp", + "zip": "https://zip.openpets.dev/pets/snoopy-23e05847/snoopy.zip" + }, + { + "id": "clippit", + "displayName": "Clippy", + "description": "A classic paperclip assistant rebuilt from Microsoft Agent animation frames.", + "preview": "https://openpets.dev/pets/clippit-904b393f/spritesheet.webp", + "zip": "https://zip.openpets.dev/pets/clippit-904b393f/clippit.zip" + }, + { + "id": "tux", + "displayName": "Tux", + "description": "A tiny pixel-adjacent Linux mascot penguin for calm coding sessions.", + "preview": "https://openpets.dev/pets/tux-de2f300f/spritesheet.webp", + "zip": "https://zip.openpets.dev/pets/tux-de2f300f/tux.zip" + }, + { + "id": "wall-e", + "displayName": "Wall-E", + "description": "A tiny weathered trash-compactor robot companion with binocular eyes, treads, and a gentle waving arm.", + "preview": "https://openpets.dev/pets/wall-e-779d5202/spritesheet.webp", + "zip": "https://zip.openpets.dev/pets/wall-e-779d5202/wall-e.zip" + }, + { + "id": "dobby", + "displayName": "Dobby", + "description": "An earnest, genuinely helpful tiny house-elf Codex pet with huge ears, wide kind eyes, and a simple worn tunic.", + "preview": "https://openpets.dev/pets/dobby-3f6746e0/spritesheet.webp", + "zip": "https://zip.openpets.dev/pets/dobby-3f6746e0/dobby.zip" + } + ] +} diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml new file mode 100644 index 00000000..752e858b --- /dev/null +++ b/apps/desktop/electron-builder.yml @@ -0,0 +1,55 @@ +appId: dev.openpets.app +productName: OpenPets +copyright: Copyright © 2026 OpenPets + +directories: + output: dist-electron + +artifactName: OpenPets-${version}-${os}-${arch}.${ext} + +files: + - dist/** + - preload.cjs + - pet-preload.cjs + - assets/** + - package.json + +asar: true +asarUnpack: + - node_modules/** +npmRebuild: false +publish: null + +mac: + icon: assets/app-icon.icns + category: public.app-category.utilities + target: + - dmg + - zip + identity: null + hardenedRuntime: false + gatekeeperAssess: false + +win: + icon: assets/app-icon.ico + target: + - nsis + - portable + verifyUpdateCodeSignature: false + +nsis: + artifactName: OpenPets-${version}-win-${arch}-setup.${ext} + oneClick: false + perMachine: false + allowToChangeInstallationDirectory: true + +portable: + artifactName: OpenPets-${version}-win-${arch}-portable.${ext} + +linux: + target: + - AppImage + - deb + - tar.gz + category: Utility + maintainer: OpenPets diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 00000000..01382a58 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,39 @@ +{ + "name": "@open-pets/desktop", + "version": "2.0.0", + "private": true, + "description": "OpenPets tray-first desktop companion app.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/alvinunreal/openpets.git", + "directory": "apps/desktop" + }, + "author": "OpenPets", + "type": "module", + "main": "dist/main.js", + "scripts": { + "dev": "pnpm build && electron .", + "package": "pnpm build && node scripts/clean-package-output.cjs && electron-builder", + "package:dir": "pnpm build && node scripts/clean-package-output.cjs && electron-builder --dir && node dist/check-packaging-contract.js --output", + "test": "node --check preload.cjs && node --check pet-preload.cjs && node dist/check-catalog-fixture.js && node dist/check-codex-pets.js && node dist/check-zip-safety.js && node dist/check-local-ipc-protocol.js && node dist/check-lease-manager.js && node dist/check-onboarding-state.js && node dist/check-update-checker.js && node dist/check-claude-memory.js && node dist/check-opencode-desktop-setup.js && node dist/check-packaging-contract.js", + "check": "pnpm typecheck && pnpm build && pnpm test", + "typecheck": "tsc --noEmit", + "build": "tsc" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "@types/yauzl": "2.10.3", + "electron": "^42.0.0", + "electron-builder": "^26.8.1", + "typescript": "^6.0.3" + }, + "dependencies": { + "@open-pets/agent-events": "workspace:*", + "@open-pets/claude": "workspace:*", + "@open-pets/cli": "workspace:*", + "@open-pets/mcp": "workspace:*", + "@open-pets/opencode": "workspace:*", + "yauzl": "3.3.0" + } +} diff --git a/apps/desktop/pet-preload.cjs b/apps/desktop/pet-preload.cjs new file mode 100644 index 00000000..a8b1f1b9 --- /dev/null +++ b/apps/desktop/pet-preload.cjs @@ -0,0 +1,88 @@ +const { ipcRenderer } = require("electron"); + +const allowedMotionStates = new Set(["idle", "run-left", "run-right"]); +const allowedReactionStates = new Set(["idle", "running-right", "running-left", "waving", "jumping", "failed", "waiting", "running", "review"]); +let lastInteractiveHit = null; +let dragging = false; + +ipcRenderer.on("openpets:pet-motion", (_event, state) => { + if (!allowedMotionStates.has(state)) { + return; + } + + const apply = () => { + document.documentElement.dataset.motionState = state; + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", apply, { once: true }); + } else { + apply(); + } +}); + +ipcRenderer.on("openpets:pet-reaction-state", (_event, state) => { + if (!allowedReactionStates.has(state)) { + return; + } + + const apply = () => { + document.documentElement.dataset.reactionState = state; + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", apply, { once: true }); + } else { + apply(); + } +}); + +const getInteractiveTarget = (event) => { + const target = document.elementFromPoint(event.clientX, event.clientY); + return target && target.closest(".pet-shell, .bubble"); +}; + +const setInteractiveHit = (interactive) => { + if (lastInteractiveHit === interactive) return; + lastInteractiveHit = interactive; + ipcRenderer.send("openpets:pet-hit-test", interactive); +}; + +const updateInteractiveHit = (event) => { + setInteractiveHit(Boolean(getInteractiveTarget(event)) || dragging); +}; + +const installMouseInterop = () => { + document.addEventListener("mousemove", (event) => { + updateInteractiveHit(event); + if (dragging) ipcRenderer.send("openpets:pet-drag-move", { screenX: event.screenX, screenY: event.screenY }); + }, { passive: true }); + + document.addEventListener("mousedown", (event) => { + const target = getInteractiveTarget(event); + setInteractiveHit(Boolean(target)); + if (event.button !== 0 || !target?.closest(".pet-shell")) return; + event.preventDefault(); + dragging = true; + setInteractiveHit(true); + ipcRenderer.send("openpets:pet-drag-start", { screenX: event.screenX, screenY: event.screenY }); + }); + + document.addEventListener("mouseup", () => { + if (!dragging) return; + dragging = false; + ipcRenderer.send("openpets:pet-drag-end"); + }); + + document.addEventListener("mouseleave", () => { + if (!dragging) setInteractiveHit(false); + }, { passive: true }); + + setInteractiveHit(false); +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", installMouseInterop, { once: true }); +} else { + installMouseInterop(); +} diff --git a/apps/desktop/preload.cjs b/apps/desktop/preload.cjs new file mode 100644 index 00000000..2a127890 --- /dev/null +++ b/apps/desktop/preload.cjs @@ -0,0 +1,1262 @@ +const { contextBridge, ipcRenderer } = require("electron"); + +const api = { + getState: () => ipcRenderer.invoke("openpets:get-state"), + getCatalog: () => ipcRenderer.invoke("openpets:get-catalog"), + getCodexPets: () => ipcRenderer.invoke("openpets:get-codex-pets"), + updatePreferences: (patch) => ipcRenderer.invoke("openpets:update-preferences", patch), + getLaunchAtLogin: () => ipcRenderer.invoke("openpets:get-launch-at-login"), + setLaunchAtLogin: (enabled) => ipcRenderer.invoke("openpets:set-launch-at-login", enabled), + getUpdateStatus: () => ipcRenderer.invoke("openpets:get-update-status"), + checkForUpdates: () => ipcRenderer.invoke("openpets:check-for-updates"), + openUpdateReleasePage: () => ipcRenderer.invoke("openpets:open-update-release-page"), + setDefaultPet: (petId) => ipcRenderer.invoke("openpets:set-default-pet", petId), + installPet: (petId) => ipcRenderer.invoke("openpets:install-pet", petId), + importCodexPet: (petId) => ipcRenderer.invoke("openpets:import-codex-pet", petId), + removePet: (petId) => ipcRenderer.invoke("openpets:remove-pet", petId), + resetDefaultPetPosition: () => ipcRenderer.invoke("openpets:reset-default-pet-position"), +}; + +const agentSetupApi = { + snapshot: (selectedPetId, commandMode) => ipcRenderer.invoke("openpets:agent-setup-snapshot", selectedPetId, commandMode), + action: (action, selectedPetId, commandMode) => ipcRenderer.invoke("openpets:agent-setup-action", action, selectedPetId, commandMode), +}; + +const onboardingApi = { + snapshot: () => ipcRenderer.invoke("openpets:onboarding-snapshot"), + complete: () => ipcRenderer.invoke("openpets:onboarding-complete"), + openPetManager: () => ipcRenderer.invoke("openpets:onboarding-open-pet-manager"), + openAgentSetup: () => ipcRenderer.invoke("openpets:onboarding-open-agent-setup"), +}; + +let activeAgentCommandMode = "published"; +let agentSetupControlStates = null; +let activePetManagerSelection = ""; +let activePetManagerFilter = "all"; +let activePetManagerItems = []; +let activePetManagerDefaultId = ""; + +contextBridge.exposeInMainWorld("openPets", api); +contextBridge.exposeInMainWorld("openpetsAgentSetup", agentSetupApi); +contextBridge.exposeInMainWorld("openpetsOnboarding", onboardingApi); + +window.addEventListener("DOMContentLoaded", () => { + const view = document.body.dataset.openpetsView; + + if (view !== "pet-manager" && view !== "settings" && view !== "agent-setup" && view !== "onboarding") { + return; + } + + if (view === "onboarding") { + void renderOnboarding(); + return; + } + + void renderCurrentState(view); + window.addEventListener("focus", () => { + void renderCurrentState(view); + }); +}); + +async function renderCurrentState(view) { + const state = await api.getState(); + + if (!isStateSnapshot(state)) { + renderError("OpenPets state is unavailable."); + return; + } + + if (view === "pet-manager") { + await renderPetManager(state); + } else if (view === "settings") { + renderSettings(state); + } else { + await renderAgentSetup(); + } +} + +async function renderOnboarding() { + const snapshot = await onboardingApi.snapshot(); + if (!isOnboardingSnapshot(snapshot)) { + renderError("Onboarding state is unavailable."); + return; + } + + requireElement("onboarding-default-pet").textContent = snapshot.defaultPetName; + requireElement("onboarding-pets-default-pet").textContent = snapshot.defaultPetName; + let currentStep = 0; + const showStep = (step) => { + currentStep = step; + for (const panel of document.querySelectorAll("[data-step-panel]")) { + panel.hidden = panel.dataset.stepPanel !== String(step); + } + for (const indicator of document.querySelectorAll("[data-step-indicator]")) { + const active = indicator.dataset.stepIndicator === String(step); + indicator.classList.toggle("active", active); + if (active) { + indicator.setAttribute("aria-current", "step"); + } else { + indicator.removeAttribute("aria-current"); + } + } + }; + + requireButton("onboarding-welcome-next").onclick = () => showStep(1); + requireButton("onboarding-pets-next").onclick = () => showStep(2); + requireButton("onboarding-agents-next").onclick = () => showStep(3); + requireButton("onboarding-open-pets").onclick = () => { void openOnboardingWindowManually("pets", onboardingApi.openPetManager); }; + requireButton("onboarding-ready-pets").onclick = () => { void onboardingApi.openPetManager().catch(renderCaughtError); }; + requireButton("onboarding-open-agents").onclick = () => { void openOnboardingWindowManually("agents", onboardingApi.openAgentSetup); }; + requireButton("onboarding-ready-agents").onclick = () => { void onboardingApi.openAgentSetup().catch(renderCaughtError); }; + requireButton("onboarding-finish").onclick = () => { + const button = requireButton("onboarding-finish"); + button.disabled = true; + button.textContent = "Finishing…"; + void onboardingApi.complete().catch((error) => { + button.disabled = false; + button.textContent = "Start using OpenPets"; + renderCaughtError(error); + }); + }; + showStep(currentStep); +} + +async function openOnboardingWindowManually(kind, opener) { + const label = kind === "pets" ? "Pet Manager" : "Integrations"; + updateOnboardingOpenStatus(kind, `Opening ${label}…`, ""); + try { + await opener(); + updateOnboardingOpenStatus(kind, `${label} opened — return here to continue.`, "success"); + markOnboardingWindowOpened(kind); + } catch (error) { + updateOnboardingOpenStatus(kind, `Couldn’t open ${label}. Try again from the button.`, "error"); + renderCaughtError(error); + } +} + +function markOnboardingWindowOpened(kind) { + const openButton = document.getElementById(kind === "pets" ? "onboarding-open-pets" : "onboarding-open-agents"); + const continueButton = document.getElementById(kind === "pets" ? "onboarding-pets-next" : "onboarding-agents-next"); + if (openButton instanceof HTMLButtonElement) openButton.hidden = true; + if (continueButton instanceof HTMLButtonElement) { + continueButton.className = "onboarding-promoted-continue"; + continueButton.textContent = "Continue"; + } +} + +function updateOnboardingOpenStatus(kind, text, state) { + const id = kind === "pets" ? "onboarding-pets-status" : "onboarding-agents-status"; + const element = document.getElementById(id); + if (!element) return; + element.textContent = text; + element.className = `onboarding-status-line${state ? ` ${state}` : ""}`; +} + +async function renderAgentSetup(selectedPetId, commandMode) { + const snapshot = await agentSetupApi.snapshot(selectedPetId, commandMode); + if (!isAgentSetupSnapshot(snapshot)) { + renderError("Claude setup status is unavailable."); + return; + } + activeAgentCommandMode = snapshot.commandMode; + agentSetupControlStates = null; + + const selected = snapshot.selectedPetId || ""; + const status = requireElement("claude-status"); + const statusTitle = requireElement("claude-status-title"); + const details = requireElement("claude-details"); + const select = requireSelect("claude-pet-select"); + const commandPreview = requireElement("claude-command-preview"); + const jsonPreview = requireElement("claude-json-preview"); + const warning = requireElement("claude-warning"); + const result = requireElement("claude-action-result"); + const devMode = requireInput("claude-dev-mode"); + const hookStatus = requireElement("claude-hooks-status"); + const hookDetails = requireElement("claude-hooks-details"); + const hookPreview = requireElement("claude-hooks-preview"); + const memoryStatus = requireElement("claude-memory-status"); + const memoryDetails = requireElement("claude-memory-details"); + + status.textContent = displayClaudeStatusLabel(snapshot); + status.className = `agent-status-pill ${statusClassFor(snapshot.status.state)}`; + statusTitle.textContent = statusTitleFor(snapshot); + details.textContent = snapshot.status.details; + renderPetSelect(select, snapshot, selected); + devMode.checked = snapshot.commandMode === "local"; + devMode.disabled = !snapshot.localDevAvailable; + commandPreview.textContent = snapshot.preview.displayCommand; + jsonPreview.textContent = JSON.stringify(snapshot.preview.mcpJson, null, 2); + warning.textContent = createClaudeSetupWarning(snapshot); + result.textContent = snapshot.lastAction ? snapshot.lastAction.message : "Claude Code may need to be restarted after MCP changes."; + hookStatus.textContent = formatHookStatus(snapshot.hookStatus.status); + hookStatus.className = `agent-status-pill ${hookStatusClassFor(snapshot.hookStatus.status)}`; + hookDetails.textContent = `${snapshot.hookStatus.message} Settings: ${snapshot.hookStatus.settingsPath}`; + hookPreview.textContent = JSON.stringify(snapshot.hookStatus.preview, null, 2); + memoryStatus.textContent = formatMemoryStatus(snapshot.memoryStatus.status); + memoryStatus.className = `agent-status-pill ${memoryStatusClassFor(snapshot.memoryStatus.status)}`; + memoryDetails.textContent = `${snapshot.memoryStatus.message} Files: ${snapshot.memoryStatus.claudeMdPath}, ${snapshot.memoryStatus.openPetsMemoryPath}`; + updateClaudeIntegrationCard(snapshot); + updateOpenCodeIntegration(snapshot, selected); + + select.onchange = () => { void renderAgentSetup(select.value, getCommandMode()); }; + devMode.onchange = () => { void renderAgentSetup(select.value, getCommandMode()); }; + decorateAgentSetupButtons(); + updateClaudeDetailActions(snapshot); + bindIntegrationHubButtons(snapshot, select); + bindAgentSetupButton("claude-refresh", () => renderAgentSetup(select.value, getCommandMode()), snapshot.busy, "Refreshing…"); + 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…"); + bindAgentSetupButton("claude-remove", () => runAgentAction("remove", select.value, getCommandMode()), snapshot.busy || !snapshot.status.canRemove, "Removing…"); + bindAgentSetupButton("claude-memory-install", () => runAgentAction("install-memory", select.value, getCommandMode()), snapshot.busy, "Updating…"); + bindAgentSetupButton("claude-hooks-doctor", () => runAgentAction("doctor-hooks", select.value, getCommandMode()), snapshot.busy, "Checking…"); + bindAgentSetupButton("claude-hooks-install", () => runAgentAction("install-hooks", select.value, getCommandMode()), snapshot.busy, "Installing…"); + bindAgentSetupButton("claude-hooks-uninstall", () => runAgentAction("uninstall-hooks", select.value, getCommandMode()), snapshot.busy || snapshot.hookStatus.status === "not_installed", "Removing…"); +} + +function updateOpenCodeIntegration(snapshot, selected) { + const opencode = snapshot.opencodeStatus; + const preview = snapshot.opencodePreview; + if (!opencode || !preview) return; + const cardStatus = document.getElementById("integration-opencode-status"); + if (cardStatus) { + cardStatus.textContent = opencode.label; + cardStatus.className = `agent-status-pill ${statusClassFor(opencode.state)}`; + } + const installCard = document.getElementById("integration-opencode-install"); + if (installCard instanceof HTMLButtonElement) { + delete installCard.dataset.loading; + setIconButtonContent(installCard, opencode.state === "configured" ? "check" : "download", opencode.state === "configured" ? "Installed" : "Install"); + installCard.disabled = snapshot.busy || !opencode.canInstall || opencode.state === "configured"; + } + const configureCard = document.getElementById("integration-opencode-configure"); + if (configureCard instanceof HTMLButtonElement) { + delete configureCard.dataset.loading; + setIconButtonContent(configureCard, "settings", "Configure"); + configureCard.disabled = false; + } + const status = document.getElementById("opencode-status"); + if (status) { status.textContent = opencode.label; status.className = `agent-status-pill ${statusClassFor(opencode.state)}`; } + const title = document.getElementById("opencode-status-title"); + 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; + const select = document.getElementById("opencode-pet-select"); + if (select instanceof HTMLSelectElement) renderPetSelect(select, snapshot, selected); + const paths = document.getElementById("opencode-paths"); + if (paths) { + const cleanup = Array.isArray(preview.cleanupConfigPaths) && preview.cleanupConfigPaths.length > 0 ? `. Cleanup: ${preview.cleanupConfigPaths.join(", ")}` : ""; + paths.textContent = `Config file: ${preview.configPath || preview.configDir}. Instructions: ${preview.instructionPath}${cleanup}`; + } + const json = document.getElementById("opencode-json-preview"); + if (json) json.textContent = JSON.stringify(preview.configPreview && Object.keys(preview.configPreview).length > 0 ? preview.configPreview : { mcp: { openpets: { type: "local", command: preview.mcpCommand, enabled: true } }, instructions: [preview.instructionPath], plugin: preview.plugin ? [preview.plugin] : [] }, null, 2); + const result = document.getElementById("opencode-action-result"); + if (result) result.textContent = snapshot.lastAction && String(snapshot.lastAction.action).startsWith("opencode-") ? snapshot.lastAction.message : "OpenCode may need to be restarted after global setup changes."; + bindAgentSetupButton("opencode-install", () => runAgentAction("opencode-install", select instanceof HTMLSelectElement ? select.value : selected, getCommandMode()), snapshot.busy || !opencode.canInstall, "Installing…"); + 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-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()); }; +} + +function updateClaudeIntegrationCard(snapshot) { + const status = document.getElementById("integration-claude-status"); + if (status) { + status.textContent = snapshot.status.state === "configured" ? "Installed" : snapshot.status.canConfigure ? "Ready" : snapshot.status.label; + status.className = `agent-status-pill ${statusClassFor(snapshot.status.state)}`; + } + + const install = document.getElementById("integration-claude-install"); + if (install instanceof HTMLButtonElement) { + delete install.dataset.loading; + if (snapshot.status.state === "configured") { + setIconButtonContent(install, "check", "Installed"); + install.disabled = true; + install.className = "agent-action secondary"; + } else if (snapshot.status.canConfigure && !snapshot.busy) { + setIconButtonContent(install, "download", "Install"); + install.disabled = false; + install.className = "agent-action primary"; + } else { + setIconButtonContent(install, "download", "Install"); + install.disabled = true; + install.className = "agent-action primary"; + } + } + + const configure = document.getElementById("integration-claude-configure"); + if (configure instanceof HTMLButtonElement) { + delete configure.dataset.loading; + setIconButtonContent(configure, "settings", "Configure"); + configure.disabled = false; + configure.className = "agent-action secondary"; + } +} + +function bindIntegrationHubButtons(snapshot, select) { + const install = document.getElementById("integration-claude-install"); + const configure = document.getElementById("integration-claude-configure"); + const opencodeInstall = document.getElementById("integration-opencode-install"); + const opencodeConfigure = document.getElementById("integration-opencode-configure"); + if (install instanceof HTMLButtonElement) { + install.onclick = async () => { + if (install.disabled || snapshot.busy) return; + install.dataset.loading = "true"; + install.disabled = true; + if (configure instanceof HTMLButtonElement) configure.disabled = true; + setIconButtonContent(install, "spinner", "Installing…"); + try { + await runAgentAction("configure", select.value, getCommandMode()); + } catch (error) { + delete install.dataset.loading; + install.disabled = false; + if (configure instanceof HTMLButtonElement) configure.disabled = false; + setIconButtonContent(install, "download", "Install"); + renderCaughtError(error); + } + }; + } + if (configure instanceof HTMLButtonElement) { + configure.onclick = () => showClaudeDetailView(); + } + if (opencodeInstall instanceof HTMLButtonElement) { + opencodeInstall.onclick = async () => { + if (opencodeInstall.disabled || snapshot.busy) return; + opencodeInstall.dataset.loading = "true"; + opencodeInstall.disabled = true; + if (opencodeConfigure instanceof HTMLButtonElement) opencodeConfigure.disabled = true; + setIconButtonContent(opencodeInstall, "spinner", "Installing…"); + try { + await runAgentAction("opencode-install", select.value, getCommandMode()); + } catch (error) { + delete opencodeInstall.dataset.loading; + opencodeInstall.disabled = false; + if (opencodeConfigure instanceof HTMLButtonElement) opencodeConfigure.disabled = false; + setIconButtonContent(opencodeInstall, "download", "Install"); + renderCaughtError(error); + } + }; + } + if (opencodeConfigure instanceof HTMLButtonElement) opencodeConfigure.onclick = () => showOpenCodeDetailView(); + const back = document.getElementById("integration-back"); + if (back instanceof HTMLButtonElement) back.onclick = showIntegrationsView; + const openCodeBack = document.getElementById("opencode-integration-back"); + if (openCodeBack instanceof HTMLButtonElement) openCodeBack.onclick = showIntegrationsView; +} + +function showClaudeDetailView() { + const grid = document.getElementById("integrations-view"); + const detail = document.getElementById("claude-detail-view"); + if (grid) grid.hidden = true; + if (detail) detail.hidden = false; + document.getElementById("claude-detail-title")?.focus(); +} + +function showIntegrationsView() { + const grid = document.getElementById("integrations-view"); + const detail = document.getElementById("claude-detail-view"); + const opencodeDetail = document.getElementById("opencode-detail-view"); + if (detail) detail.hidden = true; + if (opencodeDetail) opencodeDetail.hidden = true; + if (grid) grid.hidden = false; + document.querySelector('[data-integration-card="claude"]')?.focus(); +} + +function showOpenCodeDetailView() { + const grid = document.getElementById("integrations-view"); + const detail = document.getElementById("opencode-detail-view"); + const claude = document.getElementById("claude-detail-view"); + if (grid) grid.hidden = true; + if (claude) claude.hidden = true; + if (detail) detail.hidden = false; + document.getElementById("opencode-detail-title")?.focus(); +} + +function displayClaudeStatusLabel(snapshot) { + if (snapshot.status.canReplace && snapshot.status.canRemove) return snapshot.status.label; + if (snapshot.status.state === "configured") return "Installed"; + return snapshot.status.label; +} + +function statusTitleFor(snapshot) { + const state = snapshot.status.state; + if (state === "configured" && snapshot.status.canReplace) return "Installed with custom settings"; + if (state === "configured") return "OpenPets is connected"; + if (state === "needs_setup") return "Ready to configure"; + if (state === "detected") return "Claude detected"; + if (state === "not_detected") return "Claude not found"; + return "Needs attention"; +} + +function statusClassFor(state) { + if (state === "configured") return "success"; + if (state === "needs_setup" || state === "detected") return "info"; + if (state === "not_detected") return "muted"; + return "error"; +} + +function hookStatusClassFor(status) { + if (status === "installed") return "success"; + if (status === "needs_update") return "info"; + if (status === "error") return "error"; + return "muted"; +} + +function formatMemoryStatus(status) { + if (status === "installed") return "Installed"; + if (status === "error") return "Error"; + return "Not installed"; +} + +function memoryStatusClassFor(status) { + if (status === "installed") return "success"; + if (status === "error") return "error"; + return "muted"; +} + +function decorateAgentSetupButtons() { + for (const id of ["claude-configure", "claude-refresh", "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-copy-config"]) { + delete requireButton(id).dataset.loading; + } + setIconButtonContent(requireButton("claude-configure"), "plug", "Install integration"); + setIconButtonContent(requireButton("claude-refresh"), "refresh", "Refresh"); + setIconButtonContent(requireButton("claude-copy-command"), "copy", "Copy command"); + setIconButtonContent(requireButton("claude-replace"), "repeat", "Replace configuration"); + requireButton("claude-replace").className = "agent-action primary"; + setIconButtonContent(requireButton("claude-remove"), "trash", "Remove integration"); + setIconButtonContent(requireButton("claude-memory-install"), "book", "Update instructions"); + setIconButtonContent(requireButton("claude-hooks-doctor"), "stethoscope", "Check hooks"); + setIconButtonContent(requireButton("claude-hooks-install"), "download", "Install hooks"); + setIconButtonContent(requireButton("claude-hooks-uninstall"), "trash", "Remove hooks"); + setIconButtonContent(requireButton("opencode-install"), "download", "Install global setup"); + setIconButtonContent(requireButton("opencode-remove"), "trash", "Remove global setup"); + setIconButtonContent(requireButton("opencode-refresh"), "refresh", "Refresh"); + setIconButtonContent(requireButton("opencode-copy-config"), "copy", "Copy config preview"); +} + +function updateClaudeDetailActions(snapshot) { + const configure = requireButton("claude-configure"); + const replace = requireButton("claude-replace"); + const remove = requireButton("claude-remove"); + configure.hidden = !snapshot.status.canConfigure; + replace.hidden = !snapshot.status.canReplace; + remove.hidden = !snapshot.status.canRemove; +} + +function getCommandMode() { + if (activeAgentCommandMode === "bundled") return "bundled"; + const checkbox = requireInput("claude-dev-mode"); + return checkbox.checked ? "local" : "published"; +} + +function formatHookStatus(status) { + if (status === "installed") return "Installed"; + if (status === "needs_update") return "Needs update"; + if (status === "error") return "Error"; + return "Not installed"; +} + +function renderPetSelect(select, snapshot, selected) { + const previous = select.value || selected; + select.textContent = ""; + const defaultOption = document.createElement("option"); + defaultOption.value = ""; + defaultOption.textContent = "Default pet"; + select.append(defaultOption); + for (const pet of snapshot.petOptions) { + const option = document.createElement("option"); + option.value = pet.id; + option.textContent = pet.default ? `${pet.displayName} (${pet.id}, current default)` : `${pet.displayName} (${pet.id})`; + select.append(option); + } + select.value = snapshot.petOptions.some((pet) => pet.id === previous) ? previous : ""; +} + +function createClaudeSetupWarning(snapshot) { + const removeWarning = "Remove deletes the Claude MCP server named openpets and removes OpenPets-managed Claude instructions."; + if (snapshot.commandMode === "bundled") { + const note = "Packaged mode uses bundled OpenPets commands inside this app. Moving or deleting OpenPets may require Replace/Install again."; + if (!snapshot.status.canRemove && !snapshot.status.canReplace) return note; + if (snapshot.status.canReplace) return `${note} This existing Claude entry is treated as installed and will be kept unless you choose Replace. ${removeWarning}`; + return `${note} ${removeWarning}`; + } + if (!snapshot.status.canRemove && !snapshot.status.canReplace) return ""; + if (snapshot.status.canReplace) { + return `This existing Claude entry is treated as installed and will be kept unless you choose Replace. ${removeWarning}`; + } + return removeWarning; +} + +function bindAgentSetupButton(id, handler, disabled, loadingText) { + const button = requireButton(id); + button.disabled = Boolean(disabled); + button.onclick = () => { + if (button.disabled) return; + if (!loadingText) { + void Promise.resolve(handler()).catch(renderCaughtError); + return; + } + void runAgentSetupButtonAction(id, handler, loadingText).catch(renderCaughtError); + }; +} + +async function runAgentSetupButtonAction(id, handler, loadingText) { + const button = requireButton(id); + const previous = button.textContent || "Working…"; + setAgentSetupControlsBusy(true); + if (loadingText) { + button.dataset.loading = "true"; + setIconButtonContent(button, "spinner", loadingText); + const result = document.getElementById(id.startsWith("opencode-") ? "opencode-action-result" : "claude-action-result"); + if (result) result.textContent = loadingText; + } + try { + await Promise.resolve(handler()); + } catch (error) { + delete button.dataset.loading; + restoreAgentSetupControls(); + decorateAgentSetupButtons(); + throw error; + } +} + +function setAgentSetupControlsBusy(busy) { + const ids = [ + "claude-configure", + "claude-replace", + "claude-remove", + "claude-memory-install", + "claude-refresh", + "claude-copy-command", + "claude-hooks-doctor", + "claude-hooks-install", + "claude-hooks-uninstall", + "opencode-install", + "opencode-remove", + "opencode-refresh", + "opencode-copy-config", + ]; + if (busy) { + agentSetupControlStates = new Map(); + for (const id of ids) { + const button = document.getElementById(id); + if (button instanceof HTMLButtonElement) agentSetupControlStates.set(id, button.disabled); + } + const select = document.getElementById("claude-pet-select"); + if (select instanceof HTMLSelectElement) agentSetupControlStates.set("claude-pet-select", select.disabled); + const devMode = document.getElementById("claude-dev-mode"); + if (devMode instanceof HTMLInputElement) agentSetupControlStates.set("claude-dev-mode", devMode.disabled); + } + for (const id of ids) { + const button = document.getElementById(id); + if (button instanceof HTMLButtonElement) button.disabled = busy; + } + const select = document.getElementById("claude-pet-select"); + if (select instanceof HTMLSelectElement) select.disabled = busy; + const devMode = document.getElementById("claude-dev-mode"); + if (devMode instanceof HTMLInputElement) devMode.disabled = busy || activeAgentCommandMode === "bundled"; +} + +function restoreAgentSetupControls() { + if (!agentSetupControlStates) return; + for (const [id, disabled] of agentSetupControlStates) { + const control = document.getElementById(id); + if (control instanceof HTMLButtonElement || control instanceof HTMLSelectElement || control instanceof HTMLInputElement) { + control.disabled = Boolean(disabled); + } + } + agentSetupControlStates = null; +} + +async function runAgentAction(action, selectedPetId, commandMode) { + const snapshot = await agentSetupApi.action(action, selectedPetId || undefined, commandMode); + if (!isAgentSetupSnapshot(snapshot)) throw new Error("Claude setup action returned an invalid response."); + await renderAgentSetup(snapshot.selectedPetId || "", snapshot.commandMode); +} + +async function copyText(text, resultId = "claude-action-result", successMessage = "Copied command.") { + try { + await navigator.clipboard.writeText(text); + requireElement(resultId).textContent = successMessage; + } catch { + requireElement(resultId).textContent = text; + } +} + +async function renderPetManager(state) { + const defaultPetId = state.preferences.defaultPetId; + const [catalogState, codexState] = await Promise.all([api.getCatalog(), api.getCodexPets()]); + renderPetGallery(catalogState, codexState, state, defaultPetId); +} + +function renderPetGallery(catalogState, codexState, state, defaultPetId) { + const status = requireElement("catalog-status"); + const search = requireInput("catalog-search"); + const grid = requireElement("catalog-pets"); + const detail = requireElement("pm-detail"); + const defaultThumbnailSrc = document.body.dataset.defaultPetThumbnailSrc || ""; + + if (!isCatalogUiState(catalogState) || !isCodexPetsUiState(codexState)) { + status.textContent = "Unavailable"; + status.className = "pm-status-pill error"; + grid.textContent = ""; + detail.textContent = ""; + return; + } + + const sourceLabel = catalogState.source === "remote" ? "Live" : catalogState.source === "fixture" ? "Fixture" : "Error"; + const codexLabel = codexState.error ? "Codex unavailable" : `${codexState.pets.length} Codex`; + status.textContent = catalogState.error ? `${sourceLabel}: ${catalogState.error} · ${codexLabel}` : `${sourceLabel}: ${catalogState.pets.length} pets · ${codexLabel}`; + status.className = `pm-status-pill ${catalogState.error || codexState.error ? "error" : "success"}`; + const pets = createPetManagerItems(catalogState, codexState, state, defaultPetId, defaultThumbnailSrc); + activePetManagerItems = pets; + activePetManagerDefaultId = defaultPetId; + if (!activePetManagerSelection || !pets.some((pet) => pet.id === activePetManagerSelection)) { + activePetManagerSelection = defaultPetId || pets[0]?.id || ""; + } + + for (const filter of document.querySelectorAll("[data-pet-filter]")) { + filter.classList.toggle("active", filter.dataset.petFilter === activePetManagerFilter); + filter.setAttribute("aria-pressed", filter.dataset.petFilter === activePetManagerFilter ? "true" : "false"); + filter.onclick = () => { + activePetManagerFilter = filter.dataset.petFilter || "all"; + render(); + }; + } + + const render = () => { + for (const filter of document.querySelectorAll("[data-pet-filter]")) { + filter.classList.toggle("active", filter.dataset.petFilter === activePetManagerFilter); + filter.setAttribute("aria-pressed", filter.dataset.petFilter === activePetManagerFilter ? "true" : "false"); + } + + const query = search.value.trim().toLowerCase(); + const visiblePets = pets.filter((pet) => { + if (activePetManagerFilter === "installed" && !pet.installed) return false; + if (activePetManagerFilter === "codex" && !pet.codexPet && !pet.codexImported) return false; + const haystack = `${pet.id} ${pet.displayName} ${pet.description}`.toLowerCase(); + return haystack.includes(query); + }); + + grid.textContent = ""; + + for (const pet of visiblePets) { + grid.append(createPetGalleryCard(pet, defaultPetId, () => selectPetManagerPet(pet.id, detail))); + } + + if (visiblePets.length === 0) { + const empty = document.createElement("div"); + empty.className = "pm-empty-state"; + empty.textContent = activePetManagerFilter === "installed" ? "No installed pets match your search." : activePetManagerFilter === "codex" ? "No Codex pets match your search." : "No pets match your search."; + grid.append(empty); + } + + const selected = visiblePets.find((pet) => pet.id === activePetManagerSelection) || visiblePets[0] || pets.find((pet) => pet.id === activePetManagerSelection) || pets[0]; + if (selected) { + renderPetDetail(detail, selected, defaultPetId); + } + }; + + search.oninput = render; + render(); +} + +function createPetManagerItems(catalogState, codexState, state, defaultPetId, defaultThumbnailSrc) { + const installedById = new Map(state.pets.installed.map((pet) => [pet.id, pet])); + const codexById = new Map(codexState.pets.map((pet) => [pet.id, pet])); + const items = []; + + for (const installed of state.pets.installed) { + const catalogPet = catalogState.pets.find((pet) => pet.id === installed.id); + const codexPet = codexById.get(installed.id) || null; + const codexImported = installed.source?.kind === "codex"; + items.push(createPetManagerItem(installed.id, installed.displayName, installed.description || catalogPet?.description || codexPet?.description || "A friendly coding companion.", installed, catalogPet, codexPet, codexImported, defaultPetId, defaultThumbnailSrc)); + } + + for (const catalogPet of catalogState.pets) { + if (installedById.has(catalogPet.id)) continue; + const codexPet = codexById.get(catalogPet.id) || null; + items.push(createPetManagerItem(catalogPet.id, codexPet?.displayName || catalogPet.displayName, codexPet?.description || catalogPet.description || "A friendly coding companion.", null, catalogPet, codexPet, false, defaultPetId, defaultThumbnailSrc)); + } + + for (const codexPet of codexState.pets) { + if (installedById.has(codexPet.id) || catalogState.pets.some((pet) => pet.id === codexPet.id)) continue; + items.push(createPetManagerItem(codexPet.id, codexPet.displayName, codexPet.description || "A local Codex companion.", null, null, codexPet, false, defaultPetId, defaultThumbnailSrc)); + } + + return items; +} + +function createPetManagerItem(id, displayName, description, installed, catalogPet, codexPet, codexImported, defaultPetId, defaultThumbnailSrc) { + const preview = codexPet?.preview || (catalogPet && isAllowedCatalogPreview(catalogPet.preview) ? catalogPet.preview : ""); + const usesThumbnail = Boolean(installed?.builtIn && defaultThumbnailSrc); + return { + id, + displayName, + description, + installed, + catalogPet, + codexPet, + codexImported, + previewSrc: usesThumbnail ? defaultThumbnailSrc : preview, + previewIsSpriteSheet: !usesThumbnail, + isDefault: id === defaultPetId, + protected: Boolean(installed?.protected), + broken: Boolean(installed?.broken), + brokenReason: installed?.brokenReason || "", + }; +} + +function createPetGalleryCard(pet, defaultPetId, onSelect) { + const card = document.createElement("article"); + card.className = pet.id === activePetManagerSelection ? "pm-pet-card active" : "pm-pet-card"; + card.dataset.petId = pet.id; + card.tabIndex = 0; + card.setAttribute("role", "button"); + card.setAttribute("aria-pressed", pet.id === activePetManagerSelection ? "true" : "false"); + card.setAttribute("aria-label", `Preview ${pet.displayName}`); + card.addEventListener("click", onSelect); + card.addEventListener("keydown", (event) => { + if (event.target !== card) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(); + } + }); + + card.append(createSpriteFrame("pm-thumb", pet.previewSrc, pet.displayName, { isSpriteSheet: pet.previewIsSpriteSheet })); + + const name = document.createElement("div"); + name.className = "pm-pet-name"; + name.textContent = pet.displayName; + card.append(name); + + const action = document.createElement("button"); + action.className = createCardActionClass(pet); + action.textContent = createCardActionLabel(pet); + action.disabled = pet.isDefault || pet.broken || pet.protected; + action.addEventListener("click", (event) => { + event.stopPropagation(); + if (!pet.installed) { + runPetPrimaryAction(pet, card); + } else { + runPetRemoveAction(pet, card, defaultPetId); + } + }); + card.append(action); + + return card; +} + +function createCardActionClass(pet) { + if (pet.isDefault || pet.broken || pet.protected) return "pm-card-action status"; + if (pet.installed) return "pm-card-action danger"; + return "pm-card-action"; +} + +function createCardActionLabel(pet) { + if (pet.broken) return "Broken"; + if (pet.isDefault) return "Default"; + if (pet.protected) return "Protected"; + if (pet.installed) return "Remove"; + if (pet.codexPet) return "Import"; + return "Install"; +} + +function selectPetManagerPet(petId, detailContainer) { + activePetManagerSelection = petId; + for (const card of document.querySelectorAll(".pm-pet-card[data-pet-id]")) { + const active = card.dataset.petId === petId; + card.classList.toggle("active", active); + card.setAttribute("aria-pressed", active ? "true" : "false"); + } + + const pet = activePetManagerItems.find((item) => item.id === petId); + if (pet && detailContainer) renderPetDetail(detailContainer, pet, activePetManagerDefaultId); +} + +function renderPetDetail(container, pet, defaultPetId) { + container.textContent = ""; + + const title = document.createElement("h2"); + title.className = "pm-detail-title"; + title.textContent = pet.displayName; + container.append(title); + + const description = document.createElement("p"); + description.className = "pm-detail-description"; + description.textContent = pet.description || "A friendly coding companion."; + container.append(description); + + const stage = document.createElement("div"); + stage.className = "pm-hero-stage"; + stage.append(createSpriteFrame("pm-preview-sprite", pet.previewSrc, pet.displayName, { animated: true, isSpriteSheet: pet.previewIsSpriteSheet, state: "idle" })); + container.append(stage); + + const status = document.createElement("p"); + status.className = "pm-status-line"; + status.textContent = createPetStatusText(pet); + container.append(status); + + if (pet.brokenReason) { + const broken = document.createElement("p"); + broken.className = "error"; + broken.textContent = pet.brokenReason; + container.append(broken); + } + + const previewTitle = document.createElement("h3"); + previewTitle.className = "pm-preview-title"; + previewTitle.textContent = "Preview"; + container.append(previewTitle); + + const miniGrid = document.createElement("div"); + miniGrid.className = "pm-mini-grid"; + for (const preview of [{ label: "Thinking", state: "thinking" }, { label: "Happy", state: "happy" }, { label: "Wave", state: "wave" }]) { + const mini = document.createElement("div"); + mini.className = "pm-mini"; + mini.append(createSpriteFrame("pm-mini-sprite", pet.previewSrc, pet.displayName, { animated: true, isSpriteSheet: pet.previewIsSpriteSheet, state: preview.state })); + const text = document.createElement("span"); + text.textContent = preview.label; + mini.append(text); + miniGrid.append(mini); + } + container.append(miniGrid); + + const actions = document.createElement("div"); + actions.className = "pm-detail-actions"; + const primary = document.createElement("button"); + primary.className = pet.isDefault || pet.broken ? "status" : ""; + setIconButtonContent(primary, pet.broken ? "alert" : pet.isDefault ? "check" : pet.installed ? "star" : "download", pet.broken ? "Broken" : pet.isDefault ? "Default" : pet.installed ? "Set default" : pet.codexPet ? "Import" : "Install"); + primary.disabled = pet.broken || pet.isDefault; + primary.addEventListener("click", () => runPetPrimaryAction(pet, actions)); + actions.append(primary); + + if (pet.installed) { + const remove = document.createElement("button"); + remove.className = "secondary"; + setIconButtonContent(remove, pet.protected ? "shield" : "trash", pet.protected ? "Protected" : "Remove"); + remove.disabled = pet.protected; + remove.addEventListener("click", () => runPetRemoveAction(pet, actions, defaultPetId)); + actions.append(remove); + } + container.append(actions); +} + +function setIconButtonContent(button, icon, label) { + button.textContent = ""; + button.append(createSvgIcon(icon), document.createTextNode(label)); +} + +function createSvgIcon(name) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("width", "16"); + svg.setAttribute("height", "16"); + svg.setAttribute("aria-hidden", "true"); + svg.setAttribute("focusable", "false"); + svg.classList.add("pm-button-icon"); + + for (const d of getIconPaths(name)) { + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + path.setAttribute("d", d); + path.setAttribute("fill", "none"); + path.setAttribute("stroke", "currentColor"); + path.setAttribute("stroke-width", "2"); + path.setAttribute("stroke-linecap", "round"); + path.setAttribute("stroke-linejoin", "round"); + svg.append(path); + } + + return svg; +} + +function getIconPaths(name) { + if (name === "download") return ["M12 15V3", "M7 10l5 5 5-5", "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"]; + if (name === "star") return ["M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.12 2.12 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.12 2.12 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.12 2.12 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.12 2.12 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.12 2.12 0 0 0 1.597-1.16z"]; + if (name === "check") return ["M20 6 9 17l-5-5"]; + if (name === "trash") return ["M10 11v6", "M14 11v6", "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6", "M3 6h18", "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"]; + if (name === "shield") return ["M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"]; + if (name === "plug") return ["M12 22v-5", "M9 8V2", "M15 8V2", "M18 8v5a6 6 0 0 1-12 0V8z"]; + if (name === "refresh") return ["M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8", "M3 3v5h5", "M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16", "M16 16h5v5"]; + if (name === "copy") return ["M8 8h8v8H8z", "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2", "M10 22h10c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2"]; + if (name === "repeat") return ["m17 2 4 4-4 4", "M3 11V9a4 4 0 0 1 4-4h14", "m7 22-4-4 4-4", "M21 13v2a4 4 0 0 1-4 4H3"]; + if (name === "book") return ["M4 19.5A2.5 2.5 0 0 1 6.5 17H20", "M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5z"]; + if (name === "stethoscope") return ["M11 2v2", "M5 2v2", "M5 3H4a2 2 0 0 0-2 2v4a6 6 0 0 0 12 0V5a2 2 0 0 0-2-2h-1", "M8 15a6 6 0 0 0 12 0v-3", "M20 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4"]; + if (name === "spinner") return ["M12 3a9 9 0 1 0 9 9"]; + if (name === "settings") return ["M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z", "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"]; + return ["m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3", "M12 9v4", "M12 17h.01"]; +} + +function createPetStatusText(pet) { + if (pet.broken) return "This installed pet is broken and cannot be selected as default."; + if (pet.isDefault) return pet.protected ? "Default built-in pet. Protected from removal." : "Default pet."; + if (pet.installed && pet.codexImported) return "Imported from your local Codex pets and ready to become your default pet."; + if (pet.installed && pet.codexPet) return "Installed and ready to become your default pet. Also found in ~/.codex/pets."; + if (pet.installed) return "Installed and ready to become your default pet."; + if (pet.codexPet && !pet.catalogPet) return "Available to import from ~/.codex/pets."; + if (pet.codexPet) return "Available in the catalog and also found in ~/.codex/pets. Import uses the local Codex copy."; + return "Available to install from the catalog."; +} + +function runPetPrimaryAction(pet, busyContainer) { + if (pet.broken || pet.isDefault) return; + const importing = Boolean(!pet.installed && pet.codexPet); + setCardBusy(busyContainer, true, pet.installed ? "Setting…" : importing ? "Importing…" : "Installing…"); + const action = pet.installed ? api.setDefaultPet(pet.id) : importing ? api.importCodexPet(pet.id) : api.installPet(pet.id); + void action.then(() => { + activePetManagerSelection = pet.id; + return renderCurrentState("pet-manager"); + }).catch(renderCaughtError).finally(() => setCardBusy(busyContainer, false)); +} + +function runPetRemoveAction(pet, busyContainer, defaultPetId) { + if (!pet.installed || pet.protected) return; + setCardBusy(busyContainer, true, "Removing…"); + void api.removePet(pet.id).then(() => { + if (activePetManagerSelection === pet.id) activePetManagerSelection = defaultPetId; + return renderCurrentState("pet-manager"); + }).catch(renderCaughtError).finally(() => setCardBusy(busyContainer, false)); +} + +function createSpriteFrame(className, src, alt, options = {}) { + const animated = Boolean(options.animated); + const isSpriteSheet = options.isSpriteSheet !== false; + const state = options.state || "idle"; + const frame = document.createElement("div"); + frame.className = `pm-sprite-frame ${className}`; + frame.setAttribute("role", "img"); + frame.setAttribute("aria-label", alt); + if (!isSpriteSheet) frame.classList.add("pm-thumbnail-frame"); + if (!src) { + frame.classList.add("pm-empty-sprite"); + return frame; + } + + const image = new Image(); + image.referrerPolicy = "no-referrer"; + image.decoding = "async"; + image.addEventListener("load", () => { + frame.style.backgroundImage = `url(${JSON.stringify(src)})`; + if (!isSpriteSheet) return; + frame.classList.add(`pm-sprite-state-${state}`); + if (animated) frame.classList.add("pm-animate-sprite"); + }); + image.addEventListener("error", () => { + frame.style.backgroundImage = ""; + frame.classList.remove("pm-animate-sprite"); + frame.classList.add("pm-empty-sprite"); + }); + image.src = src; + return frame; +} + +function isAllowedCatalogPreview(value) { + if (typeof value !== "string") return false; + try { + const url = new URL(value); + return url.protocol === "https:" + && url.hostname === "openpets.dev" + && url.port === "" + && url.username === "" + && url.password === "" + && url.pathname.startsWith("/pets/") + && url.pathname.endsWith(".webp"); + } catch { + return false; + } +} + +function setCardBusy(card, busy, label) { + for (const button of card.querySelectorAll("button")) { + if (busy) { + button.dataset.previousDisabled = button.disabled ? "true" : "false"; + if (label) button.dataset.previousText = button.textContent || ""; + button.disabled = true; + if (label) button.textContent = label; + } else { + button.disabled = button.dataset.previousDisabled === "true"; + if (button.dataset.previousText) button.textContent = button.dataset.previousText; + delete button.dataset.previousDisabled; + delete button.dataset.previousText; + } + } +} + +function renderSettings(state) { + const openOnLaunch = requireInput("open-default-pet-on-launch"); + const launchAtLogin = requireInput("launch-at-login"); + const launchAtLoginDetail = requireElement("launch-at-login-detail"); + const scaleSelect = requireSelect("pet-scale"); + const scale = requireElement("pet-scale-value"); + const status = requireElement("settings-status"); + + openOnLaunch.checked = state.preferences.openDefaultPetOnLaunch; + scaleSelect.value = String(state.preferences.petScale); + openOnLaunch.disabled = false; + scaleSelect.disabled = false; + scale.textContent = `${scaleLabelFor(state.preferences.petScale)} (${state.preferences.petScale}x)`; + + bindCheckbox(openOnLaunch, "openDefaultPetOnLaunch", "Launch preference saved."); + bindScaleSelect(scaleSelect, String(state.preferences.petScale)); + bindLaunchAtLogin(launchAtLogin, launchAtLoginDetail); + bindUpdateControls(); + + const resetButton = requireButton("reset-default-pet-position"); + resetButton.disabled = false; + resetButton.onclick = () => { + status.textContent = "Resetting pet position…"; + resetButton.disabled = true; + void api.resetDefaultPetPosition().then(async () => { + await renderCurrentState("settings"); + requireElement("settings-status").textContent = "Default pet position reset."; + }).catch((error) => { + resetButton.disabled = false; + status.textContent = "Couldn’t reset pet position. Try again."; + renderCaughtError(error); + }); + }; +} + +function bindUpdateControls() { + const checkButton = requireButton("check-for-updates"); + const openButton = requireButton("open-update-release"); + checkButton.onclick = () => { + checkButton.disabled = true; + requireElement("settings-status").textContent = "Checking for updates…"; + renderUpdateStatus({ state: "checking" }); + void api.checkForUpdates().then((status) => { + renderUpdateStatus(status); + requireElement("settings-status").textContent = updateStatusMessage(status); + }).catch((error) => { + checkButton.disabled = false; + requireElement("settings-status").textContent = "Couldn’t check for updates. Try again."; + renderCaughtError(error); + }); + }; + openButton.onclick = () => { + void api.openUpdateReleasePage().catch(renderCaughtError); + }; + void api.getUpdateStatus().then((status) => { + renderUpdateStatus(status); + if (status.state === "checking") { + void api.checkForUpdates().then(renderUpdateStatus).catch(renderCaughtError); + } + }).catch(renderCaughtError); +} + +function renderUpdateStatus(status) { + const title = requireElement("update-status-title"); + const detail = requireElement("update-status-detail"); + const checkButton = requireButton("check-for-updates"); + const openButton = requireButton("open-update-release"); + checkButton.disabled = status.state === "checking"; + openButton.hidden = status.state !== "available"; + if (status.state === "available") { + title.textContent = `Update available: ${status.latestVersion || "latest"}`; + detail.textContent = `Installed: ${status.currentVersion || "unknown"}. Open the GitHub release page to download the update.`; + } else if (status.state === "current") { + title.textContent = "OpenPets is up to date"; + detail.textContent = `Installed: ${status.currentVersion || "unknown"}. Latest public release: ${status.latestVersion || "unknown"}.`; + } else if (status.state === "checking") { + title.textContent = "Checking for updates"; + detail.textContent = "Looking for the latest public GitHub release…"; + } else if (status.state === "error") { + title.textContent = "Update check unavailable"; + detail.textContent = status.error || "Couldn’t read the latest public GitHub release."; + } else { + title.textContent = "Check for updates"; + detail.textContent = "OpenPets checks public GitHub releases and opens the release page when an update is available."; + } +} + +function updateStatusMessage(status) { + if (status.state === "available") return `Update ${status.latestVersion || "latest"} is available.`; + if (status.state === "current") return "OpenPets is up to date."; + if (status.state === "error") return "Couldn’t check for updates."; + return "Update check finished."; +} + +function bindLaunchAtLogin(input, detail) { + input.disabled = true; + detail.textContent = "Checking login setting…"; + void api.getLaunchAtLogin().then((state) => { + if (!isLaunchAtLoginState(state)) throw new Error("Launch-at-login status is unavailable."); + input.checked = state.enabled; + input.disabled = !state.supported; + detail.textContent = state.supported ? "Start OpenPets automatically when you sign in." : "Launch at login is not available on this platform."; + }).catch((error) => { + input.disabled = true; + detail.textContent = "Couldn’t read login setting."; + renderCaughtError(error); + }); + input.onchange = () => { + const previous = !input.checked; + input.disabled = true; + const status = requireElement("settings-status"); + status.textContent = input.checked ? "Enabling launch at login…" : "Disabling launch at login…"; + void api.setLaunchAtLogin(input.checked).then((state) => { + if (!isLaunchAtLoginState(state)) throw new Error("Launch-at-login update failed."); + input.checked = state.enabled; + input.disabled = !state.supported; + status.textContent = state.supported ? "Launch at login preference saved." : "Launch at login is not available on this platform."; + }).catch((error) => { + input.checked = previous; + input.disabled = false; + status.textContent = "Couldn’t update launch at login. Try again."; + renderCaughtError(error); + }); + }; +} + +function bindScaleSelect(select, currentValue) { + select.onchange = () => { + const previous = currentValue; + const value = Number(select.value); + select.disabled = true; + const status = requireElement("settings-status"); + status.textContent = "Saving scale…"; + void api.updatePreferences({ petScale: value }).then(async () => { + await renderCurrentState("settings"); + requireElement("settings-status").textContent = `${scaleLabelFor(value)} pet scale saved.`; + }).catch((error) => { + select.value = previous; + select.disabled = false; + status.textContent = "Couldn’t save pet scale. Try again."; + renderCaughtError(error); + }); + }; +} + +function scaleLabelFor(value) { + if (value === 0.44) return "Small"; + if (value === 0.56) return "Medium"; + if (value === 0.72) return "Large"; + return "Custom"; +} + +function bindCheckbox(input, key, message) { + input.onchange = () => { + const previous = !input.checked; + input.disabled = true; + const status = requireElement("settings-status"); + status.textContent = "Saving…"; + void api.updatePreferences({ [key]: input.checked }).then(async () => { + await renderCurrentState("settings"); + requireElement("settings-status").textContent = message; + }).catch((error) => { + input.checked = previous; + input.disabled = false; + status.textContent = "Couldn’t save setting. Try again."; + renderCaughtError(error); + }); + }; +} + +function createBadge(label, className) { + const badge = document.createElement("span"); + badge.className = className ? `badge ${className}` : "badge"; + badge.textContent = label; + return badge; +} + +function renderCaughtError(error) { + renderError(error instanceof Error ? error.message : "OpenPets action failed."); +} + +function renderError(message) { + const error = document.querySelector("[data-error]"); + if (error) { + error.textContent = message; + } +} + +function requireElement(id) { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing element: ${id}`); + return element; +} + +function requireInput(id) { + const element = document.getElementById(id); + if (!(element instanceof HTMLInputElement)) throw new Error(`Missing input: ${id}`); + return element; +} + +function requireSelect(id) { + const element = document.getElementById(id); + if (!(element instanceof HTMLSelectElement)) throw new Error(`Missing select: ${id}`); + return element; +} + +function requireButton(id) { + const element = document.getElementById(id); + if (!(element instanceof HTMLButtonElement)) throw new Error(`Missing button: ${id}`); + return element; +} + +function isStateSnapshot(value) { + if (!isRecord(value) || !isRecord(value.preferences) || !isRecord(value.pets) || !Array.isArray(value.pets.installed)) { + return false; + } + + return typeof value.preferences.defaultPetId === "string" + && typeof value.preferences.openDefaultPetOnLaunch === "boolean" + && typeof value.preferences.speechBubblesEnabled === "boolean" + && typeof value.preferences.petScale === "number" + && typeof value.preferences.onboardingCompleted === "boolean"; +} + +function isOnboardingSnapshot(value) { + return isRecord(value) + && typeof value.defaultPetName === "string" + && typeof value.onboardingCompleted === "boolean"; +} + +function isLaunchAtLoginState(value) { + return isRecord(value) + && typeof value.supported === "boolean" + && typeof value.enabled === "boolean"; +} + +function isCatalogUiState(value) { + return isRecord(value) + && (value.source === "remote" || value.source === "fixture" || value.source === "error") + && Array.isArray(value.pets); +} + +function isCodexPetsUiState(value) { + return isRecord(value) + && value.source === "codex" + && Array.isArray(value.pets); +} + +function isAgentSetupSnapshot(value) { + return isRecord(value) + && isRecord(value.status) + && isRecord(value.hookStatus) + && isRecord(value.memoryStatus) + && isRecord(value.opencodeStatus) + && isRecord(value.opencodePreview) + && isRecord(value.preview) + && Array.isArray(value.petOptions) + && typeof value.busy === "boolean" + && (value.commandMode === "published" || value.commandMode === "local" || value.commandMode === "bundled") + && typeof value.localDevAvailable === "boolean" + && typeof value.preview.displayCommand === "string" + && isRecord(value.preview.mcpJson) + && isRecord(value.hookStatus.preview) + && typeof value.status.label === "string" + && typeof value.status.details === "string" + && typeof value.hookStatus.status === "string" + && typeof value.hookStatus.message === "string" + && typeof value.hookStatus.settingsPath === "string" + && typeof value.memoryStatus.status === "string" + && typeof value.memoryStatus.message === "string" + && typeof value.memoryStatus.claudeMdPath === "string" + && typeof value.memoryStatus.openPetsMemoryPath === "string"; +} + +function isRecord(value) { + return typeof value === "object" && value !== null; +} diff --git a/apps/desktop/scripts/clean-package-output.cjs b/apps/desktop/scripts/clean-package-output.cjs new file mode 100644 index 00000000..03f944b1 --- /dev/null +++ b/apps/desktop/scripts/clean-package-output.cjs @@ -0,0 +1,12 @@ +const { rmSync } = require("node:fs"); +const { basename, dirname, resolve } = require("node:path"); + +const scriptsDir = __dirname; +const desktopDir = resolve(scriptsDir, ".."); +const target = resolve(desktopDir, "dist-electron"); + +if (basename(target) !== "dist-electron" || dirname(target) !== desktopDir) { + throw new Error(`Refusing to clean unexpected package output path: ${target}`); +} + +rmSync(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); diff --git a/apps/desktop/scripts/release-local.mjs b/apps/desktop/scripts/release-local.mjs new file mode 100644 index 00000000..a7cc1bdc --- /dev/null +++ b/apps/desktop/scripts/release-local.mjs @@ -0,0 +1,218 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { basename, dirname, extname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const desktopDir = resolve(scriptsDir, ".."); +const repoRoot = resolve(desktopDir, "../.."); +const outputDir = join(desktopDir, "dist-electron"); +const repository = "alvinunreal/openpets"; + +const allowedArgs = new Set([ + "--dry-run", + "--yes", + "--include-optional", + "--include-mac-zip", + "--include-win-portable", + "--include-linux-deb", + "--include-linux-targz", + "--include-experimental-arm", + "--skip-checks", + "--help", +]); +const rawArgs = process.argv.slice(2).filter((arg) => arg !== "--"); +const unknownArgs = rawArgs.filter((arg) => !allowedArgs.has(arg)); +if (unknownArgs.length > 0) throw new Error(`Unknown release option(s): ${unknownArgs.join(", ")}`); +const args = new Set(rawArgs); +const dryRun = args.has("--dry-run"); +const yes = args.has("--yes"); +const includeOptional = args.has("--include-optional"); +const includeMacZip = includeOptional || args.has("--include-mac-zip"); +const includeWinPortable = includeOptional || args.has("--include-win-portable"); +const includeLinuxDeb = includeOptional || args.has("--include-linux-deb"); +const includeLinuxTarGz = includeOptional || args.has("--include-linux-targz"); +const includeExperimentalArm = args.has("--include-experimental-arm"); +const skipChecks = args.has("--skip-checks"); +if (skipChecks && yes) throw new Error("Refusing to create a release with --skip-checks. Run checks before using --yes."); + +if (args.has("--help")) { + printHelp(); + process.exit(0); +} + +const desktopPackageJson = readJson(join(desktopDir, "package.json")); +const version = desktopPackageJson.version; +const tag = `v${version}`; + +main(); + +function main() { + preflight(); + if (!skipChecks) { + run("pnpm", ["build"], { cwd: repoRoot }); + run("pnpm", ["--filter", "@open-pets/desktop", "check"], { cwd: repoRoot }); + } + + run("node", ["scripts/clean-package-output.cjs"], { cwd: desktopDir }); + mkdirSync(outputDir, { recursive: true }); + + for (const build of createBuildPlan()) { + run("pnpm", ["exec", "electron-builder", ...build.args, "--publish", "never"], { cwd: desktopDir }); + } + + const postBuildStatus = getGitStatusIgnoringPackageOutput(); + if (postBuildStatus) throw new Error(`Build/checks changed tracked or source files. Commit or revert them before releasing.\n${postBuildStatus}`); + + const artifacts = collectArtifacts(outputDir); + if (artifacts.length === 0) throw new Error("No release artifacts were produced."); + const checksumsPath = writeChecksums(artifacts); + const uploadArtifacts = [...artifacts, checksumsPath]; + + console.log("\nRelease artifacts:"); + for (const artifact of uploadArtifacts) console.log(`- ${relative(repoRoot, artifact)}`); + + if (!yes && !dryRun) { + throw new Error("Re-run with --yes to create the draft GitHub release after reviewing the artifact list."); + } + if (dryRun) { + console.log(`\nDry run complete. Would create draft release ${tag} in ${repository}.`); + return; + } + + const target = commandOutput("git", ["rev-parse", "HEAD"], { cwd: repoRoot }).trim(); + run("gh", ["release", "create", tag, "--repo", repository, "--target", target, "--draft", "--title", `OpenPets ${tag}`, "--notes", defaultReleaseNotes()], { cwd: repoRoot }); + run("gh", ["release", "upload", tag, "--repo", repository, ...uploadArtifacts], { cwd: repoRoot }); + console.log(`\nDraft release created: https://github.com/${repository}/releases/tag/${tag}`); + console.log("Draft releases are not visible to the app update checker until you publish them."); +} + +function preflight() { + if (process.platform !== "darwin") throw new Error("This local release script is intended to run from macOS."); + if (!isStableSemver(version) || version === "0.0.0") { + throw new Error(`Desktop package version must be a stable non-zero semver version. Current: ${version}`); + } + requireCommand("pnpm", ["--version"]); + requireCommand("gh", ["--version"]); + run("gh", ["auth", "status", "--hostname", "github.com"], { cwd: repoRoot }); + + const remoteUrl = commandOutput("git", ["remote", "get-url", "origin"], { cwd: repoRoot }).trim(); + if (!remoteUrl.includes(repository)) { + throw new Error(`Expected origin remote to point at ${repository}. Current origin: ${remoteUrl}`); + } + const status = commandOutput("git", ["status", "--porcelain"], { cwd: repoRoot }).trim(); + if (status) throw new Error(`Git working tree must be clean before release.\n${status}`); + + run("git", ["rev-parse", "--verify", "HEAD"], { cwd: repoRoot }); + const upstream = commandOutput("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { cwd: repoRoot }).trim(); + if (!upstream) throw new Error("Release branch must have an upstream remote branch."); + run("git", ["fetch", "--tags", "origin"], { cwd: repoRoot }); + const localHead = commandOutput("git", ["rev-parse", "HEAD"], { cwd: repoRoot }).trim(); + const remoteHead = commandOutput("git", ["rev-parse", upstream], { cwd: repoRoot }).trim(); + if (localHead !== remoteHead) throw new Error(`HEAD must be pushed to ${upstream} before release.`); + + if (commandSucceeds("git", ["rev-parse", "--verify", `refs/tags/${tag}`], { cwd: repoRoot })) { + throw new Error(`Git tag already exists locally: ${tag}`); + } + if (commandSucceeds("git", ["ls-remote", "--exit-code", "--tags", "origin", tag], { cwd: repoRoot })) { + throw new Error(`Git tag already exists on origin: ${tag}`); + } + if (commandSucceeds("gh", ["release", "view", tag, "--repo", repository], { cwd: repoRoot })) { + throw new Error(`GitHub release already exists: ${tag}`); + } +} + +function createBuildPlan() { + const plan = [ + { name: "mac dmg x64+arm64", args: ["--mac", "dmg", "--x64", "--arm64"] }, + { name: "windows nsis x64", args: ["--win", "nsis", "--x64"] }, + { name: "linux AppImage x64", args: ["--linux", "AppImage", "--x64"] }, + ]; + if (includeMacZip) plan.push({ name: "mac zip x64+arm64", args: ["--mac", "zip", "--x64", "--arm64"] }); + if (includeWinPortable) plan.push({ name: "windows portable x64", args: ["--win", "portable", "--x64"] }); + if (includeLinuxDeb) plan.push({ name: "linux deb x64", args: ["--linux", "deb", "--x64"] }); + if (includeLinuxTarGz) plan.push({ name: "linux tar.gz x64", args: ["--linux", "tar.gz", "--x64"] }); + if (includeExperimentalArm) { + plan.push({ name: "windows nsis arm64", args: ["--win", "nsis", "--arm64"] }); + plan.push({ name: "linux AppImage arm64", args: ["--linux", "AppImage", "--arm64"] }); + } + console.log("Build plan:"); + for (const build of plan) console.log(`- ${build.name}`); + return plan; +} + +function collectArtifacts(dir) { + const allowedNames = new Set(["SHA256SUMS"]); + const allowedExtensions = new Set([".dmg", ".zip", ".exe", ".AppImage", ".deb"]); + const artifacts = []; + for (const entry of readdirSync(dir)) { + const filePath = join(dir, entry); + const stat = statSync(filePath); + if (!stat.isFile()) continue; + const name = basename(filePath); + if (allowedNames.has(name) || allowedExtensions.has(extname(name)) || name.endsWith(".tar.gz")) artifacts.push(filePath); + } + return artifacts.filter((path) => basename(path) !== "SHA256SUMS").sort(); +} + +function writeChecksums(artifacts) { + const lines = artifacts.map((artifact) => `${sha256(artifact)} ${basename(artifact)}`); + const checksumsPath = join(outputDir, "SHA256SUMS"); + writeFileSync(checksumsPath, `${lines.join("\n")}\n`); + return checksumsPath; +} + +function sha256(filePath) { + return createHash("sha256").update(readFileSync(filePath)).digest("hex"); +} + +function readJson(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function getGitStatusIgnoringPackageOutput() { + return commandOutput("git", ["status", "--porcelain"], { cwd: repoRoot }) + .split("\n") + .filter((line) => line.trim() && !line.includes("apps/desktop/dist-electron/")) + .join("\n"); +} + +function isStableSemver(value) { + return /^\d+\.\d+\.\d+$/.test(value); +} + +function requireCommand(command, args) { + if (!commandSucceeds(command, args, { cwd: repoRoot })) throw new Error(`Required command is unavailable: ${command}`); +} + +function commandSucceeds(command, args, options) { + return spawnSync(command, args, { cwd: options.cwd, stdio: "ignore" }).status === 0; +} + +function commandOutput(command, args, options) { + const result = spawnSync(command, args, { cwd: options.cwd, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed:\n${result.stderr || result.stdout}`); + return result.stdout; +} + +function run(command, args, options) { + console.log(`\n$ ${command} ${args.join(" ")}`); + const result = spawnSync(command, args, { cwd: options.cwd, stdio: "inherit" }); + if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed with exit code ${result.status ?? "unknown"}.`); +} + +function defaultReleaseNotes() { + return [ + `Local draft release for OpenPets ${tag}.`, + "", + "Before publishing:", + "- Smoke test macOS, Windows, and Linux artifacts.", + "- Expect Gatekeeper/SmartScreen warnings until signing/notarization is configured.", + ].join("\n"); +} + +function printHelp() { + console.log(`Usage: pnpm release:desktop -- --yes\n\nBuilds local desktop release artifacts, creates a draft GitHub release, and uploads artifacts.\n\nDefault targets:\n - macOS dmg x64+arm64\n - Windows nsis x64\n - Linux AppImage x64\n\nOptions:\n --yes create the draft GitHub release after building\n --dry-run run checks/builds and print what would be released\n --skip-checks skip pnpm build and desktop check\n --include-optional include all optional x64 targets below\n --include-mac-zip also build macOS zip x64+arm64\n --include-win-portable also build Windows portable x64\n --include-linux-deb also build Linux deb x64\n --include-linux-targz also build Linux tar.gz x64\n --include-experimental-arm also build Windows/Linux ARM64 artifacts\n`); +} diff --git a/apps/desktop/src/agent-pet-controller.ts b/apps/desktop/src/agent-pet-controller.ts new file mode 100644 index 00000000..03ffbfc0 --- /dev/null +++ b/apps/desktop/src/agent-pet-controller.ts @@ -0,0 +1,139 @@ +import { BrowserWindow } from "electron"; + +import { getAppStateSnapshot } from "./app-state.js"; +import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js"; +import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js"; +import { clearTransientReaction, createAgentPetWindow, getTransientReactionAnimationMs, loadExplicitPetContent, mergePetTransientDisplay, setPetReactionState, type PetTransientDisplay } from "./pet-window.js"; + +const agentPetWindows = new Map(); +const transientDisplays = new Map(); +const transientTimers = new Map(); +const transientAnimationTimers = new Map(); +const dismissedAgentPets = new Set(); + +export function showAgentPet(petId: string): boolean { + if (dismissedAgentPets.has(petId)) return false; + const window = getOrCreateAgentPetWindow(petId); + if (window.isMinimized()) window.restore(); + window.showInactive(); + return true; +} + +export function closeAgentPetIfOpen(petId: string): void { + const window = agentPetWindows.get(petId); + if (!window || window.isDestroyed()) return; + agentPetWindows.delete(petId); + clearAgentDisplay(petId); + window.destroy(); +} + +export function dismissAgentPetForActiveLease(petId: string): void { + dismissedAgentPets.add(petId); + closeAgentPetIfOpen(petId); +} + +export function clearAgentPetDismissal(petId: string): void { + dismissedAgentPets.delete(petId); +} + +export function clearAgentPetLeaseState(petId: string): void { + dismissedAgentPets.delete(petId); + closeAgentPetIfOpen(petId); + clearAgentDisplay(petId); +} + +export function applyAgentPetReaction(petId: string, reaction: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } { + setAgentDisplay(petId, { reaction }); + const shown = showAgentPet(petId); + return shown ? { shown } : { shown, reason: "dismissed" }; +} + +export function applyAgentPetSay(petId: string, message: string, reaction?: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } { + setAgentDisplay(petId, { message, reaction }); + const shown = showAgentPet(petId); + return shown ? { shown } : { shown, reason: "dismissed" }; +} + +export function closeAllAgentPets(): void { + for (const petId of [...agentPetWindows.keys()]) { + closeAgentPetIfOpen(petId); + } +} + +export function refreshAgentPetContent(): void { + for (const [petId, window] of agentPetWindows.entries()) { + if (!window.isDestroyed()) { + void loadExplicitPetContent(window, petId, transientDisplays.get(petId) ?? null); + } + } +} + +function getOrCreateAgentPetWindow(petId: string): BrowserWindow { + const existing = agentPetWindows.get(petId); + if (existing && !existing.isDestroyed()) return existing; + + const pet = getAppStateSnapshot().pets.installed.find((candidate) => candidate.id === petId); + if (!pet) throw new Error(`Installed pet is unavailable: ${petId}`); + const offset = agentPetWindows.size + 1; + const initial = getDefaultPetInitialPosition(defaultPetWindowSize); + const window = createAgentPetWindow({ + petId, + displayName: pet.displayName, + position: { x: initial.x - offset * 36, y: initial.y - offset * 24 }, + display: transientDisplays.get(petId) ?? null, + onCloseRequested: () => dismissAgentPetForActiveLease(petId), + }); + + window.on("closed", () => { + agentPetWindows.delete(petId); + clearAgentDisplay(petId); + }); + agentPetWindows.set(petId, window); + return window; +} + +function setAgentDisplay(petId: string, display: PetTransientDisplay): void { + const preparedDisplay = mergePetTransientDisplay(transientDisplays.get(petId) ?? null, display); + transientDisplays.set(petId, preparedDisplay); + const existingTimer = transientTimers.get(petId); + if (existingTimer) clearTimeout(existingTimer); + const existingAnimationTimer = transientAnimationTimers.get(petId); + if (existingAnimationTimer) clearTimeout(existingAnimationTimer); + + const animationMs = getTransientReactionAnimationMs(preparedDisplay); + if (animationMs !== null && animationMs < transientDisplayMs) { + const animationTimer = setTimeout(() => { + const current = transientDisplays.get(petId); + if (!current) return; + const updated = clearTransientReaction(current); + transientDisplays.set(petId, updated); + transientAnimationTimers.delete(petId); + const window = agentPetWindows.get(petId); + if (window && !window.isDestroyed()) setPetReactionState(window, "idle"); + }, animationMs); + transientAnimationTimers.set(petId, animationTimer); + } + + const timer = setTimeout(() => { + transientDisplays.delete(petId); + transientTimers.delete(petId); + const animationTimer = transientAnimationTimers.get(petId); + if (animationTimer) clearTimeout(animationTimer); + transientAnimationTimers.delete(petId); + const window = agentPetWindows.get(petId); + if (window && !window.isDestroyed()) void loadExplicitPetContent(window, petId, null); + }, transientDisplayMs); + transientTimers.set(petId, timer); + const window = agentPetWindows.get(petId); + if (window && !window.isDestroyed()) void loadExplicitPetContent(window, petId, preparedDisplay); +} + +function clearAgentDisplay(petId: string): void { + const timer = transientTimers.get(petId); + if (timer) clearTimeout(timer); + const animationTimer = transientAnimationTimers.get(petId); + if (animationTimer) clearTimeout(animationTimer); + transientTimers.delete(petId); + transientAnimationTimers.delete(petId); + transientDisplays.delete(petId); +} diff --git a/apps/desktop/src/agent-setup.ts b/apps/desktop/src/agent-setup.ts new file mode 100644 index 00000000..83381fcf --- /dev/null +++ b/apps/desktop/src/agent-setup.ts @@ -0,0 +1,607 @@ +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, 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 { doctorOpenCodeGlobalSetup, getGlobalOpenCodeConfigDir, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@open-pets/opencode"; + +import { getAppStateSnapshot, type InstalledPetState } 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"; +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 busy: boolean; + readonly lastAction?: AgentSetupActionResult; +} + +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; +} + +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 { + 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); + + return { + selectedPetId: petId, + commandMode, + localDevAvailable: !app.isPackaged, + petOptions: getPetOptions(), + preview: preview.preview, + status, + hookStatus, + memoryStatus, + opencodeStatus: opencode.status, + opencodePreview: opencode.preview, + busy: operationRunning, + lastAction, + }; +} + +export async function runAgentSetupAction(action: AgentSetupAction, selectedPetId?: unknown, commandModeInput?: unknown): Promise { + 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, "") + .replace(/-----BEGIN [^-]+PRIVATE KEY-----[\s\S]*?-----END [^-]+PRIVATE KEY-----/gi, "") + .replace(/bearer\s+[A-Za-z0-9._~+\/-]+=*/gi, "Bearer ") + .replace(/\b(api[_-]?key|secret|password|token)\s*[:=]\s*\S+/gi, "$1=") + .slice(0, 500); +} + +function safeBuildClaudeMcpPreview(selectedPetId: string | undefined, commandMode: OpenPetsCommandMode): { readonly preview: ClaudeMcpPreview; readonly error?: string } { + try { + return { preview: buildClaudeMcpPreview(selectedPetId, commandMode) }; + } 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); + } catch (error) { + return createHookErrorStatus(error instanceof Error ? error.message : "Packaged OpenPets hook resources are unavailable."); + } +} + +function createErrorPreview(commandMode: OpenPetsCommandMode, message: string): ClaudeMcpPreview { + 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 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 { + if (action === "opencode-install") return installOpenCodeGlobal(selectedPetId, commandMode); + if (action === "opencode-remove") return removeOpenCodeGlobal(); + 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: "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 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); + } 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: process.platform === "win32" ? "opencode.cmd" : "opencode", 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." : "OpenCode was not found on PATH. You can still preview setup, but OpenCode must be installed to use it.", + configDir: formatUserPath(configDir) ?? configDir, + canInstall: prepared.ok, + 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 : {}, + }, + }; +} + +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 } | { 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 { + 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 }; + } + 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 { + 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 }; + } +} + +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 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 { + 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 { + 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 { + 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 version = await runClaudeCommand({ command: "claude", args: ["--version"] }); + if (!version.ok) { + return createStatus("not_detected", "Not detected", `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); + let entry = listed; + if (listed.present) { + const get = await runClaudeCommand(buildClaudeMcpGetCommand()); + if (get.ok) entry = classifyClaudeMcpStatus(list.stdout, get.stdout, selectedPetId, commandMode); + } + + 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 { + 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 { + 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 { + 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; + const child = spawn(command, args, { cwd: app.getPath("home"), env: createCommandEnv(), windowsHide: true, shell: false }); + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getClaudeCommandCandidates(command: string): readonly string[] { + if (command !== "claude") return [command]; + if (process.platform === "win32") return ["claude", "claude.cmd"]; + return ["claude"]; +} + +function createCommandEnv(): NodeJS.ProcessEnv { + const extraPaths = process.platform === "win32" ? [] : ["/opt/homebrew/bin", "/usr/local/bin"]; + const existingPath = process.env.PATH ?? ""; + return { ...process.env, PATH: [existingPath, ...extraPaths].filter(Boolean).join(process.platform === "win32" ? ";" : ":") }; +} + +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 & { 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, +}; diff --git a/apps/desktop/src/app-state-core.ts b/apps/desktop/src/app-state-core.ts new file mode 100644 index 00000000..356838d9 --- /dev/null +++ b/apps/desktop/src/app-state-core.ts @@ -0,0 +1,29 @@ +export interface OnboardingPreferenceLike { + readonly onboardingCompleted?: unknown; +} + +export const petScaleOptions = [ + { label: "Small", value: 0.44 }, + { label: "Medium", value: 0.56 }, + { label: "Large", value: 0.72 }, +] as const; +export type PetScaleValue = typeof petScaleOptions[number]["value"]; +export const defaultPetScale: PetScaleValue = 0.56; + +export function normalizePetScale(value: unknown): PetScaleValue { + return petScaleOptions.find((option) => option.value === value)?.value ?? defaultPetScale; +} + +export function normalizeOnboardingCompleted(value: OnboardingPreferenceLike): boolean { + return typeof value.onboardingCompleted === "boolean" ? value.onboardingCompleted : false; +} + +export function markOnboardingCompleted }>(state: T): T { + return { + ...state, + preferences: { + ...state.preferences, + onboardingCompleted: true, + }, + }; +} diff --git a/apps/desktop/src/app-state.ts b/apps/desktop/src/app-state.ts new file mode 100644 index 00000000..23838f22 --- /dev/null +++ b/apps/desktop/src/app-state.ts @@ -0,0 +1,416 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +import { app } from "electron"; + +import { defaultPetScale, markOnboardingCompleted, normalizeOnboardingCompleted, normalizePetScale, petScaleOptions, type PetScaleValue } from "./app-state-core.js"; +import { builtInPet } from "./built-in-pet.js"; +import type { Point } from "./display.js"; +import { assertSafePetId, getInstalledPetDir } from "./pet-paths.js"; + +export interface InstalledPetState { + readonly id: string; + readonly displayName: string; + readonly description?: string; + readonly builtIn: boolean; + readonly protected: boolean; + readonly installed: boolean; + readonly source?: { + readonly kind?: "catalog"; + readonly catalogVersion: 2; + readonly zip: string; + readonly preview: string; + } | { + readonly kind: "codex"; + readonly path: string; + }; + readonly broken?: boolean; + readonly brokenReason?: string; +} + +export interface OpenPetsStateV1 { + readonly version: 1; + readonly preferences: { + readonly defaultPetId: string; + readonly openDefaultPetOnLaunch: boolean; + readonly speechBubblesEnabled: boolean; + readonly petScale: number; + readonly onboardingCompleted: boolean; + }; + readonly pets: { + readonly installed: readonly InstalledPetState[]; + }; + readonly defaultPet: { + readonly position?: Point; + }; +} + +export { defaultPetScale, normalizePetScale, petScaleOptions, type PetScaleValue }; + +const stateFileName = "openpets-state.json"; +let statePath: string | null = null; +let currentState: OpenPetsStateV1 | null = null; + +export function initializeAppState(): void { + statePath = join(app.getPath("userData"), stateFileName); + const nextState = normalizeState(readStateFile(statePath)); + writeStateToDisk(nextState); + currentState = nextState; + console.log(`OpenPets state initialized at ${statePath}.`); +} + +export function getAppStateSnapshot(): OpenPetsStateV1 { + return cloneState(getInitializedState()); +} + +export function updatePreferences(patch: Partial): OpenPetsStateV1 { + const state = getInitializedState(); + const preferences = normalizePreferences({ ...state.preferences, ...patch }); + + const nextState = normalizeState({ + ...state, + preferences, + }); + + commitState(nextState); + return getAppStateSnapshot(); +} + +export function isOnboardingCompleted(): boolean { + return getInitializedState().preferences.onboardingCompleted; +} + +export function completeOnboarding(): OpenPetsStateV1 { + const state = getInitializedState(); + const nextState = normalizeState(markOnboardingCompleted(state)); + commitState(nextState); + return getAppStateSnapshot(); +} + +export function setDefaultPet(defaultPetId: string): OpenPetsStateV1 { + const state = getInitializedState(); + const targetPet = state.pets.installed.find((pet) => pet.id === defaultPetId); + + if (!targetPet) { + throw new Error(`Cannot set unknown pet as default: ${defaultPetId}`); + } + + if (targetPet.broken) { + throw new Error(`Cannot set broken pet as default: ${defaultPetId}`); + } + + const nextState = normalizeState({ + ...state, + preferences: { + ...state.preferences, + defaultPetId, + }, + }); + + commitState(nextState); + return getAppStateSnapshot(); +} + +export function setDefaultPetPosition(position: Point): OpenPetsStateV1 { + const state = getInitializedState(); + + const nextState = normalizeState({ + ...state, + defaultPet: { + ...state.defaultPet, + position: normalizePosition(position), + }, + }); + + commitState(nextState); + return getAppStateSnapshot(); +} + +export function resetDefaultPetPosition(position: Point): OpenPetsStateV1 { + return setDefaultPetPosition(position); +} + +export function getDefaultPetPosition(): Point | undefined { + return getInitializedState().defaultPet.position; +} + +export function installPetState(pet: Omit): OpenPetsStateV1 { + const state = getInitializedState(); + + if (state.pets.installed.some((installedPet) => installedPet.id === pet.id)) { + throw new Error(`Pet is already installed: ${pet.id}`); + } + + const nextState = normalizeState({ + ...state, + pets: { + installed: [ + ...state.pets.installed, + { + ...pet, + builtIn: false, + protected: false, + installed: true, + }, + ], + }, + }); + + commitState(nextState); + return getAppStateSnapshot(); +} + +export function removePetState(petId: string): OpenPetsStateV1 { + if (petId === builtInPet.id) { + throw new Error("Built-in pet cannot be removed."); + } + + const state = getInitializedState(); + const existing = state.pets.installed.find((pet) => pet.id === petId); + + if (!existing) { + throw new Error(`Pet is not installed: ${petId}`); + } + + const nextDefaultPetId = state.preferences.defaultPetId === petId ? builtInPet.id : state.preferences.defaultPetId; + + const nextState = normalizeState({ + ...state, + preferences: { + ...state.preferences, + defaultPetId: nextDefaultPetId, + }, + pets: { + installed: state.pets.installed.filter((pet) => pet.id !== petId), + }, + }); + + commitState(nextState); + return getAppStateSnapshot(); +} + +export function markPetBroken(petId: string, brokenReason: string): OpenPetsStateV1 { + const state = getInitializedState(); + + if (petId === builtInPet.id) { + return getAppStateSnapshot(); + } + + const nextState = normalizeState({ + ...state, + preferences: { + ...state.preferences, + defaultPetId: state.preferences.defaultPetId === petId ? builtInPet.id : state.preferences.defaultPetId, + }, + pets: { + installed: state.pets.installed.map((pet) => pet.id === petId ? { ...pet, broken: true, brokenReason } : pet), + }, + }); + + commitState(nextState); + return getAppStateSnapshot(); +} + +export function getStateFilePath(): string { + if (!statePath) { + throw new Error("OpenPets app state has not been initialized."); + } + + return statePath; +} + +function getInitializedState(): OpenPetsStateV1 { + if (!currentState) { + throw new Error("OpenPets app state has not been initialized."); + } + + return currentState; +} + +function readStateFile(path: string): unknown { + if (!existsSync(path)) { + return undefined; + } + + try { + return JSON.parse(readFileSync(path, "utf8")) as unknown; + } catch (error) { + console.error(`Failed to read OpenPets state from ${path}; using defaults.`, error); + return undefined; + } +} + +function normalizeState(value: unknown): OpenPetsStateV1 { + const record = isRecord(value) ? value : {}; + const defaultPetRecord = isRecord(record.defaultPet) ? record.defaultPet : {}; + const preferencesRecord = isRecord(record.preferences) ? record.preferences : {}; + const defaultState = createDefaultState(); + const position = normalizeMaybePosition(defaultPetRecord.position); + const installedPets = normalizeInstalledPets(record); + const defaultPetId = typeof preferencesRecord.defaultPetId === "string" + && installedPets.some((pet) => pet.id === preferencesRecord.defaultPetId && !pet.broken) + ? preferencesRecord.defaultPetId + : builtInPet.id; + + return { + version: 1, + preferences: normalizePreferences({ + ...defaultState.preferences, + ...preferencesRecord, + defaultPetId, + }), + pets: { + installed: installedPets, + }, + defaultPet: position ? { position } : {}, + }; +} + +function normalizePreferences(value: Partial): OpenPetsStateV1["preferences"] { + const defaultState = createDefaultState(); + + return { + defaultPetId: typeof value.defaultPetId === "string" ? value.defaultPetId : builtInPet.id, + openDefaultPetOnLaunch: typeof value.openDefaultPetOnLaunch === "boolean" + ? value.openDefaultPetOnLaunch + : defaultState.preferences.openDefaultPetOnLaunch, + speechBubblesEnabled: true, + petScale: normalizePetScale(value.petScale), + onboardingCompleted: normalizeOnboardingCompleted(value), + }; +} + +function normalizeInstalledPets(value: Record): InstalledPetState[] { + const installed = isRecord(value.pets) && Array.isArray(value.pets.installed) + ? value.pets.installed + : []; + + const normalized = installed + .map((pet) => normalizeInstalledPet(pet)) + .filter((pet): pet is InstalledPetState => Boolean(pet && pet.id !== builtInPet.id)); + + return [builtInPet, ...normalized]; +} + +function normalizeInstalledPet(value: unknown): InstalledPetState | null { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.displayName !== "string") { + return null; + } + + try { + assertSafePetId(value.id); + } catch { + return null; + } + + const brokenReason = validateInstalledPetFiles(value.id); + + return { + id: value.id, + displayName: value.displayName, + description: typeof value.description === "string" ? value.description : undefined, + builtIn: value.id === builtInPet.id ? true : value.builtIn === true, + protected: value.id === builtInPet.id ? true : value.protected === true, + installed: true, + source: normalizeSource(value.source), + broken: brokenReason ? true : typeof value.broken === "boolean" ? value.broken : undefined, + brokenReason: brokenReason ?? (typeof value.brokenReason === "string" ? value.brokenReason : undefined), + }; +} + +function createDefaultState(): OpenPetsStateV1 { + return { + version: 1, + preferences: { + defaultPetId: builtInPet.id, + openDefaultPetOnLaunch: true, + speechBubblesEnabled: true, + petScale: defaultPetScale, + onboardingCompleted: false, + }, + pets: { + installed: [builtInPet], + }, + defaultPet: {}, + }; +} + +function commitState(nextState: OpenPetsStateV1): void { + writeStateToDisk(nextState); + currentState = nextState; +} + +function writeStateToDisk(state: OpenPetsStateV1): void { + const path = getStateFilePath(); + + mkdirSync(dirname(path), { recursive: true }); + const tempPath = `${path}.${process.pid}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + renameSync(tempPath, path); +} + +function validateInstalledPetFiles(petId: string): string | undefined { + try { + const dir = getInstalledPetDir(petId); + const petJsonPath = join(dir, "pet.json"); + const spritesheetPath = join(dir, "spritesheet.webp"); + JSON.parse(readFileSync(petJsonPath, "utf8")) as unknown; + const spritesheet = statSync(spritesheetPath); + if (!spritesheet.isFile()) return "spritesheet.webp is not a file."; + if (spritesheet.size <= 0) return "spritesheet.webp is empty."; + if (spritesheet.size > 100 * 1024 * 1024) return "spritesheet.webp is too large."; + return undefined; + } catch (error) { + return error instanceof Error ? error.message : "Installed pet files are invalid."; + } +} + +function normalizeSource(value: unknown): InstalledPetState["source"] | undefined { + if (!isRecord(value)) { + return undefined; + } + + if (value.kind === "codex" && typeof value.path === "string") { + return { kind: "codex", path: value.path }; + } + + if (value.catalogVersion !== 2 || typeof value.zip !== "string" || typeof value.preview !== "string") return undefined; + + return { + kind: "catalog", + catalogVersion: 2, + zip: value.zip, + preview: value.preview, + }; +} + +function normalizeMaybePosition(value: unknown): Point | undefined { + if (!isRecord(value)) { + return undefined; + } + + return normalizePosition(value); +} + +function normalizePosition(value: Partial): Point | undefined { + if (typeof value.x !== "number" || typeof value.y !== "number") { + return undefined; + } + + if (!Number.isFinite(value.x) || !Number.isFinite(value.y)) { + return undefined; + } + + return { + x: Math.round(value.x), + y: Math.round(value.y), + }; +} + +function cloneState(state: OpenPetsStateV1): OpenPetsStateV1 { + return structuredClone(state) as OpenPetsStateV1; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/apps/desktop/src/assets.ts b/apps/desktop/src/assets.ts new file mode 100644 index 00000000..6d10b456 --- /dev/null +++ b/apps/desktop/src/assets.ts @@ -0,0 +1,110 @@ +import { join } from "node:path"; + +import { app, nativeImage, type NativeImage } from "electron"; + +const trayIconRelativePath = join("assets", "tray-icon.png"); + +export function createTrayIcon(): NativeImage { + const assetPath = join(app.getAppPath(), trayIconRelativePath); + const assetImage = nativeImage.createFromPath(assetPath); + + if (!assetImage.isEmpty()) { + return assetImage.resize({ width: 22, height: 22 }); + } + + console.error(`OpenPets tray icon asset could not be loaded from ${assetPath}; using generated fallback icon.`); + return createFallbackTrayIcon(); +} + +function createFallbackTrayIcon(): NativeImage { + const size = 32; + const bitmap = Buffer.alloc(size * size * 4, 0); + + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + if (isRoundedSquarePixel(x, y, size, 8)) { + setPixel(bitmap, size, x, y, 17, 24, 39, 255); + } + } + } + + drawCircle(bitmap, size, 11, 13, 3, 249, 250, 251, 255); + drawCircle(bitmap, size, 21, 13, 3, 249, 250, 251, 255); + drawCircle(bitmap, size, 11, 13, 1, 17, 24, 39, 255); + drawCircle(bitmap, size, 21, 13, 1, 17, 24, 39, 255); + + for (let x = 10; x <= 22; x += 1) { + const y = Math.round(21 + Math.sin((x - 10) / 12 * Math.PI) * 3); + drawCircle(bitmap, size, x, y, 1, 249, 250, 251, 255); + } + + const image = nativeImage.createFromBitmap(bitmap, { + width: size, + height: size, + scaleFactor: 1, + }); + + if (image.isEmpty()) { + console.error("OpenPets tray icon creation produced an empty image."); + } + + if (process.platform === "darwin") { + image.setTemplateImage(true); + } + + return image; +} + +function isRoundedSquarePixel(x: number, y: number, size: number, radius: number): boolean { + const left = radius; + const right = size - radius - 1; + const top = radius; + const bottom = size - radius - 1; + + if (x >= left && x <= right) return true; + if (y >= top && y <= bottom) return true; + + const cornerX = x < left ? left : right; + const cornerY = y < top ? top : bottom; + const distanceSquared = (x - cornerX) ** 2 + (y - cornerY) ** 2; + + return distanceSquared <= radius ** 2; +} + +function drawCircle( + bitmap: Buffer, + size: number, + centerX: number, + centerY: number, + radius: number, + red: number, + green: number, + blue: number, + alpha: number, +): void { + for (let y = centerY - radius; y <= centerY + radius; y += 1) { + for (let x = centerX - radius; x <= centerX + radius; x += 1) { + if (x < 0 || y < 0 || x >= size || y >= size) continue; + if ((x - centerX) ** 2 + (y - centerY) ** 2 <= radius ** 2) { + setPixel(bitmap, size, x, y, red, green, blue, alpha); + } + } + } +} + +function setPixel( + bitmap: Buffer, + size: number, + x: number, + y: number, + red: number, + green: number, + blue: number, + alpha: number, +): void { + const offset = (y * size + x) * 4; + bitmap[offset] = blue; + bitmap[offset + 1] = green; + bitmap[offset + 2] = red; + bitmap[offset + 3] = alpha; +} diff --git a/apps/desktop/src/built-in-pet.ts b/apps/desktop/src/built-in-pet.ts new file mode 100644 index 00000000..f2c5069f --- /dev/null +++ b/apps/desktop/src/built-in-pet.ts @@ -0,0 +1,7 @@ +export const builtInPet = { + id: "builtin", + displayName: "Built-in Pet", + builtIn: true, + protected: true, + installed: true, +} as const; diff --git a/apps/desktop/src/catalog-validation.ts b/apps/desktop/src/catalog-validation.ts new file mode 100644 index 00000000..6562f72b --- /dev/null +++ b/apps/desktop/src/catalog-validation.ts @@ -0,0 +1,80 @@ +export interface CatalogV2 { + readonly version: 2; + readonly generatedAt: string; + readonly pets: readonly CatalogPetV2[]; +} + +export interface CatalogPetV2 { + readonly id: string; + readonly displayName: string; + readonly description: string; + readonly preview: string; + readonly zip: string; +} + +export function validateCatalogV2(value: unknown): CatalogV2 { + if (!isRecord(value)) throw new Error("Catalog must be an object."); + if (value.version !== 2) throw new Error("Catalog version must be 2."); + if (typeof value.generatedAt !== "string" || Number.isNaN(Date.parse(value.generatedAt))) throw new Error("Catalog generatedAt must be a valid date string."); + if (!Array.isArray(value.pets)) throw new Error("Catalog pets must be an array."); + if (value.pets.length > 1000) throw new Error("Catalog has too many pets."); + + const ids = new Set(); + const pets = value.pets.map((pet) => validateCatalogPet(pet, ids)); + + return { + version: 2, + generatedAt: value.generatedAt, + pets, + }; +} + +function validateCatalogPet(value: unknown, ids: Set): CatalogPetV2 { + if (!isRecord(value)) throw new Error("Catalog pet must be an object."); + const id = validateId(value.id); + + if (ids.has(id)) throw new Error(`Duplicate catalog pet id: ${id}`); + ids.add(id); + + return { + id, + displayName: validateString(value.displayName, "displayName", 120), + description: validateString(value.description, "description", 500), + preview: validateCatalogUrl(value.preview, "preview"), + zip: validateCatalogUrl(value.zip, "zip"), + }; +} + +function validateId(value: unknown): string { + if (typeof value !== "string") throw new Error("Catalog pet id must be a string."); + if (value === "builtin") throw new Error("Catalog pet id 'builtin' is reserved."); + if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value)) throw new Error(`Invalid catalog pet id: ${value}`); + return value; +} + +function validateString(value: unknown, field: string, maxLength: number): string { + if (typeof value !== "string") throw new Error(`Catalog pet ${field} must be a string.`); + if (value.length > maxLength) throw new Error(`Catalog pet ${field} is too long.`); + return value; +} + +function validateCatalogUrl(value: unknown, field: "preview" | "zip"): string { + const raw = validateString(value, field, 2048); + const url = new URL(raw); + + if (url.protocol !== "https:") throw new Error(`${field} URL must use https.`); + if (url.username || url.password) throw new Error(`${field} URL cannot include credentials.`); + if (url.port) throw new Error(`${field} URL cannot include a custom port.`); + + if (field === "preview") { + if (url.hostname !== "openpets.dev" || !url.pathname.startsWith("/pets/")) throw new Error("Preview URL host/path is not allowed."); + } else if (url.hostname !== "zip.openpets.dev" || !url.pathname.startsWith("/pets/")) { + throw new Error("Zip URL host/path is not allowed."); + } + + return url.toString(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/apps/desktop/src/catalog.ts b/apps/desktop/src/catalog.ts new file mode 100644 index 00000000..d7e0590e --- /dev/null +++ b/apps/desktop/src/catalog.ts @@ -0,0 +1,116 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { app } from "electron"; + +import { validateCatalogV2, type CatalogPetV2, type CatalogV2 } from "./catalog-validation.js"; + +export const catalogUrl = "https://openpets.dev/pets/catalog.v2.json"; +const fixtureRelativePath = "catalog.v2.fixture.json"; +const maxCatalogBytes = 1_000_000; +const fetchTimeoutMs = 5_000; + +export interface CatalogUiState { + readonly source: "remote" | "fixture" | "error"; + readonly pets: readonly CatalogPetV2[]; + readonly generatedAt?: string; + readonly error?: string; +} + +export async function getCatalogUiState(): Promise { + const remote = await tryLoadRemoteCatalog(); + + if (remote.ok) { + return { + source: "remote", + pets: remote.catalog.pets, + generatedAt: remote.catalog.generatedAt, + }; + } + + const fixture = await tryLoadFixtureCatalog(); + + if (fixture.ok) { + return { + source: "fixture", + pets: fixture.catalog.pets, + generatedAt: fixture.catalog.generatedAt, + error: `Live catalog unavailable: ${remote.error}`, + }; + } + + return { + source: "error", + pets: [], + error: `Live catalog unavailable: ${remote.error}. Fixture unavailable: ${fixture.error}`, + }; +} + +async function tryLoadRemoteCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), fetchTimeoutMs); + + try { + const response = await fetch(catalogUrl, { + signal: controller.signal, + redirect: "error", + credentials: "omit", + }); + + validateCatalogEndpoint(response.url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const text = await readLimitedResponse(response); + return { ok: true, catalog: validateCatalogV2(JSON.parse(text) as unknown) }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "unknown error" }; + } finally { + clearTimeout(timeout); + } +} + +async function tryLoadFixtureCatalog(): Promise<{ readonly ok: true; readonly catalog: CatalogV2 } | { readonly ok: false; readonly error: string }> { + try { + return { ok: true, catalog: validateCatalogV2(await loadFixtureCatalog()) }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "unknown error" }; + } +} + +async function loadFixtureCatalog(): Promise { + const fixturePath = join(app.getAppPath(), fixtureRelativePath); + return JSON.parse(await readFile(fixturePath, "utf8")) as unknown; +} + +async function readLimitedResponse(response: Response): Promise { + const reader = response.body?.getReader(); + if (!reader) throw new Error("Catalog response body is unavailable for bounded reading."); + + const chunks: Uint8Array[] = []; + let total = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxCatalogBytes) throw new Error("Catalog response is too large."); + chunks.push(value); + } + + return new TextDecoder().decode(concatChunks(chunks, total)); +} + +function concatChunks(chunks: readonly Uint8Array[], total: number): Uint8Array { + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +function validateCatalogEndpoint(value: string): void { + const url = new URL(value); + if (url.href !== catalogUrl) throw new Error("Catalog final URL is not allowed."); +} diff --git a/apps/desktop/src/check-catalog-fixture.ts b/apps/desktop/src/check-catalog-fixture.ts new file mode 100644 index 00000000..618157e6 --- /dev/null +++ b/apps/desktop/src/check-catalog-fixture.ts @@ -0,0 +1,30 @@ +import { readFile } from "node:fs/promises"; + +import { validateCatalogV2 } from "./catalog-validation.js"; + +const fixture = JSON.parse(await readFile("catalog.v2.fixture.json", "utf8")) as unknown; +validateCatalogV2(fixture); + +const invalidCases: readonly unknown[] = [ + { version: 2, generatedAt: new Date().toISOString(), pets: [{ id: "Bad ID", displayName: "Bad", description: "", preview: "https://openpets.dev/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }] }, + { version: 2, generatedAt: new Date().toISOString(), pets: [{ id: "dup", displayName: "Dup", description: "", preview: "https://openpets.dev/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }, { id: "dup", displayName: "Dup 2", description: "", preview: "https://openpets.dev/pets/y/spritesheet.webp", zip: "https://zip.openpets.dev/pets/y/y.zip" }] }, + { version: 2, generatedAt: new Date().toISOString(), pets: [{ id: "http", displayName: "Http", description: "", preview: "http://openpets.dev/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }] }, + { version: 2, generatedAt: new Date().toISOString(), pets: [{ id: "host", displayName: "Host", description: "", preview: "https://evil.example/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }] }, + { version: 2, generatedAt: new Date().toISOString(), pets: [{ id: "builtin", displayName: "Builtin", description: "", preview: "https://openpets.dev/pets/x/spritesheet.webp", zip: "https://zip.openpets.dev/pets/x/x.zip" }] }, +]; + +for (const invalidCase of invalidCases) { + assertRejectsCatalog(invalidCase); +} + +console.log("Catalog fixture validation passed."); + +function assertRejectsCatalog(value: unknown): void { + try { + validateCatalogV2(value); + } catch { + return; + } + + throw new Error("Invalid catalog fixture case was accepted."); +} diff --git a/apps/desktop/src/check-claude-memory.ts b/apps/desktop/src/check-claude-memory.ts new file mode 100644 index 00000000..c5bc660a --- /dev/null +++ b/apps/desktop/src/check-claude-memory.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { ensureImportLine, ensureManagedImport, installClaudeOpenPetsMemory, openPetsClaudeImportLine, removeImportLine, removeOpenPetsMemoryBlock, uninstallClaudeOpenPetsMemory, upsertOpenPetsMemoryBlock } from "./claude-memory.js"; + +assert.equal(ensureImportLine("", openPetsClaudeImportLine), `${openPetsClaudeImportLine}\n`); +assert.equal(ensureImportLine("# User notes\n", openPetsClaudeImportLine), `# User notes\n\n${openPetsClaudeImportLine}\n`); +assert.equal(ensureImportLine(`# User notes\n${openPetsClaudeImportLine}\n${openPetsClaudeImportLine}\n`, openPetsClaudeImportLine), `# User notes\n\n${openPetsClaudeImportLine}\n`); +assert.equal(removeImportLine(`# User notes\n\n${openPetsClaudeImportLine}\n`, openPetsClaudeImportLine), "# User notes\n"); +assert.match(upsertOpenPetsMemoryBlock("custom\n", "\nmanaged\n\n"), /custom[\s\S]*managed/); +assert.equal((upsertOpenPetsMemoryBlock("\nold\n\n\n\nolder\n\n", "\nnew\n\n").match(/OPENPETS:START/g) ?? []).length, 1); +assert.match(ensureManagedImport("# User notes\n"), /OPENPETS:IMPORT:START[\s\S]*@~\/\.claude\/openpets\.md[\s\S]*OPENPETS:IMPORT:END/); +assert.equal(ensureManagedImport(`${openPetsClaudeImportLine}\n`), `${openPetsClaudeImportLine}\n`, "user-owned import line should not be wrapped as managed."); +assert.equal(removeOpenPetsMemoryBlock("custom\n\nmanaged\n\n"), "custom\n"); + +const dir = mkdtempSync(join(tmpdir(), "openpets-claude-memory-")); +try { + const claudeDir = join(dir, ".claude"); + const claudeMd = join(claudeDir, "CLAUDE.md"); + const openpetsMd = join(claudeDir, "openpets.md"); + mkdirSync(claudeDir); + writeFileSync(claudeMd, "# Existing Claude instructions\n\nKeep this.\n", "utf8"); + + const installed = installClaudeOpenPetsMemory(dir); + assert.equal(installed.changed, true); + assert.match(readFileSync(claudeMd, "utf8"), /Keep this\.[\s\S]*@~\/\.claude\/openpets\.md/); + assert.match(readFileSync(openpetsMd, "utf8"), /openpets_say/); + + const reinstalled = installClaudeOpenPetsMemory(dir); + assert.equal(reinstalled.changed, false); + assert.equal((readFileSync(claudeMd, "utf8").match(/@~\/\.claude\/openpets\.md/g) ?? []).length, 1); + assert.match(readFileSync(claudeMd, "utf8"), /OPENPETS:IMPORT:START/); + + writeFileSync(openpetsMd, `${readFileSync(openpetsMd, "utf8")}\nUser custom note.\n`, "utf8"); + const uninstalled = uninstallClaudeOpenPetsMemory(dir); + assert.equal(uninstalled.changed, true); + assert.doesNotMatch(readFileSync(claudeMd, "utf8"), /openpets\.md/); + assert.equal(existsSync(openpetsMd), true, "customized openpets.md should be preserved after managed block removal."); + assert.match(readFileSync(openpetsMd, "utf8"), /User custom note/); + + const userImportHome = join(dir, "user-import-home"); + const userClaudeDir = join(userImportHome, ".claude"); + mkdirSync(userClaudeDir, { recursive: true }); + writeFileSync(join(userClaudeDir, "CLAUDE.md"), `# User-owned import\n${openPetsClaudeImportLine}\n`, "utf8"); + writeFileSync(join(userClaudeDir, "openpets.md"), "User-owned content.\n", "utf8"); + installClaudeOpenPetsMemory(userImportHome); + assert.doesNotMatch(readFileSync(join(userClaudeDir, "CLAUDE.md"), "utf8"), /OPENPETS:IMPORT:START/, "pre-existing import should remain user-owned."); + uninstallClaudeOpenPetsMemory(userImportHome); + assert.match(readFileSync(join(userClaudeDir, "CLAUDE.md"), "utf8"), /@~\/\.claude\/openpets\.md/, "user-owned import should not be removed."); + assert.match(readFileSync(join(userClaudeDir, "openpets.md"), "utf8"), /User-owned content/, "user-owned openpets.md content should be preserved."); + + const symlinkHome = join(dir, "symlink-home"); + const symlinkTarget = join(dir, "outside"); + mkdirSync(symlinkHome); + mkdirSync(symlinkTarget); + symlinkSync(symlinkTarget, join(symlinkHome, ".claude")); + assert.throws(() => installClaudeOpenPetsMemory(symlinkHome)); + + const symlinkFileHome = join(dir, "symlink-file-home"); + mkdirSync(join(symlinkFileHome, ".claude"), { recursive: true }); + writeFileSync(join(dir, "outside-file"), "x", "utf8"); + symlinkSync(join(dir, "outside-file"), join(symlinkFileHome, ".claude", "CLAUDE.md")); + assert.throws(() => installClaudeOpenPetsMemory(symlinkFileHome)); + + const oversizedHome = join(dir, "oversized-home"); + mkdirSync(join(oversizedHome, ".claude"), { recursive: true }); + writeFileSync(join(oversizedHome, ".claude", "CLAUDE.md"), "x".repeat(1024 * 1024 + 1), "utf8"); + assert.throws(() => installClaudeOpenPetsMemory(oversizedHome)); +} finally { + rmSync(dir, { recursive: true, force: true }); +} + +console.error("Claude memory validation passed."); diff --git a/apps/desktop/src/check-codex-pets.ts b/apps/desktop/src/check-codex-pets.ts new file mode 100644 index 00000000..d38e1493 --- /dev/null +++ b/apps/desktop/src/check-codex-pets.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; + +import { canInlineCodexPreview, maxCodexPets, maxCodexPreviewBytes, maxCodexTotalPreviewBytes, validateCodexPetMetadata } from "./codex-pets-core.js"; + +const valid = validateCodexPetMetadata({ + id: "fixer", + displayName: " Fixer ", + description: " Repairs things. ", + spritesheetPath: "spritesheet.webp", +}, "fixer"); + +assert.deepEqual(valid, { + id: "fixer", + displayName: "Fixer", + description: "Repairs things.", + spritesheetPath: "spritesheet.webp", +}); + +assert.throws(() => validateCodexPetMetadata({ id: "other", displayName: "Other", description: "Nope", spritesheetPath: "spritesheet.webp" }, "fixer")); +assert.throws(() => validateCodexPetMetadata({ id: "builtin", displayName: "Built-in", description: "Reserved", spritesheetPath: "spritesheet.webp" }, "builtin")); +assert.throws(() => validateCodexPetMetadata({ id: "bad/id", displayName: "Bad", description: "Bad", spritesheetPath: "spritesheet.webp" }, "bad/id")); +assert.throws(() => validateCodexPetMetadata({ id: "fixer", displayName: "Fixer", description: "Nope", spritesheetPath: "../spritesheet.webp" }, "fixer")); +assert.throws(() => validateCodexPetMetadata({ id: "fixer", displayName: "", description: "Nope", spritesheetPath: "spritesheet.webp" }, "fixer")); + +assert.equal(canInlineCodexPreview(0), false); +assert.equal(canInlineCodexPreview(maxCodexPreviewBytes), true); +assert.equal(canInlineCodexPreview(maxCodexPreviewBytes + 1), false); +assert.equal(canInlineCodexPreview(Number.POSITIVE_INFINITY), false); +assert.equal(maxCodexTotalPreviewBytes, maxCodexPreviewBytes * 3); +assert.equal(maxCodexPets, 100); + +console.error("Codex pet validation passed."); diff --git a/apps/desktop/src/check-lease-manager.ts b/apps/desktop/src/check-lease-manager.ts new file mode 100644 index 00000000..ebbd27de --- /dev/null +++ b/apps/desktop/src/check-lease-manager.ts @@ -0,0 +1,60 @@ +import { LeaseManager } from "./lease-manager.js"; + +let now = 1_000; +const opened: string[] = []; +const closed: string[] = []; +const manager = new LeaseManager({ + ttlMs: 100, + now: () => now, + resolveTarget: (requestedPetId) => { + if (!requestedPetId) return { targetKind: "default", actualPetId: "builtin" }; + if (requestedPetId === "missing") return { targetKind: "default", actualPetId: "builtin", fallbackReason: "pet_not_installed" }; + return { targetKind: "explicit", actualPetId: requestedPetId }; + }, + getDefaultPetId: () => "builtin", + getPetDisplayName: (petId) => petId, + onFirstExplicitLease: (petId) => opened.push(petId), + onLastExplicitLease: (petId) => closed.push(petId), +}); + +const defaultLease = manager.acquire(); +if (!defaultLease.usingDefaultPet || defaultLease.targetKind !== "default") throw new Error("Default lease did not target default."); +manager.release(defaultLease.leaseId); +if (closed.length !== 0) throw new Error("Default release closed a temp pet."); + +const first = manager.acquire("snoopy"); +const second = manager.acquire("snoopy"); +if (opened.join(",") !== "snoopy") throw new Error("Explicit pet did not open once for multiple leases."); +manager.release(first.leaseId); +if (closed.length !== 0) throw new Error("Explicit pet closed before final lease release."); +manager.release(first.leaseId); +manager.release(second.leaseId); +if (closed.join(",") !== "snoopy") throw new Error("Explicit pet did not close after final release."); + +const missing = manager.acquire("missing"); +if (missing.fallbackReason !== "pet_not_installed" || !missing.usingDefaultPet) throw new Error("Missing pet did not fall back to default."); +manager.release(missing.leaseId); + +const expiring = manager.acquire("tux"); +now += 50; +manager.heartbeat(expiring.leaseId); +now += 75; +if (manager.cleanupExpired().length !== 0) throw new Error("Heartbeat did not extend lease."); +now += 50; +if (manager.cleanupExpired().length !== 1) throw new Error("Expired lease was not cleaned up."); + +const expiredBeforeHeartbeat = manager.acquire("dobby"); +now += 200; +assertRejects(() => manager.heartbeat(expiredBeforeHeartbeat.leaseId)); +if (manager.get(expiredBeforeHeartbeat.leaseId) !== null) throw new Error("Expired lease was still readable before cleanup."); + +console.log("Lease manager validation passed."); + +function assertRejects(callback: () => unknown): void { + try { + callback(); + } catch { + return; + } + throw new Error("Expected lease operation to reject."); +} diff --git a/apps/desktop/src/check-local-ipc-protocol.ts b/apps/desktop/src/check-local-ipc-protocol.ts new file mode 100644 index 00000000..2a084365 --- /dev/null +++ b/apps/desktop/src/check-local-ipc-protocol.ts @@ -0,0 +1,55 @@ +import { errorResponse, maxIpcMessageBytes, parseIpcRequest, validateReaction, validateSayMessage } from "./local-ipc-protocol.js"; + +const token = "test-token"; +const valid = { + id: "1", + version: 1, + token, + method: "status", + params: {}, +}; + +parseIpcRequest(JSON.stringify(valid), token); +parseIpcRequest(JSON.stringify({ ...valid, method: "pets.list" }), token); +assertRejects(() => parseIpcRequest(JSON.stringify({ ...valid, token: "bad" }), token)); +assertRejects(() => parseIpcRequest(JSON.stringify({ ...valid, version: 2 }), token)); +assertRejects(() => parseIpcRequest(JSON.stringify({ ...valid, method: "pet.install" }), token)); +assertRejects(() => parseIpcRequest("not json", token)); + +validateReaction("testing"); +validateReaction("waving"); +assertRejects(() => validateReaction("bad")); + +validateSayMessage("Working on it"); +for (const unsafe of [ + "", + "a".repeat(141), + "line one\nline two", + "```code```", + "const secret = 1", + "https://example.com", + "/Users/alvin/project/file.ts", + "api_key=abc123", +]) { + assertRejects(() => validateSayMessage(unsafe)); +} + +if (Buffer.byteLength(JSON.stringify({ message: "x".repeat(maxIpcMessageBytes) }), "utf8") <= maxIpcMessageBytes) { + throw new Error("Oversized fixture was not oversized."); +} + +const response = errorResponse("1", new Error("boom")); +if (response.ok || response.error?.code !== "internal_error") { + throw new Error("Failed to create structured error response."); +} + +console.log("Local IPC protocol validation passed."); + +function assertRejects(callback: () => unknown): void { + try { + callback(); + } catch { + return; + } + throw new Error("Expected validation to reject."); +} diff --git a/apps/desktop/src/check-onboarding-state.ts b/apps/desktop/src/check-onboarding-state.ts new file mode 100644 index 00000000..1a29e7e7 --- /dev/null +++ b/apps/desktop/src/check-onboarding-state.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; + +import { defaultPetScale, markOnboardingCompleted, normalizeOnboardingCompleted, normalizePetScale, petScaleOptions } from "./app-state-core.js"; + +assert.equal(normalizeOnboardingCompleted({}), false); +assert.equal(normalizeOnboardingCompleted({ onboardingCompleted: true }), true); +assert.equal(normalizeOnboardingCompleted({ onboardingCompleted: false }), false); +assert.equal(normalizeOnboardingCompleted({ onboardingCompleted: "true" }), false); + +const state = { + version: 1, + preferences: { + defaultPetId: "built-in", + openDefaultPetOnLaunch: true, + speechBubblesEnabled: true, + petScale: 1, + onboardingCompleted: false, + }, + pets: { + installed: [{ id: "built-in", displayName: "Built-in Pet" }], + }, +}; + +const completed = markOnboardingCompleted(state); +assert.equal(completed.preferences.onboardingCompleted, true); +assert.equal(completed.preferences.defaultPetId, "built-in"); +assert.equal(completed.preferences.openDefaultPetOnLaunch, true); +assert.equal(completed.preferences.speechBubblesEnabled, true); +assert.equal(completed.preferences.petScale, 1); +assert.deepEqual(completed.pets, state.pets); +assert.equal(state.preferences.onboardingCompleted, false); + +const preferencePatch = { + ...completed.preferences, + speechBubblesEnabled: true, +}; +assert.equal(normalizeOnboardingCompleted(preferencePatch), true); +assert.equal(preferencePatch.defaultPetId, "built-in"); +assert.equal(preferencePatch.openDefaultPetOnLaunch, true); +assert.equal(preferencePatch.speechBubblesEnabled, true); + +assert.equal(defaultPetScale, 0.56); +assert.deepEqual(petScaleOptions.map((option) => option.value), [0.44, 0.56, 0.72]); +assert.equal(normalizePetScale(0.44), 0.44); +assert.equal(normalizePetScale(0.56), 0.56); +assert.equal(normalizePetScale(0.72), 0.72); +assert.equal(normalizePetScale(1), defaultPetScale); +assert.equal(normalizePetScale("0.56"), defaultPetScale); +assert.equal(normalizePetScale(Number.NaN), defaultPetScale); +assert.equal(normalizePetScale(Number.POSITIVE_INFINITY), defaultPetScale); +assert.equal(normalizePetScale(undefined), defaultPetScale); + +console.error("Onboarding state validation passed."); diff --git a/apps/desktop/src/check-opencode-desktop-setup.ts b/apps/desktop/src/check-opencode-desktop-setup.ts new file mode 100644 index 00000000..9c3aa079 --- /dev/null +++ b/apps/desktop/src/check-opencode-desktop-setup.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { mapAsarPathToUnpacked } from "@open-pets/claude"; +import { doctorOpenCodeGlobalSetup, parseOpenCodeConfig, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "@open-pets/opencode"; + +const root = mkdtempSync(join(tmpdir(), "openpets-desktop-opencode-")); + +try { + const globalDir = join(root, "opencode-global"); + const bundledCli = mapAsarPathToUnpacked(join(root, "OpenPets.app", "Contents", "Resources", "app.asar", "node_modules", "@open-pets", "cli", "dist", "index.js")); + + assert.equal(doctorOpenCodeGlobalSetup(globalDir).status, "not_installed"); + + const install = prepareOpenCodeGlobalSetup({ + configDir: globalDir, + petId: "fixer", + cliVersion: "1.2.3", + pluginVersion: "4.5.6", + commandMode: "bundled", + cliEntryPath: bundledCli, + }); + + assert.equal(install.configPath, join(globalDir, "opencode.jsonc")); + assert.equal(install.instructionPath, join(globalDir, "openpets.md")); + assert.match(install.configWrite.content, /app\.asar\.unpacked/); + assert.doesNotMatch(install.configWrite.content, /app\.asar(?!\.unpacked)/); + assert.match(install.configWrite.content, /@open-pets\/opencode@4\.5\.6/); + + const preview = parseOpenCodeConfig(install.configWrite.content); + assert.equal(preview.ok, true, "desktop OpenCode preview must parse as JSONC without JSON.parse."); + const previewConfig = preview.value as { readonly mcp?: { readonly openpets?: { readonly command?: readonly string[] } }; readonly plugin?: readonly unknown[] }; + assert.deepEqual(previewConfig.mcp?.openpets?.command, ["node", bundledCli, "mcp", "--pet", "fixer"]); + assert.deepEqual(previewConfig.plugin, [["@open-pets/opencode@4.5.6", { pet: "fixer" }]]); + + writePreparedOpenCodeGlobalSetup(install); + assert.equal(doctorOpenCodeGlobalSetup(globalDir).status, "installed"); + assert.match(readFileSync(join(globalDir, "openpets.md"), "utf8"), /OPENPETS:START/); + + const remove = prepareOpenCodeGlobalRemove(globalDir); + assert.equal(remove.configWrites.length, 1); + writePreparedOpenCodeGlobalRemove(remove); + assert.equal(doctorOpenCodeGlobalSetup(globalDir).status, "not_installed"); + + const commentedGlobalDir = join(root, "commented-global"); + mkdirSync(commentedGlobalDir); + writeFileSync(join(commentedGlobalDir, "opencode.jsonc"), `{ + // user comment must not block desktop preview planning + "theme": "dark" + }\n`, "utf8"); + const commented = prepareOpenCodeGlobalSetup({ configDir: commentedGlobalDir, petId: "fixer", cliVersion: "1.2.3", pluginVersion: "4.5.6" }); + assert.equal(parseOpenCodeConfig(commented.configWrite.content).ok, true); + assert.match(commented.configWrite.content, /user comment/); +} finally { + rmSync(root, { recursive: true, force: true }); +} + +console.error("OpenCode desktop setup validation passed."); diff --git a/apps/desktop/src/check-packaging-contract.ts b/apps/desktop/src/check-packaging-contract.ts new file mode 100644 index 00000000..1eef05dc --- /dev/null +++ b/apps/desktop/src/check-packaging-contract.ts @@ -0,0 +1,340 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { allowedReactions } from "./local-ipc-protocol.js"; +import { pickReactionMessage, reactionMessagePools } from "./reaction-messages.js"; + +const distDir = dirname(fileURLToPath(import.meta.url)); +const appDir = dirname(distDir); +const repoRoot = resolve(appDir, "../.."); +const packageJson = JSON.parse(readFileSync(join(appDir, "package.json"), "utf8")) as { scripts?: Record; dependencies?: Record; description?: string; author?: string }; +const rootPackageJson = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8")) as { scripts?: Record }; +const builderConfigPath = join(appDir, "electron-builder.yml"); +const builderConfig = readFileSync(builderConfigPath, "utf8"); + +assert.equal(packageJson.description, "OpenPets tray-first desktop companion app."); +assert.equal(packageJson.author, "OpenPets"); +assert.match(packageJson.scripts?.package ?? "", /node scripts\/clean-package-output\.cjs && electron-builder/); +assert.match(packageJson.scripts?.["package:dir"] ?? "", /node scripts\/clean-package-output\.cjs && electron-builder --dir/); +assert.equal(rootPackageJson.scripts?.["package:desktop:dir"], "pnpm build && pnpm --filter @open-pets/desktop package:dir"); +assert.equal(packageJson.dependencies?.["@open-pets/claude"], "workspace:*"); +assert.equal(packageJson.dependencies?.["@open-pets/cli"], "workspace:*"); +assert.equal(packageJson.dependencies?.["@open-pets/mcp"], "workspace:*"); +assert.equal(packageJson.dependencies?.["@open-pets/opencode"], "workspace:*"); +assert.equal(packageJson.dependencies?.["@open-pets/agent-events"], "workspace:*"); +assert.match(builderConfig, /appId:\s*dev\.openpets\.app/); +assert.match(builderConfig, /productName:\s*OpenPets/); +assert.match(builderConfig, /output:\s*dist-electron/); +assert.match(builderConfig, /publish:\s*null/); +assert.match(builderConfig, /asar:\s*true/); +assert.match(builderConfig, /asarUnpack:/); +assert.match(builderConfig, /node_modules\/\*\*/); +assert.match(builderConfig, /dist\/\*\*/); +assert.match(builderConfig, /preload\.cjs/); +assert.match(builderConfig, /pet-preload\.cjs/); +assert.match(builderConfig, /assets\/\*\*/); +assert.match(builderConfig, /icon:\s*assets\/app-icon\.icns/); + +assert.ok(existsSync(join(appDir, "preload.cjs")), "preload.cjs must exist for packaging."); +assert.ok(existsSync(join(appDir, "pet-preload.cjs")), "pet-preload.cjs must exist for pet window motion state updates."); +assert.ok(existsSync(join(appDir, "assets", "tray-icon.png")), "tray icon must exist for packaging."); +assert.ok(existsSync(join(appDir, "assets", "app-icon.icns")), "app icon must exist for packaging."); +assert.ok(existsSync(join(appDir, "assets", "app-icon.ico")), "Windows app icon must exist for packaging."); +assertNonEmptyFile(join(appDir, "assets", "default-pet-spritesheet.webp"), "default pet spritesheet must exist for packaging."); +assertNonEmptyFile(join(appDir, "assets", "default-pet-thumbnail.png"), "default pet thumbnail must exist for Pet Manager preview."); +assertNonEmptyFile(join(appDir, "assets", "onboarding-logo.webp"), "onboarding logo asset must exist for packaging."); +assertNonEmptyFile(join(appDir, "assets", "onboarding-pets.webp"), "onboarding pet scene asset must exist for packaging."); +for (const icon of ["claude.svg", "cursor.svg", "opencode.svg", "vscode.svg", "windsurf.svg", "zed.svg"]) { + assertSafeBundledSvg(join(appDir, "assets", "integrations", icon), `integration icon must be safe and packaged: ${icon}`); +} +assert.match(readFileSync(join(appDir, "src", "assets.ts"), "utf8"), /assets["']?,\s*["']tray-icon\.png|join\("assets", "tray-icon\.png"\)/, "tray icon code must keep using assets/tray-icon.png."); +const petWindowSource = readFileSync(join(appDir, "src", "pet-window.ts"), "utf8"); +const petPreloadSource = readFileSync(join(appDir, "pet-preload.cjs"), "utf8"); +const reactionMessagesSource = readFileSync(join(appDir, "src", "reaction-messages.ts"), "utf8"); +const displaySource = readFileSync(join(appDir, "src", "display.ts"), "utf8"); +const updateCheckerSource = readFileSync(join(appDir, "src", "update-checker.ts"), "utf8"); +const traySource = readFileSync(join(appDir, "src", "tray.ts"), "utf8"); +const windowsSource = readFileSync(join(appDir, "src", "windows.ts"), "utf8"); +const agentSetupSource = readFileSync(join(appDir, "src", "agent-setup.ts"), "utf8"); +const preloadSource = readFileSync(join(appDir, "preload.cjs"), "utf8"); +const mappingDoc = readFileSync(join(repoRoot, "docs", "mapping.md"), "utf8"); +assert.match(petWindowSource, /default-pet-spritesheet\.webp/, "default pet renderer must reference the bundled WebP spritesheet asset."); +assert.match(petWindowSource, /columns:\s*8/, "default pet renderer must keep the real catalog sprite sheet column count."); +assert.match(petWindowSource, /rows:\s*9/, "default pet renderer must keep the real catalog sprite sheet row count."); +assert.match(petWindowSource, /frameWidth:\s*192/, "default pet renderer must keep the universal Codex frame width."); +assert.match(petWindowSource, /frameHeight:\s*208/, "default pet renderer must keep the universal Codex frame height."); +assert.ok(petWindowSource.includes("defaultPetSprite.frameWidth * defaultPetSprite.columns") && petWindowSource.includes("defaultPetSprite.frameHeight * defaultPetSprite.rows"), "pet renderer must derive universal spritesheet dimensions from frame size and row/column counts."); +for (const state of ["idle", "running-right", "running-left", "waving", "jumping", "failed", "waiting", "running", "review"]) { + assert.match(petWindowSource, new RegExp(`["']?${state}["']?:\\s*\\{\\s*row:`), `pet renderer must define universal sprite state: ${state}`); +} +for (const reaction of ["idle", "thinking", "working", "editing", "running", "testing", "waiting", "waving", "success", "error", "celebrating"]) { + assert.match(petWindowSource, new RegExp(`${reaction}:\\s*["']`), `pet renderer must map reaction to sprite state: ${reaction}`); + assert.match(reactionMessagesSource, new RegExp(`${reaction}:\\s*\\[`), `reaction messages must define a pool for: ${reaction}`); +} +assert.match(petWindowSource, /satisfies Record/, "reaction-to-sprite-state mapping must be exhaustive over OpenPetsReaction."); +assert.match(reactionMessagesSource, /satisfies Record/, "reaction-only bubble message pools must be exhaustive over OpenPetsReaction."); +assert.match(petWindowSource, /pickReactionMessage\(display\.reaction\)/, "reaction-only bubbles must render randomized messages instead of raw lowercase reaction ids."); +assert.match(petWindowSource, /function preparePetTransientDisplay/, "reaction-only bubbles must prepare a stable random message before rerenders."); +assert.match(petWindowSource, /function mergePetTransientDisplay/, "reaction-only events must not replace an active explicit message bubble."); +assert.match(petWindowSource, /function getTransientReactionAnimationMs/, "finite reaction animations must expose their own shorter lifetime."); +assert.match(petWindowSource, /function clearTransientReaction/, "finite reaction animations must be clearable while the bubble remains visible."); +assert.match(petWindowSource, /webContents\.send\("openpets:pet-reaction-state"/, "finite reaction animations must clear sprite state without reloading the bubble."); +assert.match(petPreloadSource, /openpets:pet-reaction-state/, "pet preload must accept in-place reaction state updates."); +assert.match(displaySource, /width:\s*220/, "pet windows must stay tightly bounded around pet and bubble."); +assert.match(displaySource, /height:\s*320/, "pet windows must be tall enough for adaptive long message bubbles at large pet scale without becoming a huge click shield."); +assert.match(petWindowSource, /function getBubbleClassName/, "pet bubbles must classify explicit messages by length."); +assert.match(petWindowSource, /is-long-message/, "pet bubbles must have a long-message layout."); +assert.match(petWindowSource, /is-very-long-message/, "pet bubbles must have a very-long-message layout for 140-character say messages."); +assert.match(petWindowSource, /body \{ -webkit-app-region: no-drag; pointer-events: none; \}/, "transparent pet window background must not capture clicks or drags."); +assert.match(petWindowSource, /function installMousePassthroughAndDrag/, "pet windows must install real mouse passthrough and controlled drag behavior."); +assert.match(petWindowSource, /setIgnoreMouseEvents\(true, \{ forward: true \}\)/, "transparent pet window background must use OS-level mouse passthrough."); +assert.match(petWindowSource, /setIgnoreMouseEvents\(false\)/, "visible pet and bubble hit targets must re-enable mouse handling."); +assert.match(petWindowSource, /\.pet-shell[\s\S]*?-webkit-app-region: no-drag; cursor: grab;/, "pet dragging must avoid Electron draggable regions so right-click context menus work."); +assert.match(petPreloadSource, /openpets:pet-hit-test/, "pet preload must report visible pet and bubble hit testing for passthrough."); +assert.match(petPreloadSource, /openpets:pet-drag-start/, "pet preload must start controlled pet dragging from the sprite."); +assert.match(petWindowSource, /function installPetContextMenu/, "pet windows must install a native right-click context menu."); +assert.match(petWindowSource, /webContents\.on\("context-menu"/, "pet context menu must be handled in the Electron main process."); +assert.match(petWindowSource, /Menu\.buildFromTemplate/, "pet context menu must use a small native Electron menu."); +assert.doesNotMatch(petPreloadSource, /setIgnoreMouseEvents/, "pet preload must not call Electron window APIs directly."); +const agentPetControllerSource = readFileSync(join(appDir, "src", "agent-pet-controller.ts"), "utf8"); +const localIpcSource = readFileSync(join(appDir, "src", "local-ipc.ts"), "utf8"); +assert.match(agentPetControllerSource, /dismissedAgentPets = new Set/, "agent pets must remember manual close while leases remain active."); +assert.match(agentPetControllerSource, /dismissAgentPetForActiveLease/, "agent pet context-menu close must dismiss the pet for the active lease."); +assert.match(agentPetControllerSource, /dismissedAgentPets\.has\(petId\)/, "dismissed agent pets must not reopen on later same-lease reactions."); +assert.match(agentPetControllerSource, /function clearAgentPetLeaseState/, "agent pet lease cleanup must clear dismissal, timers, and hidden transient state."); +assert.match(localIpcSource, /handleLastExplicitLease/, "agent pet dismissal must clear when the explicit lease group ends."); +assert.match(localIpcSource, /clearAgentPetLeaseState\(petId\)/, "last explicit lease cleanup must reset dismissed agent pet state."); +assert.match(localIpcSource, /reason: applied\.reason/, "IPC responses must report dismissed explicit pet events as not shown."); +assert.match(updateCheckerSource, /alvinunreal\/openpets/, "GitHub release notice must check the public OpenPets repository."); +assert.match(updateCheckerSource, /api\.github\.com\/repos\/\$\{githubRepository\}\/releases\/latest/, "update checker must use GitHub latest release API."); +assert.match(updateCheckerSource, /shell\.openExternal\(url\)/, "update action must open the GitHub release page externally."); +assert.match(traySource, /Update available:/, "tray menu must surface available updates."); +assert.match(windowsSource, /openpets:check-for-updates/, "settings window must be able to trigger update checks."); +assert.match(windowsSource, /openpets:open-update-release-page/, "settings window must be able to open the release page."); +assert.match(windowsSource, /id="check-for-updates"/, "settings UI must include a Check for updates button."); +assert.match(windowsSource, /id="open-update-release"/, "settings UI must include an Open release button."); +assert.match(preloadSource, /checkForUpdates/, "settings preload must expose update checks."); +assert.match(preloadSource, /\{ label: "Thinking", state: "thinking" \}/, "Pet Manager mini previews must use a non-idle thinking/review state because the hero already shows idle."); +assert.match(preloadSource, /\{ label: "Happy", state: "happy" \}/, "Pet Manager mini previews must request the happy/jumping row."); +assert.match(preloadSource, /\{ label: "Wave", state: "wave" \}/, "Pet Manager mini previews must request the waving row."); +assert.doesNotMatch(preloadSource, /\{ label: "Idle", state: "idle" \}/, "Pet Manager mini previews must not duplicate the hero idle preview."); +assert.match(preloadSource, /pm-mini-sprite[\s\S]*animated: true/, "Pet Manager mini state previews must animate spritesheet states."); +assert.match(preloadSource, /pm-sprite-state-\$\{state\}/, "Pet Manager sprite frames must apply state-specific row classes."); +assert.match(windowsSource, /pm-sprite-state-thinking \{ background-position: 0 100%; \}/, "Pet Manager thinking preview must use universal spritesheet row 8."); +assert.match(windowsSource, /pm-sprite-state-wave \{ background-position: 0 37\.5%; \}/, "Pet Manager wave preview must use universal spritesheet row 3."); +assert.match(windowsSource, /pm-sprite-state-happy \{ background-position: 0 50%; \}/, "Pet Manager happy preview must use universal spritesheet row 4."); +assert.match(windowsSource, /pm-sprite-state-thinking\.pm-animate-sprite \{ animation: pm-sprite-thinking 1\.55s steps\(6\) infinite; \}/, "Pet Manager thinking preview must animate the 6-frame review row at a calm speed."); +assert.match(windowsSource, /pm-sprite-state-wave\.pm-animate-sprite \{ animation: pm-sprite-wave 1\.25s steps\(4\) infinite; \}/, "Pet Manager wave preview must animate the 4-frame waving row at a calm speed."); +assert.match(windowsSource, /pm-sprite-state-happy\.pm-animate-sprite \{ animation: pm-sprite-happy 1\.35s steps\(5\) infinite; \}/, "Pet Manager happy preview must animate the 5-frame jumping row at a calm speed."); +assert.match(windowsSource, /@keyframes pm-sprite-thinking/, "Pet Manager must define thinking row animation keyframes."); +assert.match(windowsSource, /@keyframes pm-sprite-wave/, "Pet Manager must define wave row animation keyframes."); +assert.match(windowsSource, /@keyframes pm-sprite-happy/, "Pet Manager must define happy row animation keyframes."); +assert.match(petWindowSource, /max-width:\s*min\(204px/, "very long message bubbles must stay capped within the tight pet window."); +assert.match(petWindowSource, /-webkit-line-clamp:\s*9/, "very long message bubbles must allow enough visible lines."); +assert.match(petWindowSource, /createSpriteStateCss\("\.sprite"\)/, "built-in sprite CSS must react to reaction state."); +assert.match(petWindowSource, /createSpriteStateCss\("\.installed-sprite"\)/, "installed sprite CSS must react to reaction state."); +assert.match(petWindowSource, /html\[data-motion-state=\"\$\{motion\}\"\] \$\{selector\}/, "sprite CSS must let drag motion override reaction state."); +assert.match(petWindowSource, /\.sprite, \.installed-sprite, \.bubble/, "reduced-motion CSS must include built-in and installed sprites."); +assert.match(petWindowSource, /function createAgentPetWindow[\s\S]*?installMotionStatePublisher\(window\)/, "agent pet windows must publish motion state so dragged non-default pets run."); +assert.match(petWindowSource, /loadExplicitPetContent[\s\S]*?state\.preferences\.petScale/, "explicit agent pet windows must use the saved pet scale preference."); +assert.match(mappingDoc, /\| 3 \| `waving` \| `waving`, Claude `Notification`\. \|/, "mapping docs must describe waving animation row and notification mapping."); +assert.doesNotMatch(mappingDoc, /bubble-only|currently \*\*bubble states\*\*/i, "mapping docs must not describe reactions as bubble-only."); +for (const reaction of allowedReactions) { + const pool = reactionMessagePools[reaction]; + assert.ok(pool.length >= 20, `reaction message pool must be extensive for: ${reaction}`); + for (const message of pool) { + assert.match(message, /^[A-Z]/, `reaction message must start uppercase: ${message}`); + assert.doesNotMatch(message, /[\r\n]/, `reaction message must be single-line: ${message}`); + assert.ok(message.length <= 36, `reaction message must stay bubble-friendly: ${message}`); + } +} +assert.equal(pickReactionMessage("success", () => 0), reactionMessagePools.success[0], "reaction message picking must be deterministic when random is injected."); +const agentSetupHtmlSource = windowsSource.match(/function createAgentSetupHtml[\s\S]*?function createSettingsHtml/)?.[0] ?? ""; +assert.match(windowsSource, /onboarding-logo\.webp/, "onboarding greeting must reference the bundled OpenPets logo asset."); +assert.match(windowsSource, /onboarding-pets\.webp/, "onboarding greeting must reference the bundled pet scene asset."); +assert.ok(windowsSource.includes(`content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'"`), "onboarding image CSP must stay data-only for embedded packaged assets."); +assert.doesNotMatch(windowsSource, /data-default-pet-sprite-src|createAssetDataUrl\("default-pet-spritesheet\.webp"/, "Pet Manager must not embed the large default spritesheet into the task-window data URL."); +assert.match(windowsSource, /default-pet-thumbnail\.png/, "Pet Manager must use the small bundled default pet thumbnail for built-in preview."); +assert.ok(windowsSource.includes(`content="default-src 'none'; img-src data: https://openpets.dev; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'"`), "Pet Manager image CSP must stay scoped to data URLs and openpets.dev catalog previews."); +assert.match(windowsSource, /petManagerWindowWidth\s*=\s*1160/, "Pet Manager should use the approved wider 1160px default window width."); +assert.match(windowsSource, /petManagerWindowHeight\s*=\s*780/, "Pet Manager should use the approved taller 780px default window height."); +assert.match(agentSetupHtmlSource, /content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'"/, "Agent Setup image CSP must stay data-only for the bundled logo asset."); +assert.match(windowsSource, /integrations\/claude\.svg/, "Agent Setup integrations hub must use bundled integration SVG assets."); +assert.match(windowsSource, /integration-opencode-status/, "Agent Setup must show an enabled OpenCode integration card."); +assert.match(windowsSource, /opencode-detail-view/, "Agent Setup must include an OpenCode detail pane."); +assert.match(windowsSource, /Desktop OpenCode setup is global/, "OpenCode desktop setup must clearly warn that it is global."); +assert.match(preloadSource, /opencode-install/, "Agent Setup preload must bind OpenCode install actions."); +assert.match(preloadSource, /setIconButtonContent\(opencodeInstall, "spinner", "Installing…"\)/, "OpenCode integration-card install button must show a loading spinner."); +assert.match(preloadSource, /opencode-remove/, "Agent Setup preload must bind OpenCode remove actions."); +assert.match(preloadSource, /opencode-copy-config/, "Agent Setup preload must bind OpenCode copy-preview actions."); +assert.match(preloadSource, /opencodePreview/, "Agent Setup preload must render OpenCode previews."); +assert.match(preloadSource, /cleanupConfigPaths/, "OpenCode preview must disclose stale overlay cleanup config paths."); +assert.match(preloadSource, /configPreview/, "OpenCode preview copy must use the prepared config preview shape."); +assert.doesNotMatch(agentSetupSource, /JSON\.parse\(prepared\.configWrite\.content\)/, "OpenCode desktop preview must parse JSONC planned config safely, not JSON.parse."); +assert.doesNotMatch(agentSetupHtmlSource, /https?:\/\//, "Agent Setup must not allow or reference remote integration assets."); +assert.match(windowsSource, /agentSetupWindowWidth\s*=\s*1160/, "Agent Setup should use the approved wider 1160px default window width."); +assert.match(windowsSource, /agentSetupWindowHeight\s*=\s*780/, "Agent Setup should use the approved taller 780px default window height."); +assert.match(windowsSource, /refreshDefaultPetContent\(\);\s*refreshAgentPetContent\(\);/, "pet scale preference changes must refresh default and agent pet windows."); +assert.ok(existsSync(join(appDir, "scripts", "clean-package-output.cjs")), "package output cleanup helper must exist."); +assert.ok(existsSync(join(distDir, "main.js")), "desktop main build output must exist before packaging checks run."); +assert.ok(existsSync(join(repoRoot, "packages", "claude", "dist", "index.js")), "@open-pets/claude must be built before packaging."); +assert.ok(existsSync(join(repoRoot, "packages", "client", "dist", "index.js")), "@open-pets/client must be built before packaging."); +assert.ok(existsSync(join(repoRoot, "packages", "mcp", "dist", "index.js")), "@open-pets/mcp must be built before packaging."); +assert.ok(existsSync(join(repoRoot, "packages", "cli", "dist", "index.js")), "@open-pets/cli must be built before packaging."); +assert.ok(existsSync(join(repoRoot, "packages", "opencode", "dist", "plugin.js")), "@open-pets/opencode plugin must be built before packaging."); +assert.ok(existsSync(join(repoRoot, "packages", "agent-events", "dist", "index.js")), "@open-pets/agent-events must be built before packaging."); + +if (process.argv.includes("--output")) { + checkPackageOutput(); +} else { + checkCleanupHelper(); +} + +console.error("Packaging contract validation passed."); + +function checkPackageOutput(): void { + const outputDir = join(appDir, "dist-electron"); + assert.ok(existsSync(outputDir), "dist-electron output must exist after packaging."); + assertNoForbiddenOutput(outputDir); + assertNoEscapingSymlinks(outputDir); + + const appResourceDir = findPackagedAppResourceDir(outputDir); + assert.ok(appResourceDir, "packaged app resources directory was not found."); + assert.ok(existsSync(join(appResourceDir, "app.asar")), "packaged app.asar is missing."); + const appContents = join(appResourceDir, "app.asar.unpacked"); + assert.ok(existsSync(appContents), "packaged app.asar.unpacked resources are missing."); + assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "claude", "dist", "index.js")), "packaged @open-pets/claude runtime is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "claude", "dist", "cli.js")), "packaged @open-pets/claude CLI runtime is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "claude", "package.json")), "packaged @open-pets/claude package metadata is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "client", "dist", "index.js")), "packaged @open-pets/client runtime is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "client", "package.json")), "packaged @open-pets/client package metadata is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "mcp", "dist", "index.js")), "packaged @open-pets/mcp runtime is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "mcp", "package.json")), "packaged @open-pets/mcp package metadata is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "cli", "dist", "index.js")), "packaged @open-pets/cli runtime is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "opencode", "dist", "plugin.js")), "packaged @open-pets/opencode plugin runtime is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "opencode", "package.json")), "packaged @open-pets/opencode package metadata is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "@open-pets", "agent-events", "dist", "index.js")), "packaged @open-pets/agent-events runtime is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "@modelcontextprotocol", "sdk")), "packaged MCP SDK runtime dependency is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "zod", "index.cjs")), "packaged zod runtime dependency is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "yauzl", "index.js")), "packaged yauzl runtime dependency is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "yauzl", "fd-slicer.js")), "packaged yauzl fd-slicer helper is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "buffer-crc32", "index.js")), "packaged yauzl transitive dependency buffer-crc32 is missing."); + assert.ok(existsSync(join(appContents, "node_modules", "pend", "index.js")), "packaged yauzl transitive dependency pend is missing."); + assertRegularNonSymlink(join(appContents, "node_modules", "@open-pets", "mcp", "dist", "index.js")); + assertRegularNonSymlink(join(appContents, "node_modules", "@open-pets", "cli", "dist", "index.js")); + assertRegularNonSymlink(join(appContents, "node_modules", "@open-pets", "opencode", "dist", "plugin.js")); + assertRegularNonSymlink(join(appContents, "node_modules", "@open-pets", "claude", "dist", "cli.js")); + assertCommandSmoke(appContents); +} + +function findPackagedAppResourceDir(outputDir: string): string | null { + const candidates: string[] = []; + collectDirectories(outputDir, candidates, 4); + + for (const dir of candidates) { + if (existsSync(join(dir, "app.asar")) || existsSync(join(dir, "app", "dist", "main.js"))) { + return dir; + } + } + + return null; +} + +function collectDirectories(dir: string, result: string[], depth: number): void { + if (depth < 0 || !existsSync(dir)) return; + result.push(dir); + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) collectDirectories(join(dir, entry.name), result, depth - 1); + } +} + +function assertNoForbiddenOutput(outputDir: string): void { + const forbiddenSegments = new Set(["v1", "web", ".env", ".claude"]); + for (const path of walk(outputDir)) { + const rel = relative(outputDir, path); + const segments = rel.split(/[\\/]/g); + assert.ok(!segments.includes("docs") || !segments.includes("phases"), `package output must not include phase docs: ${rel}`); + for (const segment of segments) { + assert.ok(!forbiddenSegments.has(segment) && !segment.startsWith(".env"), `package output contains forbidden path segment: ${rel}`); + } + } +} + +function assertNoEscapingSymlinks(outputDir: string): void { + const outputReal = realpathSync(outputDir); + for (const path of walk(outputDir)) { + const stat = lstatSync(path); + if (!stat.isSymbolicLink()) continue; + const target = realpathSync(path); + assert.ok(isInside(outputReal, target), `package output symlink escapes package directory: ${relative(outputDir, path)} -> ${target}`); + } +} + +function walk(dir: string): string[] { + const result: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + result.push(path); + if (entry.isDirectory()) { + result.push(...walk(path)); + } + } + return result; +} + +function isInside(parent: string, child: string): boolean { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function checkCleanupHelper(): void { + const sentinel = join(appDir, "dist-electron", ".openpets-clean-sentinel"); + mkdirSync(dirname(sentinel), { recursive: true }); + writeFileSync(sentinel, "stale", "utf8"); + const result = spawnSync(process.execPath, [join(appDir, "scripts", "clean-package-output.cjs")], { cwd: appDir, encoding: "utf8" }); + assert.equal(result.status, 0, `cleanup helper failed: ${result.stderr || result.stdout}`); + assert.ok(!existsSync(sentinel), "cleanup helper did not remove stale package output sentinel."); +} + +function assertRegularNonSymlink(path: string): void { + assert.ok(!lstatSync(path).isSymbolicLink(), `packaged command file must not be a symlink: ${path}`); + assert.ok(lstatSync(path).isFile(), `packaged command file must be regular: ${path}`); +} + +function assertNonEmptyFile(path: string, message: string): void { + assert.ok(existsSync(path), message); + const stat = lstatSync(path); + assert.ok(stat.isFile(), message); + assert.ok(stat.size > 0, message); +} + +function assertSafeBundledSvg(path: string, message: string): void { + assertNonEmptyFile(path, message); + const source = readFileSync(path, "utf8"); + assert.doesNotMatch(source, / createParsedUpdateStatus("1.0.0", { tag_name: "release" }, 125, repo, releasesUrl)); + +console.error("Update checker validation passed."); diff --git a/apps/desktop/src/check-zip-safety.ts b/apps/desktop/src/check-zip-safety.ts new file mode 100644 index 00000000..d4ce4250 --- /dev/null +++ b/apps/desktop/src/check-zip-safety.ts @@ -0,0 +1,142 @@ +import { validateZipEntryName, assertRejectsPath, ZipEntryPathTracker, hasSupportedZipMagic } from "./zip-safety.js"; +import yauzl from "yauzl"; + +validateZipEntryName("pet-package/"); +validateZipEntryName("pet-package/pet.json"); +validateZipEntryName("pet-package/spritesheet.webp"); +validateZipEntryName("pet.json"); +validateZipEntryName("spritesheet.webp"); + +const validTracker = new ZipEntryPathTracker(); +validTracker.accept("pet-package/"); +validTracker.accept("pet-package/pet.json"); +validTracker.accept("pet-package/spritesheet.webp"); + +const validFlatTracker = new ZipEntryPathTracker(); +validFlatTracker.accept("pet.json"); +validFlatTracker.accept("spritesheet.webp"); + +for (const unsafe of [ + "../pet.json", + "pet-package/../pet.json", + "./pet.json", + "/tmp/pet.json", + "C:/tmp/pet.json", + "pet-package\\pet.json", + "//server/share/pet.json", + "pet-package/pet.json\0.png", + "pet-package/extra.js", + "pet-package/nested/pet.json", + "pet-package//pet.json", +]) { + assertRejectsPath(unsafe); +} + +assertRejectsEntrySet(["pet-package/pet.json", "pet-package/pet.json"]); +assertRejectsEntrySet(["pet-package/pet.json", "pet-package/PET.json"]); +assertRejectsEntrySet(["pet-package/pet.json", "other-pet/spritesheet.webp"]); +assertRejectsEntrySet(["pet.json", "pet-package/spritesheet.webp"]); +assertRejectsEntrySet(["pet.json", "pet-package/"]); +assertRejectsEntrySet(["spritesheet.webp", "pet-package/"]); +assertRejectsEntrySet(["pet.json", "pet.json"]); +assertRejectsEntrySet(["pet.json", "PET.json"]); + +if (!hasSupportedZipMagic(Buffer.from([0x50, 0x4b, 0x03, 0x04]))) { + throw new Error("Valid local file zip signature was rejected."); +} + +if (hasSupportedZipMagic(Buffer.from("not a zip", "utf8"))) { + throw new Error("Non-zip content was accepted as a zip."); +} + +await assertYauzlStrictFileNamesRejects("pet-package\\pet.json"); + +console.log("Zip safety validation passed."); + +function assertRejectsEntrySet(entries: readonly string[]): void { + const tracker = new ZipEntryPathTracker(); + try { + for (const entry of entries) { + tracker.accept(entry); + } + } catch { + return; + } + + throw new Error(`Unsafe zip entry set was accepted: ${entries.join(", ")}`); +} + +async function assertYauzlStrictFileNamesRejects(fileName: string): Promise { + const buffer = createSingleEmptyFileZip(fileName); + + await new Promise((resolvePromise, rejectPromise) => { + yauzl.fromBuffer(buffer, { lazyEntries: true, strictFileNames: true, validateEntrySizes: true }, (error, zipFile) => { + if (error) { + resolvePromise(); + return; + } + + if (!zipFile) { + rejectPromise(new Error("yauzl did not return a zip file.")); + return; + } + + zipFile.on("error", () => resolvePromise()); + zipFile.on("entry", () => rejectPromise(new Error("yauzl strictFileNames accepted an unsafe file name."))); + zipFile.on("end", () => rejectPromise(new Error("yauzl strictFileNames ended without rejecting unsafe file name."))); + zipFile.readEntry(); + }); + }); +} + +function createSingleEmptyFileZip(fileName: string): Buffer { + const fileNameBytes = Buffer.from(fileName, "utf8"); + const local = Buffer.alloc(30 + fileNameBytes.length); + let offset = 0; + local.writeUInt32LE(0x04034b50, offset); offset += 4; + local.writeUInt16LE(20, offset); offset += 2; + local.writeUInt16LE(0, offset); offset += 2; + local.writeUInt16LE(0, offset); offset += 2; + local.writeUInt16LE(0, offset); offset += 2; + local.writeUInt16LE(0, offset); offset += 2; + local.writeUInt32LE(0, offset); offset += 4; + local.writeUInt32LE(0, offset); offset += 4; + local.writeUInt32LE(0, offset); offset += 4; + local.writeUInt16LE(fileNameBytes.length, offset); offset += 2; + local.writeUInt16LE(0, offset); offset += 2; + fileNameBytes.copy(local, offset); + + const central = Buffer.alloc(46 + fileNameBytes.length); + offset = 0; + central.writeUInt32LE(0x02014b50, offset); offset += 4; + central.writeUInt16LE(20, offset); offset += 2; + central.writeUInt16LE(20, offset); offset += 2; + central.writeUInt16LE(0, offset); offset += 2; + central.writeUInt16LE(0, offset); offset += 2; + central.writeUInt16LE(0, offset); offset += 2; + central.writeUInt16LE(0, offset); offset += 2; + central.writeUInt32LE(0, offset); offset += 4; + central.writeUInt32LE(0, offset); offset += 4; + central.writeUInt32LE(0, offset); offset += 4; + central.writeUInt16LE(fileNameBytes.length, offset); offset += 2; + central.writeUInt16LE(0, offset); offset += 2; + central.writeUInt16LE(0, offset); offset += 2; + central.writeUInt16LE(0, offset); offset += 2; + central.writeUInt16LE(0, offset); offset += 2; + central.writeUInt32LE(0, offset); offset += 4; + central.writeUInt32LE(0, offset); offset += 4; + fileNameBytes.copy(central, offset); + + const end = Buffer.alloc(22); + offset = 0; + end.writeUInt32LE(0x06054b50, offset); offset += 4; + end.writeUInt16LE(0, offset); offset += 2; + end.writeUInt16LE(0, offset); offset += 2; + end.writeUInt16LE(1, offset); offset += 2; + end.writeUInt16LE(1, offset); offset += 2; + end.writeUInt32LE(central.length, offset); offset += 4; + end.writeUInt32LE(local.length, offset); offset += 4; + end.writeUInt16LE(0, offset); + + return Buffer.concat([local, central, end]); +} diff --git a/apps/desktop/src/claude-memory.ts b/apps/desktop/src/claude-memory.ts new file mode 100644 index 00000000..e34048e8 --- /dev/null +++ b/apps/desktop/src/claude-memory.ts @@ -0,0 +1,210 @@ +import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { randomUUID } from "node:crypto"; + +export interface ClaudeOpenPetsMemoryResult { + readonly changed: boolean; + readonly claudeMdPath: string; + readonly openPetsMemoryPath: string; +} + +export interface ClaudeOpenPetsMemoryStatus { + readonly status: "installed" | "not_installed" | "error"; + readonly message: string; + readonly claudeMdPath: string; + readonly openPetsMemoryPath: string; +} + +export const openPetsClaudeImportLine = "@~/.claude/openpets.md"; + +const openPetsImportStart = ""; +const openPetsImportEnd = ""; +const openPetsMemoryStart = ""; +const openPetsMemoryEnd = ""; +const maxClaudeMemoryBytes = 1024 * 1024; + +export function installClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemoryResult { + const paths = getClaudeMemoryPaths(homeDir); + assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath); + mkdirSync(paths.claudeDir, { recursive: true, mode: 0o700 }); + assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath); + + const currentOpenPetsMemory = readTextFile(paths.openPetsMemoryPath); + const nextOpenPetsMemory = upsertOpenPetsMemoryBlock(currentOpenPetsMemory, createOpenPetsMemoryBlock()); + const openPetsChanged = currentOpenPetsMemory !== nextOpenPetsMemory; + if (openPetsChanged) writePrivateTextFile(paths.openPetsMemoryPath, nextOpenPetsMemory); + + const currentClaudeMd = readTextFile(paths.claudeMdPath); + const nextClaudeMd = ensureManagedImport(currentClaudeMd); + const claudeMdChanged = currentClaudeMd !== nextClaudeMd; + if (claudeMdChanged) writePrivateTextFile(paths.claudeMdPath, nextClaudeMd); + + return { changed: openPetsChanged || claudeMdChanged, claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath }; +} + +export function uninstallClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemoryResult { + const paths = getClaudeMemoryPaths(homeDir); + assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath); + + let changed = false; + const currentClaudeMd = readTextFile(paths.claudeMdPath); + const hasUserOwnedImport = hasImportLineOutsideManagedBlock(currentClaudeMd); + const nextClaudeMd = removeManagedImport(currentClaudeMd); + if (currentClaudeMd !== nextClaudeMd) { + writePrivateTextFile(paths.claudeMdPath, nextClaudeMd); + changed = true; + } + + const currentOpenPetsMemory = readTextFile(paths.openPetsMemoryPath); + if (currentOpenPetsMemory) { + const nextOpenPetsMemory = removeOpenPetsMemoryBlock(currentOpenPetsMemory); + if (nextOpenPetsMemory.trim().length === 0) { + if (hasUserOwnedImport) { + writePrivateTextFile(paths.openPetsMemoryPath, ""); + } else { + rmSync(paths.openPetsMemoryPath, { force: true }); + } + changed = true; + } else if (nextOpenPetsMemory !== currentOpenPetsMemory) { + writePrivateTextFile(paths.openPetsMemoryPath, nextOpenPetsMemory); + changed = true; + } + } + + return { changed, claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath }; +} + +export function doctorClaudeOpenPetsMemory(homeDir: string): ClaudeOpenPetsMemoryStatus { + const paths = getClaudeMemoryPaths(homeDir); + try { + assertSafeClaudeMemoryPaths(paths.claudeDir, paths.claudeMdPath, paths.openPetsMemoryPath); + const claudeMd = readTextFile(paths.claudeMdPath); + const openPetsMemory = readTextFile(paths.openPetsMemoryPath); + const hasImport = hasManagedImport(claudeMd) || hasImportLineOutsideManagedBlock(claudeMd); + const hasInstructions = createOpenPetsBlockPattern().test(openPetsMemory) || /openpets_say|OpenPets MCP/i.test(openPetsMemory); + if (hasImport && hasInstructions) { + return { status: "installed", message: "Claude will load OpenPets instructions from ~/.claude/openpets.md.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath }; + } + if (hasImport) { + return { status: "not_installed", message: "Claude imports OpenPets instructions, but the OpenPets memory file is missing or incomplete.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath }; + } + if (hasInstructions) { + return { status: "not_installed", message: "OpenPets instructions exist, but Claude is not importing them yet.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath }; + } + return { status: "not_installed", message: "Claude OpenPets instructions are not installed.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath }; + } catch (error) { + return { status: "error", message: error instanceof Error ? error.message : "Claude OpenPets instruction status is unavailable.", claudeMdPath: paths.claudeMdPath, openPetsMemoryPath: paths.openPetsMemoryPath }; + } +} + +export function getClaudeMemoryPaths(homeDir: string): { readonly claudeDir: string; readonly claudeMdPath: string; readonly openPetsMemoryPath: string } { + const claudeDir = join(homeDir, ".claude"); + return { + claudeDir, + claudeMdPath: join(claudeDir, "CLAUDE.md"), + openPetsMemoryPath: join(claudeDir, "openpets.md"), + }; +} + +export function createOpenPetsMemoryBlock(): string { + return `${openPetsMemoryStart}\n## OpenPets\n\nOpenPets MCP tools may be available.\n\nUse OpenPets as a short visible status channel for meaningful coding progress:\n- Use \`openpets_say\` when starting, completing, blocking, or needing review on non-trivial work.\n- Keep messages brief, user-facing, and non-sensitive.\n- Do not include code, logs, secrets, URLs, or file paths.\n- Use \`openpets_react\` for small visual or emotional feedback.\n- Use \`openpets_status\` only when checking availability or the targeted pet.\n- Do not spam every internal step.\n${openPetsMemoryEnd}\n`; +} + +export function ensureImportLine(source: string, importLine: string): string { + const lines = source.split(/\r?\n/); + const filtered = lines.filter((line) => line.trim() !== importLine); + const base = filtered.join("\n").replace(/\s*$/u, ""); + return base ? `${base}\n\n${importLine}\n` : `${importLine}\n`; +} + +export function ensureManagedImport(source: string): string { + const withoutManagedImports = removeManagedImport(source).replace(/\s*$/u, ""); + if (withoutManagedImports.split(/\r?\n/).some((line) => line.trim() === openPetsClaudeImportLine)) { + return withoutManagedImports ? `${withoutManagedImports}\n` : ""; + } + const block = `${openPetsImportStart}\n${openPetsClaudeImportLine}\n${openPetsImportEnd}`; + return withoutManagedImports ? `${withoutManagedImports}\n\n${block}\n` : `${block}\n`; +} + +export function removeManagedImport(source: string): string { + return source.replace(createManagedImportPattern(), "").replace(/\n{3,}/g, "\n\n").replace(/\s*$/u, (match) => (match.includes("\n") ? "\n" : "")); +} + +export function removeImportLine(source: string, importLine: string): string { + return source + .split(/\r?\n/) + .filter((line) => line.trim() !== importLine) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .replace(/\s*$/u, (match) => (match.includes("\n") ? "\n" : "")); +} + +export function upsertOpenPetsMemoryBlock(source: string, block: string): string { + const withoutBlocks = source.replace(createOpenPetsBlockPattern(), "").replace(/\n{3,}/g, "\n\n").replace(/\s*$/u, ""); + return withoutBlocks ? `${withoutBlocks}\n\n${block}` : block; +} + +export function removeOpenPetsMemoryBlock(source: string): string { + const withoutBlock = source.replace(createOpenPetsBlockPattern(), "").replace(/\n{3,}/g, "\n\n").trim(); + return withoutBlock ? `${withoutBlock}\n` : ""; +} + +function createOpenPetsBlockPattern(): RegExp { + return new RegExp(`${escapeRegExp(openPetsMemoryStart)}[\\s\\S]*?${escapeRegExp(openPetsMemoryEnd)}\\n?`, "g"); +} + +function createManagedImportPattern(): RegExp { + return new RegExp(`${escapeRegExp(openPetsImportStart)}[\\s\\S]*?${escapeRegExp(openPetsImportEnd)}\\n?`, "g"); +} + +function hasManagedImport(source: string): boolean { + return createManagedImportPattern().test(source); +} + +function hasImportLineOutsideManagedBlock(source: string): boolean { + return removeManagedImport(source).split(/\r?\n/).some((line) => line.trim() === openPetsClaudeImportLine); +} + +function assertSafeClaudeMemoryPaths(claudeDir: string, claudeMdPath: string, openPetsMemoryPath: string): void { + if (existsSync(claudeDir)) { + const stat = lstatSync(claudeDir); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("Claude memory directory is not a safe directory."); + } + for (const path of [claudeMdPath, openPetsMemoryPath]) { + if (!existsSync(path)) continue; + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Claude memory file is not a safe regular file."); + if (stat.size > maxClaudeMemoryBytes) throw new Error("Claude memory file is too large for OpenPets to update safely."); + } +} + +function readTextFile(path: string): string { + if (!existsSync(path)) return ""; + return readFileSync(path, "utf8"); +} + +function writePrivateTextFile(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + assertSafeWriteTarget(path); + const tempPath = join(dirname(path), `.${process.pid}.${randomUUID()}.tmp`); + const fd = openSync(tempPath, "wx", 0o600); + try { + writeFileSync(fd, content, { encoding: "utf8" }); + } finally { + closeSync(fd); + } + assertSafeWriteTarget(path); + renameSync(tempPath, path); + try { chmodSync(path, 0o600); } catch { /* best effort */ } +} + +function assertSafeWriteTarget(path: string): void { + if (!existsSync(path)) return; + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Claude memory file is not a safe regular file."); + if (stat.size > maxClaudeMemoryBytes) throw new Error("Claude memory file is too large for OpenPets to update safely."); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/apps/desktop/src/codex-pets-core.ts b/apps/desktop/src/codex-pets-core.ts new file mode 100644 index 00000000..889f1b69 --- /dev/null +++ b/apps/desktop/src/codex-pets-core.ts @@ -0,0 +1,40 @@ +export const maxCodexPetJsonBytes = 128 * 1024; +export const maxCodexSpritesheetBytes = 100 * 1024 * 1024; +export const maxCodexPreviewBytes = 8 * 1024 * 1024; +export const maxCodexTotalPreviewBytes = 24 * 1024 * 1024; +export const maxCodexPets = 100; + +export interface CodexPetMetadata { + readonly id: string; + readonly displayName: string; + readonly description: string; + readonly spritesheetPath: "spritesheet.webp"; +} + +export function canInlineCodexPreview(size: number): boolean { + return Number.isSafeInteger(size) && size > 0 && size <= maxCodexPreviewBytes; +} + +export function validateCodexPetMetadata(value: unknown, folderName: string): CodexPetMetadata { + if (!isSafeCodexPetId(folderName)) throw new Error("Codex pet folder name is invalid."); + if (!isRecord(value)) throw new Error("pet.json must be an object."); + if (value.id !== folderName || typeof value.id !== "string") throw new Error("Codex pet id must match its folder name."); + if (!isSafeCodexPetId(value.id)) throw new Error("Codex pet id is invalid."); + if (typeof value.displayName !== "string" || value.displayName.trim().length === 0 || value.displayName.length > 80) throw new Error("Codex pet displayName is invalid."); + if (typeof value.description !== "string" || value.description.trim().length === 0 || value.description.length > 500) throw new Error("Codex pet description is invalid."); + if (value.spritesheetPath !== "spritesheet.webp") throw new Error("Codex pet spritesheetPath must be spritesheet.webp."); + return { + id: value.id, + displayName: value.displayName.trim(), + description: value.description.trim(), + spritesheetPath: "spritesheet.webp", + }; +} + +function isSafeCodexPetId(value: string): boolean { + return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(value) && value !== "builtin"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/apps/desktop/src/codex-pets.ts b/apps/desktop/src/codex-pets.ts new file mode 100644 index 00000000..b72ba81f --- /dev/null +++ b/apps/desktop/src/codex-pets.ts @@ -0,0 +1,193 @@ +import { constants } from "node:fs"; +import { lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, join, resolve, sep } from "node:path"; + +import { getAppStateSnapshot, installPetState, type OpenPetsStateV1 } from "./app-state.js"; +import { canInlineCodexPreview, maxCodexPetJsonBytes, maxCodexPets, maxCodexPreviewBytes, maxCodexSpritesheetBytes, maxCodexTotalPreviewBytes, validateCodexPetMetadata, type CodexPetMetadata } from "./codex-pets-core.js"; +import { withPetOperation } from "./pet-installation.js"; +import { assertInsideRoot, assertSafePetId, getInstalledPetDir, getPetsRoot } from "./pet-paths.js"; + +const codexPetsRoot = join(homedir(), ".codex", "pets"); + +export interface CodexPetUiState { + readonly source: "codex"; + readonly pets: readonly CodexPetUiItem[]; + readonly error?: string; +} + +export interface CodexPetUiItem { + readonly id: string; + readonly displayName: string; + readonly description: string; + readonly preview: string; +} + +export async function getCodexPetsUiState(): Promise { + try { + const root = await validateCodexRoot(); + const entries = (await readdir(codexPetsRoot, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name)); + const pets: CodexPetUiItem[] = []; + let remainingPreviewBytes = maxCodexTotalPreviewBytes; + let attemptedDirectories = 0; + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith(".")) continue; + attemptedDirectories += 1; + if (attemptedDirectories > maxCodexPets) break; + const pet = await tryReadCodexPet(root, join(root, entry.name), entry.name, remainingPreviewBytes); + if (pet) { + remainingPreviewBytes -= pet.previewBytes; + pets.push(pet.item); + } + } + + pets.sort((left, right) => left.displayName.localeCompare(right.displayName)); + return { source: "codex", pets }; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return { source: "codex", pets: [] }; + return { source: "codex", pets: [], error: error instanceof Error ? error.message : "Codex pets unavailable." }; + } +} + +export async function importCodexPet(petId: string): Promise { + return withPetOperation(petId, async () => { + assertSafePetId(petId); + if (getAppStateSnapshot().pets.installed.some((pet) => pet.id === petId)) { + throw new Error(`Pet is already installed: ${petId}`); + } + + const root = await validateCodexRoot(); + const sourceDir = resolve(root, petId); + await assertCodexPetDirectory(root, sourceDir); + const metadata = await readCodexPetMetadata(root, sourceDir, petId); + const spritesheetPath = join(sourceDir, metadata.spritesheetPath); + const spritesheet = await readRegularFile(spritesheetPath, maxCodexSpritesheetBytes, "spritesheet.webp"); + + const petsRoot = getPetsRoot(); + await mkdir(petsRoot, { recursive: true, mode: 0o700 }); + const finalDir = getInstalledPetDir(petId); + const tempDir = await mkdtemp(join(petsRoot, `.codex-import-${petId}-`)); + + try { + assertInsideRoot(petsRoot, tempDir); + await writeFile(join(tempDir, "spritesheet.webp"), spritesheet, { mode: 0o600, flag: "wx" }); + await writeFile(join(tempDir, "pet.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }); + await rm(finalDir, { recursive: true, force: true }); + await rename(tempDir, finalDir); + + try { + await validateInstalledRegularFile(join(finalDir, "spritesheet.webp")); + await validateInstalledRegularFile(join(finalDir, "pet.json")); + return installPetState({ + id: metadata.id, + displayName: metadata.displayName, + description: metadata.description, + source: { kind: "codex", path: sourceDir }, + }); + } catch (error) { + await rm(finalDir, { recursive: true, force: true }); + throw error; + } + } catch (error) { + await rm(tempDir, { recursive: true, force: true }); + throw error; + } + }); +} + +async function tryReadCodexPet(root: string, dir: string, folderName: string, remainingPreviewBytes: number): Promise<{ readonly item: CodexPetUiItem; readonly previewBytes: number } | null> { + try { + const metadata = await readCodexPetMetadata(root, dir, folderName); + const spritesheetPath = join(dir, metadata.spritesheetPath); + await validateSpritesheet(spritesheetPath); + const preview = await createSpritesheetDataUrl(spritesheetPath, remainingPreviewBytes); + return { + item: { + id: metadata.id, + displayName: metadata.displayName, + description: metadata.description, + preview: preview.dataUrl, + }, + previewBytes: preview.bytes, + }; + } catch (error) { + console.error(`Skipping invalid Codex pet at ${dir}.`, error); + return null; + } +} + +async function readCodexPetMetadata(root: string, dir: string, folderName: string): Promise { + await assertCodexPetDirectory(root, dir); + assertSafePetId(folderName); + const petJson = join(dir, "pet.json"); + const parsed = JSON.parse((await readRegularFile(petJson, maxCodexPetJsonBytes, "pet.json")).toString("utf8")) as unknown; + const metadata = validateCodexPetMetadata(parsed, folderName); + assertSafePetId(metadata.id); + return metadata; +} + +async function validateSpritesheet(path: string): Promise { + const spritesheet = await lstat(path); + if (spritesheet.isSymbolicLink()) throw new Error("spritesheet.webp cannot be a symlink."); + if (!spritesheet.isFile()) throw new Error("spritesheet.webp must be a file."); + if (spritesheet.size <= 0) throw new Error("spritesheet.webp is empty."); + if (spritesheet.size > maxCodexSpritesheetBytes) throw new Error("spritesheet.webp is too large."); +} + +async function createSpritesheetDataUrl(path: string, remainingPreviewBytes: number): Promise<{ readonly dataUrl: string; readonly bytes: number }> { + const spritesheet = await lstat(path); + if (spritesheet.isSymbolicLink() || !spritesheet.isFile() || !canInlineCodexPreview(spritesheet.size) || spritesheet.size > remainingPreviewBytes) { + return { dataUrl: "", bytes: 0 }; + } + const buffer = await readRegularFile(path, Math.min(maxCodexPreviewBytes, remainingPreviewBytes), "spritesheet.webp preview"); + return { dataUrl: `data:image/webp;base64,${buffer.toString("base64")}`, bytes: buffer.byteLength }; +} + +async function validateCodexRoot(): Promise { + const root = resolve(codexPetsRoot); + const rootStats = await lstat(root); + if (rootStats.isSymbolicLink()) throw new Error("Codex pets root cannot be a symlink."); + if (!rootStats.isDirectory()) throw new Error("Codex pets path is not a directory."); + const realRoot = await realpath(root); + if (realRoot !== root) throw new Error("Codex pets root path is not canonical."); + return root; +} + +async function assertCodexPetDirectory(root: string, target: string): Promise { + const resolvedTarget = resolve(target); + if (resolvedTarget === root || !resolvedTarget.startsWith(`${root}${sep}`) || basename(resolvedTarget).startsWith(".")) { + throw new Error("Resolved path escapes Codex pets directory."); + } + const dirStats = await lstat(resolvedTarget); + if (dirStats.isSymbolicLink()) throw new Error("Codex pet directory cannot be a symlink."); + if (!dirStats.isDirectory()) throw new Error("Codex pet path must be a directory."); + const realTarget = await realpath(resolvedTarget); + if (!realTarget.startsWith(`${root}${sep}`)) throw new Error("Codex pet directory escapes Codex pets root."); +} + +async function readRegularFile(path: string, maxBytes: number, label: string): Promise { + const stats = await lstat(path); + if (stats.isSymbolicLink()) throw new Error(`${label} cannot be a symlink.`); + if (!stats.isFile()) throw new Error(`${label} must be a file.`); + if (stats.size <= 0 || stats.size > maxBytes) throw new Error(`${label} size is invalid.`); + const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const openedStats = await file.stat(); + if (!openedStats.isFile()) throw new Error(`${label} must be a file.`); + if (openedStats.size !== stats.size || openedStats.size <= 0 || openedStats.size > maxBytes) throw new Error(`${label} size is invalid.`); + return await file.readFile(); + } finally { + await file.close(); + } +} + +async function validateInstalledRegularFile(path: string): Promise { + const stats = await lstat(path); + if (stats.isSymbolicLink()) throw new Error("Imported pet file cannot be a symlink."); + if (!stats.isFile()) throw new Error("Imported pet file must be a regular file."); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/apps/desktop/src/default-pet-controller.ts b/apps/desktop/src/default-pet-controller.ts new file mode 100644 index 00000000..5cd36ecf --- /dev/null +++ b/apps/desktop/src/default-pet-controller.ts @@ -0,0 +1,184 @@ +import { BrowserWindow, screen } from "electron"; + +import { getAppStateSnapshot, getDefaultPetPosition, resetDefaultPetPosition, setDefaultPetPosition, updatePreferences } from "./app-state.js"; +import { defaultPetWindowSize, getDefaultPetInitialPosition } from "./display.js"; +import { transientDisplayMs, type OpenPetsReaction } from "./local-ipc-protocol.js"; +import { clearTransientReaction, createDefaultPetWindow, getSafeDefaultPetPosition, getTransientReactionAnimationMs, loadDefaultPetContent, mergePetTransientDisplay, readWindowPosition, setPetReactionState, type PetTransientDisplay } from "./pet-window.js"; + +let defaultPetWindow: BrowserWindow | null = null; +let paused = false; +let transientDisplay: PetTransientDisplay | null = null; +let transientDisplayTimeout: NodeJS.Timeout | null = null; +let transientAnimationTimeout: NodeJS.Timeout | null = null; + +export function showDefaultPet(): void { + updatePreferences({ openDefaultPetOnLaunch: true }); + const window = getOrCreateDefaultPetWindow(); + + if (window.isMinimized()) { + window.restore(); + } + + window.showInactive(); +} + +export function hideDefaultPet(): void { + updatePreferences({ openDefaultPetOnLaunch: false }); + + if (!defaultPetWindow || defaultPetWindow.isDestroyed()) { + return; + } + + setDefaultPetPosition(readWindowPosition(defaultPetWindow)); + defaultPetWindow.hide(); +} + +export function isDefaultPetVisible(): boolean { + return Boolean(defaultPetWindow && !defaultPetWindow.isDestroyed() && defaultPetWindow.isVisible()); +} + +export function setDefaultPetPaused(nextPaused: boolean): void { + paused = nextPaused; + + if (!defaultPetWindow || defaultPetWindow.isDestroyed()) { + return; + } + + void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay); +} + +export function getDefaultPetPaused(): boolean { + return paused; +} + +export function refreshDefaultPetContent(): void { + if (!defaultPetWindow || defaultPetWindow.isDestroyed()) { + return; + } + + void loadDefaultPetContent(defaultPetWindow, paused, transientDisplay); +} + +export function applyExternalPetReaction(reaction: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } { + if (paused) { + return { shown: false, reason: "paused" }; + } + + setTransientDisplay({ reaction }); + showDefaultPetForExternalEvent(); + return { shown: isDefaultPetVisible() }; +} + +export function applyExternalPetSay(message: string, reaction?: OpenPetsReaction): { readonly shown: boolean; readonly reason?: string } { + if (paused) { + return { shown: false, reason: "paused" }; + } + + setTransientDisplay({ message, reaction }); + showDefaultPetForExternalEvent(); + return { shown: isDefaultPetVisible() }; +} + +export function destroyDefaultPet(): void { + if (!defaultPetWindow || defaultPetWindow.isDestroyed()) { + defaultPetWindow = null; + return; + } + + setDefaultPetPosition(readWindowPosition(defaultPetWindow)); + const window = defaultPetWindow; + defaultPetWindow = null; + window.destroy(); +} + +export function installDefaultPetDisplayHandlers(): void { + screen.on("display-added", reclampDefaultPetWindow); + screen.on("display-removed", reclampDefaultPetWindow); + screen.on("display-metrics-changed", reclampDefaultPetWindow); +} + +function getOrCreateDefaultPetWindow(): BrowserWindow { + if (defaultPetWindow && !defaultPetWindow.isDestroyed()) { + return defaultPetWindow; + } + + const position = getSafeDefaultPetPosition(getDefaultPetPosition()); + + defaultPetWindow = createDefaultPetWindow({ + position, + paused, + display: transientDisplay, + onPositionChanged: setDefaultPetPosition, + onHideRequested: hideDefaultPet, + }); + + defaultPetWindow.on("closed", () => { + defaultPetWindow = null; + }); + + return defaultPetWindow; +} + +function setTransientDisplay(display: PetTransientDisplay): void { + transientDisplay = mergePetTransientDisplay(transientDisplay, display); + + if (transientDisplayTimeout) { + clearTimeout(transientDisplayTimeout); + } + if (transientAnimationTimeout) { + clearTimeout(transientAnimationTimeout); + transientAnimationTimeout = null; + } + + const animationMs = getTransientReactionAnimationMs(transientDisplay); + if (animationMs !== null && animationMs < transientDisplayMs) { + transientAnimationTimeout = setTimeout(() => { + if (!transientDisplay) return; + transientDisplay = clearTransientReaction(transientDisplay); + transientAnimationTimeout = null; + if (defaultPetWindow && !defaultPetWindow.isDestroyed()) setPetReactionState(defaultPetWindow, "idle"); + }, animationMs); + } + + transientDisplayTimeout = setTimeout(() => { + transientDisplay = null; + transientDisplayTimeout = null; + if (transientAnimationTimeout) { + clearTimeout(transientAnimationTimeout); + transientAnimationTimeout = null; + } + refreshDefaultPetContent(); + }, transientDisplayMs); + + refreshDefaultPetContent(); +} + +function showDefaultPetForExternalEvent(): void { + const state = getAppStateSnapshot(); + if (isDefaultPetVisible() || state.preferences.openDefaultPetOnLaunch) { + showDefaultPet(); + } +} + +function reclampDefaultPetWindow(): void { + if (!defaultPetWindow || defaultPetWindow.isDestroyed()) { + return; + } + + const safePosition = readWindowPosition(defaultPetWindow); + defaultPetWindow.setPosition(safePosition.x, safePosition.y, false); + setDefaultPetPosition(safePosition); +} + +export function shouldOpenDefaultPetOnLaunch(): boolean { + return getAppStateSnapshot().preferences.openDefaultPetOnLaunch; +} + +export function resetDefaultPetToInitialPosition(): void { + const safePosition = getSafeDefaultPetPosition(getDefaultPetInitialPosition(defaultPetWindowSize)); + resetDefaultPetPosition(safePosition); + + if (defaultPetWindow && !defaultPetWindow.isDestroyed()) { + defaultPetWindow.setPosition(safePosition.x, safePosition.y, false); + } +} diff --git a/apps/desktop/src/display.ts b/apps/desktop/src/display.ts new file mode 100644 index 00000000..cb7d2061 --- /dev/null +++ b/apps/desktop/src/display.ts @@ -0,0 +1,44 @@ +import { screen } from "electron"; + +export interface Point { + readonly x: number; + readonly y: number; +} + +export interface WindowSize { + readonly width: number; + readonly height: number; +} + +export const defaultPetWindowSize: WindowSize = { + width: 220, + height: 320, +}; + +export const defaultPetWindowMargin = 24; + +export function getDefaultPetInitialPosition(size: WindowSize = defaultPetWindowSize): Point { + const { workArea } = screen.getPrimaryDisplay(); + + return { + x: Math.round(workArea.x + workArea.width - size.width - defaultPetWindowMargin), + y: Math.round(workArea.y + workArea.height - size.height - defaultPetWindowMargin), + }; +} + +export function clampToPrimaryWorkArea(position: Point, size: WindowSize = defaultPetWindowSize): Point { + const { workArea } = screen.getPrimaryDisplay(); + const minX = workArea.x; + const minY = workArea.y; + const maxX = workArea.x + Math.max(0, workArea.width - size.width); + const maxY = workArea.y + Math.max(0, workArea.height - size.height); + + return { + x: clamp(Math.round(position.x), minX, maxX), + y: clamp(Math.round(position.y), minY, maxY), + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} diff --git a/apps/desktop/src/lease-manager.ts b/apps/desktop/src/lease-manager.ts new file mode 100644 index 00000000..e340fcc0 --- /dev/null +++ b/apps/desktop/src/lease-manager.ts @@ -0,0 +1,153 @@ +import { randomUUID } from "node:crypto"; + +export type LeaseTargetKind = "default" | "explicit"; +export type LeaseFallbackReason = "invalid_pet_id" | "pet_not_installed" | "pet_broken" | "default_broken_fallback_builtin"; + +export interface PetLease { + readonly leaseId: string; + readonly requestedPetId?: string; + readonly targetKind: LeaseTargetKind; + readonly actualPetId: string; + readonly fallbackReason?: LeaseFallbackReason; + readonly acquiredAt: number; + readonly lastHeartbeatAt: number; + readonly expiresAt: number; +} + +export interface LeaseSnapshot { + readonly leaseId: string; + readonly requestedPetId?: string; + readonly targetKind: LeaseTargetKind; + readonly actualTargetPetId: string; + readonly actualTargetPetName: string; + readonly usingDefaultPet: boolean; + readonly fallbackReason?: LeaseFallbackReason; + readonly expiresAt: number; + readonly leaseActive: boolean; +} + +export interface LeaseManagerOptions { + readonly ttlMs?: number; + readonly now?: () => number; + readonly resolveTarget?: (requestedPetId: string | undefined) => { readonly targetKind: LeaseTargetKind; readonly actualPetId: string; readonly fallbackReason?: LeaseFallbackReason }; + readonly getDefaultPetId?: () => string; + readonly getPetDisplayName?: (petId: string, targetKind: LeaseTargetKind) => string; + readonly onFirstExplicitLease?: (petId: string) => void; + readonly onLastExplicitLease?: (petId: string) => void; +} + +const safePetIdPattern = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +export class LeaseManager { + readonly #leases = new Map(); + readonly #ttlMs: number; + readonly #now: () => number; + readonly #resolveTarget: (requestedPetId: string | undefined) => { readonly targetKind: LeaseTargetKind; readonly actualPetId: string; readonly fallbackReason?: LeaseFallbackReason }; + readonly #getDefaultPetId: () => string; + readonly #getPetDisplayName: (petId: string, targetKind: LeaseTargetKind) => string; + readonly #onFirstExplicitLease: (petId: string) => void; + readonly #onLastExplicitLease: (petId: string) => void; + + constructor(options: LeaseManagerOptions = {}) { + this.#ttlMs = options.ttlMs ?? 15_000; + this.#now = options.now ?? Date.now; + this.#resolveTarget = options.resolveTarget ?? (() => { throw new Error("Lease target resolver is not configured."); }); + this.#getDefaultPetId = options.getDefaultPetId ?? (() => { throw new Error("Default pet resolver is not configured."); }); + this.#getPetDisplayName = options.getPetDisplayName ?? ((petId) => petId); + this.#onFirstExplicitLease = options.onFirstExplicitLease ?? (() => {}); + this.#onLastExplicitLease = options.onLastExplicitLease ?? (() => {}); + } + + acquire(requestedPetId?: string): LeaseSnapshot { + const now = this.#now(); + const target = this.#resolveTarget(requestedPetId); + const lease: PetLease = { + leaseId: randomUUID(), + requestedPetId, + targetKind: target.targetKind, + actualPetId: target.actualPetId, + fallbackReason: target.fallbackReason, + acquiredAt: now, + lastHeartbeatAt: now, + expiresAt: now + this.#ttlMs, + }; + + const hadExplicitLease = lease.targetKind === "explicit" && this.countExplicitLeases(lease.actualPetId) > 0; + this.#leases.set(lease.leaseId, lease); + if (lease.targetKind === "explicit" && !hadExplicitLease) this.#onFirstExplicitLease(lease.actualPetId); + return this.snapshot(lease); + } + + heartbeat(leaseId: string): { readonly leaseId: string; readonly expiresAt: number } { + const lease = this.#leases.get(leaseId); + if (!lease) throw new Error("unknown_lease"); + const now = this.#now(); + if (lease.expiresAt <= now) { + this.release(leaseId); + throw new Error("unknown_lease"); + } + const next: PetLease = { ...lease, lastHeartbeatAt: now, expiresAt: now + this.#ttlMs }; + this.#leases.set(leaseId, next); + return { leaseId, expiresAt: next.expiresAt }; + } + + release(leaseId: string): { readonly released: boolean } { + const lease = this.#leases.get(leaseId); + if (!lease) return { released: false }; + this.#leases.delete(leaseId); + if (lease.targetKind === "explicit" && this.countExplicitLeases(lease.actualPetId) === 0) { + this.#onLastExplicitLease(lease.actualPetId); + } + return { released: true }; + } + + get(leaseId: string): LeaseSnapshot | null { + const lease = this.#leases.get(leaseId); + if (lease && lease.expiresAt <= this.#now()) { + this.release(leaseId); + return null; + } + return lease ? this.snapshot(lease) : null; + } + + cleanupExpired(): readonly LeaseSnapshot[] { + const now = this.#now(); + const expired: LeaseSnapshot[] = []; + for (const lease of [...this.#leases.values()]) { + if (lease.expiresAt <= now) { + expired.push(this.snapshot(lease)); + this.release(lease.leaseId); + } + } + return expired; + } + + countExplicitLeases(petId: string): number { + let count = 0; + for (const lease of this.#leases.values()) { + if (lease.targetKind === "explicit" && lease.actualPetId === petId) count += 1; + } + return count; + } + + snapshot(lease: PetLease): LeaseSnapshot { + const defaultPetId = this.#getDefaultPetId(); + const actualPetId = lease.targetKind === "default" ? defaultPetId : lease.actualPetId; + const targetKind = lease.targetKind; + return { + leaseId: lease.leaseId, + requestedPetId: lease.requestedPetId, + targetKind, + actualTargetPetId: actualPetId, + actualTargetPetName: this.#getPetDisplayName(actualPetId, targetKind), + usingDefaultPet: targetKind === "default", + fallbackReason: lease.fallbackReason, + expiresAt: lease.expiresAt, + leaseActive: true, + }; + } +} + +export function createStaleLeaseStatus(leaseId: string): { readonly ok: false; readonly appRunning: true; readonly leaseId: string; readonly leaseActive: false; readonly staleReason: "unknown_lease" } { + return { ok: false, appRunning: true, leaseId, leaseActive: false, staleReason: "unknown_lease" }; +} diff --git a/apps/desktop/src/lifecycle.ts b/apps/desktop/src/lifecycle.ts new file mode 100644 index 00000000..752d6ba9 --- /dev/null +++ b/apps/desktop/src/lifecycle.ts @@ -0,0 +1,37 @@ +import { app } from "electron"; + +import { closeAllAgentPets } from "./agent-pet-controller.js"; +import { destroyDefaultPet } from "./default-pet-controller.js"; +import { stopLocalIpcServer } from "./local-ipc.js"; +import { focusOpenTaskWindows } from "./windows.js"; + +let intentionalQuit = false; + +export function installAppLifecycle(): void { + app.on("second-instance", () => { + console.log("Second OpenPets launch requested; keeping existing instance."); + focusOpenTaskWindows(); + }); + + app.on("window-all-closed", () => { + if (!intentionalQuit) { + console.log("All OpenPets task windows closed; keeping tray app running."); + } + }); + + app.on("activate", () => { + console.log("OpenPets activate event received; not opening a dashboard window."); + }); + + app.on("before-quit", () => { + intentionalQuit = true; + stopLocalIpcServer(); + closeAllAgentPets(); + destroyDefaultPet(); + }); +} + +export function quitOpenPets(): void { + intentionalQuit = true; + app.quit(); +} diff --git a/apps/desktop/src/local-ipc-paths.ts b/apps/desktop/src/local-ipc-paths.ts new file mode 100644 index 00000000..e40541b5 --- /dev/null +++ b/apps/desktop/src/local-ipc-paths.ts @@ -0,0 +1,150 @@ +import { lstatSync, mkdirSync, writeFileSync, renameSync, rmSync, chmodSync, readFileSync } from "node:fs"; +import { randomBytes } from "node:crypto"; +import { homedir, userInfo } from "node:os"; +import { dirname, join } from "node:path"; + +import { app } from "electron"; + +import { openPetsIpcProtocol, openPetsIpcVersion } from "./local-ipc-protocol.js"; + +export interface OpenPetsDiscoveryFile { + readonly protocolVersion: 1; + readonly protocol: "openpets-ipc"; + readonly endpoint: string; + readonly token: string; + readonly appVersion: string; + readonly pid: number; + readonly platform: NodeJS.Platform; +} + +export function getDiscoveryFilePath(): string { + if (process.env.OPENPETS_DISCOVERY_FILE) { + return process.env.OPENPETS_DISCOVERY_FILE; + } + + if (process.platform === "darwin") { + return join(homedir(), "Library", "Application Support", "OpenPets", "runtime", "ipc.json"); + } + + if (process.platform === "win32") { + return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "OpenPets", "runtime", "ipc.json"); + } + + const xdg = getSecureXdgRuntimeDir(); + if (xdg) { + return join(xdg, "openpets", "ipc.json"); + } + + return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "OpenPets", "runtime", "ipc.json"); +} + +export function createIpcEndpoint(): string { + if (process.platform === "win32") { + return `\\\\.\\pipe\\openpets-${randomEndpointPart()}-${process.pid}`; + } + + const runtimeDir = getSocketRuntimeDir(); + mkdirSync(runtimeDir, { recursive: true, mode: 0o700 }); + ensurePrivateRuntimeDir(runtimeDir); + return join(runtimeDir, `openpets-${process.pid}.sock`); +} + +export function writeDiscoveryFile(endpoint: string, token: string): OpenPetsDiscoveryFile { + const discovery: OpenPetsDiscoveryFile = { + protocolVersion: openPetsIpcVersion, + protocol: openPetsIpcProtocol, + endpoint, + token, + appVersion: app.getVersion(), + pid: process.pid, + platform: process.platform, + }; + + const path = getDiscoveryFilePath(); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + try { chmodSync(dirname(path), 0o700); } catch { /* best effort */ } + const tempPath = `${path}.${process.pid}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(discovery, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + try { chmodSync(tempPath, 0o600); } catch { /* best effort */ } + renameSync(tempPath, path); + try { chmodSync(path, 0o600); } catch { /* best effort */ } + return discovery; +} + +export function removeDiscoveryFile(discovery: OpenPetsDiscoveryFile | null): void { + if (!discovery) return; + const path = getDiscoveryFilePath(); + try { + const current = JSON.parse(readFileSync(path, "utf8")) as Partial; + if (current.pid !== discovery.pid || current.token !== discovery.token || current.endpoint !== discovery.endpoint) { + return; + } + rmSync(path, { force: true }); + } catch { + // best-effort cleanup only + } +} + +export function cleanupUnixSocket(endpoint: string): void { + if (process.platform === "win32") return; + try { + rmSync(endpoint, { force: true }); + } catch { + // bind will report a real failure if cleanup was required but impossible + } +} + +export function protectUnixSocket(endpoint: string): void { + if (process.platform === "win32") return; + try { chmodSync(endpoint, 0o600); } catch { /* best effort */ } +} + +function getSocketRuntimeDir(): string { + const xdg = process.platform === "linux" ? getSecureXdgRuntimeDir() : null; + if (xdg) { + return join(xdg, "openpets"); + } + + return join("/tmp", `openpets-${getUserIdForPath()}`); +} + +function getSecureXdgRuntimeDir(): string | null { + const dir = process.env.XDG_RUNTIME_DIR; + if (!dir) return null; + + try { + const stat = lstatSync(dir); + if (!stat.isDirectory() || stat.isSymbolicLink()) return null; + if (typeof process.getuid === "function" && stat.uid !== process.getuid()) return null; + if ((stat.mode & 0o777) !== 0o700) return null; + return dir; + } catch { + return null; + } +} + +function ensurePrivateRuntimeDir(dir: string): void { + const stat = lstatSync(dir); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`OpenPets IPC runtime path is not a safe directory: ${dir}`); + } + + if (typeof process.getuid === "function" && stat.uid !== process.getuid()) { + throw new Error(`OpenPets IPC runtime directory is not owned by the current user: ${dir}`); + } + + try { chmodSync(dir, 0o700); } catch { /* best effort */ } + const updated = lstatSync(dir); + if ((updated.mode & 0o777) !== 0o700) { + throw new Error(`OpenPets IPC runtime directory is not private: ${dir}`); + } +} + +function getUserIdForPath(): string { + if (typeof process.getuid === "function") return String(process.getuid()); + try { return userInfo().username.replace(/[^a-zA-Z0-9_-]/g, "_"); } catch { return "user"; } +} + +function randomEndpointPart(): string { + return randomBytes(8).toString("hex"); +} diff --git a/apps/desktop/src/local-ipc-protocol.ts b/apps/desktop/src/local-ipc-protocol.ts new file mode 100644 index 00000000..9cc5c10a --- /dev/null +++ b/apps/desktop/src/local-ipc-protocol.ts @@ -0,0 +1,136 @@ +import { randomUUID } from "node:crypto"; + +export const openPetsIpcProtocol = "openpets-ipc"; +export const openPetsIpcVersion = 1; +export const maxIpcMessageBytes = 16 * 1024; +export const transientDisplayMs = 4_000; + +export const allowedReactions = [ + "idle", + "thinking", + "working", + "editing", + "running", + "testing", + "waiting", + "waving", + "success", + "error", + "celebrating", +] as const; + +export type OpenPetsReaction = typeof allowedReactions[number]; +export type OpenPetsIpcMethod = "hello" | "status" | "pets.list" | "lease.acquire" | "lease.heartbeat" | "lease.release" | "pet.react" | "pet.say"; + +export interface OpenPetsIpcRequest { + readonly id: string; + readonly version: number; + readonly token: string; + readonly method: OpenPetsIpcMethod; + readonly params?: unknown; +} + +export interface OpenPetsIpcResponse { + readonly id: string | null; + readonly ok: boolean; + readonly result?: unknown; + readonly error?: { + readonly code: string; + readonly message: string; + }; +} + +export function createRequestId(): string { + return randomUUID(); +} + +export function parseIpcRequest(raw: string, expectedToken: string): OpenPetsIpcRequest { + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + throw new IpcProtocolError("invalid_request", "IPC request must be valid JSON."); + } + if (!isRecord(parsed)) throw new IpcProtocolError("invalid_request", "IPC request must be an object."); + if (typeof parsed.id !== "string" || parsed.id.length < 1 || parsed.id.length > 120) throw new IpcProtocolError("invalid_request", "IPC request id is invalid."); + if (parsed.version !== openPetsIpcVersion) throw new IpcProtocolError("invalid_version", "Unsupported IPC protocol version."); + if (parsed.token !== expectedToken) throw new IpcProtocolError("invalid_token", "Invalid IPC token."); + if (parsed.method !== "hello" && parsed.method !== "status" && parsed.method !== "pets.list" && parsed.method !== "lease.acquire" && parsed.method !== "lease.heartbeat" && parsed.method !== "lease.release" && parsed.method !== "pet.react" && parsed.method !== "pet.say") { + throw new IpcProtocolError("unknown_method", "Unknown IPC method."); + } + + return { + id: parsed.id, + version: parsed.version, + token: parsed.token, + method: parsed.method, + params: parsed.params, + }; +} + +export function validateReaction(value: unknown): OpenPetsReaction { + if (typeof value !== "string" || !allowedReactions.includes(value as OpenPetsReaction)) { + throw new IpcProtocolError("invalid_params", "Invalid pet reaction."); + } + return value as OpenPetsReaction; +} + +export function validateSayMessage(value: unknown): string { + if (typeof value !== "string") throw new IpcProtocolError("invalid_params", "Message must be a string."); + const message = value.trim(); + if (message.length < 1) throw new IpcProtocolError("invalid_params", "Message cannot be empty."); + if (message.length > 140) throw new IpcProtocolError("invalid_params", "Message is too long."); + if (/[\r\n]/.test(message)) throw new IpcProtocolError("invalid_params", "Message must be single-line."); + if (/```||\b(class|import|export|const|let|var)\b/.test(message)) throw new IpcProtocolError("invalid_params", "Message looks like code."); + if (/https?:\/\/|www\.|\/[\w.-]+\/[\w./-]+|[A-Za-z]:\\/.test(message)) throw new IpcProtocolError("invalid_params", "Message contains a URL or path-like content."); + if (/(api[_-]?key|secret|token|password|passwd|BEGIN [A-Z ]+PRIVATE KEY)/i.test(message)) throw new IpcProtocolError("invalid_params", "Message looks secret-like."); + return message; +} + +export function validateOptionalLeaseId(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string" || value.length < 1 || value.length > 120 || /[\0\r\n]/.test(value)) { + throw new IpcProtocolError("invalid_params", "Invalid lease id."); + } + return value; +} + +export function validateRequestedPetId(value: unknown): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string") throw new IpcProtocolError("invalid_params", "Requested pet id must be a string."); + const trimmed = value.trim(); + if (trimmed.length < 1) return undefined; + if (Buffer.byteLength(trimmed, "utf8") > 128 || /[\x00-\x1F\x7F/\\]/.test(trimmed)) { + throw new IpcProtocolError("invalid_params", "Requested pet id is outside CLI bounds."); + } + return trimmed; +} + +export function okResponse(id: string | null, result: unknown): OpenPetsIpcResponse { + return { id, ok: true, result }; +} + +export function errorResponse(id: string | null, error: unknown): OpenPetsIpcResponse { + if (error instanceof IpcProtocolError) { + return { id, ok: false, error: { code: error.code, message: error.message } }; + } + + return { + id, + ok: false, + error: { + code: "internal_error", + message: error instanceof Error ? error.message : "IPC request failed.", + }, + }; +} + +export class IpcProtocolError extends Error { + constructor(readonly code: string, message: string) { + super(message); + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/apps/desktop/src/local-ipc.ts b/apps/desktop/src/local-ipc.ts new file mode 100644 index 00000000..cde563a6 --- /dev/null +++ b/apps/desktop/src/local-ipc.ts @@ -0,0 +1,266 @@ +import { randomBytes } from "node:crypto"; +import net from "node:net"; + +import { applyAgentPetReaction, applyAgentPetSay, clearAgentPetLeaseState, showAgentPet } from "./agent-pet-controller.js"; +import { getAppStateSnapshot } from "./app-state.js"; +import { builtInPet } from "./built-in-pet.js"; +import { applyExternalPetReaction, applyExternalPetSay, getDefaultPetPaused, isDefaultPetVisible } from "./default-pet-controller.js"; +import { createStaleLeaseStatus, LeaseManager } from "./lease-manager.js"; +import { cleanupUnixSocket, createIpcEndpoint, protectUnixSocket, removeDiscoveryFile, writeDiscoveryFile, type OpenPetsDiscoveryFile } from "./local-ipc-paths.js"; +import { errorResponse, IpcProtocolError, isRecord, maxIpcMessageBytes, okResponse, parseIpcRequest, validateOptionalLeaseId, validateReaction, validateRequestedPetId, validateSayMessage, type OpenPetsIpcRequest } from "./local-ipc-protocol.js"; + +let ipcServer: net.Server | null = null; +let ipcDiscovery: OpenPetsDiscoveryFile | null = null; +let leaseCleanupTimer: NodeJS.Timeout | null = null; +const leaseManager = new LeaseManager({ + resolveTarget: resolveLeaseTarget, + getDefaultPetId: () => getCurrentDefaultPet().id, + getPetDisplayName: (petId, targetKind) => targetKind === "default" ? getCurrentDefaultPet().displayName : getPetDisplayName(petId), + onFirstExplicitLease: showAgentPet, + onLastExplicitLease: handleLastExplicitLease, +}); + +const safePetIdPattern = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +export async function startLocalIpcServer(): Promise { + if (ipcServer) return; + + const endpoint = createIpcEndpoint(); + const token = randomBytes(32).toString("base64url"); + cleanupUnixSocket(endpoint); + + const server = net.createServer((socket) => handleSocket(socket, token)); + server.on("error", (error) => { + console.error("OpenPets local IPC server error.", error); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(endpoint, () => { + server.off("error", reject); + protectUnixSocket(endpoint); + resolve(); + }); + }); + + ipcServer = server; + ipcDiscovery = writeDiscoveryFile(endpoint, token); + leaseCleanupTimer = setInterval(() => leaseManager.cleanupExpired(), 5_000); + leaseCleanupTimer.unref?.(); + console.log(`OpenPets local IPC listening at ${endpoint}.`); +} + +export function stopLocalIpcServer(): void { + const server = ipcServer; + const discovery = ipcDiscovery; + ipcServer = null; + ipcDiscovery = null; + if (leaseCleanupTimer) clearInterval(leaseCleanupTimer); + leaseCleanupTimer = null; + removeDiscoveryFile(discovery); + + if (server) { + server.close(); + } + + if (discovery) { + cleanupUnixSocket(discovery.endpoint); + } +} + +function handleSocket(socket: net.Socket, token: string): void { + socket.setEncoding("utf8"); + socket.setTimeout(3_000, () => socket.destroy()); + + let buffer = ""; + let handled = false; + + socket.on("data", (chunk) => { + if (handled) return; + buffer += chunk; + + if (Buffer.byteLength(buffer, "utf8") > maxIpcMessageBytes) { + handled = true; + writeResponse(socket, errorResponse(null, new IpcProtocolError("invalid_request", "IPC request is too large."))); + return; + } + + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + + handled = true; + const raw = buffer.slice(0, newline); + void handleRawRequest(raw, token).then((response) => writeResponse(socket, response)); + }); + + socket.on("error", (error) => { + if (isBenignSocketCloseError(error)) return; + console.error("OpenPets local IPC client socket error.", error); + }); +} + +async function handleRawRequest(raw: string, token: string) { + let requestId: string | null = null; + try { + const request = parseIpcRequest(raw, token); + requestId = request.id; + return okResponse(request.id, await handleRequest(request)); + } catch (error) { + return errorResponse(requestId, error); + } +} + +async function handleRequest(request: OpenPetsIpcRequest): Promise { + if (request.method === "hello") { + return { + ok: true, + protocol: "openpets-ipc", + protocolVersion: 1, + appVersion: ipcDiscovery?.appVersion ?? "0.0.0", + }; + } + + if (request.method === "status") { + const params = isRecord(request.params) ? request.params : {}; + const leaseId = validateOptionalLeaseId(params.leaseId); + if (leaseId) { + const lease = leaseManager.get(leaseId); + if (!lease) return createStaleLeaseStatus(leaseId); + return { ok: true, appRunning: true, ...lease }; + } + const state = getAppStateSnapshot(); + const defaultPet = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId) ?? builtInPet; + return { + ok: true, + appRunning: true, + protocolVersion: 1, + appVersion: ipcDiscovery?.appVersion ?? "0.0.0", + defaultPet: { + id: defaultPet.id, + displayName: defaultPet.displayName, + builtIn: defaultPet.builtIn, + broken: "broken" in defaultPet && defaultPet.broken === true, + }, + paused: getDefaultPetPaused(), + defaultPetVisible: isDefaultPetVisible(), + openDefaultPetOnLaunch: state.preferences.openDefaultPetOnLaunch, + speechBubblesEnabled: state.preferences.speechBubblesEnabled, + }; + } + + if (request.method === "pets.list") { + const state = getAppStateSnapshot(); + return { + ok: true, + pets: state.pets.installed.map((pet) => ({ + id: pet.id, + displayName: pet.displayName, + builtIn: pet.builtIn, + broken: pet.broken === true, + })), + defaultPetId: state.preferences.defaultPetId, + }; + } + + if (request.method === "lease.acquire") { + const params = isRecord(request.params) ? request.params : {}; + return leaseManager.acquire(validateRequestedPetId(params.requestedPetId)); + } + + if (request.method === "lease.heartbeat") { + const params = isRecord(request.params) ? request.params : {}; + const leaseId = validateRequiredLeaseId(params.leaseId); + try { + return leaseManager.heartbeat(leaseId); + } catch { + throw new IpcProtocolError("unknown_lease", "Unknown or expired lease."); + } + } + + if (request.method === "lease.release") { + const params = isRecord(request.params) ? request.params : {}; + return leaseManager.release(validateRequiredLeaseId(params.leaseId)); + } + + if (request.method === "pet.react") { + const params = isRecord(request.params) ? request.params : {}; + const reaction = validateReaction(params.reaction); + const lease = getLeaseTarget(params.leaseId); + if (lease?.targetKind === "explicit") { + if (getDefaultPetPaused()) return { ok: true, reaction, shown: false, reason: "paused", leaseId: lease.leaseId }; + const applied = applyAgentPetReaction(lease.actualTargetPetId, reaction); + return { ok: true, reaction, shown: applied.shown, reason: applied.reason, leaseId: lease.leaseId }; + } + const applied = applyExternalPetReaction(reaction); + return { ok: true, reaction, shown: applied.shown, reason: applied.reason }; + } + + const params = isRecord(request.params) ? request.params : {}; + const message = validateSayMessage(params.message); + const reaction = params.reaction === undefined ? undefined : validateReaction(params.reaction); + const lease = getLeaseTarget(params.leaseId); + if (lease?.targetKind === "explicit") { + if (getDefaultPetPaused()) return { ok: true, shown: false, reason: "paused", reaction, leaseId: lease.leaseId }; + const applied = applyAgentPetSay(lease.actualTargetPetId, message, reaction); + return { ok: true, shown: applied.shown, reason: applied.reason, reaction, leaseId: lease.leaseId }; + } + const applied = applyExternalPetSay(message, reaction); + return { ok: true, shown: applied.shown, reason: applied.reason, reaction }; +} + +function validateRequiredLeaseId(value: unknown): string { + const leaseId = validateOptionalLeaseId(value); + if (!leaseId) throw new IpcProtocolError("invalid_params", "Lease id is required."); + return leaseId; +} + +function getLeaseTarget(value: unknown) { + const leaseId = validateOptionalLeaseId(value); + if (!leaseId) return null; + const lease = leaseManager.get(leaseId); + if (!lease) throw new IpcProtocolError("unknown_lease", "Unknown or expired lease."); + return lease; +} + +function handleLastExplicitLease(petId: string): void { + clearAgentPetLeaseState(petId); +} + +function writeResponse(socket: net.Socket, response: unknown): void { + if (socket.destroyed || !socket.writable) return; + socket.end(`${JSON.stringify(response)}\n`); +} + +function isBenignSocketCloseError(error: NodeJS.ErrnoException): boolean { + return error.code === "EPIPE" || error.code === "ECONNRESET" || error.code === "ERR_STREAM_DESTROYED"; +} + +function resolveLeaseTarget(requestedPetId: string | undefined): { readonly targetKind: "default" | "explicit"; readonly actualPetId: string; readonly fallbackReason?: "invalid_pet_id" | "pet_not_installed" | "pet_broken" | "default_broken_fallback_builtin" } { + const defaultPet = getCurrentDefaultPetWithFallback(); + if (!requestedPetId || requestedPetId === builtInPet.id || requestedPetId === defaultPet.id) { + return { targetKind: "default", actualPetId: defaultPet.id, fallbackReason: defaultPet.fallbackReason }; + } + if (!safePetIdPattern.test(requestedPetId)) { + return { targetKind: "default", actualPetId: defaultPet.id, fallbackReason: "invalid_pet_id" }; + } + const pet = getAppStateSnapshot().pets.installed.find((candidate) => candidate.id === requestedPetId); + if (!pet) return { targetKind: "default", actualPetId: defaultPet.id, fallbackReason: "pet_not_installed" }; + if (pet.broken) return { targetKind: "default", actualPetId: defaultPet.id, fallbackReason: "pet_broken" }; + return { targetKind: "explicit", actualPetId: pet.id }; +} + +function getCurrentDefaultPet(): { readonly id: string; readonly displayName: string } { + const pet = getCurrentDefaultPetWithFallback(); + return { id: pet.id, displayName: pet.displayName }; +} + +function getCurrentDefaultPetWithFallback(): { readonly id: string; readonly displayName: string; readonly fallbackReason?: "default_broken_fallback_builtin" } { + const state = getAppStateSnapshot(); + const configuredDefault = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId); + if (configuredDefault && !configuredDefault.broken) return configuredDefault; + return { ...builtInPet, fallbackReason: "default_broken_fallback_builtin" }; +} + +function getPetDisplayName(petId: string): string { + return getAppStateSnapshot().pets.installed.find((pet) => pet.id === petId)?.displayName ?? petId; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts new file mode 100644 index 00000000..9100ff8e --- /dev/null +++ b/apps/desktop/src/main.ts @@ -0,0 +1,53 @@ +import { app } from "electron"; + +import { initializeAppState, isOnboardingCompleted } from "./app-state.js"; +import { installDefaultPetDisplayHandlers, shouldOpenDefaultPetOnLaunch, showDefaultPet } from "./default-pet-controller.js"; +import { installAppLifecycle } from "./lifecycle.js"; +import { startLocalIpcServer } from "./local-ipc.js"; +import { createAppTray, refreshTrayMenu } from "./tray.js"; +import { checkForGitHubReleaseUpdate } from "./update-checker.js"; +import { installInternalUiHandlers, openTaskWindow } from "./windows.js"; + +// OpenPets does not store browser passwords, cookies, or encrypted app secrets. +// Keep Chromium/Electron from prompting for macOS Keychain or Linux keyring access +// during startup/profile initialization. +app.commandLine.appendSwitch("use-mock-keychain"); +app.commandLine.appendSwitch("password-store", "basic"); + +const gotSingleInstanceLock = app.requestSingleInstanceLock(); + +if (!gotSingleInstanceLock) { + app.quit(); +} else { + installAppLifecycle(); + + app.whenReady().then(async () => { + app.setName("OpenPets"); + + if (process.platform === "darwin") { + app.dock?.hide(); + } + + initializeAppState(); + installInternalUiHandlers(); + createAppTray(); + installDefaultPetDisplayHandlers(); + await startLocalIpcServer(); + if (shouldOpenDefaultPetOnLaunch()) { + showDefaultPet(); + } + if (!isOnboardingCompleted()) { + try { + openTaskWindow("onboarding"); + } catch (error) { + console.error("Failed to open OpenPets onboarding; continuing with tray app.", error); + } + } + refreshTrayMenu(); + void checkForGitHubReleaseUpdate().then(() => refreshTrayMenu()); + console.log("OpenPets desktop shell ready."); + }).catch((error: unknown) => { + console.error("Failed to start OpenPets desktop shell.", error); + app.quit(); + }); +} diff --git a/apps/desktop/src/pet-installation.ts b/apps/desktop/src/pet-installation.ts new file mode 100644 index 00000000..6ba375e9 --- /dev/null +++ b/apps/desktop/src/pet-installation.ts @@ -0,0 +1,338 @@ +import { createWriteStream } from "node:fs"; +import { mkdtemp, mkdir, readFile, rename, rm, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { Transform } from "node:stream"; + +import yauzl from "yauzl"; +import type { Entry, ZipFile } from "yauzl"; + +import { getAppStateSnapshot, installPetState, removePetState, setDefaultPet, type OpenPetsStateV1 } from "./app-state.js"; +import { getCatalogUiState } from "./catalog.js"; +import type { CatalogPetV2 } from "./catalog-validation.js"; +import { builtInPet } from "./built-in-pet.js"; +import { assertInsideRoot, assertSafePetId, getInstalledPetDir, getPetsRoot } from "./pet-paths.js"; +import { assertOutputPathInside, hasSupportedZipMagic, ZipEntryPathTracker } from "./zip-safety.js"; + +const maxZipDownloadBytes = 50 * 1024 * 1024; +const maxExtractedTotalBytes = 200 * 1024 * 1024; +const maxFiles = 500; +const maxIndividualFileBytes = 100 * 1024 * 1024; +const downloadTimeoutMs = 30_000; + +const operations = new Set(); + +export async function installPet(petId: string): Promise { + return withPetOperation(petId, async () => { + assertSafePetId(petId); + + if (getAppStateSnapshot().pets.installed.some((pet) => pet.id === petId)) { + throw new Error(`Pet is already installed: ${petId}`); + } + + const catalogPet = await getCatalogPet(petId); + const zip = await downloadPetZip(catalogPet.zip); + const petsRoot = getPetsRoot(); + await mkdir(petsRoot, { recursive: true, mode: 0o700 }); + + const finalDir = getInstalledPetDir(petId); + const tempDir = await mkdtemp(join(petsRoot, `.install-${petId}-`)); + + try { + assertInsideRoot(petsRoot, tempDir); + await extractPetZip(zip, tempDir); + await validateExtractedPet(tempDir); + await rm(finalDir, { recursive: true, force: true }); + await rename(tempDir, finalDir); + + try { + return installPetState({ + id: catalogPet.id, + displayName: catalogPet.displayName, + description: catalogPet.description, + source: { + catalogVersion: 2, + zip: catalogPet.zip, + preview: catalogPet.preview, + }, + }); + } catch (error) { + await rm(finalDir, { recursive: true, force: true }); + throw error; + } + } catch (error) { + await rm(tempDir, { recursive: true, force: true }); + throw error; + } + }); +} + +export async function removePet(petId: string): Promise { + return withPetOperation(petId, async () => { + if (petId === builtInPet.id) { + throw new Error("Built-in pet cannot be removed."); + } + assertSafePetId(petId); + const dir = getInstalledPetDir(petId); + const state = removePetState(petId); + try { + await rm(dir, { recursive: true, force: true }); + } catch (error) { + throw new Error(`Pet was removed from OpenPets state, but local files could not be deleted from ${dir}. You may need to remove them manually. ${error instanceof Error ? error.message : ""}`.trim()); + } + return state; + }); +} + +export async function setDefaultInstalledPet(petId: string): Promise { + return withPetOperation(petId, async () => { + if (petId !== builtInPet.id) { + assertSafePetId(petId); + } + return setDefaultPet(petId); + }); +} + +export async function withPetOperation(key: string, callback: () => Promise): Promise { + if (operations.has(key)) { + throw new Error("An operation for this pet is already in progress."); + } + + operations.add(key); + try { + return await callback(); + } finally { + operations.delete(key); + } +} + +async function getCatalogPet(petId: string): Promise { + const catalog = await getCatalogUiState(); + const pet = catalog.pets.find((candidate) => candidate.id === petId); + if (!pet) { + throw new Error(`Pet is not available in the validated catalog: ${petId}`); + } + return pet; +} + +async function downloadPetZip(zipUrl: string): Promise { + validateZipUrl(zipUrl); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), downloadTimeoutMs); + + try { + const response = await fetch(zipUrl, { + signal: controller.signal, + redirect: "error", + credentials: "omit", + }); + + validateZipUrl(response.url); + if (response.url !== zipUrl) throw new Error("Zip download final URL changed."); + if (!response.ok) throw new Error(`Zip download failed with HTTP ${response.status}.`); + + const buffer = await readLimitedResponse(response, maxZipDownloadBytes); + validateZipMagic(buffer); + return buffer; + } finally { + clearTimeout(timeout); + } +} + +function validateZipUrl(value: string): void { + const url = new URL(value); + if (url.protocol !== "https:") throw new Error("Zip URL must use https."); + if (url.hostname !== "zip.openpets.dev") throw new Error("Zip URL host is not allowed."); + if (!url.pathname.startsWith("/pets/")) throw new Error("Zip URL path is not allowed."); + if (url.username || url.password) throw new Error("Zip URL cannot include credentials."); + if (url.port) throw new Error("Zip URL cannot include a custom port."); +} + +async function readLimitedResponse(response: Response, maxBytes: number): Promise { + const reader = response.body?.getReader(); + if (!reader) throw new Error("Zip response body is unavailable for bounded reading."); + + const chunks: Uint8Array[] = []; + let total = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) throw new Error("Zip download is too large."); + chunks.push(value); + } + + return Buffer.concat(chunks, total); +} + +function validateZipMagic(buffer: Buffer): void { + if (!hasSupportedZipMagic(buffer)) { + throw new Error("Downloaded file has an unsupported zip signature."); + } +} + +async function extractPetZip(zip: Buffer, tempDir: string): Promise { + const zipFile = await openZipFromBuffer(zip); + const pathTracker = new ZipEntryPathTracker(); + const seenRequired = new Set(); + let fileCount = 0; + let extractedTotal = 0; + + try { + await new Promise((resolvePromise, rejectPromise) => { + let settled = false; + + const reject = (error: unknown): void => { + if (settled) return; + settled = true; + zipFile.close(); + rejectPromise(error instanceof Error ? error : new Error("Zip extraction failed.")); + }; + + zipFile.on("error", reject); + zipFile.on("end", () => { + if (settled) return; + settled = true; + resolvePromise(); + }); + + zipFile.on("entry", (entry) => { + void processEntry(entry).then(() => { + if (!settled) zipFile.readEntry(); + }).catch(reject); + }); + + const processEntry = async (entry: Entry): Promise => { + validateEntryMetadata(entry); + const safePath = pathTracker.accept(entry.fileName); + + if (safePath.isDirectory) { + return; + } + + if (!safePath.relativeOutputPath) { + throw new Error("Zip file entry is missing an output path."); + } + + fileCount += 1; + if (fileCount > maxFiles) throw new Error("Zip contains too many files."); + if (entry.uncompressedSize > maxIndividualFileBytes) throw new Error("Zip entry is too large."); + extractedTotal += entry.uncompressedSize; + if (extractedTotal > maxExtractedTotalBytes) throw new Error("Zip extracted total is too large."); + + const outputPath = resolve(tempDir, safePath.relativeOutputPath); + assertOutputPathInside(tempDir, outputPath); + seenRequired.add(safePath.relativeOutputPath); + await writeEntry(entry, zipFile, outputPath, entry.uncompressedSize); + }; + + zipFile.readEntry(); + }); + } finally { + zipFile.close(); + } + + if (!seenRequired.has("pet.json") || !seenRequired.has("spritesheet.webp")) { + throw new Error("Zip must contain pet.json and spritesheet.webp."); + } +} + +function openZipFromBuffer(buffer: Buffer): Promise { + return new Promise((resolvePromise, rejectPromise) => { + yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true, strictFileNames: true }, (error, zipFile) => { + if (error) { + rejectPromise(error); + return; + } + + if (!zipFile) { + rejectPromise(new Error("Zip file could not be opened.")); + return; + } + + resolvePromise(zipFile); + }); + }); +} + +function validateEntryMetadata(entry: Entry): void { + if (entry.isEncrypted()) throw new Error("Encrypted zip entries are not supported."); + if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) { + throw new Error("Unsupported zip entry compression method."); + } + if (entry.compressedSize > maxZipDownloadBytes) throw new Error("Zip entry compressed size is too large."); + if (entry.uncompressedSize > maxIndividualFileBytes) throw new Error("Zip entry uncompressed size is too large."); + + const unixMode = getUnixMode(entry); + if (unixMode === null) return; + + const type = unixMode & 0o170000; + const isKnownFileType = type !== 0; + const isRegularFile = type === 0o100000; + const isDirectory = type === 0o040000; + if (isKnownFileType && !isRegularFile && !isDirectory) { + throw new Error("Zip entry special files are not supported."); + } +} + +function getUnixMode(entry: Entry): number | null { + if ((entry.versionMadeBy >> 8) !== 3) { + return null; + } + + return (entry.externalFileAttributes >> 16) & 0o177777; +} + +function writeEntry(entry: Entry, zipFile: ZipFile, outputPath: string, expectedBytes: number): Promise { + return new Promise((resolvePromise, rejectPromise) => { + zipFile.openReadStream(entry, (error, readStream) => { + if (error) { + rejectPromise(error); + return; + } + + if (!readStream) { + rejectPromise(new Error("Zip entry stream could not be opened.")); + return; + } + + let actualBytes = 0; + const counter = new Transform({ + transform(chunk: Buffer, _encoding, callback) { + actualBytes += chunk.byteLength; + if (actualBytes > maxIndividualFileBytes) { + callback(new Error("Zip entry exceeded individual size limit.")); + return; + } + callback(null, chunk); + }, + }); + + pipeline(readStream, counter, createWriteStream(outputPath, { mode: 0o600 })) + .then(() => { + if (actualBytes !== expectedBytes) { + rejectPromise(new Error("Zip entry extracted size did not match metadata.")); + return; + } + + resolvePromise(); + }) + .catch(rejectPromise); + }); + }); +} + +async function validateExtractedPet(tempDir: string): Promise { + const petJsonPath = join(tempDir, "pet.json"); + const spritesheetPath = join(tempDir, "spritesheet.webp"); + assertOutputPathInside(tempDir, petJsonPath); + assertOutputPathInside(tempDir, spritesheetPath); + + JSON.parse(await readFile(petJsonPath, "utf8")) as unknown; + + const spritesheet = await stat(spritesheetPath); + if (!spritesheet.isFile()) throw new Error("spritesheet.webp must be a file."); + if (spritesheet.size <= 0) throw new Error("spritesheet.webp is empty."); + if (spritesheet.size > maxIndividualFileBytes) throw new Error("spritesheet.webp is too large."); +} diff --git a/apps/desktop/src/pet-paths.ts b/apps/desktop/src/pet-paths.ts new file mode 100644 index 00000000..48b5f0e7 --- /dev/null +++ b/apps/desktop/src/pet-paths.ts @@ -0,0 +1,30 @@ +import { join, resolve, sep } from "node:path"; + +import { app } from "electron"; + +export function getPetsRoot(): string { + return join(app.getPath("userData"), "pets"); +} + +export function getInstalledPetDir(petId: string): string { + assertSafePetId(petId); + const root = getPetsRoot(); + const target = resolve(root, petId); + assertInsideRoot(root, target); + return target; +} + +export function assertSafePetId(petId: string): void { + if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(petId) || petId === "builtin") { + throw new Error(`Invalid installed pet id: ${petId}`); + } +} + +export function assertInsideRoot(root: string, target: string): void { + const resolvedRoot = resolve(root); + const resolvedTarget = resolve(target); + + if (resolvedTarget !== resolvedRoot && !resolvedTarget.startsWith(`${resolvedRoot}${sep}`)) { + throw new Error("Resolved path escapes OpenPets pets directory."); + } +} diff --git a/apps/desktop/src/pet-window.ts b/apps/desktop/src/pet-window.ts new file mode 100644 index 00000000..e4af7fc5 --- /dev/null +++ b/apps/desktop/src/pet-window.ts @@ -0,0 +1,545 @@ +import { app, BrowserWindow, ipcMain, Menu, type IpcMainEvent } from "electron"; +import { mkdir, stat, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { getAppStateSnapshot, markPetBroken, type PetScaleValue } from "./app-state.js"; +import { clampToPrimaryWorkArea, defaultPetWindowSize, getDefaultPetInitialPosition, type Point } from "./display.js"; +import { builtInPet } from "./built-in-pet.js"; +import { getInstalledPetDir } from "./pet-paths.js"; +import type { OpenPetsReaction } from "./local-ipc-protocol.js"; +import { pickReactionMessage } from "./reaction-messages.js"; + +export interface DefaultPetWindowOptions { + readonly position: Point; + readonly paused: boolean; + readonly display: PetTransientDisplay | null; + readonly onPositionChanged: (position: Point) => void; + readonly onHideRequested: () => void; +} + +export interface AgentPetWindowOptions { + readonly petId: string; + readonly displayName: string; + readonly position: Point; + readonly display: PetTransientDisplay | null; + readonly onCloseRequested: () => void; +} + +export interface PetTransientDisplay { + readonly reaction?: OpenPetsReaction; + readonly message?: string; + readonly reactionMessage?: string; +} + +type PetMotionState = "idle" | "run-left" | "run-right"; +type UniversalSpriteState = "idle" | "running-right" | "running-left" | "waving" | "jumping" | "failed" | "waiting" | "running" | "review"; + +const motionToSpriteState = { + idle: "idle", + "run-right": "running-right", + "run-left": "running-left", +} as const satisfies Record; + +const reactionToSpriteState = { + idle: "idle", + thinking: "review", + working: "running", + editing: "running", + running: "running", + testing: "waiting", + waiting: "waiting", + waving: "waving", + success: "jumping", + error: "failed", + celebrating: "jumping", +} as const satisfies Record; + +const defaultPetSprite = { + fileName: "default-pet-spritesheet.webp", + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + states: { + idle: { row: 0, frames: 6, durationMs: 5500, iterations: "infinite" }, + "running-right": { row: 1, frames: 8, durationMs: 1060 }, + "running-left": { row: 2, frames: 8, durationMs: 1060 }, + waving: { row: 3, frames: 4, durationMs: 700, iterations: 2 }, + jumping: { row: 4, frames: 5, durationMs: 840, iterations: 2 }, + failed: { row: 5, frames: 8, durationMs: 1220, iterations: 2 }, + waiting: { row: 6, frames: 6, durationMs: 1010 }, + running: { row: 7, frames: 6, durationMs: 820 }, + review: { row: 8, frames: 6, durationMs: 1030 }, + } satisfies Record, +} as const; + +export function createDefaultPetWindow(options: DefaultPetWindowOptions): BrowserWindow { + const window = createBasePetWindow("OpenPets — Default Pet", options.position); + installMousePassthroughAndDrag(window); + installMotionStatePublisher(window); + installPetContextMenu(window, { label: "Hide pet", click: options.onHideRequested }); + + const savePosition = debounce(() => { + if (window.isDestroyed()) { + return; + } + + options.onPositionChanged(readWindowPosition(window)); + }, 150); + + window.on("move", savePosition); + window.on("moved", savePosition); + window.on("close", () => { + options.onPositionChanged(readWindowPosition(window)); + }); + + void loadDefaultPetContent(window, options.paused, options.display); + + return window; +} + +export function createAgentPetWindow(options: AgentPetWindowOptions): BrowserWindow { + const window = createBasePetWindow(`OpenPets — ${options.displayName}`, options.position); + installMousePassthroughAndDrag(window); + installMotionStatePublisher(window); + installPetContextMenu(window, { label: "Close pet", click: options.onCloseRequested }); + void loadExplicitPetContent(window, options.petId, options.display); + return window; +} + +function installPetContextMenu(window: BrowserWindow, action: { readonly label: string; readonly click: () => void }): void { + window.webContents.on("context-menu", (event) => { + event.preventDefault(); + if (window.isDestroyed()) return; + Menu.buildFromTemplate([{ label: action.label, click: action.click }]).popup({ window }); + }); +} + +function installMousePassthroughAndDrag(window: BrowserWindow): void { + let dragging: { readonly startScreenX: number; readonly startScreenY: number; readonly startWindowX: number; readonly startWindowY: number } | null = null; + + const isFromWindow = (event: IpcMainEvent): boolean => event.sender === window.webContents; + const setPassthrough = (passthrough: boolean): void => { + if (window.isDestroyed()) return; + if (passthrough) window.setIgnoreMouseEvents(true, { forward: true }); + else window.setIgnoreMouseEvents(false); + }; + + const handleHitTest = (event: IpcMainEvent, interactive: unknown): void => { + if (!isFromWindow(event)) return; + setPassthrough(!interactive && !dragging); + }; + + const handleDragStart = (event: IpcMainEvent, point: unknown): void => { + if (!isFromWindow(event) || !isScreenPoint(point)) return; + const [startWindowX, startWindowY] = window.getPosition(); + dragging = { startScreenX: point.screenX, startScreenY: point.screenY, startWindowX, startWindowY }; + setPassthrough(false); + }; + + const handleDragMove = (event: IpcMainEvent, point: unknown): void => { + if (!isFromWindow(event) || !dragging || !isScreenPoint(point)) return; + window.setPosition(dragging.startWindowX + Math.round(point.screenX - dragging.startScreenX), dragging.startWindowY + Math.round(point.screenY - dragging.startScreenY), false); + }; + + const handleDragEnd = (event: IpcMainEvent): void => { + if (!isFromWindow(event)) return; + dragging = null; + }; + + ipcMain.on("openpets:pet-hit-test", handleHitTest); + ipcMain.on("openpets:pet-drag-start", handleDragStart); + ipcMain.on("openpets:pet-drag-move", handleDragMove); + ipcMain.on("openpets:pet-drag-end", handleDragEnd); + window.webContents.once("did-finish-load", () => setPassthrough(true)); + window.on("closed", () => { + ipcMain.off("openpets:pet-hit-test", handleHitTest); + ipcMain.off("openpets:pet-drag-start", handleDragStart); + ipcMain.off("openpets:pet-drag-move", handleDragMove); + ipcMain.off("openpets:pet-drag-end", handleDragEnd); + }); +} + +function isScreenPoint(value: unknown): value is { readonly screenX: number; readonly screenY: number } { + return typeof value === "object" && value !== null && typeof (value as { readonly screenX?: unknown }).screenX === "number" && typeof (value as { readonly screenY?: unknown }).screenY === "number"; +} + +function createBasePetWindow(title: string, position: Point): BrowserWindow { + const window = new BrowserWindow({ + title, + width: defaultPetWindowSize.width, + height: defaultPetWindowSize.height, + x: position.x, + y: position.y, + frame: false, + transparent: true, + resizable: false, + maximizable: false, + minimizable: false, + fullscreenable: false, + skipTaskbar: true, + alwaysOnTop: true, + show: false, + hasShadow: false, + backgroundColor: "#00000000", + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + preload: join(app.getAppPath(), "pet-preload.cjs"), + }, + }); + + window.setMenu(null); + window.setAlwaysOnTop(true, "floating"); + + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event, url) => { + if (isAllowedPetDocumentUrl(url)) return; + event.preventDefault(); + }); + window.webContents.on("will-redirect", (event) => { + event.preventDefault(); + }); + window.webContents.on("did-fail-load", (_event, errorCode, errorDescription) => { + console.error("Failed to load default pet window.", { errorCode, errorDescription }); + }); + window.webContents.on("render-process-gone", (_event, details) => { + console.error("Default pet renderer process gone.", details); + }); + + return window; +} + +export async function loadDefaultPetContent(window: BrowserWindow, paused: boolean, display: PetTransientDisplay | null = null): Promise { + const html = await createDefaultPetHtml(paused, display); + await loadPetHtmlFile(window, html, "default").catch((error: unknown) => { + console.error("Failed to load default pet URL.", error); + }); +} + +export async function loadExplicitPetContent(window: BrowserWindow, petId: string, display: PetTransientDisplay | null = null): Promise { + try { + const state = getAppStateSnapshot(); + const pet = state.pets.installed.find((candidate) => candidate.id === petId); + if (!pet || pet.broken || pet.id === builtInPet.id) { + throw new Error(`Cannot render explicit pet: ${petId}`); + } + const html = await createInstalledPetHtml(pet.id, pet.displayName, false, display, state.preferences.petScale as PetScaleValue); + await loadPetHtmlFile(window, html, `explicit-${pet.id}`); + } catch (error: unknown) { + console.error(`Failed to load explicit pet ${petId} URL.`, error); + } +} + +export function preparePetTransientDisplay(display: PetTransientDisplay): PetTransientDisplay { + if (!display.reaction || display.message || display.reactionMessage) return display; + return { ...display, reactionMessage: pickReactionMessage(display.reaction) }; +} + +export function mergePetTransientDisplay(current: PetTransientDisplay | null, next: PetTransientDisplay): PetTransientDisplay { + if (next.message || !next.reaction || !current?.message) return preparePetTransientDisplay(next); + return { ...current, reaction: next.reaction }; +} + +export function getTransientReactionAnimationMs(display: PetTransientDisplay): number | null { + if (!display.reaction) return null; + const state = reactionToSpriteState[display.reaction]; + const row = defaultPetSprite.states[state]; + const iterations = "iterations" in row ? row.iterations : "infinite"; + return typeof iterations === "number" ? row.durationMs * iterations : null; +} + +export function clearTransientReaction(display: PetTransientDisplay): PetTransientDisplay { + if (!display.reaction) return display; + return { ...display, reaction: undefined }; +} + +export function setPetReactionState(window: BrowserWindow, state: UniversalSpriteState): void { + if (window.isDestroyed()) return; + window.webContents.send("openpets:pet-reaction-state", state); +} + +export function getSafeDefaultPetPosition(position: Point | undefined): Point { + return clampToPrimaryWorkArea(position ?? getDefaultPetInitialPosition(), defaultPetWindowSize); +} + +export function readWindowPosition(window: BrowserWindow): Point { + const [x, y] = window.getPosition(); + return clampToPrimaryWorkArea({ x, y }, defaultPetWindowSize); +} + +async function createDefaultPetHtml(paused: boolean, display: PetTransientDisplay | null): Promise { + const installedPetHtml = await tryCreateInstalledPetHtml(paused, display); + if (installedPetHtml) { + return installedPetHtml; + } + + const spriteUrl = pathToFileURL(join(app.getAppPath(), "assets", defaultPetSprite.fileName)).toString(); + const bubble = createBubbleMarkup(display, paused); + const stateRows = defaultPetSprite.states; + const scale = getAppStateSnapshot().preferences.petScale as PetScaleValue; + + return ` + + + + + + OpenPets Default Pet + + + +
+ ${bubble} +
+ +
+
+ + `; +} + +async function tryCreateInstalledPetHtml(paused: boolean, display: PetTransientDisplay | null): Promise { + const state = getAppStateSnapshot(); + const selected = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId); + + if (!selected || selected.id === builtInPet.id || selected.broken) { + return null; + } + + try { + return await createInstalledPetHtml(selected.id, selected.displayName, paused, display, state.preferences.petScale as PetScaleValue); + } catch (error) { + console.error(`Failed to render installed default pet ${selected.id}; falling back to built-in pet.`, error); + try { + markPetBroken(selected.id, error instanceof Error ? error.message : "Installed pet rendering failed."); + } catch (markError) { + console.error(`Failed to mark installed pet ${selected.id} broken.`, markError); + } + return null; + } +} + +async function createInstalledPetHtml(petId: string, displayName: string, paused: boolean, display: PetTransientDisplay | null, scale: PetScaleValue): Promise { + const spritesheetPath = join(getInstalledPetDir(petId), "spritesheet.webp"); + const spritesheet = await stat(spritesheetPath); + if (!spritesheet.isFile() || spritesheet.size <= 0 || spritesheet.size > 100 * 1024 * 1024) { + throw new Error("Installed pet spritesheet is missing or too large."); + } + + const imageUrl = pathToFileURL(spritesheetPath).toString(); + const bubble = createBubbleMarkup(display, paused); + const stateRows = defaultPetSprite.states; + + return ` + + + + + + OpenPets Default Pet + + + +
+ ${bubble} +
+ +
+
+ + `; +} + +function createPetWindowCss(paused: boolean, scale: PetScaleValue): string { + const opacity = paused ? "0.62" : "1"; + const playState = paused ? "paused" : "running"; + const scaledWidth = Math.ceil(defaultPetSprite.frameWidth * scale); + const scaledHeight = Math.ceil(defaultPetSprite.frameHeight * scale); + const petBottom = 22; + const bubbleBottom = Math.ceil(petBottom + scaledHeight + 3); + return ` + :root { color-scheme: dark; --pet-opacity: ${opacity}; --play-state: ${playState}; } + html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; user-select: none; -webkit-font-smoothing: antialiased; } + html { color: #172033; } + body { -webkit-app-region: no-drag; pointer-events: none; } + .stage { width: 100%; height: 100%; position: relative; box-sizing: border-box; overflow: visible; } + .pet-shell { position: absolute; left: 50%; bottom: ${petBottom}px; width: ${scaledWidth}px; height: ${scaledHeight}px; display: block; opacity: var(--pet-opacity); filter: drop-shadow(0 10px 12px rgba(15, 23, 42, 0.24)) drop-shadow(0 2px 3px rgba(15, 23, 42, 0.18)); transform: translateX(-50%); transition-property: opacity, filter; transition-duration: 180ms; transition-timing-function: cubic-bezier(0.2, 0, 0, 1); pointer-events: auto; -webkit-app-region: no-drag; cursor: grab; } + .bubble { position: absolute; left: 50%; bottom: ${bubbleBottom}px; z-index: 2; box-sizing: border-box; max-width: min(200px, calc(100vw - 14px)); max-height: 92px; padding: 8px 10px; background: #fff; color: #000; font: 700 11px/14px "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace; text-align: left; border: 2px solid #000; border-radius: 4px; box-shadow: 2px 2px 0 #000; white-space: normal; overflow-wrap: anywhere; word-break: normal; overflow: hidden; pointer-events: auto; -webkit-app-region: no-drag; transform: translateX(-50%); opacity: 1; animation: bubble-in 160ms steps(2, end); } + .bubble-text { display: -webkit-box; overflow: hidden; -webkit-line-clamp: 5; -webkit-box-orient: vertical; } + .bubble::after, .bubble::before { content: none; } + .bubble.is-reaction { max-width: min(142px, calc(100vw - 10px)); max-height: 64px; padding: 6px 8px; font: 750 11px/13px "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace; text-align: center; overflow-wrap: break-word; } + .bubble.is-reaction .bubble-text { -webkit-line-clamp: 3; } + .bubble.is-long-message { max-width: min(204px, calc(100vw - 14px)); max-height: 106px; font-size: 10.5px; line-height: 13px; } + .bubble.is-long-message .bubble-text { -webkit-line-clamp: 7; } + .bubble.is-very-long-message { max-width: min(204px, calc(100vw - 14px)); max-height: 126px; font-size: 10px; line-height: 13px; } + .bubble.is-very-long-message .bubble-text { -webkit-line-clamp: 9; } + @keyframes bubble-in { from { opacity: 0; transform: translateX(-50%) translateY(3px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } + @media (prefers-reduced-motion: reduce) { .sprite, .installed-sprite, .bubble { animation: none !important; } } + `; +} + +function createSpriteStateCss(selector: ".sprite" | ".installed-sprite"): string { + const reactionRules = Object.keys(defaultPetSprite.states).map((state) => createSpriteRule(`html[data-reaction-state="${state}"] ${selector}`, state as UniversalSpriteState)); + const motionRules = (Object.entries(motionToSpriteState) as Array<[PetMotionState, UniversalSpriteState]>) + .filter(([motion]) => motion !== "idle") + .map(([motion, state]) => createSpriteRule(`html[data-motion-state="${motion}"] ${selector}`, state)); + return [...reactionRules, ...motionRules].join("\n"); +} + +function createSpriteRule(selector: string, state: UniversalSpriteState): string { + const row = defaultPetSprite.states[state]; + const iterations = "iterations" in row ? row.iterations : "infinite"; + return `${selector} { --sprite-row-y: -${row.row * defaultPetSprite.frameHeight}px; --sprite-frames: ${row.frames}; --sprite-duration: ${row.durationMs}ms; --sprite-iterations: ${iterations}; }`; +} + +function getReactionSpriteState(reaction: OpenPetsReaction | undefined): UniversalSpriteState { + return reaction ? reactionToSpriteState[reaction] : "idle"; +} + +function createBubbleMarkup(display: PetTransientDisplay | null, paused: boolean): string { + const text = display?.message ?? display?.reactionMessage ?? (display?.reaction ? pickReactionMessage(display.reaction) : undefined) ?? (paused ? "Paused" : ""); + if (!text) return ""; + const className = getBubbleClassName(text, Boolean(display?.message && !display?.reactionMessage)); + return `
${escapeHtml(text)}
`; +} + +function getBubbleClassName(text: string, isExplicitMessage: boolean): string { + if (!isExplicitMessage) return "bubble is-reaction"; + const lengthClass = text.length > 95 ? " is-very-long-message" : text.length > 56 ? " is-long-message" : ""; + return `bubble is-message${lengthClass}`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function escapeCssUrl(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", ""); +} + +function installMotionStatePublisher(window: BrowserWindow): void { + let lastX = window.getPosition()[0]; + let lastSent: PetMotionState = "idle"; + let idleTimer: NodeJS.Timeout | null = null; + + const sendMotionState = (state: PetMotionState): void => { + if (window.isDestroyed() || lastSent === state) return; + lastSent = state; + window.webContents.send("openpets:pet-motion", state); + }; + + const scheduleIdle = (): void => { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + idleTimer = null; + sendMotionState("idle"); + }, 180); + }; + + const handleMove = (): void => { + if (window.isDestroyed()) return; + const [x] = window.getPosition(); + const deltaX = x - lastX; + lastX = x; + + if (Math.abs(deltaX) >= 3) { + sendMotionState(deltaX > 0 ? "run-right" : "run-left"); + } + scheduleIdle(); + }; + + window.on("move", handleMove); + window.on("moved", handleMove); + window.webContents.on("did-finish-load", () => { + lastSent = "idle"; + window.webContents.send("openpets:pet-motion", "idle"); + }); + window.on("closed", () => { + if (idleTimer) clearTimeout(idleTimer); + }); +} + +function isAllowedPetDocumentUrl(url: string): boolean { + return url.startsWith("data:text/html") || url.startsWith("file://"); +} + +async function loadPetHtmlFile(window: BrowserWindow, html: string, name: string): Promise { + const dir = join(app.getPath("userData"), "rendered-pets"); + await mkdir(dir, { recursive: true }); + const safeName = name.replace(/[^a-z0-9_-]/gi, "-").slice(0, 80) || "pet"; + const filePath = join(dir, `${safeName}.html`); + await writeFile(filePath, html, "utf8"); + await window.loadFile(filePath); +} + +function debounce(callback: () => void, delayMs: number): () => void { + let timeout: NodeJS.Timeout | undefined; + + return () => { + if (timeout) { + clearTimeout(timeout); + } + + timeout = setTimeout(callback, delayMs); + }; +} diff --git a/apps/desktop/src/reaction-messages.ts b/apps/desktop/src/reaction-messages.ts new file mode 100644 index 00000000..8169df47 --- /dev/null +++ b/apps/desktop/src/reaction-messages.ts @@ -0,0 +1,251 @@ +import type { OpenPetsReaction } from "./local-ipc-protocol.js"; + +export const reactionMessagePools = { + idle: [ + "Hanging out", + "Right here", + "Ready when you are", + "Keeping watch", + "Standing by", + "At your service", + "Waiting patiently", + "Holding position", + "Keeping you company", + "Taking a tiny breather", + "Still here", + "Ready for the next thing", + "Watching the workspace", + "Calm and ready", + "Just vibing", + "No rush", + "Quietly on duty", + "Settled in", + "Available if needed", + "Resting the paws", + ], + thinking: [ + "Thinking it through", + "Let me think", + "Checking the clues", + "One moment", + "Looking closer", + "Puzzling this out", + "Tracing the idea", + "Connecting the dots", + "Sorting the pieces", + "Reading the room", + "Following the thread", + "Considering options", + "Making a tiny plan", + "Looking for the clean path", + "Running the thought maze", + "Checking assumptions", + "Finding the pattern", + "Thinking carefully", + "Letting the gears turn", + "Studying the situation", + ], + working: [ + "Working on it", + "Making progress", + "On the case", + "Handling it", + "Steady progress", + "Moving things along", + "Doing the work", + "Chipping away", + "Keeping momentum", + "Building this up", + "Putting pieces together", + "Getting it done", + "Making it happen", + "Carrying the task", + "Tiny paws at work", + "Staying focused", + "Working the problem", + "Pushing forward", + "Keeping busy", + "Taking care of it", + ], + editing: [ + "Making edits", + "Tuning things up", + "Shaping the changes", + "Polishing this", + "Adjusting the details", + "Refining the edges", + "Cleaning this up", + "Smoothing the rough bits", + "Moving text around", + "Improving the shape", + "Making it tidier", + "Trimming the fluff", + "Sharpening the result", + "Fitting pieces neatly", + "Nudging this into place", + "Reworking a bit", + "Tidying the change", + "Polishing the paws off it", + "Aligning the details", + "Making the diff nicer", + ], + running: [ + "Running now", + "Here we go", + "Putting it in motion", + "Off and running", + "Letting it run", + "Starting the engine", + "Kicking things off", + "Sending it through", + "Launching the command", + "Giving it a spin", + "Running the task", + "In motion now", + "Wheels are turning", + "Command is underway", + "Letting the machine work", + "On the move", + "Firing it up", + "Executing the plan", + "Making the terminal hum", + "Running the route", + ], + testing: [ + "Testing this", + "Checking behavior", + "Running the checks", + "Verifying things", + "Looking for issues", + "Putting it through its paces", + "Checking the safety rails", + "Looking for regressions", + "Giving it a careful look", + "Testing the path", + "Checking the result", + "Running the guardrails", + "Sniffing out bugs", + "Making sure it holds", + "Trying to break it gently", + "Watching the tests", + "Confirming the fix", + "Inspecting the behavior", + "Looking for loose ends", + "Verifying the tiny details", + ], + waiting: [ + "Waiting on you", + "Paused for input", + "Standing by", + "Ready for approval", + "Holding here", + "Need a decision", + "Waiting for the nod", + "Paused at the gate", + "Your call", + "Holding the line", + "Waiting patiently", + "Ready when approved", + "Just need permission", + "Awaiting direction", + "Stopped for a choice", + "Keeping this warm", + "Waiting for your move", + "At the checkpoint", + "Paused with purpose", + "Standing at attention", + ], + waving: [ + "Hello there", + "Just checking in", + "I see you", + "Tiny wave", + "Over here", + "Little hello", + "Hi hi", + "Friendly ping", + "Waving from the corner", + "Noticed something", + "Attention please", + "Small wave incoming", + "Quick hello", + "Popping in", + "Soft ping", + "Tiny paws waving", + "Hello from OpenPets", + "Checking the window", + "Here with an update", + "A little wave", + ], + success: [ + "Done", + "All set", + "That worked", + "Nice and tidy", + "Finished up", + "Success", + "That landed", + "Looks good", + "Wrapped up", + "Clean finish", + "Task complete", + "Green lights", + "Everything passed", + "Good to go", + "Nicely done", + "Ready for the next one", + "That came together", + "Finished cleanly", + "Tiny win secured", + "No loose ends found", + ], + error: [ + "Something broke", + "Hit a snag", + "That needs a look", + "Not quite there", + "Trouble spotted", + "Found a problem", + "That did not pass", + "Oops, a bump", + "Needs attention", + "Something complained", + "The path got bumpy", + "This needs fixing", + "A check failed", + "Not happy yet", + "Found a rough edge", + "There is a hiccup", + "That went sideways", + "Issue detected", + "Tiny alarm bells", + "Let us inspect this", + ], + celebrating: [ + "Tiny victory", + "We did it", + "Worth a little dance", + "That deserves a cheer", + "Celebrating this", + "Little party time", + "Happy paws", + "Victory wiggle", + "That feels good", + "Small celebration", + "Confetti in spirit", + "Cheering quietly", + "That is a win", + "Nice little triumph", + "Joyful moment", + "Paws in the air", + "Tiny parade", + "We earned this", + "Celebration mode", + "Happy little bounce", + ], +} as const satisfies Record; + +export function pickReactionMessage(reaction: OpenPetsReaction, random: () => number = Math.random): string { + const pool = reactionMessagePools[reaction]; + return pool[Math.floor(random() * pool.length) % pool.length] ?? reaction; +} diff --git a/apps/desktop/src/state.ts b/apps/desktop/src/state.ts new file mode 100644 index 00000000..06c89a1b --- /dev/null +++ b/apps/desktop/src/state.ts @@ -0,0 +1,12 @@ +export interface ShellState { + paused: boolean; +} + +export const shellState: ShellState = { + paused: false, +}; + +export function togglePaused(): boolean { + shellState.paused = !shellState.paused; + return shellState.paused; +} diff --git a/apps/desktop/src/tray.ts b/apps/desktop/src/tray.ts new file mode 100644 index 00000000..d0e9ccb2 --- /dev/null +++ b/apps/desktop/src/tray.ts @@ -0,0 +1,110 @@ +import { Menu, Tray, type MenuItemConstructorOptions } from "electron"; + +import { getAppStateSnapshot, isOnboardingCompleted } from "./app-state.js"; +import { createTrayIcon } from "./assets.js"; +import { hideDefaultPet, isDefaultPetVisible, setDefaultPetPaused, showDefaultPet } from "./default-pet-controller.js"; +import { quitOpenPets } from "./lifecycle.js"; +import { shellState, togglePaused } from "./state.js"; +import { getUpdateStatus, openUpdateReleasePage } from "./update-checker.js"; +import { openTaskWindow } from "./windows.js"; + +let tray: Tray | null = null; + +export function createAppTray(): Tray { + if (tray) { + return tray; + } + + tray = new Tray(createTrayIcon()); + tray.setToolTip("OpenPets"); + refreshTrayMenu(); + console.log("OpenPets tray created."); + + return tray; +} + +export function refreshTrayMenu(): void { + if (!tray) { + return; + } + + const state = getAppStateSnapshot(); + const defaultPet = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId && !pet.broken) ?? state.pets.installed[0]; + const defaultPetName = defaultPet?.displayName ?? "Built-in Pet"; + + const continueSetupItems = isOnboardingCompleted() + ? [] + : [ + { + label: "Continue Setup...", + click: () => openTaskWindow("onboarding"), + }, + { type: "separator" as const }, + ]; + + const menu = Menu.buildFromTemplate([ + { + label: "OpenPets", + enabled: false, + }, + ...createUpdateMenuItems(), + { type: "separator" }, + ...continueSetupItems, + { + label: `Default Pet: ${defaultPetName}`, + click: () => openTaskWindow("pet-manager"), + }, + { + label: isDefaultPetVisible() ? "Hide Default Pet" : "Show Default Pet", + click: () => { + if (isDefaultPetVisible()) { + hideDefaultPet(); + } else { + showDefaultPet(); + } + + refreshTrayMenu(); + }, + }, + { + label: shellState.paused ? "Resume All Pets" : "Pause All Pets", + click: () => { + const paused = togglePaused(); + setDefaultPetPaused(paused); + console.log(paused ? "OpenPets paused." : "OpenPets resumed."); + refreshTrayMenu(); + }, + }, + { type: "separator" }, + { + label: "Manage Pets...", + click: () => openTaskWindow("pet-manager"), + }, + { + label: "Integrations...", + click: () => openTaskWindow("agent-setup"), + }, + { + label: "Settings...", + click: () => openTaskWindow("settings"), + }, + { type: "separator" }, + { + label: "Quit OpenPets", + click: () => quitOpenPets(), + }, + ]); + + tray.setContextMenu(menu); +} + +function createUpdateMenuItems(): MenuItemConstructorOptions[] { + const status = getUpdateStatus(); + if (status.state !== "available") return []; + return [ + { + label: `Update available: ${status.latestVersion ?? "latest"}...`, + click: () => { void openUpdateReleasePage(); }, + }, + ]; +} diff --git a/apps/desktop/src/update-checker.ts b/apps/desktop/src/update-checker.ts new file mode 100644 index 00000000..701d10b7 --- /dev/null +++ b/apps/desktop/src/update-checker.ts @@ -0,0 +1,124 @@ +import { app, shell } from "electron"; +import https from "node:https"; + +import { createParsedUpdateStatus, normalizeVersion } from "./update-version.js"; + +export type UpdateStatusState = "idle" | "checking" | "available" | "current" | "error"; + +export interface UpdateStatus { + readonly state: UpdateStatusState; + readonly currentVersion: string; + readonly latestVersion?: string; + readonly releaseUrl?: string; + readonly checkedAt?: number; + readonly error?: string; +} + +interface GitHubReleaseResponse { + readonly tag_name?: unknown; + readonly name?: unknown; + readonly html_url?: unknown; +} + +const githubRepository = process.env.OPENPETS_GITHUB_REPOSITORY || "alvinunreal/openpets"; +const latestReleaseApiUrl = `https://api.github.com/repos/${githubRepository}/releases/latest`; +const releasesPageUrl = `https://github.com/${githubRepository}/releases`; +const releaseCheckTimeoutMs = 6_000; + +let updateStatus: UpdateStatus = { state: "idle", currentVersion: getCurrentAppVersion() }; +let checkInFlight: Promise | null = null; + +export function getUpdateStatus(): UpdateStatus { + return updateStatus; +} + +export async function checkForGitHubReleaseUpdate(): Promise { + if (checkInFlight) return checkInFlight; + updateStatus = { ...updateStatus, state: "checking", currentVersion: getCurrentAppVersion() }; + checkInFlight = fetchLatestRelease() + .then((release) => { + const currentVersion = getCurrentAppVersion(); + updateStatus = createUpdateStatusFromLatestRelease(currentVersion, release, Date.now()); + return updateStatus; + }) + .catch((error: unknown) => { + updateStatus = createUpdateErrorStatus(getCurrentAppVersion(), error, Date.now()); + return updateStatus; + }) + .finally(() => { + checkInFlight = null; + }); + return checkInFlight; +} + +export async function openUpdateReleasePage(): Promise { + const url = validateGitHubReleaseUrl(updateStatus.releaseUrl) || releasesPageUrl; + await shell.openExternal(url); +} + +export function createUpdateStatusFromLatestRelease(currentVersion: string, release: GitHubReleaseResponse, checkedAt: number): UpdateStatus { + return createParsedUpdateStatus(currentVersion, release, checkedAt, githubRepository, releasesPageUrl); +} + +export function createUpdateErrorStatus(currentVersion: string, error: unknown, checkedAt: number): UpdateStatus { + return { + state: "error", + currentVersion, + checkedAt, + error: error instanceof Error ? error.message : "Update check failed.", + }; +} + +function getCurrentAppVersion(): string { + return normalizeVersion(app.getVersion()) || "0.0.0"; +} + +function validateGitHubReleaseUrl(value: unknown): string | null { + if (typeof value !== "string") return null; + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.hostname !== "github.com") return null; + if (!url.pathname.startsWith(`/${githubRepository}/releases/`)) return null; + return url.toString(); + } catch { + return null; + } +} + +function fetchLatestRelease(): Promise { + return new Promise((resolve, reject) => { + const request = https.get(latestReleaseApiUrl, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": `OpenPets/${getCurrentAppVersion()}`, + }, + timeout: releaseCheckTimeoutMs, + }, (response) => { + let body = ""; + response.setEncoding("utf8"); + response.on("data", (chunk: string) => { + body += chunk; + if (Buffer.byteLength(body, "utf8") > 128 * 1024) { + request.destroy(new Error("GitHub release response is too large.")); + } + }); + response.on("end", () => { + if (response.statusCode === 404) { + reject(new Error("No public OpenPets releases found yet.")); + return; + } + if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) >= 300) { + reject(new Error(`GitHub release check failed with HTTP ${response.statusCode ?? "unknown"}.`)); + return; + } + try { + resolve(JSON.parse(body) as GitHubReleaseResponse); + } catch { + reject(new Error("GitHub release response was not valid JSON.")); + } + }); + }); + request.on("timeout", () => request.destroy(new Error("GitHub release check timed out."))); + request.on("error", reject); + }); +} diff --git a/apps/desktop/src/update-version.ts b/apps/desktop/src/update-version.ts new file mode 100644 index 00000000..b867d4a0 --- /dev/null +++ b/apps/desktop/src/update-version.ts @@ -0,0 +1,62 @@ +export interface UpdateStatusFromReleaseInput { + readonly tag_name?: unknown; + readonly name?: unknown; + readonly html_url?: unknown; +} + +export interface ParsedUpdateStatus { + readonly state: "available" | "current"; + readonly currentVersion: string; + readonly latestVersion: string; + readonly releaseUrl: string; + readonly checkedAt: number; +} + +export function normalizeVersion(value: unknown): string | null { + if (typeof value !== "string") return null; + const match = value.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/); + return match ? `${Number(match[1])}.${Number(match[2])}.${Number(match[3])}` : null; +} + +export function createParsedUpdateStatus(currentVersion: string, release: UpdateStatusFromReleaseInput, checkedAt: number, repository: string, releasesPageUrl: string): ParsedUpdateStatus { + const latestVersion = normalizeVersion(release.tag_name) || normalizeVersion(release.name); + if (!latestVersion) throw new Error("Latest GitHub release did not include a valid version tag."); + const releaseUrl = validateGitHubReleaseUrl(release.html_url, repository) || releasesPageUrl; + return { + state: isVersionNewer(latestVersion, currentVersion) ? "available" : "current", + currentVersion, + latestVersion, + releaseUrl, + checkedAt, + }; +} + +function validateGitHubReleaseUrl(value: unknown, repository: string): string | null { + if (typeof value !== "string") return null; + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.hostname !== "github.com") return null; + if (!url.pathname.startsWith(`/${repository}/releases/`)) return null; + return url.toString(); + } catch { + return null; + } +} + +export function isVersionNewer(candidate: string, current: string): boolean { + const nextParts = parseVersionParts(candidate); + const currentParts = parseVersionParts(current); + if (!nextParts || !currentParts) return false; + for (let index = 0; index < nextParts.length; index += 1) { + if (nextParts[index] > currentParts[index]) return true; + if (nextParts[index] < currentParts[index]) return false; + } + return false; +} + +function parseVersionParts(value: string): readonly [number, number, number] | null { + const version = normalizeVersion(value); + if (!version) return null; + const [major, minor, patch] = version.split(".").map(Number); + return Number.isInteger(major) && Number.isInteger(minor) && Number.isInteger(patch) ? [major, minor, patch] : null; +} diff --git a/apps/desktop/src/windows.ts b/apps/desktop/src/windows.ts new file mode 100644 index 00000000..ee44b71a --- /dev/null +++ b/apps/desktop/src/windows.ts @@ -0,0 +1,1154 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { app, BrowserWindow, ipcMain, type IpcMainInvokeEvent } from "electron"; + +import { getAgentSetupSnapshot, runAgentSetupAction } from "./agent-setup.js"; +import { refreshAgentPetContent } from "./agent-pet-controller.js"; +import { completeOnboarding, getAppStateSnapshot, normalizePetScale, petScaleOptions, updatePreferences } from "./app-state.js"; +import { getCatalogUiState } from "./catalog.js"; +import { getCodexPetsUiState, importCodexPet } from "./codex-pets.js"; +import { refreshDefaultPetContent, resetDefaultPetToInitialPosition } from "./default-pet-controller.js"; +import { installPet, removePet, setDefaultInstalledPet } from "./pet-installation.js"; +import { checkForGitHubReleaseUpdate, getUpdateStatus, openUpdateReleasePage } from "./update-checker.js"; + +type TaskWindowKind = "pet-manager" | "agent-setup" | "settings" | "onboarding"; + +interface TaskWindowDefinition { + readonly title: string; + readonly heading: string; + readonly description: string; +} + +const taskWindowDefinitions: Record = { + "pet-manager": { + title: "OpenPets — Pet Manager", + heading: "Manage Pets", + description: "Install pets from the validated catalog, switch your active companion, and manage local pets.", + }, + "agent-setup": { + title: "OpenPets — Integrations", + heading: "Integrations", + description: "Connect OpenPets to coding tools with explicit confirmation.", + }, + settings: { + title: "OpenPets — Settings", + heading: "Settings", + description: "Tune how OpenPets starts and resets your desktop companion.", + }, + onboarding: { + title: "OpenPets — Welcome", + heading: "Welcome to OpenPets", + description: "Set up your pets and coding-agent integrations, or skip anything and come back later from the tray.", + }, +}; + +const taskWindows = new Map(); +let internalUiHandlersInstalled = false; +const taskWindowWidth = 900; +const taskWindowHeight = 760; +const petManagerWindowWidth = 1160; +const petManagerWindowHeight = 780; +const agentSetupWindowWidth = 1160; +const agentSetupWindowHeight = 780; +const assetDataUrlCache = new Map(); + +export function installInternalUiHandlers(): void { + if (internalUiHandlersInstalled) { + return; + } + + internalUiHandlersInstalled = true; + + ipcMain.handle("openpets:get-state", (event) => { + assertAllowedSender(event, ["pet-manager", "settings", "agent-setup"]); + return getAppStateSnapshot(); + }); + + ipcMain.handle("openpets:onboarding-snapshot", (event) => { + assertAllowedSender(event, ["onboarding"]); + const state = getAppStateSnapshot(); + const defaultPet = state.pets.installed.find((pet) => pet.id === state.preferences.defaultPetId) ?? state.pets.installed[0]; + return { + defaultPetName: defaultPet?.displayName ?? "Built-in Pet", + onboardingCompleted: state.preferences.onboardingCompleted, + }; + }); + + ipcMain.handle("openpets:onboarding-complete", async (event) => { + assertAllowedSender(event, ["onboarding"]); + completeOnboarding(); + const { refreshTrayMenu } = await import("./tray.js"); + refreshTrayMenu(); + setTimeout(() => closeTaskWindow("onboarding"), 0); + return { onboardingCompleted: true }; + }); + + ipcMain.handle("openpets:onboarding-open-pet-manager", (event) => { + assertAllowedSender(event, ["onboarding"]); + openTaskWindow("pet-manager"); + }); + + ipcMain.handle("openpets:onboarding-open-agent-setup", (event) => { + assertAllowedSender(event, ["onboarding"]); + openTaskWindow("agent-setup"); + }); + + ipcMain.handle("openpets:get-catalog", async (event) => { + assertAllowedSender(event, ["pet-manager"]); + return getCatalogUiState(); + }); + + ipcMain.handle("openpets:get-codex-pets", async (event) => { + assertAllowedSender(event, ["pet-manager"]); + return getCodexPetsUiState(); + }); + + ipcMain.handle("openpets:update-preferences", (event, patch: unknown) => { + assertAllowedSender(event, ["settings"]); + const previousScale = getAppStateSnapshot().preferences.petScale; + const state = updatePreferences(validatePreferencePatch(patch)); + if (state.preferences.petScale !== previousScale) { + refreshDefaultPetContent(); + refreshAgentPetContent(); + } + return state; + }); + + ipcMain.handle("openpets:get-launch-at-login", (event) => { + assertAllowedSender(event, ["settings"]); + return getLaunchAtLoginState(); + }); + + ipcMain.handle("openpets:set-launch-at-login", (event, enabled: unknown) => { + assertAllowedSender(event, ["settings"]); + if (typeof enabled !== "boolean") throw new Error("Invalid launch-at-login value."); + if (!isLaunchAtLoginSupported()) return getLaunchAtLoginState(); + app.setLoginItemSettings({ openAtLogin: enabled, openAsHidden: true }); + return getLaunchAtLoginState(); + }); + + ipcMain.handle("openpets:get-update-status", (event) => { + assertAllowedSender(event, ["settings"]); + return getUpdateStatus(); + }); + + ipcMain.handle("openpets:check-for-updates", async (event) => { + assertAllowedSender(event, ["settings"]); + const status = await checkForGitHubReleaseUpdate(); + const { refreshTrayMenu } = await import("./tray.js"); + refreshTrayMenu(); + return status; + }); + + ipcMain.handle("openpets:open-update-release-page", async (event) => { + assertAllowedSender(event, ["settings"]); + await openUpdateReleasePage(); + }); + + ipcMain.handle("openpets:set-default-pet", async (event, petId: unknown) => { + assertAllowedSender(event, ["pet-manager"]); + if (typeof petId !== "string") { + throw new Error("Invalid pet id."); + } + + const state = await setDefaultInstalledPet(petId); + refreshDefaultPetContent(); + return state; + }); + + ipcMain.handle("openpets:install-pet", async (event, petId: unknown) => { + assertAllowedSender(event, ["pet-manager"]); + if (typeof petId !== "string") { + throw new Error("Invalid pet id."); + } + + return installPet(petId); + }); + + ipcMain.handle("openpets:import-codex-pet", async (event, petId: unknown) => { + assertAllowedSender(event, ["pet-manager"]); + if (typeof petId !== "string") { + throw new Error("Invalid pet id."); + } + + return importCodexPet(petId); + }); + + ipcMain.handle("openpets:remove-pet", async (event, petId: unknown) => { + assertAllowedSender(event, ["pet-manager"]); + if (typeof petId !== "string") { + throw new Error("Invalid pet id."); + } + + const state = await removePet(petId); + refreshDefaultPetContent(); + return state; + }); + + ipcMain.handle("openpets:reset-default-pet-position", (event) => { + assertAllowedSender(event, ["settings"]); + resetDefaultPetToInitialPosition(); + return getAppStateSnapshot(); + }); + + ipcMain.handle("openpets:agent-setup-snapshot", async (event, selectedPetId: unknown, commandMode: unknown) => { + assertAllowedSender(event, ["agent-setup"]); + return getAgentSetupSnapshot(selectedPetId, commandMode); + }); + + ipcMain.handle("openpets:agent-setup-action", async (event, action: unknown, selectedPetId: unknown, commandMode: unknown) => { + assertAllowedSender(event, ["agent-setup"]); + if (action !== "configure" && action !== "replace" && action !== "remove" && action !== "install-memory" && action !== "doctor-hooks" && action !== "install-hooks" && action !== "uninstall-hooks" && action !== "opencode-install" && action !== "opencode-remove") { + throw new Error("Invalid agent setup action."); + } + + return runAgentSetupAction(action, selectedPetId, commandMode); + }); +} + +export function openTaskWindow(kind: TaskWindowKind): void { + const existingWindow = taskWindows.get(kind); + + if (existingWindow && !existingWindow.isDestroyed()) { + if (existingWindow.isMinimized()) { + existingWindow.restore(); + } + + existingWindow.show(); + existingWindow.focus(); + console.log(`Focused existing ${kind} window.`); + return; + } + + const definition = taskWindowDefinitions[kind]; + const width = kind === "pet-manager" ? petManagerWindowWidth : kind === "agent-setup" ? agentSetupWindowWidth : taskWindowWidth; + const height = kind === "pet-manager" ? petManagerWindowHeight : kind === "agent-setup" ? agentSetupWindowHeight : taskWindowHeight; + const window = new BrowserWindow({ + title: definition.title, + width, + height, + minWidth: 720, + minHeight: 520, + show: false, + backgroundColor: "#0f172a", + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + ...(kind === "pet-manager" || kind === "settings" || kind === "agent-setup" || kind === "onboarding" ? { preload: getPreloadPath() } : {}), + }, + }); + + taskWindows.set(kind, window); + window.setMenu(null); + + window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.webContents.on("will-navigate", (event) => { + event.preventDefault(); + }); + window.webContents.on("will-redirect", (event) => { + event.preventDefault(); + }); + window.webContents.on("did-fail-load", (_event, errorCode, errorDescription) => { + console.error(`Failed to load ${kind} placeholder window.`, { errorCode, errorDescription }); + }); + window.webContents.on("render-process-gone", (_event, details) => { + console.error(`${kind} placeholder renderer process gone.`, details); + }); + + window.on("closed", () => { + taskWindows.delete(kind); + console.log(`Closed ${kind} window.`); + }); + + window.once("ready-to-show", () => { + window.show(); + window.focus(); + }); + + window.loadURL(createTaskWindowDataUrl(kind, definition)).catch((error: unknown) => { + console.error(`Failed to load ${kind} placeholder URL.`, error); + }); + + console.log(`Opened ${kind} window.`); +} + +export function closeTaskWindow(kind: TaskWindowKind): void { + const window = taskWindows.get(kind); + if (window && !window.isDestroyed()) { + window.close(); + } +} + +export function focusOpenTaskWindows(): void { + for (const window of taskWindows.values()) { + if (window.isDestroyed()) { + continue; + } + + if (window.isMinimized()) { + window.restore(); + } + + window.show(); + window.focus(); + } +} + +function createTaskWindowDataUrl(kind: TaskWindowKind, definition: TaskWindowDefinition): string { + if (kind === "pet-manager") { + return createDataUrl(createPetManagerHtml(definition)); + } + + if (kind === "settings") { + return createDataUrl(createSettingsHtml(definition)); + } + + if (kind === "onboarding") { + return createDataUrl(createOnboardingHtml(definition)); + } + + return createDataUrl(createAgentSetupHtml(definition)); +} + +function createPlaceholderHtml(definition: TaskWindowDefinition): string { + const html = ` + + + + + + ${escapeHtml(definition.title)} + + + +
+

OpenPets Phase 01

+

${escapeHtml(definition.heading)}

+

${escapeHtml(definition.description)}

+ Placeholder window +
+ + `; + + return html; +} + +function createPetManagerHtml(definition: TaskWindowDefinition): string { + const logoUrl = createAssetDataUrl("onboarding-logo.webp", "image/webp"); + const defaultThumbnailUrl = createAssetDataUrl("default-pet-thumbnail.png", "image/png"); + + return ` + + + + + + + ${escapeHtml(definition.title)} + ${createTaskWindowStyles()} + + +
+ + + +

+
+ + `; +} + +function createOnboardingHtml(definition: TaskWindowDefinition): string { + const logoUrl = createAssetDataUrl("onboarding-logo.webp", "image/webp"); + const petsUrl = createAssetDataUrl("onboarding-pets.webp", "image/webp"); + + return ` + + + + + + ${escapeHtml(definition.title)} + ${createTaskWindowStyles()} + + +
+
+

OpenPets

+

${escapeHtml(definition.heading)}

+

${escapeHtml(definition.description)}

+
+ + +
+
+ +

Your AI coding companion

+

OpenPets lives in your tray and gives your coding agents a friendly desktop companion — one command at a time.

+

Starting with the bundled pet.

+
+ +
+
+ +
+ + + + +

Closing this window before finishing keeps setup available from the tray.

+

+
+ + `; +} + +function createAgentSetupHtml(definition: TaskWindowDefinition): string { + const logoUrl = createAssetDataUrl("onboarding-logo.webp", "image/webp"); + const integrationIcons = { + claude: createAssetDataUrl("integrations/claude.svg", "image/svg+xml"), + cursor: createAssetDataUrl("integrations/cursor.svg", "image/svg+xml"), + opencode: createAssetDataUrl("integrations/opencode.svg", "image/svg+xml"), + vscode: createAssetDataUrl("integrations/vscode.svg", "image/svg+xml"), + windsurf: createAssetDataUrl("integrations/windsurf.svg", "image/svg+xml"), + zed: createAssetDataUrl("integrations/zed.svg", "image/svg+xml"), + }; + + return ` + + + + + + ${escapeHtml(definition.title)} + ${createTaskWindowStyles()} + + +
+
+
+ +

OpenPets

+

Integrations

+

Install Claude or OpenCode integrations now, or configure the details when you need them.

+
+ +
+ + +
+
Checking
+

OpenCode

Connect OpenCode globally to your OpenPets companion.

+
+ + +
+
+
+
Soon
+

Cursor

Coming soon.

+
+
+
Soon
+

VS Code

Coming soon.

+
+
+
Soon
+

Windsurf

Coming soon.

+
+
+
Soon
+

Zed

Coming soon.

+
+
+
+ + + + + +

+
+ + `; +} +function createSettingsHtml(definition: TaskWindowDefinition): string { + const scaleOptionsHtml = petScaleOptions.map((option) => ``).join(""); + return ` + + + + + + ${escapeHtml(definition.title)} + ${createTaskWindowStyles()} + + +
+
+

OpenPets

+

${escapeHtml(definition.heading)}

+

${escapeHtml(definition.description)}

+
+
+
+ + General +

Startup and companion behavior

+
+
+ + +
+
+
+ + Pet +

Desktop pet controls

+
+
+ +
+ + Reset default pet position + Moves the default pet back near the bottom-right of the primary display. + + +
+
+
+
+ + Updates +

App updates

+
+
+
+ + Checking for updates + OpenPets checks public GitHub releases and opens the release page when an update is available. + + + + + +
+
+

Changes save automatically.

+

+
+ + `; +} + +function createTaskWindowStyles(): string { + return ``; +} + +function createDataUrl(html: string): string { + return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; +} + +function createAssetDataUrl(fileName: string, mimeType: string): string { + const cacheKey = `${mimeType}:${fileName}`; + const cached = assetDataUrlCache.get(cacheKey); + if (cached) return cached; + + const bytes = readFileSync(join(app.getAppPath(), "assets", fileName)); + const dataUrl = `data:${mimeType};base64,${bytes.toString("base64")}`; + assetDataUrlCache.set(cacheKey, dataUrl); + return dataUrl; +} + +function getPreloadPath(): string { + return join(app.getAppPath(), "preload.cjs"); +} + +function assertAllowedSender(event: IpcMainInvokeEvent, allowedKinds: readonly TaskWindowKind[]): void { + const actualKind = getTaskWindowKindForWebContents(event.sender.id); + + if (!actualKind || !allowedKinds.includes(actualKind)) { + throw new Error("OpenPets internal UI request came from an unexpected window."); + } +} + +function getTaskWindowKindForWebContents(webContentsId: number): TaskWindowKind | null { + for (const [kind, window] of taskWindows) { + if (!window.isDestroyed() && window.webContents.id === webContentsId) { + return kind; + } + } + + return null; +} + +function validatePreferencePatch(value: unknown): { openDefaultPetOnLaunch?: boolean; petScale?: number } { + if (!isRecord(value)) { + throw new Error("Invalid preferences patch."); + } + + const patch: { openDefaultPetOnLaunch?: boolean; petScale?: number } = {}; + + if ("openDefaultPetOnLaunch" in value) { + if (typeof value.openDefaultPetOnLaunch !== "boolean") throw new Error("Invalid open-on-launch value."); + patch.openDefaultPetOnLaunch = value.openDefaultPetOnLaunch; + } + + if ("petScale" in value) { + const scale = normalizePetScale(value.petScale); + if (scale !== value.petScale) throw new Error("Invalid pet scale value."); + patch.petScale = scale; + } + + return patch; +} + +function getLaunchAtLoginState(): { supported: boolean; enabled: boolean } { + if (!isLaunchAtLoginSupported()) return { supported: false, enabled: false }; + return { supported: true, enabled: app.getLoginItemSettings().openAtLogin }; +} + +function isLaunchAtLoginSupported(): boolean { + return process.platform === "darwin" || process.platform === "win32"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/apps/desktop/src/zip-safety.ts b/apps/desktop/src/zip-safety.ts new file mode 100644 index 00000000..4206a2c2 --- /dev/null +++ b/apps/desktop/src/zip-safety.ts @@ -0,0 +1,103 @@ +import { resolve, sep } from "node:path"; + +export interface SafeZipEntryPath { + readonly originalName: string; + readonly normalizedName: string; + readonly topLevelDirectory: string; + readonly relativeOutputPath: "pet.json" | "spritesheet.webp" | null; + readonly isDirectory: boolean; +} + +export class ZipEntryPathTracker { + readonly #normalizedPaths = new Set(); + readonly #caseFoldedPaths = new Set(); + #topLevelDirectory: string | null = null; + + accept(fileName: string): SafeZipEntryPath { + const entry = validateZipEntryName(fileName); + + if (this.#topLevelDirectory !== null && entry.topLevelDirectory !== this.#topLevelDirectory) { + throw new Error("Zip contains mixed or multiple top-level layouts."); + } + + this.#topLevelDirectory = entry.topLevelDirectory; + + if (this.#normalizedPaths.has(entry.normalizedName)) { + throw new Error(`Duplicate zip entry path: ${entry.normalizedName}`); + } + + const caseFolded = entry.normalizedName.toLocaleLowerCase("en-US"); + if (this.#caseFoldedPaths.has(caseFolded)) { + throw new Error(`Case-insensitive zip entry collision: ${entry.normalizedName}`); + } + + this.#normalizedPaths.add(entry.normalizedName); + this.#caseFoldedPaths.add(caseFolded); + return entry; + } +} + +export function validateZipEntryName(fileName: string): SafeZipEntryPath { + if (fileName.includes("\0")) throw new Error("Zip entry contains NUL byte."); + if (fileName.includes("\\")) throw new Error("Zip entry contains backslash separator."); + if (fileName.startsWith("/") || fileName.startsWith("//")) throw new Error("Zip entry is absolute."); + if (/^[a-zA-Z]:\//.test(fileName)) throw new Error("Zip entry contains Windows drive path."); + if (fileName.includes("//")) throw new Error("Zip entry contains empty path segment."); + + const parts = fileName.split("/").filter(Boolean); + if (parts.some((part) => part === "..")) throw new Error("Zip entry contains parent traversal."); + if (parts.some((part) => part === ".")) throw new Error("Zip entry contains current-directory segment."); + + const isDirectory = fileName.endsWith("/"); + if (isDirectory) { + if (parts.length !== 1) throw new Error("Zip directory layout is unsupported."); + return { + originalName: fileName, + normalizedName: parts.join("/"), + topLevelDirectory: parts[0] ?? "", + relativeOutputPath: null, + isDirectory: true, + }; + } + + if (parts.length !== 1 && parts.length !== 2) throw new Error("Zip must contain pet files at the root or under exactly one top-level directory."); + + const leaf = parts.at(-1); + if (leaf !== "pet.json" && leaf !== "spritesheet.webp") { + throw new Error(`Unexpected zip file: ${leaf}`); + } + + return { + originalName: fileName, + normalizedName: parts.join("/"), + topLevelDirectory: parts.length === 1 ? "" : parts[0] ?? "", + relativeOutputPath: leaf, + isDirectory: false, + }; +} + +export function assertOutputPathInside(tempDir: string, outputPath: string): void { + const root = resolve(tempDir); + const target = resolve(outputPath); + if (target !== root && !target.startsWith(`${root}${sep}`)) { + throw new Error("Zip entry output path escapes temp directory."); + } +} + +export function hasSupportedZipMagic(buffer: Buffer): boolean { + if (buffer.length < 4 || buffer[0] !== 0x50 || buffer[1] !== 0x4b) { + return false; + } + + const signature = buffer.readUInt32LE(0); + return signature === 0x04034b50 || signature === 0x06054b50; +} + +export function assertRejectsPath(fileName: string): void { + try { + validateZipEntryName(fileName); + } catch { + return; + } + throw new Error(`Unsafe zip path was accepted: ${fileName}`); +} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 00000000..df59da57 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/docs/claude-integration.md b/docs/claude-integration.md new file mode 100644 index 00000000..2f0bc1a2 --- /dev/null +++ b/docs/claude-integration.md @@ -0,0 +1,238 @@ +# Claude integration + +This document describes how OpenPets Desktop integrates with Claude Code, what files are configured, how Claude reaches the desktop app, and which Claude events trigger pet reactions or speech. + +## Integration surfaces + +Claude support has three related surfaces: + +1. **MCP tools** — Claude gets the `openpets_status`, `openpets_react`, and `openpets_say` tools. +2. **Claude memory instructions** — Claude is told when it should use those tools. +3. **Claude hooks** — Claude lifecycle and tool events automatically trigger OpenPets reactions. + +The desktop setup UI for these surfaces is implemented in `apps/desktop/src/agent-setup.ts` and exposed to the renderer through `openpets:agent-setup-snapshot` and `openpets:agent-setup-action`. + +This document primarily describes **Desktop user/global setup**. The CLI also supports **project-local Claude setup**; see [Project-local Claude setup](#project-local-claude-setup). + +## Files managed by OpenPets + +### Claude MCP entry + +The MCP entry is installed into Claude Code by running a Claude command shaped like: + +```sh +claude mcp add --scope user openpets -- npx -y @open-pets/mcp +``` + +If the user selected a specific pet in the desktop setup UI, the command includes that pet: + +```sh +claude mcp add --scope user openpets -- npx -y @open-pets/mcp --pet +``` + +In local development or packaged desktop mode, OpenPets can use `node ` instead of `npx`. + +Packaged and local `node ` commands require `node` to be available on Claude Code's `PATH`. Desktop setup reports an error instead of installing a bundled command if `node --version` cannot run from the agent environment. + +### Claude memory + +OpenPets writes two user-level Claude memory files: + +```text +~/.claude/CLAUDE.md +~/.claude/openpets.md +``` + +`~/.claude/CLAUDE.md` receives a managed import block: + +```md + +@~/.claude/openpets.md + +``` + +`~/.claude/openpets.md` receives the managed instruction block: + +```md + +## OpenPets + +OpenPets MCP tools may be available. + +Use OpenPets as a short visible status channel for meaningful coding progress: +- Use `openpets_say` when starting, completing, blocking, or needing review on non-trivial work. +- Keep messages brief, user-facing, and non-sensitive. +- Do not include code, logs, secrets, URLs, or file paths. +- Use `openpets_react` for small visual or emotional feedback. +- Use `openpets_status` only when checking availability or the targeted pet. +- Do not spam every internal step. + +``` + +The memory installer is idempotent. It updates only the managed blocks, avoids symlinks and unsafe files, writes private files, and uses atomic temp-file writes. + +### Claude hooks + +Claude hooks are installed into: + +```text +~/.claude/settings.json +``` + +OpenPets-managed hook commands include this marker: + +```text +--openpets-managed +``` + +The normal published hook command is: + +```sh +npx -y @open-pets/claude hook --openpets-managed +``` + +If a pet is selected: + +```sh +npx -y @open-pets/claude hook --openpets-managed --pet +``` + +OpenPets installs the command for these Claude hook events: + +```text +UserPromptSubmit +PreToolUse +PermissionRequest +Notification +Stop +StopFailure +``` + +Each hook entry is a Claude command hook with a short timeout and async execution enabled: + +```json +{ + "type": "command", + "command": "npx -y @open-pets/claude hook --openpets-managed", + "timeout": 3, + "async": true, + "asyncRewake": false +} +``` + +OpenPets backs up `settings.json` before changing it and removes only hooks containing the `--openpets-managed` marker. + +## Project-local Claude setup + +The `@open-pets/cli` package can configure a project-local Claude integration from a project directory: + +```sh +openpets configure --agent claude --pet +``` + +Project-local setup differs from Desktop user/global setup: + +- It uses `claude mcp add-json openpets ... --scope local` from the target project directory. +- It writes hooks to `/.claude/settings.local.json`. +- Hook commands include both `--openpets-managed` and `--project-local`. +- Project-local hook entries use `timeout: 10`, `async: true`, and `asyncRewake: false`. +- The MCP and hooks are always configured with a selected `--pet `. + +When a global OpenPets Claude hook runs, it checks whether the current Claude project already has a project-local OpenPets hook. If it finds one, the global hook does not send a duplicate reaction. This avoids double pet events when both global Desktop setup and project-local CLI setup exist. + +Project-local files live inside the project and may be committed depending on the user's repository policy. They can contain the selected pet id. + +## Runtime path + +When Claude calls an OpenPets tool or when a Claude hook fires, the runtime path is: + +```text +Claude Code + -> @open-pets/mcp or @open-pets/claude hook + -> @open-pets/client + -> OpenPets desktop local IPC discovery file + -> OpenPets desktop IPC socket/pipe + -> default pet controller or explicit agent pet controller +``` + +The desktop app writes a discovery file such as: + +```text +macOS: ~/Library/Application Support/OpenPets/runtime/ipc.json +Windows: %APPDATA%/OpenPets/runtime/ipc.json +Linux: $XDG_RUNTIME_DIR/openpets/ipc.json, or ~/.config/OpenPets/runtime/ipc.json fallback +``` + +That file contains the IPC endpoint and a per-run token. Clients must send that token with every request. + +## MCP tools + +Claude sees these tools when the MCP server is configured: + +| Tool | Purpose | Desktop IPC method | +| --- | --- | --- | +| `openpets_status` | Check whether OpenPets is reachable and which pet is targeted. | `status` | +| `openpets_react` | Set a short reaction on the target pet. | `pet.react` | +| `openpets_say` | Show a short safe speech bubble, optionally with a reaction. | `pet.say` | + +`openpets_say` is validated before it reaches the desktop app. Messages must be short, single-line, and must not look like code, logs, secrets, URLs, or file paths. + +## Pet targeting + +If the MCP server or hook command was configured without `--pet`, events target the desktop default pet. + +If it was configured with `--pet `, the process asks the desktop app for a lease. A valid installed non-default pet opens as an explicit agent pet window. Missing, invalid, broken, built-in, or default pet requests fall back to the default pet. + +Explicit leases expire after a short TTL unless refreshed. When the last explicit lease for a pet expires or is released, OpenPets clears and closes that agent pet window. + +## Claude hook reaction and speech mapping + +Claude hooks are decorative and best-effort. They must not block, approve, deny, or change Claude's behavior. If OpenPets is closed or IPC is unavailable, the hook exits successfully and silently. + +| Claude event | Trigger condition | Reaction | Speech bubble | +| --- | --- | --- | --- | +| `UserPromptSubmit` | User submits a prompt to Claude. | `thinking`, throttled. | None. | +| `PreToolUse` | Tool name is `Edit`, `Write`, or `MultiEdit`. | `editing` | None. | +| `PreToolUse` | Tool name is `Bash` and command text looks test-like. | `testing` | None. | +| `PreToolUse` | Tool name is `Bash` and command text does not look test-like. | None. | None. | +| `PreToolUse` | Any other tool. | None. | None. | +| `PermissionRequest` | Claude asks for approval. | `waiting` | `Approval needed`, throttled with a short cooldown. | +| `Notification` | Claude emits a notification hook event. | None. | None. | +| `Stop` | Claude finishes a response. | `success`, throttled. | None. | +| `StopFailure` | Claude stop/finalization fails. | `error` | One error-pool message, throttled. | +| Unknown event | Event is not recognized. | None | None. | + +Test-like Bash detection is intentionally coarse and private. It may inspect only a bounded command string to classify the reaction. The command text is never sent to speech. + +Claude hooks do **not** use the left/right walking animations or the `running` reaction. The `running-right` and `running-left` sprite rows are reserved for pet drag/move motion, and generic Bash/shell activity is silent by default. Hook reactions use quieter phase changes instead: editing maps to the generic active-work row, thinking maps to `review`, waiting/testing maps to `waiting`, success maps to `jumping`, and errors map to `failed`. + +## Hook speech pools + +When a hook is allowed to speak, it chooses one static local message from the matching pool: + +| Category | Possible messages | +| --- | --- | +| `thinking` | `Thinking it through`, `Let me check`, `On it`, `Working it out` | +| `success` | `Done`, `That worked`, `All set`, `Nice, finished` | +| `error` | `Something failed`, `Needs another look`, `Hit a snag`, `Not quite there` | +| `permission` | `Approval needed` | + +Speech is throttled by category so hooks do not spam bubbles. Current speech cooldowns are: + +| Category | Cooldown | +| --- | --- | +| `permission` | 3 seconds | +| `thinking`, `success`, `error` | 20 seconds each | + +Normal thinking and success hooks no longer speak, so their speech pools are retained for shared/static message support but are not used by Claude's default quiet hook policy. Reaction-only hooks are also deduped, with the same reaction suppressed for about 10 seconds. + +Throttle state stores only speech-category and reaction timestamp keys, never prompts, commands, tool input, output, code, logs, or transcripts. + +## Safety rules + +- Hook stdout stays empty so Claude does not ingest accidental context. +- Hook errors are swallowed unless debug logging is enabled. +- Hook debug logs sanitize path-like values. MCP/client-side validation separately rejects secret-looking speech. +- Speech is static and local; it does not include model-generated text. +- Tool input and command text are used only for coarse reaction classification. +- Managed setup refuses unsafe symlinks and non-regular files. Claude memory files also have an oversized-file safety limit before OpenPets edits them. diff --git a/docs/implementation-process.md b/docs/implementation-process.md new file mode 100644 index 00000000..becac0a6 --- /dev/null +++ b/docs/implementation-process.md @@ -0,0 +1,355 @@ +# OpenPets 2.0 implementation process + +## Goal + +OpenPets 2.0 should be implemented in clear, manually verifiable phases. + +The process should optimize for: + +- Great developer experience. +- Smooth incremental progress. +- Working software after each phase. +- Manual user confirmation before moving to the next numbered phase or meaningful user-visible sub-phase. +- Oracle review before implementation plans are accepted. +- Oracle review after implementation before asking the user to confirm. + +This is not a big-bang implementation. Each phase should be large enough to be meaningful, but small enough that the user can manually verify the result with confidence. + +## Core workflow for every phase + +Each phase should follow the same loop: + +1. Define the phase goal and manual acceptance criteria. +2. Scout the current codebase and relevant v1 references. +3. Check current documentation for important Electron, Node, MCP, Claude, or platform behavior when needed. +4. Draft the phase implementation plan. +5. Send the plan to Oracle for architecture/process review. +6. Fix the plan based on Oracle feedback. +7. Implement the phase. +8. Run automated checks that fit the phase. +9. Send the implemented diff to Oracle for code/maintainability review. +10. Fix actionable Oracle feedback. +11. Provide the user a concise manual verification guide. +12. Wait for user confirmation before starting the next numbered phase, or any meaningful sub-phase that changes user-visible behavior. + +## Global definition of done for each phase + +A phase is not done until all relevant items below are true: + +- The agreed acceptance criteria for the phase are met. +- Relevant automated checks pass. +- The manual verification guide is written with exact commands, actions, and expected results. +- Known limitations are documented. +- Persistent/config-changing behavior has rollback, uninstall, or restore coverage. +- Security-sensitive changes receive explicit review. +- Oracle implementation review has been completed. +- Oracle feedback disposition is recorded: fixed, deferred, or rejected with reason. +- No unrelated refactors or silent scope expansions are included. +- The user has confirmed the phase manually before moving on. + +## Oracle review packet requirements + +Before implementation, the phase plan sent to Oracle should include: + +- Goal. +- Non-goals. +- Files/packages expected to change. +- Architecture choices and tradeoffs. +- Security/privacy concerns. +- Automated test/check plan. +- Manual acceptance criteria. + +After implementation, the diff sent to Oracle should include: + +- Summary of implemented changes. +- Diff or exact changed file list. +- Test/check results. +- Known limitations. +- Deviations from the approved plan, if any. +- Specific concerns where review should focus. + +## Scope-control rule + +If implementation reveals that a phase is larger than expected, stop and split the phase rather than silently continuing. + +Do not let Oracle feedback or discovered complexity turn one phase into a much larger phase unless the user explicitly agrees. + +## Phase sizing rule + +Phases should be relevant and manually testable. + +Avoid phases that are too small, such as: + +- Rename one file. +- Add one empty package. +- Add one isolated helper with no visible outcome. + +Avoid phases that are too large, such as: + +- Build all Electron, MCP, Claude, pet catalog, onboarding, and packaging in one pass. +- Implement all supported agents before Claude works end-to-end. + +A good phase should produce a concrete checkpoint the user can confirm, for example: + +- “I can launch the desktop app and see the tray menu.” +- “I can see and drag the default pet.” +- “Claude Code is detected correctly on my machine.” +- “Claude Code configuration preview looks correct and backups are created.” +- “A real Claude session makes the pet react.” + +## Proposed implementation phases + +### Phase 0: V2 workspace foundation and DX + +Goal: + +Create the fresh v2 workspace structure and developer workflow without building product features yet. + +Acceptance criteria: + +- V2 workspace exists separately from v1 reference code. +- Development uses Node-based tooling and does not require Bun at runtime. +- Basic scripts are clear and documented. +- Type checking/lint/build commands are defined. +- The package shape matches the planned `@open-pets/*` architecture. + +Manual confirmation: + +- User can install dependencies and run the basic workspace checks. + +### Phase 1: Tray-first desktop shell + +Goal: + +Create the Electron desktop app shell with tray/menu behavior and task-specific placeholder windows. + +Acceptance criteria: + +- App launches as a tray/menu bar app. +- Tray menu matches the agreed initial menu. +- Manage Pets, Configure Agents, and Settings open placeholder windows. +- Quit exits cleanly. + +Manual confirmation: + +- User can launch the app, inspect tray/menu items, open placeholder windows, and quit cleanly. + +### Phase 2: Default pet window foundation + +Goal: + +Show the bundled default pet reliably as a floating desktop companion. + +Acceptance criteria: + +- Bundled default pet appears on launch. +- Pet window is frameless/transparent/always-on-top where supported. +- Pet does not steal focus from the editor/terminal. +- Pet is draggable. +- Pet position is persisted. +- Show/hide default pet works from tray. +- Pause/resume all pets has visible behavior. +- Platform-specific limitations for transparency, focus, dragging, and always-on-top behavior are documented. + +Manual confirmation: + +- User can see, drag, hide/show, pause/resume, restart, and confirm the pet position persists. + +### Phase 3: Local app state and pet manager basics + +Goal: + +Make pet ownership and default-pet behavior real in the app. + +Acceptance criteria: + +- App persists core preferences. +- Built-in pet is protected from removal. +- Pet Manager can show installed pets. +- User can set default pet among installed pets. +- Settings can control important early preferences such as speech enabled and open default pet on launch. + +Manual confirmation: + +- User can change default-related settings, restart app, and see preferences persist. + +### Phase 4: V2 pet catalog and installation + +Goal: + +Support browsing and installing remote pets from the v2 catalog flow. + +Acceptance criteria: + +- App fetches the v2 catalog. +- Pet Manager shows catalog pets with loading/error states. +- User can install a pet from zip. +- Installed pet is validated before use. +- Zip install path prevents zip-slip/path traversal. +- Zip size/file count limits exist. +- Installation never executes scripts from downloaded pets. +- Failed installs clean up partial files. +- User can remove removable installed pets. +- User can set installed pet as default. +- App remains usable if catalog fetch fails. + +Manual confirmation: + +- User can install a real catalog pet, set it as default, restart, and see it work. + +### Phase 5: Local IPC and `@open-pets/client` + +Goal: + +Create the private local control plane between adapters and the desktop app. + +Acceptance criteria: + +- Desktop app exposes local-only IPC. +- Discovery file is written while app is running. +- Discovery file location and permissions are documented per platform. +- Startup token/version validation exists. +- Token lifetime and stale discovery cleanup are defined. +- Local-only threat model is documented. +- `@open-pets/client` can connect and call status/say/react-style methods. +- Invalid token/version requests are rejected. + +Manual confirmation: + +- User/developer can run a local test command and see the pet react through IPC. + +### Phase 6: `@open-pets/mcp` agent-neutral integration + +Goal: + +Make OpenPets usable from MCP-capable coding agents through the minimal public tool set. + +Acceptance criteria: + +- MCP server runs with Node/npx. +- Tools exist: `openpets_status`, `openpets_say`, `openpets_react`. +- MCP startup immediately acquires a lease and opens/shows the configured/default pet. +- `--pet` chooses target pet. +- Missing explicit pet falls back to default and reports fallback. +- If app is installed but not running, MCP attempts to launch it. +- If app is missing, MCP stays alive in degraded mode and reports clearly. +- Lease cleanup works on process exit and TTL fallback. + +Manual confirmation: + +- User can run MCP manually or from a supported agent and see status/say/react work. + +### Phase 7: Claude Code detection and configuration + +Goal: + +Implement the dedicated Claude Code setup experience before full hook behavior. + +Acceptance criteria: + +- Agent Setup detects Claude Code on the user's machine where possible. +- Shows detected/not detected/configured/needs setup/error states. +- Shows exactly what configuration changes would be made. +- Requires confirmation before editing config. +- Creates backups before editing config. +- Config edits are idempotent. +- Config writes are atomic where practical. +- Dry-run/diff preview exists before writes. +- Backup restore behavior is defined. +- Expected Claude config paths are documented per platform. +- Doctor/check reports actionable status. +- Uninstall/remove integration path exists for changes made by OpenPets. +- Uninstall verification confirms OpenPets-managed config was removed without damaging unrelated user config. + +Manual confirmation: + +- User can verify Claude Code detection, preview config changes, apply setup, inspect backup behavior, run doctor, and remove integration if needed. + +### Phase 8: Full Claude enhanced hooks integration + +Goal: + +Make Claude Code feel polished through hook-driven reactions and short safe speech. + +Acceptance criteria: + +- `@open-pets/claude` installs global Claude hooks safely. +- Hook events map to agreed reactions. +- Speech comes from local short message pools/templates. +- Speech is throttled/selective. +- Permission/approval cases use clear fixed messages. +- Notification hooks do not speak by default. +- Speech safety rules are enforced. +- Hooks route to active Claude MCP leases when reliable and otherwise fall back to default pet. +- Hook event contract is documented. +- Ambiguous Claude/MCP/app routing states have defined fallback behavior. + +Manual confirmation: + +- User runs a real Claude Code session and verifies thinking/working/testing/waiting/success/error reactions plus short safe speech. + +### Phase 9: First-run onboarding end-to-end + +Goal: + +Connect the already-working pieces into the agreed first-run experience. + +Acceptance criteria: + +- First-run onboarding opens appropriately. +- Default pet appears early. +- Optional pet install step works or gracefully skips on catalog failure. +- Agent setup step uses the real Agent Setup flow. +- Done state clearly says OpenPets is ready. +- Onboarding completion persists. + +Manual confirmation: + +- User can reset onboarding state, run through onboarding, skip or complete steps, and confirm the app lands in a ready state. + +### Phase 10: MVP hardening and packaging + +Goal: + +Prepare the complete Claude-focused MVP for real use. + +Acceptance criteria: + +- Concrete platform validation matrix is defined for macOS, Windows, and Linux. +- Packaging path is tested against the agreed validation matrix. +- App lifecycle edge cases are handled. +- IPC cleanup and stale discovery behavior are reliable. +- Pet install failure cases are understandable. +- Claude install/uninstall/doctor paths are robust. +- Core automated tests/checks pass. +- Manual MVP demo checklist passes. + +Manual confirmation: + +- User can install or run the packaged app and complete the full MVP demo: app launch, default pet, pet install/default change, Claude setup, real Claude reactions/speech, cleanup, quit. + +## Oracle review policy + +Oracle should review two things for each meaningful phase: + +1. The phase plan before implementation begins. +2. The implemented diff before the user is asked for manual confirmation. + +Oracle feedback should be handled as follows: + +- Fix correctness, safety, maintainability, data-loss, security, and architecture issues before user confirmation. +- Discuss optional scope expansions with the user instead of silently expanding the phase. +- Record Oracle feedback disposition as fixed, deferred, or rejected with reason. +- Do not let Oracle feedback turn one phase into a much larger phase unless the user agrees. + +## User confirmation policy + +After implementation and Oracle fixes, the user should receive: + +- What changed. +- What commands to run. +- What manual behavior to verify. +- Known limitations for this phase. +- A clear question asking whether to proceed to the next phase. + +Do not proceed to the next numbered phase, or any meaningful sub-phase that changes user-visible behavior, until the user confirms the current checkpoint is acceptable. diff --git a/docs/mapping.md b/docs/mapping.md new file mode 100644 index 00000000..50f93b29 --- /dev/null +++ b/docs/mapping.md @@ -0,0 +1,252 @@ +# OpenPets reaction, speech, and Claude hook mapping + +This document explains how OpenPets decides which pet reaction is shown, when the pet says something, and how Claude Code hooks and OpenCode plugin events map to reactions/messages. + +## Important terms + +- **Reaction**: one of the allowed OpenPets reaction names, for example `thinking`, `success`, or `error`. +- **Speech/message**: a short text bubble sent with `openpets_say` or generated by a Claude hook. +- **Hook speech**: automatic short speech generated by Claude Code hook events. +- **Target pet**: the pet that receives the reaction/message. This is either the desktop default pet or a project/agent-selected pet through a lease. + +## Allowed reactions + +OpenPets currently accepts these reactions: + +| Reaction | Intended meaning | +| --- | --- | +| `idle` | Neutral / no special task state. | +| `thinking` | The agent is thinking or starting to process a prompt. | +| `working` | Generic tool/work activity. | +| `editing` | The agent is editing or writing files. | +| `running` | The agent is running a command that is not classified as tests. | +| `testing` | The agent is running tests. | +| `waiting` | The agent is waiting for permission or another blocking state. | +| `waving` | The pet waves for notifications/attention. | +| `success` | The agent completed successfully. | +| `error` | The agent failed or hit a problem. | +| `celebrating` | Positive manual reaction; not currently emitted by Claude hooks. | + +Source of truth: `packages/client/src/protocol.ts` and `apps/desktop/src/local-ipc-protocol.ts`. + +## What a reaction visually does today + +Reactions drive the pet spritesheet animation and can also show temporary bubble text above the pet. For example, `thinking` uses the review/thinking animation row and, when sent as a reaction-only event, displays a short randomized status line such as `Thinking it through` or `Checking the clues`. + +The universal Codex/OpenPets spritesheet has these animation rows: + +| Row | Spritesheet state | Trigger/reaction mapping | +| --- | --- | --- | +| 0 | `idle` | No active reaction, message-only speech, or `idle`. Slow 5500ms loop. | +| 1 | `running-right` | Drag right via motion state `run-right`. | +| 2 | `running-left` | Drag left via motion state `run-left`. | +| 3 | `waving` | `waving`, Claude `Notification`. | +| 4 | `jumping` | `success`, `celebrating`. | +| 5 | `failed` | `error`. | +| 6 | `waiting` | `waiting`, `testing`, `PermissionRequest`. | +| 7 | `running` | `working`, `editing`, `running`. | +| 8 | `review` | `thinking`. | + +Drag motion overrides reaction animation while the pet is being moved. When dragging stops, the sprite returns to the active reaction animation until the transient display clears, then returns to idle. + +One-shot feedback animations do not loop for the full 4-second bubble lifetime: + +| Spritesheet state | Iterations | +| --- | --- | +| `waving` | 2 loops | +| `jumping` | 2 loops | +| `failed` | 2 loops | + +Long-running states (`idle`, `waiting`, `running`, `review`, and drag run states) continue looping while active. + +## Bubble priority + +When a transient display has both a message and a reaction, the message wins. + +| Input | Bubble text shown | +| --- | --- | +| Reaction only: `openpets_react({ reaction: "testing" })` | A stable randomized status line from the `testing` message pool, for example `Running the checks`. | +| Message only: `openpets_say({ message: "Done" })` | `Done` | +| Message + reaction: `openpets_say({ message: "Done", reaction: "success" })` | `Done` | + +The reaction is still sent with the message, but the visible bubble text is the message. + +Source of truth: `apps/desktop/src/pet-window.ts` (`createBubbleMarkup`). + +## How long bubbles stay visible + +Reaction/message bubbles are transient and clear after: + +```text +4 seconds +``` + +Source of truth: `apps/desktop/src/local-ipc-protocol.ts` (`transientDisplayMs = 4_000`). + +## Claude hook event mapping + +When OpenPets Claude hooks are installed, these Claude Code hook events are registered: + +```text +UserPromptSubmit +PreToolUse +PermissionRequest +Notification +Stop +StopFailure +``` + +Source of truth: `packages/claude/src/hook-settings.ts`. + +### Hook event to reaction/message table + +| Claude hook event | Condition | Reaction sent | Does it speak? | Possible speech text | +| --- | --- | --- | --- | --- | +| `UserPromptSubmit` | Any user prompt submitted. | `thinking` | Yes, throttled. | `Thinking it through`, `Let me check`, `On it`, `Working it out` | +| `PreToolUse` | Tool is `Edit`, `Write`, or `MultiEdit`. | `editing` | No. | — | +| `PreToolUse` | Tool is `Bash` and command looks like a test command. | `testing` | No. | — | +| `PreToolUse` | Tool is `Bash` and command does not look like a test command. | `running` | No. | — | +| `PreToolUse` | Any other tool. | `working` | No. | — | +| `PermissionRequest` | Claude asks for permission/approval. | `waiting` | Yes, shorter cooldown. | `Approval needed` | +| `Notification` | Claude sends a notification. | `waving` | No. | — | +| `Stop` | Claude completes normally. | `success` | Yes, throttled. | `Done`, `That worked`, `All set`, `Nice, finished` | +| `StopFailure` | Claude run fails/stops with failure. | `error` | Yes, throttled. | `Something failed`, `Needs another look`, `Hit a snag`, `Not quite there` | +| Unknown event | Hook event name exists but is not recognized. | None. | No. | — | +| Invalid/missing event | Payload is invalid or has no event name. | None. | No. | — | + +Source of truth: `packages/claude/src/hooks.ts` and `packages/claude/src/hook-messages.ts`. + +### Bash test detection + +For `PreToolUse` with `tool_name: "Bash"`, OpenPets classifies the command as `testing` if the command contains one of these patterns: + +```text +test +vitest +jest +pytest +npm test +pnpm test +yarn test +cargo test +go test +``` + +Otherwise Bash maps to `running`. + +## Hook speech throttling + +Hooks do not speak on every matching event. Speech is throttled so the pet does not spam bubbles. + +| Speech category | Events that use it | Cooldown | +| --- | --- | --- | +| `thinking` | `UserPromptSubmit` | 20 seconds | +| `success` | `Stop` | 20 seconds | +| `error` | `StopFailure` | 20 seconds | +| `permission` | `PermissionRequest` | 3 seconds | + +If speech is throttled, OpenPets still sends the reaction with `pet.react`. Example: if `Stop` happens twice within 20 seconds, the second one sends `success` as a reaction only instead of saying another success phrase. + +Source of truth: `packages/claude/src/hooks.ts` (`speechCooldownMs`, `permissionCooldownMs`, `shouldSendSpeech`). + +## OpenCode plugin event mapping + +When OpenPets OpenCode setup is installed, OpenCode loads the `@open-pets/opencode` plugin. Plugin hooks return immediately and schedule OpenPets IPC calls in the background so OpenCode is not blocked by pet activity. + +| OpenCode hook/event | Condition | Reaction sent | Does it speak? | +| --- | --- | --- | --- | +| `chat.message` | User/chat activity. | `thinking` | Yes, throttled. | +| `tool.execute.before` | Edit/write/patch-like tool name. | `editing` | No. | +| `tool.execute.before` | Shell/bash-like tool name and test-like command category. | `testing` | No. | +| `tool.execute.before` | Shell/bash-like tool name, not test-like. | `running` | No. | +| `tool.execute.before` | Other tools. | `working` | No. | +| `event` | Bus event `permission.asked`. | `waiting` | Yes, short approval-needed speech. | +| Internal/self OpenPets tools | OpenPets MCP/status/say/react calls. | None. | No. | + +OpenCode speech uses the same safe shared message pools as Claude hooks. It never includes prompt text, commands, output, code, logs, paths, URLs, or secrets. + +Source of truth: `packages/opencode/src/opencode-plugin-runtime.ts` and `packages/agent-events/src/index.ts`. + +## Hook speech safety rules + +Hook-generated speech must be safe before it is sent. It must be: + +- 1–140 characters. +- Single-line. +- Not code-like. +- Not a URL. +- Not a path. +- Not secret-like. + +If a generated speech message fails validation, the hook ignores the error and does not break Claude Code. + +Source of truth: `packages/claude/src/hooks.ts`, `packages/opencode/src/opencode-plugin-runtime.ts`, and `packages/agent-events/src/index.ts`. + +## MCP tool mapping + +The public MCP tools are intentionally small: + +| MCP tool | Input | Effect | +| --- | --- | --- | +| `openpets_status` | none | Checks whether OpenPets is running and which pet is targeted. Does not change pet state. | +| `openpets_react` | `{ reaction }` | Drives the reaction animation and may show a short randomized temporary bubble from that reaction's message pool. | +| `openpets_say` | `{ message, reaction? }` | Sends a message bubble, optionally with a reaction. The message is what appears in the bubble. | + +### MCP message validation + +`openpets_say` messages must be: + +- 1–140 characters after trimming. +- Single-line. +- Not code-like. +- Not a URL. +- Not path-like. +- Not secret-like. + +Source of truth: `packages/mcp/src/tools.ts` and `apps/desktop/src/local-ipc-protocol.ts`. + +## Target pet routing + +OpenPets decides which pet gets the reaction/message as follows: + +| Situation | Target | +| --- | --- | +| No selected/configured pet. | Desktop default pet. | +| Selected/configured pet is the built-in pet or current default pet. | Desktop default pet. | +| Selected/configured pet is installed and not broken. | Explicit agent pet window for that pet. | +| Selected/configured pet is invalid, missing, or broken. | Falls back to desktop default pet. | + +Claude hooks, OpenCode plugin events, and MCP use a short-lived lease when a project/configured pet is specified. That lease routes `say`/`react` to the explicit pet window. + +Source of truth: `apps/desktop/src/local-ipc.ts` (`resolveLeaseTarget`), `packages/claude/src/hooks.ts` (`acquireHookLease`), and `packages/opencode/src/opencode-plugin-runtime.ts`. + +## Paused behavior + +If the default pet is paused: + +- Default-pet reactions/messages are not shown and return `reason: "paused"`. +- Explicit agent pet reactions/messages also check the default paused state and are not shown when paused. + +Source of truth: `apps/desktop/src/default-pet-controller.ts` and `apps/desktop/src/local-ipc.ts`. + +## Visibility behavior + +Default pet: + +- A reaction/message refreshes the default pet content. +- The default pet is only shown for external events if it is already visible or `openDefaultPetOnLaunch` is enabled. + +Explicit agent pet: + +- A reaction/message opens/shows that agent pet window. +- The agent pet window is closed when its explicit lease expires and no explicit lease remains. + +Source of truth: `apps/desktop/src/default-pet-controller.ts`, `apps/desktop/src/agent-pet-controller.ts`, and `apps/desktop/src/local-ipc.ts`. + +## Current product implications + +1. Hook emotions now change sprite animation rows; bubbles are still used for short visible text. +2. Only some hooks/events make the pet speak. Claude prompt/permission/stop events and OpenCode chat/permission events can produce speech. Tool-use hooks only react. +3. Speech is intentionally short and safe. Hooks/plugins never display prompts, command output, code, logs, paths, URLs, or secrets. +4. Messages override reaction text visually. If a message is present, the bubble shows the message, not a randomized reaction line, while the reaction still controls animation. +5. `celebrating` exists as an allowed manual reaction but is not emitted by Claude hooks today. diff --git a/docs/mvp-validation.md b/docs/mvp-validation.md new file mode 100644 index 00000000..44322c9e --- /dev/null +++ b/docs/mvp-validation.md @@ -0,0 +1,199 @@ +# OpenPets MVP validation + +This is the authoritative local MVP closeout checklist for OpenPets 2.0. + +The current checkpoint is a macOS local dogfooding package built with `electron-builder --dir`. It is not a public distribution release. + +## MVP readiness summary + +| Area | Status | Notes | +| --- | --- | --- | +| Tray/menu bar app | Ready for macOS local validation | Validate from the packaged app, not only `pnpm dev:desktop`. | +| Default pet | Ready for macOS local validation | Confirm show/hide, pause/resume, dragging, and restart persistence basics. | +| Default pet animation | Ready for macOS local validation | Confirm bundled sprite animation, left/right drag direction changes, and idle settle. | +| First-run onboarding | Ready for macOS local validation | Can be retested by removing OpenPets app data. | +| Pet Manager | Ready for macOS local validation | Catalog availability may affect install verification. | +| Settings persistence | Ready for macOS local validation | Toggle a preference, restart, and confirm persistence. | +| Local IPC/client smoke | Ready for macOS local validation | Run while the packaged app is open. | +| Claude Agent Setup preview | Ready for macOS local validation | Preview should use packaged bundled `node .../app.asar.unpacked/...` paths. | +| Claude config mutation | Optional/manual only | Requires user confirmation, backups, and cleanup/removal when done. | +| Windows/Linux packages | Unverified | Documented gap until tested on those platforms. | +| Signing/notarization/installers/auto-update | Deferred | Required before public distribution. | + +## Artifact identity + +| Field | Value | +| --- | --- | +| Workspace package version | `2.0.0` | +| Desktop package version | `2.0.0` | +| Local package command | `pnpm package:desktop:dir` | +| Package output | `apps/desktop/dist-electron/` | +| macOS app output | `apps/desktop/dist-electron/mac-arm64/OpenPets.app` on Apple Silicon | +| Manual tester/date/result | Fill in during validation | + +## Prerequisites + +- Node.js `>=20`. +- `pnpm@11.0.8` from the workspace root. +- A workspace checkout for developer validation commands. +- macOS for the current local package validation flow. +- `node` available on Claude's PATH if you opt into packaged Claude MCP/hooks validation from Claude itself. + +## Automated validation commands + +Run from the workspace root in this order: + +```bash +pnpm test +pnpm check +pnpm package:desktop:dir +``` + +Run `pnpm package:desktop:dir` last because desktop package tests exercise the cleanup helper and may remove `apps/desktop/dist-electron`. + +The package script also cleans `apps/desktop/dist-electron` before packaging to avoid stale output masking regressions. + +## Package behavior notes + +Phase 10C enables ASAR packaging for Electron app contents and unpacks `node_modules/**` so Claude can execute bundled MCP/hook commands as regular files with external `node`. ASAR is packaging hygiene, not a security boundary. + +The builder config sets `npmRebuild: false` because the current desktop runtime dependencies are JavaScript-only. Revisit that setting if native modules are added. + +The macOS package uses an OpenPets MVP `.icns` derived from the current 64×64 tray icon. It may look soft at large sizes. Higher-resolution branding and Windows/Linux icon assets remain future polish. + +## Platform matrix + +| Platform | Target | Status | Notes | +| --- | --- | --- | --- | +| macOS | `electron-builder --dir` unpacked app | Validate locally now | Unsigned local app may require right-click → Open outside the dev machine. | +| Windows | `electron-builder --dir` unpacked app | Unverified | Unsigned apps may trigger Defender/SmartScreen warnings; not part of this MVP closeout. | +| Linux | `electron-builder --dir` unpacked app | Unverified | Desktop integration varies by distro/window manager; not part of this MVP closeout. | + +Installer targets, notarization, code signing, auto-update, and public distribution are deferred. + +## macOS manual packaged-app checklist + +Run this checklist after the automated commands pass. + +1. Launch the generated packaged app from `apps/desktop/dist-electron/`. + - Expected: OpenPets starts without using the dev Electron entrypoint. +2. Confirm the tray/menu bar icon appears. + - Expected: Tray/menu items are available, including Manage Pets, Configure Agents, Settings, show/hide, pause/resume, and Quit. +3. Confirm the default pet behavior. + - Expected: The default pet appears as an animated bundled sprite, can be shown/hidden, paused/resumed, dragged, and still behaves correctly after app restart. + - Expected: Dragging right switches to a right-facing/running animation; dragging left switches to a left-facing/running animation; stopping settles back to idle without jitter. +4. Complete onboarding if it appears. + - Expected: Onboarding can be completed or skipped according to the UI, and does not block using the built-in default pet. + - To retest first-run onboarding, remove the OpenPets app data directory for your platform before launching again. +5. Open Pet Manager. + - Expected: Installed pets and catalog state render. If the catalog is unavailable, record the catalog error and mark catalog install unverified for that run. +6. If a catalog pet is available, install it and set it as default. + - Expected: Install succeeds, the pet can become default, and the app remains usable after restart. + - Expected: The installed default pet shows one animated frame sequence, not the full square spritesheet, and drag left/right changes its animation direction. + - Expected: Pet Manager keeps the current default card actionable; removable default pets can still be removed safely. +7. Open Settings, toggle a preference, quit, relaunch, and confirm persistence. + - Expected: The changed preference persists across restart. +8. While the packaged app is running, run the local IPC smoke command from the workspace root: + + ```bash + pnpm --filter @open-pets/client smoke:status + ``` + + - Expected: The client discovers the running packaged app and receives status without token/version errors. +9. Open Agent Setup and inspect the Claude preview without applying changes. + - Expected: Packaged previews use `node` commands pointing inside the packaged app, not unpublished `npx -y @open-pets/*` commands. + - Expected MCP shape: `node .../app.asar.unpacked/node_modules/@open-pets/mcp/dist/index.js`. + - Expected hooks shape: `node .../app.asar.unpacked/node_modules/@open-pets/claude/dist/cli.js hook --openpets-managed`. +10. Optional mutating Claude validation: configure/install Claude MCP/hooks only if you intentionally want to modify your real Claude settings. + - Expected: OpenPets shows what it will change and creates backups before editing. + - Cleanup: use Agent Setup remove/uninstall actions when done if you do not want to keep the integration. +11. Quit OpenPets from the tray. + - Expected: The app exits cleanly and removes transient local IPC discovery state. + +## Pet and chat bubble quality checks + +While OpenPets is running, trigger a speech bubble from the workspace root: + +```bash +pnpm --filter @open-pets/client smoke:say "Working on it" thinking +``` + +Expected: the bubble is compact, readable, visually attached to the pet, and does not look like an oversized wireframe card. + +Trigger a reaction: + +```bash +pnpm --filter @open-pets/client smoke:react success +``` + +Expected: the pet remains animated and the bubble/reaction presentation stays readable and short. + +## Claude packaged command notes + +Packaged Claude integration uses bundled commands inside the packaged OpenPets app instead of unpublished package names. + +Expected command resources live under `app.asar.unpacked`, not `app.asar`, because Claude launches them through external `node`: + +```text +.../OpenPets.app/Contents/Resources/app.asar.unpacked/node_modules/@open-pets/mcp/dist/index.js +.../OpenPets.app/Contents/Resources/app.asar.unpacked/node_modules/@open-pets/claude/dist/cli.js +``` + +Packaged bundled Claude commands require `node` to be available on Claude's PATH. + +Claude settings may contain absolute paths into the packaged OpenPets app. Moving, deleting, or replacing the app may require opening Agent Setup and using Replace/Install again. Use Agent Setup remove/uninstall before deleting the app if you want to remove OpenPets-managed Claude entries. + +## Safe vs mutating validation + +Safe validation: + +- Running workspace tests/checks/package commands. +- Launching the packaged app. +- Inspecting Agent Setup previews. +- Running `pnpm --filter @open-pets/client smoke:status` against the local packaged app. + +Mutating validation: + +- Applying Claude MCP configuration. +- Installing Claude hooks. +- Removing/uninstalling Claude integration. +- Deleting OpenPets app data to retest onboarding. + +Only run mutating steps intentionally. Automated tests must use temp fixtures and must not edit real Claude settings or real OpenPets user data. + +## Cleanup and data-loss notes + +OpenPets stores normal app state in Electron's per-user app data directory. The app logs the exact state file path on startup: + +```text +OpenPets state initialized at /openpets-state.json. +``` + +Deleting that app data directory removes installed pets, default-pet selection, onboarding completion, and preferences for that platform user. + +Claude configuration is separate from OpenPets app data. Uninstalling or deleting OpenPets does not automatically remove Claude MCP entries or Claude hooks. + +Package output can be removed safely: + +```text +apps/desktop/dist-electron/ +``` + +## Unsigned local-app warnings + +The current package output is unsigned and not notarized. + +- macOS may warn that the app is from an unidentified developer when moved to another machine. +- Windows may show Defender/SmartScreen prompts. +- Linux behavior depends on distro and desktop environment. + +These warnings are expected for local MVP validation and are not acceptable for public distribution. + +## Known limitations and post-MVP follow-ups + +- Public distribution requires signing, notarization where applicable, installer targets, and an update strategy. +- Windows and Linux package behavior need real platform validation. +- Windows/Linux app icon assets are not configured yet. +- The current macOS app icon is MVP-quality and generated from a 64×64 source. +- Published `npx -y @open-pets/*` package flows remain future distribution work; local packaged Claude setup uses bundled app paths. +- `asarUnpack: node_modules/**` is broad for MVP reliability and can be narrowed after packaged command needs are fully characterized. diff --git a/docs/opencode.md b/docs/opencode.md new file mode 100644 index 00000000..f6dfd05e --- /dev/null +++ b/docs/opencode.md @@ -0,0 +1,287 @@ +# OpenCode integration + +This document describes how OpenPets Desktop integrates with OpenCode, what files are configured, how OpenCode reaches the desktop app, and which OpenCode events trigger pet reactions or speech. + +## Integration surfaces + +OpenCode support has three related surfaces: + +1. **MCP tools** — OpenCode gets the `openpets_status`, `openpets_react`, and `openpets_say` tools through `@open-pets/cli mcp`. +2. **OpenCode instructions** — OpenCode loads a managed `openpets.md` instruction file telling agents when to use the tools. +3. **OpenCode plugin hooks** — `@open-pets/opencode` turns OpenCode activity into automatic pet reactions and short safe speech. + +The desktop setup UI for OpenCode is implemented in `apps/desktop/src/agent-setup.ts`. It previews, installs, doctors, and removes global OpenCode setup through `@open-pets/opencode` helpers. + +This document covers **Desktop global setup** first. The CLI also supports **project-local OpenCode setup**; see [Project-local OpenCode setup](#project-local-opencode-setup). + +## Global config location + +OpenPets uses OpenCode's global config directory: + +```text +OPENCODE_CONFIG_DIR, if set +Windows: %APPDATA%/opencode +macOS/Linux: $XDG_CONFIG_HOME/opencode, or ~/.config/opencode fallback +``` + +Inside that directory OpenPets considers these config files, in order: + +```text +config.json +opencode.json +opencode.jsonc +``` + +If no file exists, the default creation target is: + +```text +opencode.jsonc +``` + +OpenPets also writes a global instruction file: + +```text +/openpets.md +``` + +## Files and config managed by OpenPets + +OpenCode global setup writes or updates three config fields. + +### MCP entry + +Published setup creates an MCP entry like: + +```jsonc +{ + "mcp": { + "openpets": { + "type": "local", + "command": ["npx", "-y", "@open-pets/cli@", "mcp"], + "enabled": true + } + } +} +``` + +If a pet is selected, the command includes it: + +```jsonc +"command": ["npx", "-y", "@open-pets/cli@", "mcp", "--pet", ""] +``` + +In local development or packaged desktop mode, OpenPets can use: + +```jsonc +"command": ["node", "", "mcp", ...] +``` + +Packaged and local `node ` commands require `node` to be available on OpenCode's `PATH`. Desktop setup reports an error instead of installing a bundled command if `node --version` cannot run from the agent environment. + +### Instructions + +OpenPets adds the managed instruction file to OpenCode's `instructions` array: + +```jsonc +{ + "instructions": ["/openpets.md"] +} +``` + +That file contains: + +```md + +## OpenPets + +OpenPets MCP tools may be available. + +Use OpenPets as a short visible status channel for meaningful coding progress: +- Use `openpets_say` when starting, completing, blocking, or needing review on non-trivial work. +- Keep messages brief, user-facing, and non-sensitive. +- Do not include code, logs, secrets, URLs, or file paths. +- Use `openpets_react` for small visual or emotional feedback. +- Use `openpets_status` only when checking availability or the targeted pet. +- Do not spam every internal step. + +``` + +### Plugin + +OpenPets adds the OpenCode plugin: + +```jsonc +{ + "plugin": ["@open-pets/opencode@"] +} +``` + +If a pet is selected, the plugin entry includes options: + +```jsonc +{ + "plugin": [["@open-pets/opencode@", { "pet": "" }]] +} +``` + +The plugin id is: + +```text +open-pets-opencode +``` + +## Setup behavior + +OpenPets global setup is conservative: + +- It parses OpenCode config as JSONC, not strict JSON. +- It preserves unrelated user config. +- It avoids unsafe symlinks and oversized files. +- It writes temp files and backups before replacing existing files. +- It refuses to overwrite custom OpenPets-like config entries that it cannot prove are managed by OpenPets. +- It cleans up stale managed entries from any non-selected global config file when one selected config file becomes the owner. + +OpenPets chooses one global config file as the owner for the managed MCP, instructions, and plugin entries. It first prefers the effective owner of relevant array fields such as `plugin` or `instructions`, then reuses an existing managed OpenPets owner, then uses the highest-precedence existing global config file, and finally creates `opencode.jsonc` if none exists. Setup refuses ambiguous cases, such as plugin and instruction arrays living in different files, managed OpenPets entries appearing in multiple global files, or higher-precedence arrays shadowing lower-precedence user entries. + +Removal deletes only OpenPets-managed MCP, instructions, and plugin entries. It leaves unrelated OpenCode config intact. + +## Project-local OpenCode setup + +The `@open-pets/cli` package can configure OpenCode inside a project: + +```sh +openpets configure --agent opencode --pet +``` + +Project-local setup writes inside the current project directory: + +```text +.opencode/opencode.jsonc +.opencode/openpets.md +``` + +If another supported OpenCode project config already exists, OpenPets updates that file instead of creating `.opencode/opencode.jsonc`. Project-local setup adds the same three kinds of entries as global setup: + +- `mcp.openpets` using `@open-pets/cli mcp --pet `. +- `instructions` pointing to `.opencode/openpets.md`. +- `plugin` using `@open-pets/opencode` with `{ "pet": "" }`. + +Project-local config and instructions can be committed to the repository and include the selected pet id. That is useful for shared project identity, but users should review those files before committing. + +## Runtime path + +When OpenCode calls an OpenPets MCP tool or when the OpenCode plugin reacts to an event, the runtime path is: + +```text +OpenCode + -> @open-pets/cli mcp or @open-pets/opencode plugin + -> @open-pets/client + -> OpenPets desktop local IPC discovery file + -> OpenPets desktop IPC socket/pipe + -> default pet controller or explicit agent pet controller +``` + +The desktop app writes a discovery file such as: + +```text +macOS: ~/Library/Application Support/OpenPets/runtime/ipc.json +Windows: %APPDATA%/OpenPets/runtime/ipc.json +Linux: $XDG_RUNTIME_DIR/openpets/ipc.json, or ~/.config/OpenPets/runtime/ipc.json fallback +``` + +That file contains the IPC endpoint and a per-run token. Clients must send that token with every request. + +## MCP tools + +OpenCode sees these tools when the MCP entry is enabled: + +| Tool | Purpose | Desktop IPC method | +| --- | --- | --- | +| `openpets_status` | Check whether OpenPets is reachable and which pet is targeted. | `status` | +| `openpets_react` | Set a short reaction on the target pet. | `pet.react` | +| `openpets_say` | Show a short safe speech bubble, optionally with a reaction. | `pet.say` | + +Depending on OpenCode's MCP naming, tool names may appear with a server prefix, for example `openpets_openpets_say`. The OpenCode plugin explicitly ignores OpenPets tool calls so the pet does not react to its own status messages. + +`openpets_say` is validated before it reaches the desktop app. Messages must be short, single-line, and must not look like code, logs, secrets, URLs, or file paths. + +## Pet targeting + +If the MCP command or plugin was configured without a pet, events target the desktop default pet. + +If configured with a pet, OpenPets asks the desktop app for a lease: + +```text +lease.acquire({ requestedPetId: "" }) +``` + +Routing rules: + +- Missing `pet` option -> default pet. +- Requested pet is the default pet or built-in pet -> default pet. +- Invalid, missing, or broken pet -> default pet with a fallback reason. +- Valid installed non-default pet -> explicit agent pet window. + +Explicit leases expire after a short TTL unless refreshed. The plugin caches and reuses a lease while it is still valid. When the last explicit lease for a pet expires or is released, OpenPets clears and closes that agent pet window. + +## OpenCode plugin reaction and speech mapping + +The OpenCode plugin is decorative and best-effort. It schedules OpenPets work in the background and returns immediately so it does not slow down OpenCode hooks. If OpenPets is closed or IPC is unavailable, the plugin swallows the error. + +| OpenCode event/hook | Trigger condition | Reaction | Speech bubble | +| --- | --- | --- | --- | +| `chat.message` | OpenCode receives a chat message. | `thinking`, throttled. | None. | +| `tool.execute.before` | Tool name contains `edit`, `write`, `patch`, or `apply_patch`. | `editing` | None. | +| `tool.execute.before` | Tool name contains `bash`, `shell`, or `terminal`, and command args look test-like. | `testing` | None. | +| `tool.execute.before` | Tool name contains `bash`, `shell`, or `terminal`, and command args do not look test-like. | None. | None. | +| `tool.execute.before` | Any other non-OpenPets tool. | None. | None. | +| `event` | Bus event type is `permission.asked` for a non-OpenPets tool. | `waiting` | `Approval needed`, throttled with a short cooldown. | +| `event` | Bus event type is `session.error`. | `error` | One error-pool message, throttled. | +| `event` | Bus event type is `session.status` and status type is `idle`. | `success`, throttled. | None. | +| `tool.execute.after` | Any tool completed. | None | None. | +| OpenPets MCP tool | Tool name is `openpets_status`, `openpets_say`, `openpets_react`, or server-prefixed equivalent. | None | None. | +| Unknown event | Event is not recognized. | None | None. | + +Test-like command detection is intentionally coarse and private. It may inspect only a bounded command string to classify the reaction. The command text is never sent to speech. + +OpenCode hooks do **not** use the left/right walking animations or the `running` reaction. The `running-right` and `running-left` sprite rows are reserved for pet drag/move motion, and generic shell activity is silent by default. Hook reactions use quieter phase changes instead: editing maps to the generic active-work row, thinking maps to `review`, waiting/testing maps to `waiting`, success maps to `jumping`, and errors map to `failed`. + +## Plugin speech pools + +When the plugin is allowed to speak, it chooses one static local message from the matching pool: + +| Category | Possible messages | +| --- | --- | +| `thinking` | `Thinking it through`, `Let me check`, `On it`, `Working it out` | +| `success` | `Done`, `That worked`, `All set`, `Nice, finished` | +| `error` | `Something failed`, `Needs another look`, `Hit a snag`, `Not quite there` | +| `permission` | `Approval needed` | + +Speech is throttled by category so hooks do not spam bubbles. Current speech cooldowns are: + +| Category | Cooldown | +| --- | --- | +| `permission` | 3 seconds | +| `thinking`, `success`, `error` | 20 seconds each | + +Normal thinking and success hooks no longer speak, so their speech pools are retained for shared/static message support but are not used by OpenCode's default quiet plugin policy. Reaction-only hooks are also deduped, with the same reaction suppressed for about 10 seconds. + +OpenCode stores throttle state separately from Claude in an OpenCode-specific file, for example: + +```text +Windows: %LOCALAPPDATA%/OpenPets/opencode-hook-throttle.json +macOS/Linux: ${XDG_STATE_HOME:-~/.local/state}/openpets/opencode-hook-throttle.json +fallback: os.tmpdir()/openpets-/opencode-hook-throttle.json +``` + +Throttle state stores only speech-category and reaction timestamp keys, never prompts, commands, tool input, output, code, logs, or transcripts. + +## Safety rules + +- Plugin hooks return immediately and never await OpenPets IPC directly. +- Plugin errors are swallowed unless debug logging is enabled. +- Debug logs sanitize paths and secret-looking values. +- Speech is static and local; it does not include model-generated text. +- Tool args and command text are used only for coarse reaction classification. +- The plugin ignores OpenPets MCP tools to avoid feedback loops. +- Managed setup refuses unsafe symlinks, non-regular files, oversized config files, and conflicting custom OpenPets-like config. diff --git a/docs/phases/phase-00-workspace-foundation.md b/docs/phases/phase-00-workspace-foundation.md new file mode 100644 index 00000000..1b7814a9 --- /dev/null +++ b/docs/phases/phase-00-workspace-foundation.md @@ -0,0 +1,383 @@ +# Phase 00: Workspace foundation and DX + +## Goal + +Create the fresh OpenPets 2.0 workspace foundation with excellent developer experience and Node/npm-compatible runtime behavior. + +This phase should establish the project skeleton and basic commands needed for later implementation phases, without building real product features yet. + +## Non-goals + +This phase does not implement: + +- Tray/menu app behavior. +- Floating pet windows. +- Pet rendering. +- Pet catalog browsing or installation. +- Local IPC. +- MCP tools. +- Claude detection/configuration/hooks. +- First-run onboarding. +- Packaging/release builds. + +This phase should also avoid large migrations from v1. v1 remains reference material only. + +## User-visible/manual outcome + +After this phase, the user/developer should be able to install dependencies and run basic workspace checks from a clean v2 workspace. + +Expected manual outcome: + +```text +I can install dependencies, inspect the v2 workspace structure, and run basic check/build scripts without using Bun. +``` + +## Acceptance criteria + +- A fresh v2 workspace structure exists outside `v1/`. +- v1 remains untouched except as reference material. +- Runtime and published package design is Node/npm/npx-compatible. +- Bun is not required for v2 runtime behavior. +- Development tooling is documented clearly. +- Node baseline is documented. +- Workspace boundaries exclude v1 and web app code from v2 workspace commands. +- Initial packages are protected from accidental publishing while they are skeletons. +- Basic workspace scripts exist for checking/building the initial skeleton. +- Package names align with the agreed `@open-pets/*` organization. +- The initial package/app structure supports later phases without overbuilding empty abstractions. +- The repo clearly distinguishes current v2 code from v1 reference code. + +## Proposed files/directories + +Create or update: + +```text +package.json +pnpm-workspace.yaml +pnpm-lock.yaml +tsconfig.base.json +.gitignore +README.md # if useful for v2 developer commands + +apps/ + desktop/ + package.json + tsconfig.json + src/ + main.ts # placeholder or minimal entry, no real tray behavior yet + +packages/ + client/ + package.json + tsconfig.json + src/index.ts + + mcp/ + package.json + tsconfig.json + src/index.ts + + claude/ + package.json + tsconfig.json + src/index.ts + + cli/ + package.json + tsconfig.json + src/index.ts + + pet-format/ + package.json + tsconfig.json + src/index.ts + +``` + +Important constraint: + +- Do not add large placeholder systems just to fill packages. +- If a package would be purely empty and creates more maintenance than value, keep it minimal with only package metadata and a tiny exported placeholder/type needed for build checks. +- Defer `packages/shared` until there is a concrete first use. Avoid creating a dumping-ground package in Phase 00. + +## Technical approach + +Use pnpm workspaces for development ergonomics. + +Recommended baseline: + +```text +Node: 20+ +Package manager: pnpm via Corepack +Module style: ESM +Build: plain TypeScript `tsc` for initial packages +``` + +Node 20+ is a conservative baseline with broad ecosystem support. A later phase may raise this if Electron or another dependency requires it. + +Important distinction: + +```text +Developer package manager: pnpm +Runtime/user interface: Node/npm/npx +``` + +This means: + +- Contributors may run `pnpm install` and `pnpm check` during development. +- Contributors should use Corepack or an explicitly documented pnpm version from the root `packageManager` field. +- Published packages must be usable through normal Node/npm/npx flows. +- `@open-pets/mcp` should eventually support `npx -y @open-pets/mcp`. +- `@open-pets/cli` should eventually support `npx -y @open-pets/cli ...`. +- No v2 package should rely on Bun runtime APIs. + +Workspace inclusion rule: + +```yaml +packages: + - "apps/*" + - "packages/*" +``` + +Do not include: + +```text +v1/** +web/** +``` + +The existing `web/` app and `v1/` references must remain outside v2 workspace commands. + +Package privacy rules: + +- Root `package.json` is `private: true`. +- `apps/desktop` is `private: true`. +- Skeleton packages should be `private: true` during Phase 00 unless there is a concrete reason to make one publishable immediately. +- Public package names may still be reserved in package metadata, but avoid accidental publication of empty packages. + +Initial scripts should be simple and predictable, for example: + +```json +{ + "scripts": { + "check": "pnpm -r check", + "typecheck": "pnpm -r typecheck", + "build": "pnpm -r build" + } +} +``` + +Each initial package should expose minimal `check`, `typecheck`, and `build` scripts where practical. + +Linting is deferred in Phase 00 unless it is trivial to add without expanding scope. TypeScript strictness is the initial code-quality gate. + +Use TypeScript project configuration that can scale to multiple packages without making Phase 00 too complex. + +Initial TypeScript/build convention: + +- Use `type: "module"`. +- Use strict TypeScript settings. +- Use `moduleResolution` appropriate for modern Node ESM. +- Emit to `dist/`. +- Generate declarations for packages where practical. +- Use source maps where practical. +- Avoid bundlers until a later phase requires them. + +Recommended package intent: + +- `apps/desktop` — Electron desktop app, implemented in later phases. +- `packages/client` — local IPC client, implemented in Phase 05. +- `packages/mcp` — MCP stdio server, implemented in Phase 06. +- `packages/claude` — Claude detection/configuration/hooks package, implemented in Phases 07–08. +- `packages/cli` — explicit user-run CLI commands, implemented after core app/client behavior exists. +- `packages/pet-format` — shared pet manifest/catalog validation types, used by pet manager/catalog install phases. + +Deferred package: + +- `packages/shared` — defer until a concrete shared constant/type exists. Do not create it in Phase 00 by default. + +Electron app note: + +- `apps/desktop/src/main.ts` is compile-only placeholder code in Phase 00. +- Do not add real tray behavior, renderer framework, Electron window code, or packaging setup in Phase 00. +- Avoid adding Electron/Vite/React dependencies until Phase 01 unless needed for a minimal compile check. + +## Risks and tradeoffs + +### Risk: too much empty scaffolding + +Creating every planned package immediately may produce fake progress and maintenance overhead. + +Mitigation: + +- Keep package contents minimal. +- Add only enough for workspace checks and future package boundaries. +- Do not implement fake APIs before their phase. +- Defer `packages/shared` until needed. + +### Risk: pnpm confusion vs npm/npx requirement + +The project may use pnpm for development while users need npm/npx compatibility. + +Mitigation: + +- Document the distinction clearly. +- Avoid pnpm-specific runtime assumptions. +- Keep package `bin`/publish behavior npm-compatible when those packages become real. + +### Risk: v1 assumptions leaking into v2 + +v1 is Bun-oriented and has known broken agent configuration flows. + +Mitigation: + +- Do not copy v1 structure blindly. +- Treat v1 as reference only. +- Re-check docs and design before implementing each real feature phase. + +### Risk: Phase 00 becomes abstract tooling work + +Phase 00 should enable work, not become a tooling project. + +Mitigation: + +- Keep tooling minimal. +- Avoid CI/release/publishing complexity until later hardening phases. + +### Risk: v2 workspace accidentally runs v1 or web commands + +The repository contains existing `v1/` and `web/` folders with their own tooling. Accidentally including them in pnpm workspaces would blur boundaries and may pull Bun-era assumptions into v2. + +Mitigation: + +- `pnpm-workspace.yaml` includes only `apps/*` and `packages/*`. +- `v1/**` and `web/**` remain outside v2 workspace commands. + +### Risk: accidental publish of skeleton packages + +Early package skeletons should not be published before they are real. + +Mitigation: + +- Mark root, apps, and skeleton packages `private: true` in Phase 00. +- Revisit publish settings only in the phase where a package is ready for actual npm usage. + +## Security/privacy notes + +This phase should not introduce network behavior, local IPC, config editing, pet zip extraction, or agent hooks. + +Security/privacy expectations for this phase: + +- No secrets or credentials are added. +- No telemetry is added. +- No install scripts should perform surprising system changes. +- No package should require postinstall behavior for core development. +- Skeleton packages should be private to prevent accidental empty-package publication. + +## Test/check plan + +Automated checks: + +```bash +pnpm install +pnpm check +pnpm typecheck +pnpm build +``` + +Expected result: + +- Dependency install succeeds. +- TypeScript/workspace checks pass. +- Typecheck passes under the agreed Node/ESM/strict TypeScript settings. +- Initial package builds pass or intentionally no-op with clear scripts. + +If package scripts are intentionally minimal, they should still be explicit and understandable. + +## Manual verification guide + +After implementation, the user should verify: + +1. Run dependency install: + + ```bash + pnpm install + ``` + +2. Run checks: + + ```bash + pnpm check + ``` + +3. Run typecheck: + + ```bash + pnpm typecheck + ``` + +4. Run build: + + ```bash + pnpm build + ``` + +5. Inspect that v2 code lives outside `v1/`: + + ```text + apps/desktop + packages/client + packages/mcp + packages/claude + packages/cli + packages/pet-format + ``` + +6. Confirm no Bun command is required for v2 checks/builds. + +7. Confirm `pnpm-workspace.yaml` does not include `v1/**` or `web/**`. + +Manual acceptance question: + +```text +Does Phase 00 pass on your machine: dependencies install, checks/typechecks/builds run, and the v2 workspace structure looks right? +``` + +## Oracle plan review + +Oracle reviewed the Phase 00 plan and approved it with required minor revisions before implementation. + +Summary of Oracle feedback: + +- Tighten workspace boundary so v2 workspace excludes `v1/**` and `web/**`. +- Define Node/tooling baseline. +- Protect skeleton packages from accidental publishing. +- Clarify scripts and build conventions. +- Avoid adding Electron implementation/dependencies too early. +- Defer `packages/shared` unless immediately needed. + +## Oracle feedback disposition + +- Fixed: Added workspace inclusion/exclusion rule for `apps/*` and `packages/*`, excluding `v1/**` and `web/**`. +- Fixed: Added Node 20+, Corepack/pnpm, ESM, and plain TypeScript build baseline. +- Fixed: Added privacy rules for root/app/skeleton packages. +- Fixed: Added `typecheck` script expectation and deferred linting explicitly. +- Fixed: Added TypeScript/build convention notes. +- Fixed: Clarified `apps/desktop/src/main.ts` is compile-only placeholder and no tray/Electron implementation belongs in Phase 00. +- Fixed: Deferred `packages/shared` until concrete need exists. + +## Oracle implementation review + +Oracle reviewed the implemented Phase 00 diff after successful validation with: + +```bash +pnpm install && pnpm check && pnpm typecheck && pnpm build +``` + +Oracle found no blocking correctness, security, or spec issues and approved Phase 00 for manual user verification. + +Implementation review disposition: + +- Fixed: Updated stale `docs/workflow.md` current-action wording so the workflow document remains reusable across phases. +- Deferred: README Corepack guidance can be expanded later if needed; root `packageManager` already pins pnpm. +- Deferred: Package script/tsconfig simplifications can be revisited if they become maintenance overhead. diff --git a/docs/phases/phase-01-tray-desktop-shell.md b/docs/phases/phase-01-tray-desktop-shell.md new file mode 100644 index 00000000..dc003e95 --- /dev/null +++ b/docs/phases/phase-01-tray-desktop-shell.md @@ -0,0 +1,334 @@ +# Phase 01: Tray-first desktop shell + +## Goal + +Create the first runnable OpenPets desktop app shell as a tray/menu-bar-first Electron app. + +This phase should prove the desktop process, tray icon/menu, placeholder task windows, and clean quit flow work before building pet rendering or integrations. + +## Non-goals + +This phase does not implement: + +- Floating pet windows. +- Transparent/always-on-top pet rendering. +- Pet dragging or persisted pet position. +- Real Pet Manager catalog behavior. +- Real Agent Setup detection/configuration. +- Real Settings persistence. +- Local IPC. +- MCP integration. +- Claude hooks. +- First-run onboarding. +- Production packaging/signing/notarization. + +## User-visible/manual outcome + +After this phase, the user/developer should be able to run the desktop app and confirm: + +```text +OpenPets launches as a tray/menu-bar app, exposes the agreed tray menu, opens placeholder windows, and quits cleanly. +``` + +## Acceptance criteria + +- Desktop app can be launched with a documented development command. +- App creates a tray/menu-bar icon. +- App does not open a dashboard/main window automatically on startup. +- Tray menu contains the agreed initial entries: + + ```text + OpenPets + ──────────────── + Default Pet: + Show Default Pet / Hide Default Pet + Pause All Pets / Resume All Pets + ──────────────── + Manage Pets... + Configure Agents... + Settings... + ──────────────── + Quit OpenPets + ``` + +- `OpenPets` is a disabled title row. +- `Default Pet: ` exists and can open the placeholder Pet Manager window. +- `Show Default Pet / Hide Default Pet` exists but may be disabled or no-op until Phase 02. +- `Pause All Pets / Resume All Pets` exists and toggles visible menu state, even if no pets exist yet. +- `Manage Pets...` opens a placeholder Pet Manager window. +- `Configure Agents...` opens a placeholder Agent Setup window. +- `Settings...` opens a placeholder Settings window. +- Placeholder windows are single-instance: choosing the same menu item focuses/reopens the existing window instead of spawning duplicates. +- Closing a placeholder window destroys only that window, clears its window reference, and does not quit the app. +- The app process is single-instance: a second launch must not create duplicate tray icons. +- `Quit OpenPets` exits the app cleanly. +- macOS behavior is handled intentionally: closing placeholder windows does not quit; explicit quit does quit. +- Basic app lifecycle errors are logged clearly during development. +- Existing Phase 00 checks continue to pass. + +## Proposed files/directories + +Update: + +```text +apps/desktop/package.json +apps/desktop/tsconfig.json +apps/desktop/src/main.ts +README.md +``` + +Likely add: + +```text +apps/desktop/src/tray.ts +apps/desktop/src/windows.ts +apps/desktop/src/state.ts +apps/desktop/src/lifecycle.ts +apps/desktop/src/assets.ts +``` + +Exact file names can change if a simpler structure is better, but Phase 01 should keep the app shell understandable. + +## Technical approach + +Use Electron for the desktop shell. + +Recommended Phase 01 behavior: + +- Add Electron as a development/runtime dependency of `apps/desktop` only. +- Add a dev command such as `pnpm --filter @open-pets/desktop dev`. +- Keep renderer placeholders minimal; do not introduce a frontend framework yet unless required. +- Use simple BrowserWindow placeholder windows with hardcoded data URLs for Phase 01. +- Keep all real product logic out of placeholders. +- Use a simple generated/nativeImage tray icon suitable for development. +- Keep all windows task-specific; do not create a dashboard. + +Recommended initial desktop scripts: + +```json +{ + "scripts": { + "dev": "pnpm build && electron .", + "check": "pnpm typecheck", + "typecheck": "tsc --noEmit", + "build": "tsc" + } +} +``` + +Electron launch approach should be simple for Phase 01: + +- Compile TypeScript to `dist/`. +- Start Electron against `dist/main.js`. +- Avoid production packager setup until Phase 10. + +Because Phase 01 uses `tsc` only, avoid static asset copy complexity: + +- Placeholder window HTML should be hardcoded data URLs. +- Tray icon should be generated in code through Electron/nativeImage or otherwise not require copied runtime assets. +- Do not place required runtime HTML/icons under `src/` unless the implementation also includes an explicit copy strategy. + +Electron lifecycle rules: + +- `app.whenReady()` creates the tray only. +- No window opens on startup. +- `window-all-closed` does not quit the app. +- macOS `activate` does not create a dashboard/main window. +- `Quit OpenPets` sets an intentional quit flag and calls `app.quit()`. +- Use `app.requestSingleInstanceLock()` so a second launch does not create duplicate tray icons. +- On second instance, keep/focus the existing app where practical and exit the second process. +- Set the app name to `OpenPets` where practical. + +macOS tray/Dock decision: + +- Call `app.dock.hide()` on macOS for the tray/menu-bar-first shell. +- Do not show a Dock icon in normal Phase 01 operation. +- If this causes practical development issues, document them as known limitations instead of changing the product direction silently. + +Tray/menu state: + +- Default pet label can use a hardcoded placeholder name such as `Default Pet: Built-in Pet` or `Default Pet: Snoopy` until real pet state exists. +- Use `Default Pet: Built-in Pet` for Phase 01. +- `Default Pet: Built-in Pet` opens the placeholder Pet Manager window. +- `Show Default Pet` is disabled until Phase 02. +- Pause state can be an in-memory boolean for now. +- Pause toggle only changes the in-memory menu label between `Pause All Pets` and `Resume All Pets`. + +Placeholder windows: + +- Pet Manager placeholder should make clear it is placeholder UI. +- Agent Setup placeholder should make clear it is placeholder UI. +- Settings placeholder should make clear it is placeholder UI. +- Each placeholder window should have a stable title. +- Opening a placeholder should show/restore/focus the existing window if it already exists. +- Closing a placeholder should destroy it and set its reference to `null`. + +## Risks and tradeoffs + +### Risk: adding frontend framework too early + +Phase 01 only needs placeholder windows. Adding Vue/React/etc. now could slow the shell checkpoint. + +Mitigation: + +- Use minimal static placeholder UI in Phase 01. +- Decide renderer framework later when real UI work begins. + +### Risk: platform tray differences + +Tray/menu behavior differs between macOS, Windows, and Linux. + +Mitigation: + +- Implement the simplest Electron-supported tray/menu behavior first. +- Document known platform limitations from manual testing. +- Do not attempt packaging or advanced native integration in Phase 01. + +### Risk: app accidentally becomes dashboard-first + +Opening a main window automatically would conflict with the agreed product surface. + +Mitigation: + +- Do not open any main/dashboard window on startup. +- Only tray and explicitly requested task windows exist. + +### Risk: app process quits unexpectedly when windows close + +Electron defaults can differ by platform and window lifecycle. + +Mitigation: + +- Manage app quit explicitly. +- Closing placeholder task windows should not quit the app. +- `Quit OpenPets` should be the clear exit path. + +### Risk: static assets fail at runtime + +Running Electron from `dist/main.js` while assets live under `src/` can break if assets are not copied. + +Mitigation: + +- Use data URLs for placeholder HTML in Phase 01. +- Generate the development tray icon in code or use an asset strategy that does not depend on unconfigured copies. + +## Security/privacy notes + +This phase should not touch user agent configuration, shell profiles, Claude settings, local IPC sockets, network, pet zips, or user data beyond normal Electron app startup. + +Security/privacy expectations: + +- No telemetry. +- No network calls. +- No config file edits. +- No auto-start/login item changes. +- No shell command execution beyond documented development scripts. +- Placeholder windows should not enable risky web content behavior unnecessarily. + +Required BrowserWindow safety defaults for placeholders: + +- `nodeIntegration: false`. +- `contextIsolation: true`. +- `sandbox: true`. +- No preload script unless absolutely needed. Phase 01 should not need one. +- No remote content loaded. +- Deny navigation away from the placeholder content. +- Deny new-window creation. +- If using data URLs, all content must be hardcoded and no user data should be interpolated. + +Placeholder HTML should include a restrictive content security policy where practical. + +## Test/check plan + +Automated checks: + +```bash +pnpm check +pnpm typecheck +pnpm build +``` + +Manual app run command should be documented after implementation, likely: + +```bash +pnpm --filter @open-pets/desktop dev +``` + +Expected automated result: + +- TypeScript checks pass. +- Desktop app build passes. +- Existing workspace package checks continue to pass. + +## Manual verification guide + +After implementation, the user should verify: + +1. Start the desktop app with the documented command. +2. Confirm no dashboard/main window opens automatically. +3. Confirm OpenPets appears in the tray/menu bar. +4. Open the tray menu and verify entries match the Phase 01 menu. +5. Click `Manage Pets...` and confirm one placeholder Pet Manager window opens. +6. Click `Manage Pets...` again and confirm it focuses/reuses the existing window instead of spawning duplicates. +7. Repeat for `Configure Agents...`. +8. Repeat for `Settings...`. +9. Toggle `Pause All Pets` and confirm the menu changes to `Resume All Pets`. +10. Close placeholder windows and confirm the app stays running in tray/menu bar. +11. Choose `Quit OpenPets` and confirm the app exits cleanly. + +Manual acceptance question: + +```text +Does Phase 01 pass on your machine: tray app launches, menu matches, placeholder windows work, and quit is clean? +``` + +## Oracle plan review + +Oracle reviewed the initial Phase 01 spec and blocked implementation until the spec was tightened. + +Summary of required Oracle feedback: + +- Define Electron lifecycle behavior explicitly. +- Add app-level single-instance behavior. +- Specify asset strategy so `tsc` builds do not break runtime HTML/icons. +- Make BrowserWindow security defaults mandatory. +- Choose placeholder window close semantics. +- Choose menu placeholder states. +- Specify exact desktop scripts. +- Add macOS tray/Dock behavior. + +## Oracle feedback disposition + +- Fixed: Added explicit Electron lifecycle rules. +- Fixed: Added `app.requestSingleInstanceLock()` requirement. +- Fixed: Chose data URLs/generated icon strategy to avoid asset-copy issues in Phase 01. +- Fixed: Made BrowserWindow security defaults mandatory. +- Fixed: Chose destroy-on-close semantics for placeholder windows. +- Fixed: Chose `Default Pet: Built-in Pet`, disabled `Show Default Pet`, and in-memory pause-label toggle for Phase 01. +- Fixed: Added exact `dev` script shape: `pnpm build && electron .`. +- Fixed: Added macOS `app.dock.hide()` decision. + +## Oracle implementation review + +Oracle reviewed the implemented Phase 01 diff after successful validation with: + +```bash +pnpm check && pnpm typecheck && pnpm build && pnpm --filter @open-pets/desktop exec electron --version +``` + +Initial implementation review blocked manual verification until two issues were fixed: + +- Tray icon used an SVG data URL that may not be consistently supported by Electron tray icons. +- Placeholder `window.loadURL(...)` rejection was not handled explicitly. + +Implementation review disposition: + +- Fixed: Replaced SVG tray icon with generated raw bitmap data through `nativeImage.createFromBitmap(...)`. +- Fixed: Added `image.isEmpty()` logging for tray icon creation failures. +- Fixed: Added explicit `loadURL(...).catch(...)` logging. +- Fixed: Attached `ready-to-show` before `loadURL(...)`. +- Fixed: Added `render-process-gone` logging. +- Fixed: Hardened placeholder CSP with `base-uri 'none'`, `form-action 'none'`, and `frame-src 'none'`. +- Fixed: Updated README wording for `apps/desktop`. + +Oracle re-reviewed the fixes and approved Phase 01 for manual user verification. diff --git a/docs/phases/phase-02-default-pet-window.md b/docs/phases/phase-02-default-pet-window.md new file mode 100644 index 00000000..3e5ab48d --- /dev/null +++ b/docs/phases/phase-02-default-pet-window.md @@ -0,0 +1,420 @@ +# Phase 02: Default pet window foundation + +## Goal + +Show the bundled default pet as a real floating desktop companion window. + +This phase should prove the core visible OpenPets experience: a small pet appears, can be dragged, can be shown/hidden from the tray, can be paused/resumed, and remembers its position across app restarts. + +## Non-goals + +This phase does not implement: + +- Pet catalog browsing or installation. +- Multiple installed pets. +- Non-default temporary agent pets. +- MCP leases. +- Local IPC. +- Claude integration. +- Speech bubbles. +- Complex physics. +- Advanced desktop-edge/running behavior. +- Complex multi-monitor behavior beyond safe positioning. +- First-run onboarding. +- Production packaging. + +## User-visible/manual outcome + +After this phase, the user/developer should be able to run the desktop app and confirm: + +```text +The bundled default pet appears as a small floating window, can be dragged, can be hidden/shown from the tray, pause/resume visibly affects it, and its position persists across restarts. +``` + +## Acceptance criteria + +- Desktop app still launches tray-first. +- Bundled default pet appears automatically on app launch. +- Pet window is frameless. +- Pet window has transparent background where supported by the platform. +- Pet window is always-on-top by default. +- Pet window does not steal focus from the active editor/terminal where supported. +- Pet window is draggable by the user. +- Pet window remembers its last position across app restarts. +- Initial default position is near the bottom-right of the primary display work area. +- Initial position is clamped to the visible work area. +- Stored position is clamped if it is no longer visible/safe. +- `Show Default Pet` / `Hide Default Pet` tray item works for the persistent default pet. +- `Pause All Pets` / `Resume All Pets` visibly affects the pet. +- Closing placeholder task windows does not close the pet or quit the app. +- Quitting OpenPets closes the pet window cleanly. +- Phase 01 placeholder windows and tray behavior continue to work. +- Known platform limitations are documented after manual testing. +- Hidden state is session-only in Phase 02: every fresh app launch shows the default pet. +- Display changes while running re-clamp the pet to a visible work area. + +## Proposed files/directories + +Likely update: + +```text +apps/desktop/package.json +apps/desktop/src/main.ts +apps/desktop/src/tray.ts +apps/desktop/src/state.ts +apps/desktop/src/windows.ts +README.md +``` + +Likely add: + +```text +apps/desktop/src/pet-window.ts +apps/desktop/src/pet-state.ts +apps/desktop/src/default-pet-controller.ts +apps/desktop/src/display.ts +``` + +Exact file names can change if a simpler structure is better. + +## Technical approach + +Keep this phase intentionally simple and stable. + +### Pet rendering + +Use a minimal bundled default pet renderer for Phase 02. + +Recommended approach: + +- Render a small hardcoded pet placeholder using inline generated HTML/CSS/SVG or canvas from TypeScript. +- Avoid adding a real spritesheet animation system until the pet format/catalog phases unless it is simpler to reuse a safe bundled asset. +- The pet should visibly look like a pet/companion, not a blank test square. +- Pause/resume should visibly change the pet state, for example normal idle animation vs still/dimmed/paused label. +- Do not put required runtime pet assets under `src/` unless an explicit copy strategy is added. +- Prefer inline generated content for Phase 02 so plain `tsc` builds remain sufficient. + +This phase is about window behavior, not final pet art or animation fidelity. + +### Pet window behavior + +Use a dedicated `BrowserWindow` for the default pet. + +Use fixed Phase 02 window bounds: + +```text +width: 180 DIP +height: 180 DIP +margin from work area: 24 DIP +``` + +The window should be tight around the visible pet to avoid a large transparent click-blocking overlay. + +Recommended BrowserWindow properties: + +- `frame: false` +- `transparent: true` +- `resizable: false` +- `skipTaskbar: true` +- `alwaysOnTop: true` +- secure `webPreferences` similar to Phase 01 placeholders + +Use conservative always-on-top behavior. Do not use aggressive screen-saver/topmost hacks in Phase 02. + +Focus behavior: + +- When showing the pet automatically or from tray, use `showInactive()` where practical so the pet does not steal focus from the active editor/terminal. +- Clicking or dragging the pet may focus the pet window on some platforms; this is best-effort and should be documented after manual testing. +- Do not use fragile native hacks in Phase 02. +- If perfect no-focus behavior is not possible on a platform, document it as a known limitation. + +Dragging: + +- The user should be able to drag the pet window. +- Preferred implementation: CSS `-webkit-app-region: drag` for the pet surface so no IPC/preload is needed. +- If CSS app-region cannot satisfy the behavior, use a narrow preload bridge only for drag movement. +- Do not introduce broad renderer privileges for dragging. + +Pause/resume renderer updates: + +- Preferred implementation: reload/regenerate the local pet HTML with the paused state. +- Alternative: use a narrow preload bridge only if needed. +- Do not use a broad `executeJavaScript` control channel. + +### Default pet ownership model + +Add a small default-pet controller/module to own default pet lifecycle. + +The tray should not directly manage pet window internals. + +Recommended controller API shape: + +```ts +showDefaultPet(): void +hideDefaultPet(): void +isDefaultPetVisible(): boolean +setDefaultPetPaused(paused: boolean): void +destroyDefaultPet(): void +``` + +Responsibilities: + +- Create the pet window on app launch. +- Show/hide default pet. +- Track visible state for the current app session. +- Apply paused state to the renderer. +- Persist and restore position. +- Destroy the pet window on app quit. + +Hidden state rule: + +- `Hide Default Pet` hides the window but does not destroy it. +- Hidden state is session-only in Phase 02. +- On fresh app launch, the default pet appears automatically again. +- Position persists; hidden/visible preference does not persist yet. + +### Persistence + +Persist only the minimum state needed for Phase 02: + +- Default pet position. +- Paused state may remain in-memory for this phase unless persistence is trivial. + +Use Electron `app.getPath("userData")` for local app state. + +Recommended state file: + +```text +/openpets-state.json +``` + +State writes should be simple and safe: + +- JSON file. +- Atomic write where practical: write temp file then rename. +- Validate/read defensively. +- Ignore corrupted state with a dev log and fall back to defaults. + +### Safe positioning + +Use the primary display work area for Phase 02. + +Rules: + +- Position values use Electron DIP coordinates. +- Store integer `x` and `y` values for the full pet window bounds. +- Initial position: bottom-right of primary display work area with 24 DIP margin. +- Stored position: clamp to visible work area on launch. +- Clamp the full 180x180 DIP window so it remains fully visible. +- Do not attempt advanced multi-monitor restoration yet. +- If displays change, keep the pet visible rather than preserving exact off-screen coordinates. +- Listen for display changes where practical and re-clamp the default pet to the primary work area. + +### Tray integration + +Update the Phase 01 tray item: + +- If default pet visible: show `Hide Default Pet`. +- If default pet hidden: show `Show Default Pet`. +- Clicking toggles the default pet window. +- `Pause All Pets` / `Resume All Pets` updates the pet visual state. + +Default pet lifetime: + +- Default pet is persistent. +- It is not closed due to agent lifecycle in future phases. +- In this phase, hiding does not destroy the pet window. +- The pet window is destroyed only when OpenPets quits. + +## Risks and tradeoffs + +### Risk: no-focus behavior differs by platform + +Electron focus behavior varies by macOS/Windows/Linux. + +Mitigation: + +- Use documented Electron APIs only. +- Prefer stable behavior over hacks. +- Document platform limitations after manual testing. + +### Risk: transparent windows behave differently by platform + +Transparent frameless windows can vary on Linux/window managers. + +Mitigation: + +- Implement best-effort Electron transparent window behavior. +- Keep fallback acceptable: pet remains visible even if transparency is imperfect. +- Document limitations. + +### Risk: dragging requires IPC/preload before local IPC phase + +Dragging may require communication between renderer and main process, but Phase 05 local IPC is a separate adapter/app control plane. + +Mitigation: + +- If needed, use a very narrow Electron preload bridge only for pet window dragging. +- Do not implement external/local OpenPets IPC in this phase. +- Keep the bridge private to the pet renderer. + +### Risk: state file corruption + +Simple JSON state can be corrupted by interrupted writes or manual edits. + +Mitigation: + +- Read defensively. +- Validate shape. +- Atomic write where practical. +- Fall back to defaults without crashing. + +### Risk: scope creep into pet engine + +It is tempting to build the final sprite/animation system now. + +Mitigation: + +- Keep rendering minimal. +- Build only enough to verify floating pet behavior. +- Defer final manifest/spritesheet rendering to catalog/pet-format phases. + +### Risk: pet window blocks clicks over transparent areas + +Transparent BrowserWindows still have rectangular bounds and may intercept clicks. + +Mitigation: + +- Keep the Phase 02 pet window small and tight around the pet. +- Do not create a large transparent overlay. +- Defer click-through/advanced hit-testing behavior unless it becomes necessary. + +## Security/privacy notes + +This phase should not touch coding-agent configs, Claude settings, MCP, local adapter IPC, network, or pet zip extraction. + +Security/privacy expectations: + +- No telemetry. +- No network calls. +- No remote content loaded in pet window. +- No config file edits outside OpenPets own userData state file. +- No shell command execution. +- Pet renderer uses hardcoded local content only. +- Pet renderer includes a restrictive CSP. +- Pet renderer denies navigation, redirects, and new windows. +- Pet renderer logs load failures and renderer crashes clearly during development. +- BrowserWindow security defaults are mandatory: + - `nodeIntegration: false` + - `contextIsolation: true` + - `sandbox: true` where compatible with required drag behavior + - no remote content + - deny navigation/new windows + +If a preload bridge is needed for dragging, it must expose only minimal drag APIs and no general Node/Electron access. + +## Test/check plan + +Automated checks: + +```bash +pnpm check +pnpm typecheck +pnpm build +``` + +Manual app run command: + +```bash +pnpm --filter @open-pets/desktop dev +``` + +Expected automated result: + +- TypeScript checks pass. +- Desktop app build passes. +- Existing workspace package checks continue to pass. + +## Manual verification guide + +After implementation, the user should verify: + +1. Start the desktop app: + + ```bash + pnpm --filter @open-pets/desktop dev + ``` + +2. Confirm no dashboard/main window opens automatically. +3. Confirm tray icon/menu still appears. +4. Confirm the default pet appears automatically. +5. Confirm the pet is a small frameless floating window. +6. Confirm the pet stays above normal windows where supported. +7. Confirm clicking/dragging the pet does not unexpectedly steal focus from the active app where supported. +8. Drag the pet to a new position. +9. Quit OpenPets from the tray. +10. Restart OpenPets and confirm the pet position persists. +11. Use tray `Hide Default Pet` and confirm the pet hides without quitting the app. +12. Use tray `Show Default Pet` and confirm the same default pet returns. +13. Hide the pet, quit OpenPets, restart OpenPets, and confirm the pet appears again because hidden state is not persisted in Phase 02. +14. Toggle `Pause All Pets` and confirm the pet visibly changes to paused/quiet state. +15. Toggle `Resume All Pets` and confirm the pet returns to normal state. +16. Open/close placeholder Pet Manager, Configure Agents, and Settings windows and confirm the pet remains alive. +17. If practical, move/change displays and confirm the pet remains visible or is re-clamped on restart. +18. Quit OpenPets and confirm all windows close cleanly. + +Manual acceptance question: + +```text +Does Phase 02 pass on your machine: default pet appears, can be dragged, persists position, show/hide works, pause/resume is visible, and quit is clean? +``` + +## Oracle plan review + +Oracle reviewed the initial Phase 02 spec and blocked implementation until lifecycle, visibility, asset, focus/drag, controller, and security details were tightened. + +Summary of required Oracle feedback: + +- Clarify default pet launch visibility and hidden-state persistence. +- Clarify hide vs destroy behavior. +- Add a default-pet controller ownership model instead of tray owning lifecycle. +- Replace required `src/assets/default-pet.svg` asset idea with inline generated content or explicit copy strategy. +- Define fixed window size and clamping rules. +- Define focus behavior precisely, especially `showInactive()` and click/drag limitations. +- Choose drag strategy order. +- Define pause/resume renderer update mechanism. +- Add pet-window security hardening details. +- Add manual verification for hidden/restart and display re-clamping behavior. + +## Oracle feedback disposition + +- Fixed: Default pet appears on every fresh launch; hidden state is session-only in Phase 02. +- Fixed: `Hide Default Pet` hides but does not destroy the pet window; destroy happens on app quit. +- Fixed: Added default-pet controller ownership model and API shape. +- Fixed: Replaced runtime asset recommendation with inline generated pet HTML/CSS/SVG/canvas. +- Fixed: Added fixed 180x180 DIP window size, 24 DIP margin, integer DIP coordinates, and full-window clamping rules. +- Fixed: Clarified `showInactive()` focus behavior and click/drag best-effort limitations. +- Fixed: Chose CSS `-webkit-app-region: drag` first; narrow preload bridge only if necessary. +- Fixed: Chose reload/regenerate local pet HTML for pause/resume first; narrow preload only if necessary. +- Fixed: Added CSP/navigation/new-window/load-failure/render-crash security requirements for pet renderer. +- Fixed: Added manual verification for hidden/restart behavior and display re-clamping. + +## Oracle implementation review + +Oracle reviewed the implemented Phase 02 diff after successful validation with: + +```bash +pnpm check && pnpm typecheck && pnpm build +``` + +Initial implementation review blocked manual verification until two issues were fixed: + +- The initial tray menu label could be stale because the tray was created before the default pet was shown. +- A pending debounced position save could read from a destroyed pet window if the user dragged and quit quickly. + +Implementation review disposition: + +- Fixed: Exported `refreshTrayMenu()` and called it after `showDefaultPet()` on startup so the tray label reflects the visible pet. +- Fixed: Guarded the debounced position save with `window.isDestroyed()` before reading window position. + +Oracle re-reviewed the fixes and approved Phase 02 for manual user verification. diff --git a/docs/phases/phase-03-local-state-pet-manager-basics.md b/docs/phases/phase-03-local-state-pet-manager-basics.md new file mode 100644 index 00000000..a7811054 --- /dev/null +++ b/docs/phases/phase-03-local-state-pet-manager-basics.md @@ -0,0 +1,481 @@ +# Phase 03: Local state and Pet Manager basics + +## Goal + +Turn the current desktop shell into a minimally real local-first app by formalizing app state and making Pet Manager / Settings show and update the built-in default pet behavior. + +This phase should prove that OpenPets owns local preferences and installed-pet state before remote catalog installation, MCP, or Claude integration are added. + +## Non-goals + +This phase does not implement: + +- Remote v2 pet catalog fetching. +- Downloading or installing pet zip files. +- Removing downloaded pets. +- Multiple real pet assets beyond the bundled built-in pet. +- Full pet manifest/spritesheet engine. +- Agent setup detection/configuration. +- Local adapter IPC. +- MCP integration. +- Claude hooks. +- Speech bubbles. +- First-run onboarding. +- Production packaging. + +## User-visible/manual outcome + +After this phase, the user/developer should be able to run the app and confirm: + +```text +Pet Manager shows the bundled built-in pet as installed/default/protected, Settings controls core local preferences, and those preferences persist across restarts. +``` + +## Acceptance criteria + +- Existing tray shell and default pet window behavior continue to work. +- App state has a clear versioned schema. +- State is loaded after `app.whenReady()` and not at module import time. +- State remains local-only under Electron `app.getPath("userData")`. +- State reads are defensive and corrupted/invalid state falls back safely. +- State writes are atomic where practical. +- `app-state.ts` is the only owner/writer of `openpets-state.json`. +- Phase 02 `pet-state.ts` is retired or delegates to `app-state.ts` so pet position writes cannot clobber preferences/pets. +- Existing unversioned Phase 02 state is migrated to V1 while preserving default pet position. +- Built-in pet exists in app state as an installed/protected pet. +- Built-in pet cannot be removed. +- Built-in pet is the default pet unless the state says otherwise. +- Pet Manager is upgraded from placeholder to a minimal real local view. +- Pet Manager shows installed pets, including the built-in pet. +- Pet Manager shows badges/states for `Built-in`, `Installed`, `Default`, and `Protected` where applicable. +- Pet Manager shows that built-in pet removal is unavailable/disabled. +- Pet Manager can set an installed pet as default; in Phase 03 this may be a no-op if only the built-in pet exists, but the UI/state path should be real. +- Settings is upgraded from placeholder to a minimal real preferences view. +- Settings can control `open default pet on app launch`. +- Settings can control `speech bubbles enabled`, even though speech bubbles are not implemented yet. +- `petScale` may exist as inert schema state with default `1`, but visible pet scale controls are deferred to a later phase. +- Settings can reset the default pet position. +- Preferences persist across app restarts. +- If `open default pet on app launch` is disabled, app starts tray-first without showing the default pet. +- `Show Default Pet` can still show the default pet manually when launch-open is disabled. +- If `open default pet on app launch` is re-enabled, the pet appears on future launches. +- Phase 02 hidden-state rule remains: hiding from tray is session-only and is not the same as disabling open-on-launch. +- Automated checks pass. + +## Proposed files/directories + +Likely update: + +```text +apps/desktop/src/main.ts +apps/desktop/src/tray.ts +apps/desktop/src/windows.ts +apps/desktop/src/default-pet-controller.ts +apps/desktop/src/pet-state.ts +apps/desktop/src/pet-window.ts +README.md +``` + +Likely add: + +```text +apps/desktop/src/app-state.ts +apps/desktop/src/built-in-pet.ts +apps/desktop/src/settings-controller.ts +apps/desktop/src/pet-manager-controller.ts +apps/desktop/src/renderer-html.ts +apps/desktop/src/preload.ts # only if needed for safe internal Electron UI actions +``` + +Exact file names can change if a simpler structure is better. + +## Technical approach + +Keep Phase 03 local and minimal. This is not the catalog phase and not a full UI framework phase. + +### App state model + +Formalize a versioned local app state file. + +Recommended state path: + +```text +/openpets-state.json +``` + +Recommended state shape: + +```ts +interface OpenPetsStateV1 { + version: 1; + preferences: { + defaultPetId: string; + openDefaultPetOnLaunch: boolean; + speechBubblesEnabled: boolean; + petScale: number; + }; + pets: { + installed: Array<{ + id: string; + displayName: string; + builtIn: boolean; + protected: boolean; + }>; + }; + defaultPet: { + position?: { + x: number; + y: number; + }; + }; +} +``` + +Default state: + +- `defaultPetId`: built-in pet id. +- `openDefaultPetOnLaunch`: `true`. +- `speechBubblesEnabled`: `true`. +- `petScale`: `1`. +- Installed pets contains the built-in pet. + +State rules: + +- Built-in pet is always present, even if an old/corrupt state omits it. +- Default pet falls back to built-in pet if missing/invalid. +- Canonical built-in pet metadata wins over state file metadata. +- Protected/built-in flags cannot be disabled by a user-edited/corrupt state file. +- `petScale` remains `1` in Phase 03 unless later implemented. +- Position should continue to use safe Phase 02 clamping rules. +- State-derived strings must be treated as untrusted and escaped before rendering. + +### State ownership + +Move Phase 02 position persistence into the broader app state module as a required part of this phase. + +Avoid split-brain state where different modules write unrelated JSON shapes to the same file. + +Recommended ownership: + +- `app-state.ts` owns reading/writing `openpets-state.json`. +- Controllers request state updates through narrow functions. +- State is loaded only after `app.whenReady()`. +- No app state reads happen at module import time. +- `pet-state.ts` should be removed or changed to delegate to `app-state.ts`. +- No module other than `app-state.ts` writes directly to `openpets-state.json`. +- All state updates are read/modify/write against one in-memory normalized state object. +- State updates should be serialized in-process to avoid debounced pet-position saves clobbering Settings updates. + +Recommended `app-state.ts` API shape: + +```ts +initializeAppState(): void +getAppStateSnapshot(): OpenPetsStateV1 +updatePreferences(patch: Partial): OpenPetsStateV1 +setDefaultPet(defaultPetId: string): OpenPetsStateV1 +setDefaultPetPosition(position: { x: number; y: number }): OpenPetsStateV1 +resetDefaultPetPosition(): OpenPetsStateV1 +``` + +The API can differ if implementation finds a simpler shape, but the ownership rules must remain. + +Startup ordering: + +1. `app.whenReady()` resolves. +2. `app.setName("OpenPets")` runs. +3. App state is loaded/normalized through `initializeAppState()`. +4. Tray is created. +5. Display/lifecycle handlers are installed. +6. Default pet is shown only if `openDefaultPetOnLaunch` is true. +7. Tray menu is refreshed so labels match actual pet visibility. + +Migration rule: + +- Phase 02 already wrote unversioned state shaped like `{ defaultPet: { position } }`. +- Phase 03 must read that V0/unversioned shape and migrate it to V1. +- The migrated V1 state must preserve valid `defaultPet.position`. +- Unknown/corrupt state should not crash the app. + +### Built-in pet model + +Define one bundled built-in pet record in code. + +Recommended values: + +```ts +id: "builtin" +displayName: "Built-in Pet" +builtIn: true +protected: true +``` + +This is a local app-state pet record, not the final downloadable pet manifest contract. + +### Pet Manager basics + +Upgrade the Pet Manager placeholder into a minimal real local view. + +Recommended UI content: + +- Title: `Pet Manager`. +- Installed pets section. +- Built-in pet card/row. +- Badges: `Built-in`, `Installed`, `Default`, `Protected`. +- Disabled remove action for built-in pet. +- `Set Default` action for installed pets that are not default. +- Empty/loading/catalog messaging should clearly say catalog browsing arrives in a later phase. + +Phase 03 does not need a polished catalog grid. It only needs to prove local installed/default state. + +### Settings basics + +Upgrade the Settings placeholder into a minimal real preferences view. + +Initial settings: + +- `Open default pet on app launch` toggle. +- `Speech bubbles enabled` toggle. +- `Pet scale` visible disabled/deferred row or omitted from UI; do not implement scaling controls in Phase 03. +- `Reset default pet position` button. + +Settings behavior: + +- Changes persist to local state. +- Toggling `Open default pet on app launch` affects next app launch, not necessarily the current pet visibility. +- Reset position should move the visible default pet back to bottom-right immediately if visible and update persisted state. +- If the pet is hidden, reset position updates persisted state; the next show/restart uses the reset position. +- If the pet window has not been created because `openDefaultPetOnLaunch` is disabled, reset position updates persisted state only. +- Speech setting persists but has no visible speech behavior yet. + +### Internal renderer interaction + +If Pet Manager/Settings need clickable controls, use safe internal Electron IPC/preload only for those task windows. + +Important distinction: + +- This is Electron renderer-to-main IPC for app UI. +- This is not the Phase 05 local adapter IPC protocol for MCP/CLI/Claude packages. + +Required preload/security behavior if used: + +- `contextIsolation: true`. +- `nodeIntegration: false`. +- `sandbox: true` where compatible. +- Preload exposes only narrow methods needed for Phase 03 UI actions. +- No generic filesystem, shell, or Electron object exposure. +- Validate all action names/payloads in main process. +- Validate sender `webContents` belongs to an allowed internal task window. + +Recommended preload/API contract: + +```ts +window.openPets.getState(): Promise +window.openPets.updatePreferences(patch: { + openDefaultPetOnLaunch?: boolean; + speechBubblesEnabled?: boolean; +}): Promise +window.openPets.setDefaultPet(petId: string): Promise +window.openPets.resetDefaultPetPosition(): Promise +``` + +Rules: + +- Do not expose a generic `invoke(action, payload)` API. +- Do not expose filesystem/shell/Electron access. +- Validate booleans as booleans. +- Validate `petId` as a known installed pet id before setting default. +- Validate that reset-position requests come from Settings. +- Renderer should query latest state when opened/focused and after mutations. +- Avoid `webContents.executeJavaScript` as a state/control channel. + +If a simpler approach can keep views read-only and use tray/menu actions for state changes, that is acceptable only if acceptance criteria remain met. Since Settings needs toggles/buttons, a narrow preload bridge is likely appropriate. + +### Window/rendering approach + +Do not introduce React/Vue/Vite yet. + +Use generated local HTML for Pet Manager and Settings, similar to prior phases, unless implementation complexity becomes unreasonable. + +Renderer content should: + +- Be local/hardcoded. +- Include restrictive CSP. +- Deny navigation/new windows. +- Log load/render failures. +- Escape all state-derived strings before insertion into HTML. + +## Risks and tradeoffs + +### Risk: state shape churn + +Early state schemas can become hard to migrate later. + +Mitigation: + +- Add `version: 1` now. +- Keep schema minimal. +- Validate defensively. +- Normalize state on read to ensure built-in/default invariants. + +### Risk: overbuilding UI before real catalog + +Pet Manager could become a dashboard or fake catalog. + +Mitigation: + +- Only show installed local pets in Phase 03. +- Clearly label catalog browsing as a later phase. +- Do not add search/categories/catalog fetch yet. + +### Risk: internal IPC scope creep + +Adding preload/UI IPC could become too broad. + +Mitigation: + +- Expose only Phase 03 actions. +- Validate payloads. +- Validate sender window/webContents. +- Do not expose filesystem/shell/Electron. +- Keep this separate from the future local adapter IPC. + +### Risk: position state regression + +Moving Phase 02 position persistence into app state could break drag/restart behavior. + +Mitigation: + +- Preserve Phase 02 manual verification. +- Keep position clamping and atomic writes. +- Re-run drag/restart verification. + +## Security/privacy notes + +This phase remains local-only. + +Security/privacy expectations: + +- No telemetry. +- No network calls. +- No remote content. +- No coding-agent config edits. +- No shell command execution. +- No pet zip extraction. +- App writes only its own local state file under `app.getPath("userData")`. +- Renderer IPC, if used, is narrow and validated. +- Built-in pet cannot be removed through UI or state corruption. +- State files are treated as untrusted input. +- State-derived renderer content is escaped. + +## Test/check plan + +Automated checks: + +```bash +pnpm check +pnpm typecheck +pnpm build +``` + +Manual app run command: + +```bash +pnpm --filter @open-pets/desktop dev +``` + +Expected automated result: + +- TypeScript checks pass. +- Desktop app build passes. +- Existing workspace package checks continue to pass. + +## Manual verification guide + +After implementation, the user should verify: + +1. Start the desktop app: + + ```bash + pnpm --filter @open-pets/desktop dev + ``` + +2. Confirm tray and default pet still appear. +3. Open `Manage Pets...`. +4. Confirm the built-in pet is shown as installed/default/protected. +5. Confirm built-in pet cannot be removed. +6. Confirm setting built-in pet as default is disabled/no-op because it is already default. +7. Open `Settings...`. +8. Toggle `Speech bubbles enabled`, quit, restart, and confirm the setting persisted. +9. Toggle `Open default pet on app launch` off, quit, restart, and confirm the default pet does not appear automatically. +10. Use tray `Show Default Pet` and confirm the default pet can still be shown manually. +11. Toggle `Open default pet on app launch` back on, quit, restart, and confirm the default pet appears automatically again. +12. Drag the pet to a new position, quit, restart, and confirm position still persists. +13. Use `Reset default pet position` and confirm the pet moves back near bottom-right and persists there after restart. +14. If a Phase 02 unversioned state file exists, confirm position is preserved after Phase 03 migration. +15. If practical, temporarily corrupt `openpets-state.json`, restart, and confirm app falls back safely with built-in pet/default preferences. +16. Confirm placeholder/real task windows still do not quit the app when closed. +17. Quit OpenPets and confirm clean exit. + +Manual acceptance question: + +```text +Does Phase 03 pass on your machine: Pet Manager shows built-in pet state, Settings preferences persist, open-on-launch works, reset position works, and Phase 02 pet behavior still works? +``` + +## Oracle plan review + +Oracle reviewed the initial Phase 03 spec and blocked implementation until state ownership/migration and internal preload/IPC details were tightened. + +Summary of required Oracle feedback: + +- Make Phase 02 state migration mandatory. +- Retire or delegate `pet-state.ts` so there is one state-file writer. +- Add V0/unversioned state migration preserving position. +- Define startup ordering and open-on-launch gating. +- Define app-state API and whole-state write semantics. +- Define preload/API contract and validation rules. +- Require escaping state-derived renderer content. +- Require windows to render/query latest state after mutations. +- Defer visible pet scale controls. +- Define reset-position behavior for visible, hidden, and not-created pet states. +- Add manual verification for corrupted state and V0 migration. + +## Oracle feedback disposition + +- Fixed: Made Phase 02 position migration into `app-state.ts` mandatory. +- Fixed: Required `app-state.ts` to be the only owner/writer of `openpets-state.json`. +- Fixed: Added V0/unversioned migration preserving `defaultPet.position`. +- Fixed: Added startup ordering with `initializeAppState()` before tray/show-default-pet logic. +- Fixed: Added recommended app-state API and whole-state update semantics. +- Fixed: Added concrete preload/API contract and validation rules. +- Fixed: Required sender validation for internal renderer IPC. +- Fixed: Required escaping all state-derived renderer content. +- Fixed: Required renderer state refresh after mutations. +- Fixed: Deferred visible pet scale controls; `petScale` remains inert default state only. +- Fixed: Defined reset-position behavior for visible, hidden, and not-yet-created pet states. +- Fixed: Added manual verification for V0 migration and corrupt-state fallback. + +## Oracle implementation review + +Oracle reviewed the implemented Phase 03 diff after successful validation with: + +```bash +pnpm check && pnpm typecheck && pnpm build +``` + +Oracle found no blocking correctness, security, state migration, or scope issues and approved Phase 03 for manual user verification. + +Implementation review disposition: + +- Approved: Versioned app state and V0 migration preserve Phase 02 pet position. +- Approved: `pet-state.ts` was removed and `app-state.ts` owns `openpets-state.json`. +- Approved: Open-on-launch gating and reset-position behavior are implemented. +- Approved: Pet Manager and Settings use narrow internal Electron IPC with sender and payload validation. +- Approved: Generated UI uses DOM/text rendering for state-derived values instead of HTML injection. +- Fixed optional: Added `node --check preload.cjs` to the desktop `check` script so the plain JS preload gets syntax validation. + +Deferred optional improvements: + +- Back up corrupt JSON before overwriting with normalized defaults. +- Add pure tests for state normalization/migration once a test framework exists. +- Derive tray default-pet label from app state when multiple real pets exist. diff --git a/docs/phases/phase-04-v2-pet-catalog-installation.md b/docs/phases/phase-04-v2-pet-catalog-installation.md new file mode 100644 index 00000000..ca82749c --- /dev/null +++ b/docs/phases/phase-04-v2-pet-catalog-installation.md @@ -0,0 +1,411 @@ +# Phase 04: V2 pet catalog and installation + +Status: superseded by split phases after Oracle review. + +Oracle blocked this combined phase because the v2 catalog endpoint is not available yet and safe zip installation is security-critical enough to deserve its own focused phase. + +Split replacement: + +- `phase-04a-v2-catalog-read-only.md` — v2 catalog contract/generation/fetch validation and read-only searchable Pet Manager UI. +- `phase-04b-safe-pet-installation.md` — safe zip install/remove/default switching/local rendering. + +## Goal + +Make Pet Manager browse the remote v2 pet catalog and install/remove real downloaded pets locally, while keeping the built-in pet as protected fallback. + +This phase should prove the core pet ownership loop: + +```text +Fetch catalog → browse/search pets → install pet zip safely → show installed state → set default → remove removable pets +``` + +## Non-goals + +This phase does not implement: + +- Pet update system. +- Categories/tags/favorites/ratings/popularity UI. +- Advanced sorting. +- CLI install commands. +- MCP/local adapter IPC. +- Claude integration. +- Speech bubbles. +- First-run onboarding. +- Production packaging. +- Full animation/physics engine beyond rendering installed pet enough to verify default switching. + +## User-visible/manual outcome + +After this phase, the user/developer should be able to run the app and confirm: + +```text +Pet Manager fetches the v2 catalog, shows available pets, installs a pet, lets the user set it as default, persists it across restart, and can remove removable installed pets. +``` + +If the catalog cannot be fetched, OpenPets should remain usable with the built-in pet and already-installed pets. + +## Acceptance criteria + +- Existing tray, default pet, Pet Manager, and Settings behavior continue to work. +- Pet Manager fetches catalog from: + + ```text + https://openpets.dev/pets/catalog.v2.json + ``` + +- Catalog fetch has loading and error states. +- Catalog fetch failure does not break built-in pet behavior. +- Catalog data is validated before use. +- Pet Manager shows catalog pets in a simple searchable list/grid. +- Pet Manager shows installed/default/protected states. +- User can install a catalog pet from its `zip` URL. +- Downloaded zip is validated before extraction/install. +- Zip install prevents zip-slip/path traversal. +- Zip install has size and file-count limits. +- Zip install never executes scripts from downloaded pets. +- Failed installs clean up partial files. +- Installed pets are stored locally under OpenPets user data. +- Installed pet metadata is persisted in app state. +- User can set an installed non-built-in pet as default. +- Default pet window reflects the selected default pet after setting default. +- Default pet selection persists across restart. +- User can remove removable installed pets. +- Built-in pet cannot be removed. +- Removing the current default pet falls back to the built-in pet. +- App remains usable if installed pet files are missing/corrupt; fallback to built-in pet. +- Automated checks pass. + +## Proposed files/directories + +Likely update: + +```text +apps/desktop/src/app-state.ts +apps/desktop/src/default-pet-controller.ts +apps/desktop/src/pet-window.ts +apps/desktop/src/windows.ts +apps/desktop/preload.cjs +README.md +packages/pet-format/src/index.ts +``` + +Likely add: + +```text +apps/desktop/src/catalog.ts +apps/desktop/src/pet-install.ts +apps/desktop/src/installed-pets.ts +apps/desktop/src/download.ts +apps/desktop/src/zip.ts +``` + +Exact file names can change if a simpler structure is better. + +## Technical approach + +Keep this phase focused on safe catalog/install basics, not marketplace polish. + +### Catalog contract + +Use the agreed v2 catalog URL: + +```text +https://openpets.dev/pets/catalog.v2.json +``` + +Expected minimal catalog shape: + +```ts +interface CatalogV2 { + version: 2; + generatedAt: string; + pets: CatalogPetV2[]; +} + +interface CatalogPetV2 { + id: string; + displayName: string; + description: string; + preview: string; + zip: string; + format: { + type: "spritesheet"; + columns: number; + rows: number; + frameWidth: number; + frameHeight: number; + }; +} +``` + +Validation rules: + +- `version` must be `2`. +- `pets` must be an array. +- `id`, `displayName`, `description`, `preview`, and `zip` must be strings. +- `id` must be a safe local identifier, for example `/^[a-z0-9][a-z0-9_-]{0,63}$/`. +- `zip` and `preview` must be `https:` URLs. +- `format.type` must be `spritesheet`. +- Format dimensions/counts must be positive finite integers within safe bounds. + +Use `packages/pet-format` for shared catalog/pet types and validation helpers if practical. + +### Catalog UI + +Pet Manager should show: + +- Search input. +- Installed pets and catalog pets in one simple view or clearly separated sections. +- Pet name. +- Short description. +- Preview image if safe/practical. +- Badges: `Built-in`, `Installed`, `Default`, `Protected`. +- Actions: `Install`, `Set Default`, `Remove`, disabled where not valid. +- Loading/error states. + +Do not add categories/tags/favorites/sorting beyond search. + +### Installed pet storage + +Store installed pets under app user data. + +Recommended directory: + +```text +/pets// +``` + +Installed pet state should include enough metadata to render/manage it later: + +```ts +interface InstalledPetState { + id: string; + displayName: string; + description?: string; + builtIn: boolean; + protected: boolean; + localPath?: string; + previewPath?: string; + format?: CatalogPetV2["format"]; + source?: { + catalogVersion: 2; + zip: string; + preview: string; + }; +} +``` + +The built-in pet remains a code-defined protected pet and does not need localPath. + +### Zip download/install safety + +This is the highest-risk part of Phase 04. + +Required behavior: + +- Download only `https:` zip URLs from validated catalog entries. +- Enforce max download size before/during download. +- Enforce max extracted file count. +- Enforce max individual file size and total extracted size where practical. +- Extract only into a temporary install directory first. +- Reject absolute paths. +- Reject paths containing `..` after normalization. +- Reject entries that would escape the temp install directory. +- Reject symlinks/hardlinks if zip library exposes them. +- Never execute files from downloaded pets. +- Validate required pet files/manifest before finalizing install. +- Move temp install directory into final `/pets/` atomically where practical. +- Clean temp directory on failure. + +Recommended limits for initial phase: + +```text +max zip download: 50 MB +max extracted total: 200 MB +max files: 500 +``` + +These can be adjusted by implementation if real catalog pets need different limits, but the final chosen limits must be documented. + +### Installed pet validation + +Initial installed pet validation can be minimal but real. + +Required: + +- Installation directory exists. +- At least one plausible sprite/preview file exists, or the catalog `preview` was cached successfully. +- Catalog format metadata is persisted. +- Pet id/displayName in state match the validated catalog entry. + +If the actual zip structure from current catalog is known to contain a manifest, validate that manifest too. Do not invent a complex new manifest requirement if current pets do not have it yet; record the discovered shape in this phase doc after implementation. + +### Default pet rendering after install + +Phase 04 does not need final spritesheet animation quality. + +But when a user sets an installed pet as default, the default pet window should visibly reflect that choice. + +Acceptable Phase 04 rendering: + +- Built-in pet keeps current generated pet rendering. +- Installed pet default can show cached preview/spritesheet image in the pet window, scaled into the 180x180 pet window. +- If preview/spritesheet fails, fallback to built-in generated pet and show installed/default state in Pet Manager. + +Do not build the full animation-state engine yet unless it is simpler than a static preview. + +### App state updates + +Extend V1 state conservatively or introduce V2 state if necessary. + +Rules: + +- Built-in pet must always be present and protected. +- Installed pet records are normalized on read. +- Default pet id must point to an installed pet or fallback to built-in. +- Removing current default sets default to built-in. +- State writes remain owned by `app-state.ts`. + +### Internal UI IPC/preload + +Extend the existing narrow internal UI API only as needed. + +Candidate methods: + +```ts +getCatalog(): Promise +installPet(petId: string): Promise +removePet(petId: string): Promise +setDefaultPet(petId: string): Promise +``` + +Rules: + +- No generic `invoke(action, payload)`. +- Validate sender window. +- Validate pet id. +- Do not expose filesystem/shell/Electron. +- Render state-derived/catalog-derived strings through DOM text APIs or HTML escaping. + +## Risks and tradeoffs + +### Risk: Phase 04 is too large + +Catalog browsing, downloading, zip validation, install, remove, default switching, and rendering can become large. + +Mitigation: + +- Keep UI minimal. +- Do not add categories/tags/favorites/update system. +- If zip/install complexity becomes larger than expected, split Phase 04 into: + 1. Catalog browse/read-only. + 2. Safe install/remove/default. + +### Risk: zip extraction security bugs + +Zip-slip/path traversal is a real risk. + +Mitigation: + +- Treat all zip entries as untrusted. +- Normalize and verify every output path. +- Extract to temp directory. +- Clean up on failure. +- Oracle implementation review should focus heavily on install safety. + +### Risk: unknown current pet zip structure + +Current web/catalog pets may not match a clean v2 manifest structure yet. + +Mitigation: + +- Scout current `web/public/pets` metadata and one real zip before implementation. +- Do not blindly copy v1 assumptions. +- Keep validation compatible with real available pets while still safe. +- Record discovered zip shape in this phase doc after implementation. + +### Risk: remote images/content in Electron UI + +Loading remote preview URLs directly in Electron windows can expand attack surface. + +Mitigation: + +- Prefer fetching/caching previews through main process or use safe image-only rendering. +- Do not load arbitrary remote HTML. +- Keep CSP restrictive. +- Validate preview URL as `https:`. + +## Security/privacy notes + +This phase introduces network and untrusted zip handling. + +Security/privacy expectations: + +- Only fetch the catalog URL and validated `https:` pet assets/zips. +- No telemetry. +- No cookies/auth/accounts. +- No remote HTML content. +- No script execution from downloaded pets. +- No shell command execution. +- All downloaded zip contents are treated as untrusted. +- All catalog/download strings rendered in UI are escaped or inserted as text. +- Pet install writes only under OpenPets user data paths. +- Fail closed on validation errors. + +## Test/check plan + +Automated checks: + +```bash +pnpm check +pnpm typecheck +pnpm build +``` + +Manual app run command: + +```bash +pnpm --filter @open-pets/desktop dev +``` + +Implementation should add unit-style tests for pure validation/path-safety helpers if a lightweight test approach is practical in this phase. If no test framework is added, document why and rely on manual verification plus Oracle review. + +## Manual verification guide + +After implementation, the user should verify: + +1. Start the desktop app: + + ```bash + pnpm --filter @open-pets/desktop dev + ``` + +2. Open `Manage Pets...`. +3. Confirm built-in pet still appears as installed/default/protected. +4. Confirm catalog loading state appears, then catalog pets appear. +5. Search for a pet by name and confirm filtering works. +6. Install one real catalog pet. +7. Confirm it appears as installed. +8. Set the installed pet as default. +9. Confirm the default pet window visibly changes or shows that pet's preview. +10. Quit and restart; confirm installed pet and default selection persist. +11. Remove the installed pet. +12. Confirm default falls back to built-in if removed pet was default. +13. Confirm built-in pet still cannot be removed. +14. Temporarily disconnect network or block catalog fetch and confirm the app remains usable with installed/built-in pets. +15. Confirm Settings and Phase 03 preferences still work. +16. Quit OpenPets and confirm clean exit. + +Manual acceptance question: + +```text +Does Phase 04 pass on your machine: catalog loads, search works, a pet installs safely, default switching persists, removal works, and built-in fallback remains reliable? +``` + +## Oracle plan review + +Pending. + +## Oracle feedback disposition + +Pending. diff --git a/docs/phases/phase-04a-v2-catalog-read-only.md b/docs/phases/phase-04a-v2-catalog-read-only.md new file mode 100644 index 00000000..faf4c06d --- /dev/null +++ b/docs/phases/phase-04a-v2-catalog-read-only.md @@ -0,0 +1,336 @@ +# Phase 04A: V2 catalog contract and read-only Pet Manager catalog + +## Goal + +Establish the v2 pet catalog contract and make Pet Manager fetch, validate, cache, search, and display the catalog in read-only mode. + +This phase does **not** install pets yet. It prepares the catalog foundation safely before the security-critical zip install phase. + +## Non-goals + +- Downloading/installing pet zips. +- Removing downloaded pets. +- Setting downloaded pets as default. +- Rendering downloaded pet sprites in the default pet window. +- CLI/MCP/Claude/onboarding/packaging. +- Categories/tags/favorites/ratings/popularity UI. + +## User-visible/manual outcome + +After this phase: + +```text +Pet Manager shows the built-in installed pet and a searchable read-only catalog of available pets from the v2 catalog contract. +``` + +If catalog fetch fails, Pet Manager still works for installed/built-in pets and shows a clear catalog error. + +## Blocking context from Oracle + +Oracle found that the target URL is currently not available: + +```text +https://openpets.dev/pets/catalog.v2.json +``` + +Phase 04A must therefore include making a real v2 catalog artifact available in the repository/deploy path, or explicitly add a temporary development fallback while the production endpoint is being deployed. + +Updated decision for Phase 04A: + +- Also create the real web artifact at `web/public/pets/catalog.v2.json` now that updating `web/` is explicitly approved. +- Update `web/scripts/sync-pets.js` so future `sync:pets` runs regenerate `catalog.v2.json`. +- Create a tracked v2-shaped development fixture under the tracked v2 workspace. +- Desktop app attempts the final production URL first. +- If production URL is unavailable, desktop app falls back only to the tracked v2-shaped fixture. +- Production deployment of `web/public/pets/catalog.v2.json` can be tested after the web app is redeployed. +- Because `web/` is root-ignored, any required web files must be force-added intentionally when committing from the root repo. + +## Acceptance criteria + +- Existing tray/default pet/Pet Manager/Settings behavior continues to work. +- A v2 catalog contract is defined in code with validation helpers. +- The v2 catalog artifact shape is based on real current pet metadata, not invented fields. +- A deterministic tracked v2 catalog fixture exists for local development and manual verification. +- The real web v2 catalog artifact exists at `web/public/pets/catalog.v2.json`. +- `web/scripts/sync-pets.js` regenerates the v2 catalog artifact. +- The tracked fixture is v2-shaped; no silent v1 `install.json` fallback is allowed. +- Web changes are intentionally included despite `web/` being root-ignored. +- Desktop catalog fetch validates data before use. +- Catalog fetch has loading, success, and error states. +- Catalog fetch failure does not break built-in pet behavior. +- Pet Manager shows installed local pets plus read-only catalog entries. +- Pet Manager search filters catalog pets by display name/description/id. +- Catalog entries show `Install coming in Phase 04B` or disabled install actions. +- Pet Manager does not fetch remote HTML. +- Remote preview rendering is safe or deferred. If preview rendering is included, images are fetched/validated by main process or constrained to safe image-only rendering. +- Automated checks pass. + +## Catalog contract + +The Phase 04A canonical v2 catalog shape is: + +```ts +interface CatalogV2 { + version: 2; + generatedAt: string; + pets: CatalogPetV2[]; +} + +interface CatalogPetV2 { + id: string; + displayName: string; + description: string; + preview: string; + zip: string; +} +``` + +Do not require spritesheet `format` fields in Phase 04A unless the catalog generator can derive them from real current pet data. + +Validation rules: + +- `version` is `2`. +- `generatedAt` is a string. +- `pets` is an array. +- `id` matches `/^[a-z0-9][a-z0-9_-]{0,63}$/`. +- `id` must not be `builtin`; `builtin` is reserved for the protected bundled pet. +- Duplicate ids are rejected. +- `displayName`, `description`, `preview`, and `zip` are strings. +- `preview` and `zip` are `https:` URLs. +- Allowed hostnames are explicit and documented. Initial expected hosts: + - `openpets.dev` + - `zip.openpets.dev` +- Maximum pets: 1000. +- Maximum `id` length: 64. +- Maximum `displayName` length: 120. +- Maximum `description` length: 500. +- Maximum URL length: 2048. +- `generatedAt` must parse as a valid date. +- URL credentials are rejected. +- URLs with non-default ports are rejected. +- Catalog URL is exactly `https://openpets.dev/pets/catalog.v2.json`. +- `preview` URLs must use host `openpets.dev` and path under `/pets/`. +- `zip` URLs must use host `zip.openpets.dev` and path under `/pets/`, even though zips are not downloaded in Phase 04A. + +Mapping from current web metadata to v2: + +- v2 `id` = existing `installId`. +- v2 `displayName` = existing `displayName`. +- v2 `description` = existing `description` if present, otherwise `""`. +- v2 `preview` = absolute `https://openpets.dev` URL from existing `spritesheetPath`. +- v2 `zip` = absolute zip URL from existing `zipPath`. + +`builtin` is reserved and rejected as a catalog pet id. + +## Current metadata source + +Existing useful web files: + +```text +web/public/pets/manifest.json +web/public/pets/install.json +web/app/lib/pets.generated.js +web/scripts/sync-pets.js +``` + +Phase 04A should scout these before implementation and create both: + +- a real web v2 catalog artifact from current web metadata; +- a tracked local v2 fixture for desktop fallback. + +Tracked development fixture: + +```text +apps/desktop/catalog.v2.fixture.json +``` + +Production artifact: + +```text +web/public/pets/catalog.v2.json +``` + +Because `web/` is currently ignored by root `.gitignore` and outside the v2 pnpm workspace, web changes must be added intentionally with force-add from the root repo if committing them there. + +## Desktop catalog service + +Add a small catalog service in the desktop app or `packages/pet-format`: + +- Fetch catalog from the target URL. +- Validate response shape. +- Normalize to a UI-safe catalog model. +- Add and track the v2 fixture at `apps/desktop/catalog.v2.fixture.json` during implementation. +- On fetch failure, load the tracked v2 fixture as a development fallback. +- Defer cache file behavior to a later phase unless implementation discovers it is trivial and safe. +- Do not store catalog cache in `openpets-state.json` in Phase 04A. +- Return UI state that clearly distinguishes live catalog, fixture fallback, cached catalog, and error. + +Do not cache unvalidated catalog data. + +Phase 04A catalog source precedence: + +1. Remote live catalog from `https://openpets.dev/pets/catalog.v2.json`. +2. If remote fetch/validation fails, validated tracked fixture at `apps/desktop/catalog.v2.fixture.json`. +3. If fixture validation also fails, show catalog error while keeping installed/built-in pets usable. + +Do not use v1 `install.json` or `manifest.json` as runtime fallback in the desktop app. + +Fetch requirements: + +- Main process only. +- Timeout required. +- Maximum response bytes required. +- Redirect policy required; final URL must still pass protocol/host/port/credential validation. +- Allowed hosts only. +- No cookies/auth. + +## Pet Manager read-only UI + +Pet Manager should show: + +- Installed pets section, including built-in protected pet. +- Catalog section. +- Search input. +- Catalog loading state. +- Catalog error state. +- Catalog empty state. +- Disabled `Install` button/label for catalog pets: `Install in next phase`. + +Preview policy for Phase 04A: + +- Defer preview image rendering by default. +- Show text-only catalog cards/rows first. +- If preview rendering is added, it must be main-process fetched/validated/cached and rendered as `data:` with `img-src data:` CSP. +- Renderer must not directly fetch arbitrary remote previews. + +Do not implement install/remove/default switching for remote catalog pets in Phase 04A. + +## Security/privacy notes + +This phase introduces catalog network fetch but not zip extraction. + +Security expectations: + +- Fetch only the catalog URL and optionally validated preview images. +- No cookies/auth/accounts. +- No telemetry. +- No remote HTML. +- No zip downloads yet. +- Catalog strings are untrusted and rendered with DOM text APIs or escaped. +- URL host/protocol validation is mandatory. +- Fail closed on invalid catalog data. +- Catalog fetch happens in the main process only. +- Renderer receives sanitized catalog UI models only. + +## Test/check plan + +Automated checks: + +```bash +pnpm check +pnpm typecheck +pnpm build +``` + +Phase 04A must add a lightweight validation check that runs under `pnpm check`. + +At minimum, the check should validate: + +- The tracked fixture is valid v2 catalog data. +- Invalid ids are rejected. +- Duplicate ids are rejected. +- Non-https URLs are rejected. +- Disallowed hosts are rejected. +- `builtin` id is rejected. + +## Manual verification guide + +1. Run: + + ```bash + pnpm --filter @open-pets/desktop dev + ``` + +2. Open `Manage Pets...`. +3. Confirm built-in pet still appears as installed/default/protected. +4. Confirm catalog loading state appears. +5. Confirm catalog pets appear if the catalog source is reachable. +6. Search by pet name and confirm filtering works. +7. Confirm catalog pet install buttons/actions are disabled/read-only for Phase 04A. +8. Simulate catalog failure if practical and confirm built-in/installed pets still work with a clear error. +9. Confirm Settings and default pet behavior from Phase 03 still work. + +Manual acceptance question: + +```text +Does Phase 04A pass on your machine: Pet Manager shows a validated searchable read-only catalog, handles catalog failure, and preserves built-in/default pet behavior? +``` + +## Oracle plan review + +Oracle reviewed the split Phase 04A spec and agreed the split resolves the zip/install security overreach, but blocked implementation until the catalog source/artifact story was concrete. + +Summary of required Oracle feedback: + +- Choose a concrete v2 catalog artifact/development source because the production endpoint currently returns 404. +- Define v2 `id` mapping from current metadata. +- Make the Phase 04A catalog schema canonical, not “ideal.” +- Use only v2-shaped fallback catalogs; no silent v1 fallback. +- Add validation bounds and exact host/protocol rules. +- Ensure catalog fetch is main-process-only with timeout/byte limits/redirect/final URL validation. +- Decide preview policy; recommended text-only/defer previews. +- Use a separate validated catalog cache file, not `openpets-state.json`. +- Make catalog validation checks mandatory under `pnpm check`. +- Do not rely on ignored untracked `web/` files. + +## Oracle feedback disposition + +- Fixed: Chose a tracked development fixture at `apps/desktop/catalog.v2.fixture.json`. +- Fixed: Desktop attempts production URL first and falls back only to v2-shaped fixture if unavailable. +- Fixed: Production `web/public/pets/catalog.v2.json` deployment is documented as separate unless explicitly approved. +- Fixed: Defined v2 `id = installId` mapping from current web metadata. +- Fixed: Made the Phase 04A v2 schema canonical. +- Fixed: Disallowed silent v1 fallback. +- Fixed: Added max pets/string/URL bounds, `generatedAt` validation, credential/port rejection, and explicit allowed hosts. +- Fixed: Required main-process fetch with timeout, byte limit, redirect/final URL validation. +- Fixed: Deferred preview rendering by default; renderer does not fetch remote previews. +- Fixed: Required separate validated catalog cache if caching is implemented. +- Fixed: Required catalog validation checks under `pnpm check`. +- Fixed: Clarified implementation must add and track `apps/desktop/catalog.v2.fixture.json`. +- Fixed: Clarified source precedence: remote live catalog, then validated fixture, then error. +- Fixed: Clarified field-specific URL rules for catalog, preview, and zip URLs. +- Updated: User approved updating `web/`, so Phase 04A now also creates `web/public/pets/catalog.v2.json` and updates `web/scripts/sync-pets.js`. + +## Oracle implementation review + +Oracle reviewed the implemented Phase 04A diff after successful validation with: + +```bash +pnpm check +pnpm typecheck +pnpm build +``` + +Initial implementation review blocked manual verification until catalog fetch hardening was fixed: + +- Redirect behavior was too permissive. +- Timeout covered only headers, not full bounded body read. +- Missing stream body fell back to unbounded `response.text()`. + +Implementation review disposition: + +- Fixed: Remote catalog fetch now uses `redirect: "error"`. +- Fixed: Abort timeout remains active through the full bounded body read and is cleared in `finally`. +- Fixed: Missing response stream body fails closed instead of falling back to unbounded text read. + +After the user approved updating and redeploying `web/`, the implementation was extended to create the real production web catalog artifact: + +- `web/scripts/sync-pets.js` now writes `catalog.v2.json`. +- `web/public/pets/catalog.v2.json` was generated from current web pet metadata. +- `https://openpets.dev/pets/catalog.v2.json` was deployed and verified to return `200 application/json` with `version: 2`. + +Oracle re-reviewed the web update and approved Phase 04A for manual verification. + +Manual verification disposition: + +- User manually verified and approved Phase 04A. diff --git a/docs/phases/phase-04b-safe-pet-installation.md b/docs/phases/phase-04b-safe-pet-installation.md new file mode 100644 index 00000000..c7da537f --- /dev/null +++ b/docs/phases/phase-04b-safe-pet-installation.md @@ -0,0 +1,403 @@ +# Phase 04B: Safe pet installation, removal, and default switching + +## Goal + +Allow users to install pets from the validated v2 catalog, set an installed pet as the default, render the selected default pet, and remove removable installed pets safely. + +This phase turns the Phase 04A read-only catalog into the first real pet ownership flow: + +```text +Catalog pet → safe download/install → installed state → set default → visible pet changes → remove → fallback +``` + +## Non-goals + +- Pet update system. +- Categories/tags/favorites/ratings/popularity UI. +- CLI install command. +- MCP/local adapter IPC. +- Claude integration. +- Speech bubbles. +- First-run onboarding. +- Production packaging. +- Full animation-state engine. +- Remote preview rendering in Pet Manager. + +## User-visible/manual outcome + +After this phase, the user can install a catalog pet, set it as default, see the floating pet window reflect the selected pet, restart and keep the selection, remove it, and safely fall back to the built-in pet. + +## Acceptance criteria + +- Existing Phase 04A catalog fetch/search/read-only behavior continues to work. +- Pet Manager enables `Install` for catalog pets that are not installed. +- Installing a pet downloads only its validated catalog `zip` URL. +- Zip download uses allowed host/protocol/path rules from Phase 04A. +- Zip download has timeout and byte limits. +- Zip extraction prevents zip-slip/path traversal. +- Zip extraction rejects absolute paths, `..`, backslashes as separators, Windows drive paths, UNC paths, NUL bytes, duplicate normalized paths, case-insensitive collisions, symlinks/hardlinks, encrypted/unsupported entries, and entries that escape the temp directory. +- Zip extraction does not preserve executable permissions. +- Zip extraction never executes downloaded files. +- Install extracts into a temp directory first. +- Install validates required pet files before finalizing. +- Failed installs clean up temp files and do not update installed pet state. +- Successful install moves/copies validated files into `/pets//`. +- Installed pet state is persisted only after install finalization succeeds. +- Install/remove/default state write failures propagate to the UI and do not report success. +- Pet Manager shows installed state after install. +- User can set an installed pet as default. +- Default pet id persists across restart. +- Floating pet window visibly reflects the selected default pet. +- If installed pet rendering fails, the app falls back to built-in pet rendering without crashing. +- User can remove installed non-built-in pets. +- Built-in pet cannot be removed. +- Removing the current default pet sets default back to built-in and refreshes the pet window. +- Remove failures leave app state consistent and understandable. +- Reinstall/overwrite of an already installed pet is not supported in Phase 04B. +- Concurrent duplicate install/remove/default actions for the same pet are prevented. +- Automated checks include zip/path safety tests. + +## Required current zip shape + +Phase 04B must inspect current real zips before implementation and document the exact accepted shape. + +Expected current shape from existing catalog zips: + +```text +/pet.json +/spritesheet.webp +``` + +Required install validation for Phase 04B: + +- Exactly one top-level directory is allowed, or files may be normalized from a single safe top-level directory. +- `pet.json` must exist. +- `spritesheet.webp` must exist. +- `pet.json` must be valid JSON. +- `spritesheet.webp` must be present and under size limits. +- The installed pet id comes from the catalog id, not from trusting zip paths. +- Final installed directory contains only normalized `pet.json` and `spritesheet.webp`. + +If real zips differ, update this section before implementation and re-check with Oracle. + +## Install storage + +Store installed pets under: + +```text +/pets// +``` + +Final installed shape for Phase 04B: + +```text +/pets//pet.json +/pets//spritesheet.webp +``` + +Do not store or trust arbitrary absolute paths in app state. + +State should store safe metadata and derive local paths from `userData` + safe pet id. + +## Download and extraction limits + +Initial limits: + +```text +max zip download: 50 MB +max extracted total: 200 MB +max files: 500 +max individual file: 100 MB +download timeout: 30 seconds +``` + +If real catalog pets require different limits, update this spec before implementation. + +## URL policy + +Download URL must come from a validated v2 catalog entry. + +Zip URL rules: + +- `https:` only. +- Host exactly `zip.openpets.dev`. +- Path starts with `/pets/`. +- No credentials. +- No custom port. +- Redirects should be rejected or manually validated before following. +- Phase 04B should reject redirects for zip downloads by default. +- Final URL must still satisfy all rules. + +Install-from-fixture behavior: + +- Installing from the validated fixture fallback is allowed in development because fixture entries use the same `zip.openpets.dev` URLs and URL validation rules. +- UI should not hide that the catalog source is fixture fallback. + +Reinstall policy: + +- If a pet is already installed, `Install` is disabled. +- Phase 04B does not overwrite/reinstall installed pets. +- A future update/reinstall flow can be designed later. + +## Zip safety policy + +Treat all zip entries as untrusted. + +Before extraction: + +- Verify zip magic/signature enough to reject obvious non-zip content. +- Use a zip library that reads central directory metadata and exposes entry types/sizes. +- Reject encrypted or unsupported archives. + +For each entry: + +- Normalize separators. +- Reject `\` as path separator in entry names. +- Reject NUL bytes. +- Reject absolute POSIX paths. +- Reject Windows drive paths such as `C:\...` or `C:/...`. +- Reject UNC paths such as `//server/share` or `\\server\share`. +- Reject any segment equal to `..`. +- Resolve output path against temp install directory and verify it remains inside that directory. +- Reject duplicate normalized paths. +- Reject case-insensitive path collisions. +- Reject symlinks/hardlinks/special files if exposed by the zip library. +- Reject encrypted or unsupported entries. +- Never execute files. +- Do not preserve executable permissions. +- Validate compressed and uncompressed sizes during extraction; do not trust metadata only. +- Reject extra non-directory files beyond the accepted normalized final shape: `pet.json` and `spritesheet.webp`. + +Removal path safety: + +- Derive removal path only from `/pets/`. +- Verify resolved path remains inside the pets root before deletion. +- Never remove paths from persisted state directly. + +Mandatory tests/checks must cover malicious path cases. + +## App state changes + +Extend the existing V1 app state shape in a backward-compatible way. Do not introduce a V2 state migration in Phase 04B unless implementation proves V1 extension is insufficient. + +Recommended installed pet state: + +```ts +interface InstalledPetState { + id: string; + displayName: string; + description?: string; + builtIn: boolean; + protected: boolean; + installed: boolean; + source?: { + catalogVersion: 2; + zip: string; + preview: string; + }; + broken?: boolean; + brokenReason?: string; +} +``` + +Rules: + +- Built-in pet remains always present/protected. +- Default pet id must point to an installed pet or fall back to built-in. +- Removing current default sets default to built-in before/with state update. +- If installed files are missing/corrupt on startup, mark the pet broken and fall back to built-in if needed. Do not crash. +- App state is updated only after file operations succeed. +- Broken installed pet policy: mark the pet as `broken: true`, keep it removable in Pet Manager, and fall back to built-in if it is/was the default. +- Do not auto-remove broken pets in Phase 04B. +- Persisted state should not store absolute local paths. + +State operation order: + +- Install: download zip → validate archive → extract to temp → validate final files → move to final directory → write state → report success. +- Install failure before state write: clean temp/final partial files and leave state unchanged. +- Remove non-default: validate derived removal path → remove installed record from state → delete files; if file deletion fails, keep state consistent and show a warning/error. +- Remove current default: update state so default falls back to built-in and installed record is removed atomically, refresh pet window, then delete files. +- Default switch: validate installed/non-broken pet → update state → refresh pet window; if state write fails, do not refresh/report success. +- Operation locks prevent install/remove/default races for the same pet id. + +## Default pet rendering + +Phase 04B does not need final animation engine quality. + +Rendering rules: + +- Built-in pet keeps current generated renderer. +- Installed default pet uses local installed `spritesheet.webp` as a static visible image inside the existing 180x180 pet window. +- Do not load remote image URLs in the pet window. +- Main process reads local installed `spritesheet.webp`, size-checks it, converts it to `data:image/webp;base64,...`, and passes that data URL into generated pet HTML. +- Pet window CSP must allow `img-src data:` and continue to deny remote content. +- Do not use broad `file://` access to `userData` in the pet renderer. +- If installed image fails, fallback to built-in generated renderer and log clearly. +- If installed image fails for the selected default, mark installed pet broken where practical and fall back to built-in rendering. +- Setting default should refresh the visible pet window immediately. + +## Pet Manager UI behavior + +Catalog card actions: + +- Not installed: `Install`. +- Installing: disabled `Installing…`. +- Installed and not default: `Set Default`, `Remove`. +- Installed and default: disabled `Default`, `Remove` if removable. +- Built-in: disabled protected remove. + +Show clear errors for install/remove failures. + +Disable duplicate actions while an operation for a pet is in progress. + +## Internal IPC/preload API + +Extend the current narrow internal UI API: + +```ts +installPet(petId: string): Promise +removePet(petId: string): Promise +setDefaultPet(petId: string): Promise +``` + +Rules: + +- Sender must be Pet Manager. +- `petId` must be a safe known catalog/installed id. +- No generic invoke API. +- No filesystem/shell/Electron exposure. +- Main process validates all operation preconditions. + +## Dependencies + +Phase 04B may add a zip library only after checking current, maintained npm options. + +Requirements for zip library choice: + +- Works on Node/npm without Bun runtime. +- Supports safe iteration/extraction or exposes enough metadata to enforce safety policy. +- Actively maintained. +- Does not require native build steps if avoidable. + +Use librarian/docs lookup before selecting the library. + +Current recommendation to verify before implementation: + +- Use a maintained pure-JS zip library such as `yauzl`/successor or another actively maintained package that supports lazy entry iteration and exposes entry metadata. +- Document final chosen library and rationale in this phase doc before or during implementation review. + +Final Phase 04B choice: + +- Use `yauzl@3.3.0` with `lazyEntries: true` and `validateEntrySizes: true`. +- Rationale: pure JavaScript, works with Node/npm without Bun or native build steps, exposes central-directory entry metadata before extraction, supports sequential entry processing, exposes compressed/uncompressed sizes, encryption flags, compression method, and external file attributes needed for fail-closed validation. +- Phase 04B extraction still applies OpenPets' own path, size, duplicate, collision, file type, and final-shape validation instead of relying only on the library defaults. + +## Risks and tradeoffs + +### Risk: zip extraction security bugs + +Mitigation: + +- Mandatory path-safety tests. +- Mandatory zip magic/central-directory checks where supported by chosen library. +- Temp extraction only. +- Fail closed on suspicious entries. +- Oracle implementation review focuses heavily on install safety. + +### Risk: app state/file operation inconsistency + +Mitigation: + +- Update state only after final install succeeds. +- On remove, update default fallback and state in a safe order. +- Handle partial delete/orphan files by keeping app usable and showing clear state. + +### Risk: rendering scope creep + +Mitigation: + +- Static local spritesheet preview only. +- Defer animation engine to later. + +## Security/privacy notes + +- No telemetry. +- No cookies/auth/accounts. +- No remote HTML. +- No script execution from downloaded pets. +- No shell command execution. +- No pet zip contents outside OpenPets userData paths. +- All downloaded files are untrusted. +- Fail closed on validation errors. + +## Test/check plan + +Automated checks: + +```bash +pnpm check +pnpm typecheck +pnpm build +``` + +Phase 04B must add automated checks for: + +- Safe zip path normalization. +- Rejection of traversal/absolute/Windows/UNC/NUL paths. +- Duplicate/case-insensitive collision rejection. +- Zip magic/non-zip rejection. +- Fixture/current catalog zip shape validation where practical. + +## Manual verification guide + +1. Run: + + ```bash + pnpm --filter @open-pets/desktop dev + ``` + +2. Open `Manage Pets...`. +3. Install a catalog pet, for example Snoopy. +4. Confirm it appears installed. +5. Set it as default. +6. Confirm floating pet visibly changes to the installed pet image. +7. Quit/restart and confirm installed/default state persists. +8. Remove the installed pet. +9. Confirm default falls back to built-in. +10. Confirm built-in cannot be removed. +11. Confirm catalog/search/settings still work. + +Manual acceptance question: + +```text +Does Phase 04B pass on your machine: safe install works, installed default rendering works, remove/fallback works, and built-in protection remains reliable? +``` + +## Oracle plan review + +Oracle reviewed the initial Phase 04B spec and blocked implementation until state schema, broken pet policy, install/reinstall behavior, zip validation, removal safety, rendering strategy, and dependency choice were sharpened. + +## Oracle feedback disposition + +- Fixed: Chose backward-compatible V1 state extension instead of V2 migration. +- Fixed: Defined broken installed pet policy: mark broken/removable, fallback to built-in, do not auto-remove. +- Fixed: Explicitly allowed install from validated fixture fallback during development. +- Fixed: Forbid reinstall/overwrite in Phase 04B. +- Fixed: Required install/remove/default state write failures to propagate. +- Fixed: Required final installed directory to contain only `pet.json` and `spritesheet.webp`. +- Fixed: Added zip magic/central-directory validation requirement. +- Fixed: Required streaming download byte limit and timeout through full body read. +- Fixed: Added removal path safety rules. +- Fixed: Chose main-process data URL rendering with `img-src data:` CSP. +- Fixed: Required chosen zip library and rationale to be documented before/with implementation. + +Implementation review disposition: + +- Fixed: Open `yauzl` with `strictFileNames: true` so backslash entries are rejected before extraction instead of normalized. +- Fixed: App state writes now write the candidate state to disk before mutating in-memory state, preventing state/disk divergence on write failure. +- Fixed: Persisted installed pet ids are revalidated during normalization; invalid entries are dropped. +- Fixed: Startup normalization marks installed pets broken when required local files are missing/corrupt and falls back to built-in if the persisted default is broken. +- Fixed: Zip checks now include duplicate/case-collision entry sets, non-zip magic rejection, empty path segment rejection, and an archive-level `yauzl` `strictFileNames` backslash rejection check. +- Fixed: Remove-file deletion failures now report that state removal succeeded but local files may require manual cleanup. +- Fixed: Tightened initial zip magic acceptance to normal local-file and empty-archive signatures. +- Deferred: Broader archive-level tests for encrypted entries, unsupported compression, and symlink/special-file metadata remain valuable but are not required before manual Phase 04B verification because the implementation rejects those through entry metadata before writing file contents. diff --git a/docs/phases/phase-05-local-ipc-client.md b/docs/phases/phase-05-local-ipc-client.md new file mode 100644 index 00000000..728c567d --- /dev/null +++ b/docs/phases/phase-05-local-ipc-client.md @@ -0,0 +1,368 @@ +# Phase 05: Local IPC and client package + +## Goal + +Create the private local control plane between the Electron desktop app and Node-based adapters such as `@open-pets/mcp`, `@open-pets/claude`, and `@open-pets/cli`. + +After this phase, a developer can run the desktop app and use `@open-pets/client` from Node to call local IPC methods that report app status and make the visible default pet react in a small, manually verifiable way. + +## Non-goals + +- MCP server package implementation. +- Claude hooks implementation. +- Auto-launching the desktop app from the client. +- Remote HTTP/TCP API. +- Agent lease lifecycle and heartbeat cleanup. +- Full speech bubble product UI. +- Rich animation-state engine. +- Production installer integration. +- Pet install/remove/default over public IPC. + +## User-visible/manual outcome + +With the desktop app running, a developer can run a local Node command from `packages/client` that connects through a discovery file to the desktop app and calls: + +```text +status +pet.react +pet.say +``` + +The desktop app rejects invalid tokens/versions. `pet.react` and `pet.say` produce a visible temporary state in the existing default pet window, enough to prove the local control plane works before MCP is built. + +## Acceptance criteria + +- Desktop app starts a local-only IPC server on app ready. +- IPC transport is cross-platform: + - macOS/Linux: Unix domain socket. + - Windows: named pipe. +- Desktop app writes a discovery file while the IPC server is running. +- Discovery file includes protocol version, endpoint, random startup token, app version, platform, and pid. +- Discovery file does not contain durable secrets beyond the current startup token. +- Discovery file and runtime directory use restrictive permissions where supported. +- Unix socket paths are short enough for common `sockaddr_un` limits. +- Unix stale socket files are cleaned up before bind and on normal shutdown. +- Windows named pipe path uses the `\\.\pipe\...` namespace and does not depend on filesystem cleanup. +- Every request includes protocol version and startup token. +- Invalid token requests are rejected. +- Invalid protocol version requests are rejected. +- Malformed JSON and unknown methods return errors without crashing the app. +- IPC request size is bounded. +- The server handles socket/client errors without crashing. +- `@open-pets/client` can read discovery, connect, send one request, and return typed results. +- `@open-pets/client` has connection/request timeout behavior. +- Initial methods are implemented: + - `hello` + - `status` + - `pet.react` + - `pet.say` +- `status` reports whether the app is reachable, app version, protocol version, default pet id, default pet display name, and paused/open-default-pet state where available. +- `pet.react` accepts the initial public reaction names from `task.txt` and visibly updates the default pet label/state temporarily. +- `pet.say` accepts a short message, enforces the Phase speech/message safety rules at a minimal level, and visibly updates the default pet label/message temporarily. +- Public IPC does not expose pet installation, removal, default switching, filesystem, shell, Electron, or generic invoke access. +- Automated checks cover protocol validation and client request/response parsing where practical. + +## Proposed files/directories + +Expected additions/changes: + +```text +docs/phases/phase-05-local-ipc-client.md +apps/desktop/src/local-ipc.ts +apps/desktop/src/local-ipc-protocol.ts +apps/desktop/src/default-pet-controller.ts +apps/desktop/src/pet-window.ts +apps/desktop/src/main.ts +packages/client/src/index.ts +packages/client/src/protocol.ts +packages/client/src/discovery.ts +packages/client/src/check-client-protocol.ts +packages/client/package.json +``` + +Exact file split may change during implementation if a simpler structure is clearer. + +## Technical approach + +### Transport + +Use Node `node:net` in the Electron main process and in `@open-pets/client`. + +Platform endpoint rules: + +- macOS/Linux: create a Unix socket under a user-owned OpenPets runtime directory. +- Windows: create a named pipe path like `\\.\pipe\openpets--`. + +No TCP listener is used in Phase 05. + +### Runtime directory and discovery + +Use a shared deterministic discovery location so the app and client can agree without Electron imports in the client package. + +Discovery file location: + +```text +macOS: ~/Library/Application Support/OpenPets/runtime/ipc.json +Linux: $XDG_RUNTIME_DIR/openpets/ipc.json when secure, otherwise ~/.config/OpenPets/runtime/ipc.json +Windows: %APPDATA%\OpenPets\runtime\ipc.json +``` + +Socket/pipe endpoint location: + +```text +macOS: /tmp/openpets-/openpets-.sock +Linux: $XDG_RUNTIME_DIR/openpets/openpets-.sock when secure, otherwise /tmp/openpets-/openpets-.sock +Windows: \\.\pipe\openpets-- +``` + +Do not put Unix socket files under the longer application-support discovery directory because common Unix socket path length limits are around 103-107 bytes. + +Implementation can centralize path derivation constants in both desktop and client, as long as both sides agree and the path is documented. + +Permission targets: + +- Runtime directories: `0700` where supported. +- Discovery file: `0600` where supported. +- Unix socket: protected by the runtime directory and `chmod 0600` after bind where supported. +- Windows: rely on user AppData ACLs plus the startup token in Phase 05; do not claim a strong named-pipe ACL unless it is implemented. + +Discovery shape: + +```json +{ + "protocolVersion": 1, + "protocol": "openpets-ipc", + "endpoint": "/short/path/openpets.sock", + "token": "random-startup-token", + "appVersion": "0.0.0", + "pid": 12345, + "platform": "darwin" +} +``` + +Write discovery atomically through a temp file then rename where practical. Remove the discovery file on normal app shutdown if it still points at the current pid/token. + +Treat discovery as untrusted in `@open-pets/client`: reject malformed JSON, wrong protocol, wrong protocol version, mismatched platform, unsupported endpoint shape, TCP-like host/port values, missing token, missing pid, and oversized discovery files. + +Threat model: the startup token protects against accidental or cross-user access when file permissions hold. It is not a strong same-user security boundary. A same-user process that can read the discovery file can control the pet for that app run. + +### Protocol + +Use newline-delimited JSON over the local socket/pipe, one request and one response per connection in Phase 05. This keeps the client simple and avoids stream multiplexing before MCP needs it. + +Protocol limits: + +```text +max request bytes: 16 KiB +max response bytes in client: 16 KiB +connect timeout: 2 seconds +response timeout: 3 seconds +malformed/no-newline behavior: close with structured error when possible, otherwise close socket +``` + +Request: + +```json +{ + "id": "uuid-or-random-id", + "version": 1, + "token": "startup-token", + "method": "status", + "params": {} +} +``` + +Response: + +```json +{ + "id": "same-id", + "ok": true, + "result": {} +} +``` + +Error response: + +```json +{ + "id": "same-id-if-known", + "ok": false, + "error": { + "code": "invalid_token", + "message": "Invalid IPC token." + } +} +``` + +### Methods + +`hello`: + +- Confirms protocol compatibility and token validation. +- Returns app version and protocol version. + +`status`: + +- Returns current default pet summary and basic app state. + +`pet.react`: + +- Params: `{ "reaction": "testing" }`. +- Valid reactions: `idle`, `thinking`, `working`, `editing`, `running`, `testing`, `waiting`, `success`, `error`, `celebrating`. +- Updates the existing default pet renderer with a visible label/state for a short TTL. + +`pet.say`: + +- Params: `{ "message": "On it!", "reaction": "working" }`. +- Message must be short, single-line, not code-block-like, and not obviously secret-like. +- Updates the existing default pet renderer with a visible message for a short TTL. +- Respects `speechBubblesEnabled` and paused state where practical. +- Replaces the current transient message/reaction and resets its TTL instead of queuing unbounded messages. +- All dynamic message/reaction/display text must be HTML-escaped before renderer insertion. + +### Client package + +`@open-pets/client` should expose typed helpers: + +```ts +createOpenPetsClient(options?): OpenPetsClient +client.hello() +client.status() +client.react(reaction) +client.say(message, options?) +``` + +The client reads discovery by default, connects to the endpoint, sends exactly one request, waits for one response, then closes. + +Unavailable/stale discovery semantics: + +- `client.status()` should return a typed unavailable/degraded result when discovery is missing, stale, malformed, or connection times out. +- Other method helpers may throw structured client errors for unavailable app state. + +### Desktop integration + +- Start IPC after app state and default-pet display handlers are initialized. +- Stop IPC during app shutdown. +- `pet.react` / `pet.say` should show the default pet if open-default-pet-on-launch is enabled or if the default pet window is already visible. It should not create extra pet windows beyond the default pet in Phase 05. +- Keep renderer content generated by the main process with a strict CSP. No remote content. + +## Risks and tradeoffs + +### Risk: local IPC security assumptions + +Mitigation: + +- Local-only socket/pipe; no TCP. +- Random startup token required on every request. +- Restrictive runtime directory, discovery file, and Unix socket permissions where available. +- Small method surface. +- Request size limit and fail-closed validation. + +### Risk: cross-platform runtime path differences + +Mitigation: + +- Keep endpoint and discovery path logic small and documented. +- Prefer short Unix socket paths. +- Avoid clever platform-specific IPC beyond Node `net`. + +### Risk: implementing too much MCP lifecycle too early + +Mitigation: + +- Phase 05 does not implement leases, heartbeats, MCP tools, auto-launch, or cleanup semantics for agent-owned temporary pets. +- It only proves the private app/client control plane. + +## Security/privacy notes + +- No remote API. +- No TCP listener. +- No shell command execution. +- No pet install/remove/default mutation over this initial public IPC. +- Startup token is random and per app run. +- Requests are bounded and parsed as untrusted input. +- `pet.say` must not display multiline/code/secret-looking content. +- Discovery file is a local convenience file, not durable account/auth state. + +## Test/check plan + +Automated checks: + +```bash +pnpm check +pnpm typecheck +pnpm build +pnpm --filter @open-pets/desktop check +pnpm --filter @open-pets/client check +``` + +Phase 05 should add checks for: + +- Discovery validation rejects malformed/wrong protocol/wrong platform/bad endpoint values. +- Endpoint generation covers Unix and Windows pipe shape without requiring Windows to run tests. +- Request validation rejects missing/invalid token. +- Request validation rejects unsupported protocol version. +- Unknown methods return structured errors. +- Malformed/oversized request handling is fail-closed. +- `pet.say` message validation rejects multiline, code-block-like, overlong, URL/path-heavy, or secret-looking input where practical. +- Client protocol parsing handles ok/error responses. + +## Manual verification guide + +1. Run the desktop app: + + ```bash + pnpm --filter @open-pets/desktop dev + ``` + +2. In another terminal, run the client smoke command added by this phase, for example: + + ```bash + pnpm --filter @open-pets/client smoke:status + pnpm --filter @open-pets/client smoke:react testing + pnpm --filter @open-pets/client smoke:say "Working on it" working + ``` + +3. Confirm status returns app/default-pet information. +4. Confirm invalid token smoke check fails with a structured rejection. +5. Confirm the visible default pet label/state changes for react/say. +6. Quit OpenPets and confirm the client reports that the app is unavailable. + +Manual acceptance question: + +```text +Does Phase 05 pass on your machine: local IPC discovery works, @open-pets/client can call status/react/say, invalid tokens are rejected, and quitting the app makes the client report unavailable? +``` + +## Oracle plan review + +Oracle reviewed the Phase 05 plan and approved the architecture/scope with should-fix disposition required before implementation. + +## Oracle feedback disposition + +- Fixed: Made discovery path and socket/pipe endpoint paths concrete, keeping Unix socket paths short. +- Fixed: Required client-side discovery validation as untrusted input and forbade TCP-like discovery behavior. +- Fixed: Added concrete permission targets for runtime directories, discovery files, Unix sockets, and clarified Windows limitations. +- Fixed: Clarified same-user threat model: startup token is not a strong same-user security boundary. +- Fixed: Defined stale/unavailable discovery semantics for `client.status()`. +- Fixed: Added numeric protocol limits for request/response bytes and timeouts. +- Fixed: Required `pet.say` to respect speech/paused controls where practical, replace transient messages, and HTML-escape dynamic text. +- Fixed: Tightened automated check requirements for discovery validation, endpoint generation, invalid token/version/method, malformed/oversized input, say validation, and client response parsing. +- Fixed: Noted package Node typings/build requirement for `@open-pets/client`. +- Deferred: Environment override such as `OPENPETS_DISCOVERY_FILE` is useful for smoke tests and may be implemented if it stays simple. + +Implementation review disposition: + +- Fixed: Escaped built-in pet transient label/message text before injecting into generated pet HTML. +- Fixed: External `pet.react`/`pet.say` no longer force-show the default pet when the user has hidden it and disabled open-default-pet-on-launch; events only show an already-visible pet or the normal open-on-launch default pet. +- Fixed: Hardened fallback `/tmp/openpets-` runtime directory handling with ownership, symlink, and mode checks before trusting it. +- Fixed: Discovery file parsing now checks file size before read and wraps malformed JSON in structured client errors. +- Fixed: Discovery cleanup now removes the file only if it still points at the current pid/token/endpoint. +- Fixed: Client-side Unix endpoint validation now only accepts expected OpenPets runtime socket filename/directory patterns. +- Fixed: Paused behavior is quiet: `pet.say` and `pet.react` do not show/update visible transient state while paused. +- Fixed: Windows pipe suffix now uses cryptographic random bytes instead of `Math.random()`. +- Fixed: Reused the protocol transient display TTL constant instead of a hardcoded value. +- Fixed: Corrected macOS/Linux fallback socket endpoint generation to use literal `/tmp/openpets-` so it matches the documented path and client validation. +- Fixed: Malformed/oversized local IPC requests now return `invalid_request` structured errors instead of generic internal errors where possible. +- Deferred: Response id matching, stronger Windows pipe ACLs, and broader cross-platform endpoint-generation tests can be added with the MCP/packaging phases if needed. diff --git a/docs/phases/phase-06a-thin-mcp-tools.md b/docs/phases/phase-06a-thin-mcp-tools.md new file mode 100644 index 00000000..dbf4b2d7 --- /dev/null +++ b/docs/phases/phase-06a-thin-mcp-tools.md @@ -0,0 +1,356 @@ +# Phase 06A: Thin `@open-pets/mcp` tools over local IPC + +## Goal + +Make OpenPets minimally usable from MCP-capable coding agents through a thin Node/npm/npx MCP server package over the existing Phase 05 local IPC methods. + +This sub-phase turns the Phase 05 local IPC control plane into the first agent-facing integration: + +```text +MCP-capable agent → @open-pets/mcp stdio server → @open-pets/client → desktop local IPC → visible pet +``` + +## Non-goals + +- Claude Code config detection/setup UI. +- Claude enhanced hooks. +- Agent Setup window implementation. +- Pet install/remove/default tools exposed to agents. +- Remote HTTP/TCP MCP server. +- Full packaging/release publishing. +- Complex session/project routing. +- Advanced animation engine. +- Direct app auto-install. MCP may try to launch an installed app later, but must not install it. + +## User-visible/manual outcome + +After this phase, a developer can run the local `@open-pets/mcp` package as a stdio MCP server, call: + +```text +openpets_status +openpets_say +openpets_react +``` + +and see the running OpenPets desktop pet respond through local IPC. + +If OpenPets is not running, the MCP server remains alive and `openpets_status` reports a clear degraded/unavailable status instead of crashing. + +This phase is explicitly **not** the full Phase 06 lease lifecycle. Full real `--pet` routing, non-default temporary pet windows, lease heartbeat/release, fallback for missing explicit pets, and TTL cleanup are split into follow-up Phase 06B. + +## Acceptance criteria + +- `@open-pets/mcp` builds and runs with Node/npm/pnpm and is shaped for future `npx -y @open-pets/mcp` usage. +- Package has a `bin` entry and compiled entrypoint has a Node shebang. +- MCP server uses stdio transport and never writes logs to stdout. +- MCP server exposes exactly these initial tools: + - `openpets_status` + - `openpets_say` + - `openpets_react` +- `openpets_status` calls `@open-pets/client.status()` and reports reachable/degraded app state as both human-readable text and structured content where supported. +- `openpets_say` calls `@open-pets/client.say()` with the user-provided short message and optional reaction. +- `openpets_react` calls `@open-pets/client.react()` with one allowed reaction. +- MCP tool schemas enforce the public reaction list from `task.txt`. +- MCP tool schemas keep `openpets_say.message` short enough for Phase 05 IPC limits and speech safety. +- Tool errors return MCP tool errors (`isError: true`) rather than crashing the server. +- If desktop app/discovery/local IPC is unavailable, `openpets_status` reports degraded state clearly. +- If desktop app/discovery/local IPC is unavailable, `openpets_say` and `openpets_react` return clear tool errors. +- No tool can install pets, remove pets, set default pet, browse catalog, edit settings, access filesystem, run shell commands, or expose generic IPC. +- `--pet ` is parsed and included in MCP status/config output. +- `--pet` is clearly reported as configured-but-not-yet-routed in Phase 06A; actual tools still affect the desktop app's current default pet through Phase 05 IPC. +- `openpets_status` clearly distinguishes `configuredPetId` from `actualTargetPetId`. +- If `--pet` is omitted, status reports that the MCP server targets the user's current default pet. +- MCP process lifecycle handlers close the MCP server cleanly on `SIGINT`/`SIGTERM` where available. +- No Bun runtime requirement. +- Automated checks cover MCP argument parsing, tool input validation helpers, and degraded status formatting where practical. + +## Scope note: Phase 06A versus 06B + +The high-level Phase 06 target in `docs/implementation-process.md` includes immediate lease acquire/release, `--pet` opening explicit non-default pets, fallback for missing explicit pets, and TTL cleanup. + +Current implementation state after Phase 05 provides `hello`, `status`, `pet.react`, and `pet.say` over local IPC, but does not yet expose `lease.acquire`, `lease.heartbeat`, or `lease.release`, and the desktop app still has only a persistent default-pet window. + +Oracle plan review required this split. This spec is Phase 06A: + +```text +Phase 06A: thin MCP tools over default-pet IPC +Phase 06B: MCP lease lifecycle and explicit non-default pet windows +``` + +Do not silently expand Phase 06A into lease lifecycle work. + +## Proposed files/directories + +Expected additions/changes: + +```text +docs/phases/phase-06a-thin-mcp-tools.md +packages/mcp/package.json +packages/mcp/src/index.ts +packages/mcp/src/args.ts +packages/mcp/src/tools.ts +packages/mcp/src/check-mcp-contract.ts +packages/mcp/tsconfig.json +packages/client/src/index.ts (only if minor client result typing is needed) +pnpm-lock.yaml +``` + +Exact file split may change if a simpler implementation is clearer. + +## Technical approach + +### MCP SDK + +Use the stable official TypeScript MCP SDK for this npm/npx-facing package: + +```text +@modelcontextprotocol/sdk +zod +``` + +Do not use alpha split SDK packages unless the stable SDK proves incompatible and the reason is documented. + +### Server shape + +- Entry file starts with `#!/usr/bin/env node`. +- `@open-pets/client`, `@modelcontextprotocol/sdk`, and the schema validation library are runtime dependencies when used by the MCP server. +- Create MCP server with name `open-pets` / package name `@open-pets/mcp`. +- Connect using stdio transport. +- Use stderr for diagnostics only. +- Keep stdout reserved for MCP protocol. +- Register three tools with small descriptions and schemas. +- Keep tool handlers pure/testable by injecting the client/status functions where practical. + +### Tools + +`openpets_status`: + +- No required params. +- Returns app availability, configured `--pet` target if present, default-pet status from the desktop app, and guidance if unavailable. +- Should be safe/read-only/idempotent. +- Sanitizes degraded/unavailable errors before returning them to agents; do not expose home paths, socket paths, tokens, or raw Node stack details. + +Structured status shape: + +```ts +interface OpenPetsMcpStatus { + ok: boolean; + appRunning: boolean; + configuredPetId?: string; + actualTargetPetId?: string; + actualTargetPetName?: string; + usingDefaultPet: boolean; + routingImplemented: false; + unavailableReason?: string; + fallbackReason?: string; +} +``` + +`openpets_react`: + +Params: + +```json +{ + "reaction": "testing" +} +``` + +Valid reactions: + +```text +idle +thinking +working +editing +running +testing +waiting +success +error +celebrating +``` + +`openpets_say`: + +Params: + +```json +{ + "message": "Working on it", + "reaction": "working" +} +``` + +Rules: + +- Message max 140 characters. +- Single-line. +- No code-block-like content. +- No obvious secrets, URLs, or long path-like content. +- Optional reaction must be from the public reaction list. + +The desktop IPC remains the final enforcement layer; MCP schemas are an early validation layer. + +### CLI args + +Parse only the initial simple args: + +```text +--pet +--help +--version +``` + +Rules: + +- Pet id uses the same safe id shape as catalog pets. +- Do not add `--agent` or `--workspace`. +- Do not make `--pet` silently install pets. +- For Phase 06A-style behavior, `--pet` is reported in status but may not yet open non-default pets until lease lifecycle is implemented. +- If `--pet` is provided, `openpets_status` must explicitly say explicit pet routing is not implemented in Phase 06A and the actual target remains the desktop default pet. + +### Degraded behavior + +`@open-pets/client.status()` already returns a typed unavailable result when discovery/connect fails. MCP should preserve this behavior in `openpets_status`. + +For `say`/`react`, return `isError: true` with a concise message such as: + +```text +OpenPets desktop app is not running or local IPC is unavailable. Open OpenPets and try again. +``` + +### Lifecycle + +- Handle `SIGINT`/`SIGTERM` by closing MCP server/transport where supported. +- Keep server alive even if OpenPets desktop app is unavailable. +- Do not implement public `openpets_release`. + +Phase 06B lease lifecycle must later: + +- Acquire on MCP startup, not first tool call. +- Heartbeat while alive. +- Release on shutdown/signals/stdin close where practical. +- Use desktop TTL fallback for orphan cleanup. +- Fall back to default pet for missing explicit pet and report fallback in status. + +## Risks and tradeoffs + +### Risk: SDK API drift + +Mitigation: + +- Verify current official MCP TypeScript package before implementation. +- Keep SDK wrapper code small. +- Add package-local checks. + +### Risk: stdout corruption + +Mitigation: + +- Never use `console.log` in MCP runtime path. +- Use stderr for diagnostics. + +### Risk: scope creep into full lease/multi-pet lifecycle + +Mitigation: + +- Thin MCP tools over existing local IPC are the manually verifiable Phase 06A checkpoint. +- Lease lifecycle is explicitly Phase 06B. + +### Risk: agents displaying private content through `openpets_say` + +Mitigation: + +- MCP schema limits message shape. +- Desktop IPC validates speech safety again. +- Tool descriptions explicitly say not to send code, logs, secrets, URLs, or file paths. + +## Security/privacy notes + +- MCP server is stdio only. +- No TCP/HTTP listener. +- No filesystem/shell/Electron access exposed to agents. +- Agent-facing tool surface stays minimal. +- Pet management remains user-controlled in the desktop app. +- `openpets_say` is for short status/personality messages only. +- No Bun runtime dependency. + +## Test/check plan + +Automated checks: + +```bash +pnpm check +pnpm --filter @open-pets/mcp check +pnpm --filter @open-pets/mcp build +``` + +Phase 06A should add checks for: + +- CLI arg parsing for `--pet`, `--help`, `--version`, invalid args. +- Reaction validation accepts only public reactions. +- `openpets_say` validation rejects overlong/multiline/code/secret/path/URL-like messages. +- Degraded status formatting when client reports unavailable. +- Built output has a shebang or package bin points at the expected executable. +- MCP contract check starts the stdio server and verifies `tools/list` returns exactly `openpets_status`, `openpets_say`, and `openpets_react`. +- Contract check verifies unavailable app does not crash the MCP server. +- Contract check verifies no unexpected stdout noise outside MCP protocol messages. + +## Manual verification guide + +1. Run desktop app: + + ```bash + pnpm --filter @open-pets/desktop dev + ``` + +2. Build MCP package: + + ```bash + pnpm --filter @open-pets/mcp build + ``` + +3. Run an MCP inspector or local MCP client against the package entrypoint. + +4. Call `openpets_status` and confirm it reports OpenPets reachable/default pet info. + +5. Call `openpets_react` with `testing` and confirm the visible pet reacts. + +6. Call `openpets_say` with `Working on it` and `working` and confirm the visible pet message appears. + +7. Quit OpenPets and call `openpets_status` again; confirm it reports degraded/unavailable clearly while MCP remains alive. + +Manual acceptance question: + +```text +Does Phase 06A pass on your machine: @open-pets/mcp runs over stdio, exposes status/say/react, talks to the running desktop app through local IPC, reports configured `--pet` as not-yet-routed, and reports degraded status when OpenPets is unavailable? +``` + +## Oracle plan review + +Oracle reviewed the initial Phase 06 plan and blocked calling it complete Phase 06 because real lease lifecycle and `--pet` routing are larger cross-cutting desktop/client work. Oracle approved the architecture only as Phase 06A. + +## Oracle feedback disposition + +- Fixed: Renamed/split scope to Phase 06A thin MCP tools over local IPC. +- Fixed: Deferred lease lifecycle, real non-default `--pet` windows, heartbeat/release, fallback, and TTL cleanup to Phase 06B. +- Fixed: Required status/manual docs to clearly distinguish configured `--pet` from actual default-pet target in Phase 06A. +- Fixed: Switched SDK plan to stable `@modelcontextprotocol/sdk` instead of alpha split package. +- Fixed: Required runtime dependencies for `@open-pets/client`, MCP SDK, and schema validation library. +- Fixed: Promoted runtime dependency requirement into the package/server acceptance criteria. +- Fixed: Corrected expected spec path and Phase 06A check wording after rename. +- Fixed: Tightened package/npx acceptance: bin entry, shebang, executable compiled entry, no Bun, no stdout logs. +- Fixed: Defined structured MCP status shape. +- Fixed: Required sanitized degraded errors so local paths/tokens/raw Node details are not returned to agents. +- Fixed: Required MCP contract check for tool list, invalid inputs, unavailable app behavior, and stdout protocol cleanliness. +- Fixed: Clarified tool error semantics: unavailable app returns `isError: true` for say/react rather than crashing the server. +- Deferred: Auto-launch of installed OpenPets app is explicitly left for Phase 06B or packaging phase. + +Implementation review disposition: + +- Fixed: Replaced POSIX-only `chmod +x` build script with a Node-based post-build executable step that no-ops on Windows. +- Fixed: Replaced POSIX `X_OK` contract assertion with shebang/bin validation. +- Fixed: MCP package `check` builds `@open-pets/client` first so package-local checks do not depend on stale client `dist` output. +- Fixed: Added stdio smoke contract coverage using the built `dist/index.js`, verifying tool list and degraded `openpets_status` with a missing discovery file. +- Fixed: Unavailable `openpets_status` human text now still explains configured `--pet` and Phase 06A deferred routing. diff --git a/docs/phases/phase-06b-mcp-lease-lifecycle.md b/docs/phases/phase-06b-mcp-lease-lifecycle.md new file mode 100644 index 00000000..c563a4e8 --- /dev/null +++ b/docs/phases/phase-06b-mcp-lease-lifecycle.md @@ -0,0 +1,461 @@ +# Phase 06B: MCP lease lifecycle and explicit pet routing + +## Goal + +Complete the core MCP runtime behavior that Phase 06A intentionally deferred: MCP startup should acquire a desktop-app lease for the configured/default pet, explicit non-default `--pet` values should open temporary pet windows, leases should heartbeat while alive, and temporary explicit pets should close when their last active lease ends or expires. + +This phase turns the Phase 06A thin tool path into the real agent-neutral lifecycle described in `task.txt`: + +```text +@open-pets/mcp --pet snoopy starts + → desktop app receives lease.acquire + → snoopy window opens as temporary agent pet + → tools target snoopy through lease id + → MCP exits or crashes + → release or TTL cleanup removes lease + → snoopy closes if no other lease/user pin needs it +``` + +## Non-goals + +- Claude Code detection/configuration UI. +- Claude enhanced hooks. +- Agent Setup window implementation. +- Auto-launching installed OpenPets desktop app from MCP. +- Production packaging/release validation. +- Pet install/remove/default tools exposed to agents. +- Complex agent/session/workspace routing beyond one target pet per MCP process. +- Remote HTTP/TCP MCP server. +- Advanced animation engine. + +## User-visible/manual outcome + +After this phase: + +- Running `@open-pets/mcp` without `--pet` acquires a lease for the desktop default pet and tools affect the default pet. +- Running `@open-pets/mcp --pet ` opens that installed pet as a temporary agent pet and tools affect that pet. +- Quitting the MCP process releases the lease. +- A temporary explicit non-default pet closes after the last active lease using it ends. +- If MCP crashes, desktop TTL cleanup expires the orphaned lease and closes the temporary pet. +- If `--pet` is missing or invalid/uninstalled, desktop falls back to the default pet and `openpets_status` reports the fallback clearly. + +## Acceptance criteria + +- Desktop local IPC adds internal methods: + - `lease.acquire` + - `lease.heartbeat` + - `lease.release` + - lease-scoped `pet.react` + - lease-scoped `pet.say` +- `@open-pets/client` exposes typed helpers for acquiring, heartbeating, releasing, status, say, and react with optional lease id. +- `@open-pets/mcp` acquires a lease during startup after MCP server initialization, not on first tool call. +- `@open-pets/mcp --pet ` sends requested pet id to `lease.acquire`. +- `@open-pets/mcp` accepts a bounded raw `--pet` string and lets desktop validate/fallback; only values that exceed length/control-character limits are fatal CLI errors. +- Raw `--pet` CLI bound: UTF-8 byte length must be 1-128 bytes and must not contain control characters, NUL, path separators, or whitespace-only content. Values that pass this bound but are not safe pet ids are sent to desktop and become `invalid_pet_id` fallback. +- If `--pet` is omitted, lease acquisition targets the current desktop default pet. +- If explicit `--pet` is installed and not broken, desktop opens/shows that pet as a temporary explicit agent pet. +- If explicit `--pet` is missing, not installed, invalid, or broken, desktop falls back to built-in/current default pet and records fallback reason. +- `openpets_status` reports: + - `configuredPetId` + - `leaseId` + - `actualTargetPetId` + - `actualTargetPetName` + - `usingDefaultPet` + - `fallbackReason` when applicable + - `routingImplemented: true` +- `openpets_say` and `openpets_react` target the acquired lease's actual pet. +- If lease acquisition fails because OpenPets is unavailable, MCP remains alive in degraded mode and `openpets_status` reports unavailable clearly. +- In degraded mode, `openpets_say` and `openpets_react` return MCP tool errors without crashing the server. +- MCP sends heartbeat while alive for an acquired lease. +- MCP releases its lease on graceful shutdown paths where practical: + - `SIGINT` + - `SIGTERM` + - normal process exit path + - stdio transport close when exposed by SDK/transport +- Desktop expires stale leases by TTL if MCP crashes or release is not delivered. +- Default pet remains persistent and is never auto-closed due to MCP lease release/expiry. +- Explicit non-default pet windows close after the last active lease for that pet ends/expires. +- Multiple MCP processes using the same explicit non-default pet keep one pet window open until all their leases end. +- Multiple MCP processes using the default pet do not close the default pet on release. +- Temporary explicit pet windows use local installed `spritesheet.webp` data URLs only; no remote images. +- Temporary explicit pet windows are draggable and basic-positioned without needing final multi-monitor polish. +- Operation is local-only; no TCP/HTTP. +- Public MCP tool set remains exactly `openpets_status`, `openpets_say`, `openpets_react`. +- No public MCP tool can install/remove/default pets, edit settings, access filesystem, run shell commands, or expose generic IPC. +- Automated checks cover lease state transitions, fallback behavior, TTL cleanup logic, and MCP status mapping. + +## Proposed files/directories + +Expected changes: + +```text +docs/phases/phase-06b-mcp-lease-lifecycle.md +apps/desktop/src/local-ipc-protocol.ts +apps/desktop/src/local-ipc.ts +apps/desktop/src/lease-manager.ts +apps/desktop/src/agent-pet-controller.ts +apps/desktop/src/pet-window.ts +apps/desktop/src/default-pet-controller.ts +apps/desktop/src/app-state.ts (only if helper accessors are needed) +apps/desktop/src/check-lease-manager.ts +packages/client/src/index.ts +packages/client/src/protocol.ts +packages/client/src/check-client-protocol.ts +packages/mcp/src/index.ts +packages/mcp/src/tools.ts +packages/mcp/src/check-mcp-contract.ts +``` + +Exact file split may change if implementation reveals a simpler structure. + +## Technical approach + +### Lease model + +Desktop app owns lease state in memory only. Leases are not persisted. + +Recommended in-memory shape: + +```ts +interface PetLease { + leaseId: string; + requestedPetId?: string; + targetKind: "default" | "explicit"; + actualPetId: string; + fallbackReason?: string; + acquiredAt: number; + lastHeartbeatAt: number; + expiresAt: number; +} +``` + +Initial timing: + +```text +heartbeat interval from MCP: 5 seconds +desktop lease TTL: 15 seconds +desktop cleanup interval: 5 seconds +``` + +Use generated random lease ids from desktop app. Do not trust client-provided lease ids for acquisition. + +Target-kind rules: + +- `targetKind: "default"` means tools route to the persistent default pet controller and release/expiry must never close the default pet. +- `targetKind: "explicit"` means tools route to a temporary explicit non-default pet window and release/expiry participates in explicit pet refcount cleanup. +- Fallback leases use `targetKind: "default"` even when the user requested an explicit pet. +- If the desktop default changes after a `targetKind: "default"` lease is acquired, that lease continues to target the live default controller. Status should report the live current default pet as actual target. + +### Desktop target resolution + +`lease.acquire` params: + +```json +{ + "requestedPetId": "snoopy" +} +``` + +Resolution rules: + +1. If no `requestedPetId`, actual pet is current default pet. +2. If requested pet is `builtin` or equals current default, actual pet is default pet and remains persistent. This check happens before installed-pet path helpers that reject `builtin`. +3. If requested pet exists, is installed, is not broken, and is not built-in/default, actual pet is requested pet and its temporary window opens. +4. If requested pet is missing/broken/invalid, actual pet is current default pet, `targetKind` is `default`, fallback reason is recorded, and status reports fallback. +5. If current default is somehow missing/broken, use built-in fallback. + +Fallback reasons must be short machine-readable codes, not raw exception text or local paths: + +```text +invalid_pet_id +pet_not_installed +pet_broken +default_broken_fallback_builtin +``` + +### IPC request/result contracts + +`lease.acquire` result includes: + +```json +{ + "leaseId": "random-id", + "requestedPetId": "snoopy", + "targetKind": "explicit", + "actualTargetPetId": "snoopy", + "actualTargetPetName": "Snoopy", + "usingDefaultPet": false, + "fallbackReason": null, + "expiresAt": 1234567890 +} +``` + +`lease.heartbeat` accepts `{ "leaseId": "random-id" }` and returns `{ "leaseId": "random-id", "expiresAt": 1234567890 }`. Unknown/expired lease heartbeat returns structured `unknown_lease` error and MCP marks context degraded/stale. + +`lease.release` accepts `{ "leaseId": "random-id" }` and returns `{ "released": true }`. Release is idempotent for shutdown races; unknown/already-released leases return `{ "released": false }`. + +`status` may accept `{ "leaseId": "random-id" }`. Known leases return target details. Unknown/expired leases return degraded/stale lease status without raw local errors. + +Stale status shape: + +```json +{ + "ok": false, + "appRunning": true, + "leaseId": "random-id", + "leaseActive": false, + "staleReason": "unknown_lease" +} +``` + +### Pet windows + +Current default pet window remains controlled by `default-pet-controller.ts`. + +Add a separate agent/leased pet controller for explicit non-default pets: + +- One `BrowserWindow` per explicit non-default pet id. +- Window uses a safe rendering helper that takes an explicit pet id and reads only local installed `spritesheet.webp` as a data URL. +- Initial position offsets from the default pet/primary work area so temporary pets do not fully overlap. +- Windows are always-on-top/frameless/transparent like default pet. +- Temporary pet windows use `showInactive` / non-focus-stealing behavior like the default pet. +- Position persistence for temporary agent pets is not required in Phase 06B. +- Close temporary window when no active lease targets that pet. + +Avoid storing remote URLs or absolute paths in durable state. + +### Local IPC protocol + +Extend Phase 05 request methods: + +```text +lease.acquire +lease.heartbeat +lease.release +pet.react +pet.say +``` + +`pet.react` and `pet.say` should accept optional `leaseId`; without a lease id they keep Phase 05 default-pet behavior for backward compatibility. + +Validation: + +- Lease id must be a known active lease for lease-scoped say/react. +- Unknown/expired lease id returns structured error. +- Say/reaction safety validation remains enforced in desktop IPC. +- Request byte limits/token/version validation remain unchanged. + +### Client package + +Add typed client helpers: + +```ts +client.acquireLease({ requestedPetId? }) +client.heartbeatLease(leaseId) +client.releaseLease(leaseId) +client.status({ leaseId? }) +client.react(reaction, { leaseId? }) +client.say(message, { reaction?, leaseId? }) +``` + +Keep existing Phase 05/06A call signatures backward-compatible where practical. + +### MCP package + +MCP startup flow: + +1. Parse CLI args. +2. Create MCP server and register tools. +3. Start a shared lease-initialization promise before or during server connection. +4. Connect stdio transport. +5. Tool handlers await the shared lease-initialization promise before using lease state, so tools cannot race ahead of acquisition. +6. If acquire succeeds, store lease context and start heartbeat timer. +7. If acquire fails, remain in degraded mode. + +Tool behavior: + +- `openpets_status` reports lease/degraded state. +- `openpets_say`/`openpets_react` require an active lease to target explicit/default pet; if no lease, return `isError: true` with clear unavailable guidance. + +Shutdown: + +- Clear heartbeat timer. +- Best-effort `lease.release` if a lease exists. +- Close MCP server. +- Do not rely on async work in Node `exit`; signals and transport close are the practical release paths. +- Heartbeat timer should be `unref()` where available so it does not keep an otherwise-dead MCP process alive. +- Heartbeat failures must be caught; unknown lease responses mark context degraded/stale. +- Heartbeat timer should stop after unknown/stale lease to avoid repeated failures. + +Do not write diagnostics to stdout. + +### Auto-launch + +Auto-launch of installed OpenPets desktop app remains deferred. It is important product behavior, but packaging/app discovery is not mature enough yet. Phase 06B focuses on lease lifecycle when the app is already running. + +## Risks and tradeoffs + +### Risk: phase grows into multi-pet architecture refactor + +Mitigation: + +- Keep Phase 06B to temporary explicit pet windows only. +- Do not implement user-pinned non-default pets beyond leases. +- Do not add persistent positions for temporary pets. +- Split again if full multi-pet manager changes become necessary. + +### Risk: orphaned windows after MCP crash + +Mitigation: + +- Desktop TTL cleanup is mandatory. +- MCP release is best-effort, not the only cleanup path. + +### Risk: wrong pet targeted + +Mitigation: + +- Desktop owns requested→actual resolution. +- `openpets_status` reports requested/configured and actual target. +- Missing/broken explicit pets fall back to default with reason. +- `targetKind` separates persistent default-controller routing from explicit temporary pet-window routing. + +### Risk: privacy/spam through lease-scoped speech + +Mitigation: + +- Reuse existing `pet.say` validation and speech settings. +- Keep transient message replacement behavior; no unbounded queue. + +## Security/privacy notes + +- Local-only IPC remains token/version protected. +- Lease ids are random, app-generated, and in-memory only. +- Fallback reasons are sanitized machine-readable codes only. +- No remote API. +- No shell execution. +- No pet installation/removal/default mutation exposed to agents. +- Speech safety rules still apply. +- No durable agent session state is persisted. + +## Test/check plan + +Automated checks: + +```bash +pnpm check +pnpm --filter @open-pets/desktop check +pnpm --filter @open-pets/client check +pnpm --filter @open-pets/mcp check +``` + +Phase 06B should add checks for: + +- Lease acquire default target. +- Lease acquire explicit installed target. +- Invalid CLI `--pet` string is passed to desktop fallback path when bounded. +- Missing/broken requested pet fallback. +- Heartbeat extends expiry. +- Heartbeat unknown lease returns structured stale/degraded behavior. +- Release removes lease. +- Release is idempotent for unknown/already-released lease. +- TTL cleanup expires stale lease. +- Status for expired lease reports stale/degraded behavior. +- Status/errors do not leak local paths/tokens/raw filesystem messages. +- Last lease release/expiry closes explicit temporary pet controller state. +- Multiple leases for same explicit pet keep that window until the final lease ends. +- Default pet lease release/expiry does not close persistent default pet. +- Unknown/expired lease id rejects lease-scoped say/react. +- MCP status maps configured/requested/actual/fallback/routing fields correctly. +- MCP say/react checks prove handlers wait for lease init and pass `leaseId` to the client. + +## Manual verification guide + +1. Start desktop: + + ```bash + pnpm --filter @open-pets/desktop dev + ``` + +2. Ensure at least one extra pet is installed from Pet Manager, for example `snoopy`. + +3. Run MCP without `--pet` in an MCP inspector/client: + + ```bash + node packages/mcp/dist/index.js + ``` + +4. Call `openpets_status`; confirm it reports the current default pet as actual target and `routingImplemented: true`. + +5. Call `openpets_react` / `openpets_say`; confirm the default pet reacts/says. + +6. Run MCP with explicit installed pet: + + ```bash + node packages/mcp/dist/index.js --pet snoopy + ``` + +7. Confirm Snoopy opens as a separate temporary pet window and status reports actual target `snoopy`. + +8. Call `openpets_react` / `openpets_say`; confirm Snoopy reacts/says, not the default pet. + +9. Stop the MCP process; confirm Snoopy closes after release/cleanup while the default pet remains. + +10. Start two MCP processes for the same explicit pet; confirm closing one keeps the temp pet open and closing both closes it. + +11. Hard-kill/abruptly close one MCP process for an explicit pet; wait for TTL cleanup and confirm the temp pet closes. + +12. Run MCP with missing pet: + + ```bash + node packages/mcp/dist/index.js --pet does-not-exist + ``` + +13. Confirm status reports fallback to default pet and tools affect the default pet. + +14. Run MCP with malformed-but-bounded pet input and confirm desktop fallback is reported rather than exposing local errors. + +Manual acceptance question: + +```text +Does Phase 06B pass on your machine: MCP startup acquires leases, --pet opens an installed non-default temporary pet, tools target the leased pet, missing pets fall back clearly, and release/TTL cleanup closes only temporary explicit pets? +``` + +## Oracle plan review + +Oracle reviewed the initial Phase 06B plan and approved the overall architecture, but blocked implementation until `--pet` fallback behavior and default-versus-explicit lease targeting were sharpened. + +## Oracle feedback disposition + +- Fixed: `@open-pets/mcp` should accept bounded raw `--pet` strings and let desktop validate/fallback, so invalid/uninstalled requested pets can follow product fallback behavior. +- Fixed: Added `targetKind: "default" | "explicit"` to distinguish persistent default-controller leases from explicit temporary pet-window leases. +- Fixed: Defined IPC/result contracts for acquire, heartbeat, release, and lease-scoped status. +- Fixed: Defined unknown heartbeat, idempotent release, and expired lease status behavior. +- Fixed: Required lease-initialization promise so tools cannot race ahead of startup acquisition. +- Fixed: Required heartbeat timer `unref()`, caught heartbeat failures, stale/degraded marking, and no reliance on async `exit` handlers. +- Fixed: Required sanitized machine-readable fallback reason codes only. +- Fixed: Required explicit-pet rendering helper that reads only local installed `spritesheet.webp` for a given pet id. +- Fixed: Expanded automated and manual tests for invalid `--pet`, release idempotency, unknown heartbeat, expired status, leakage prevention, multi-lease refcount, crash TTL cleanup, and default-pet persistence. +- Fixed: Defined exact raw `--pet` bounds and control/path/whitespace rejection before desktop fallback. +- Fixed: Defined stale `status({ leaseId })` response shape. +- Fixed: Required built-in/default requested ids to be special-cased before installed-pet path validation. +- Fixed: Clarified default-target leases report the live current default controller target if default changes after acquisition. +- Fixed: Required MCP say/react checks proving lease-init waiting and lease id forwarding. +- Fixed: Required temporary pet windows to use non-focus-stealing `showInactive` behavior. +- Fixed: Required heartbeat timer to stop after unknown/stale lease. + +Implementation review disposition: + +- Fixed: `LeaseManager.heartbeat()` and `get()` now reject/remove expired leases before cleanup can revive or use them. +- Fixed: MCP status now preserves stale/unknown lease state instead of reporting cached active leases. +- Fixed: MCP degraded reasons used in tool errors are sanitized before returning to agents. +- Fixed: Explicit leases preserve `targetKind: "explicit"` for their lifetime instead of downcasting if the pet later becomes default. +- Fixed: Explicit pet say/react respect paused and speech-disabled behavior through IPC before updating temporary windows. +- Fixed: Explicit pet rendering errors are caught/logged to avoid unhandled rejections. +- Fixed: `default_broken_fallback_builtin` fallback reason is emitted when the configured default is unusable. +- Fixed: Raw `--pet` bounds reject all control characters. +- Deferred: Additional transport-close release handling beyond signals remains useful, but current release plus desktop TTL cleanup is sufficient for Phase 06B manual verification. +- Fixed: MCP status prefers live desktop lease status fields over cached acquire-time lease data when available. +- Fixed: Unavailable/degraded MCP status still reports `routingImplemented: true` for Phase 06B. +- Fixed: MCP help text now describes real `--pet` routing and fallback instead of stale Phase 06A behavior. +- Fixed: Added best-effort release on stdio transport close in addition to signal handling. diff --git a/docs/phases/phase-07-claude-detection-configuration.md b/docs/phases/phase-07-claude-detection-configuration.md new file mode 100644 index 00000000..c7ddbfab --- /dev/null +++ b/docs/phases/phase-07-claude-detection-configuration.md @@ -0,0 +1,330 @@ +# Phase 07: Claude Code Detection and MCP Configuration + +## Goal + +Implement the first real Agent Setup experience for Claude Code: detect whether Claude Code appears available, show current OpenPets MCP configuration status, preview the exact MCP configuration command/file shape, and apply/remove a safe MCP configuration only after explicit user confirmation. + +This phase makes Claude Code usable with the Phase 06 MCP lease routing path. It does not implement Claude hooks yet. + +## Non-goals + +- No Claude hook install, uninstall, or hook event handling. That belongs in Phase 08. +- No Cursor, VS Code, Windsurf, OpenCode, or Antigravity configuration. +- No automatic silent edits during onboarding or app launch. +- No agent-managed pet installation/removal/default-pet changes. +- No attempt to parse or preserve every undocumented field in Claude's internal `~/.claude.json`; prefer official CLI commands for writes. +- No auto-launch of Claude Code itself. + +## User-visible/manual outcome + +From the tray menu, **Configure Agents...** opens a real Claude Code setup window instead of a placeholder. The user can: + +- See Claude Code as detected, not detected, configured, needs setup, or error. +- See the OpenPets MCP command that will be configured. +- Choose default-pet routing or an explicit installed pet for the Claude MCP server. +- Run a doctor/check to see actionable status. +- Click Configure only after seeing the planned change. +- Remove the OpenPets MCP entry if it was configured by OpenPets. + +## Acceptance criteria + +- Agent Setup window has a Claude Code card with clear status: + - `Detected` + - `Not detected` + - `Configured` + - `Needs setup` + - `Error / needs attention` +- Detection is best-effort and non-invasive: + - Check for a usable `claude` binary on `PATH` via `claude --version` with a short timeout. + - Check MCP status using `claude mcp list` with a short timeout when Claude is available. + - If Claude is missing or commands fail, report actionable text without crashing the app. +- Configuration target for Phase 07 is Claude Code user scope using the official CLI: + - `claude mcp add --scope user openpets -- npx -y @open-pets/mcp` + - With explicit pet: `claude mcp add --scope user openpets -- npx -y @open-pets/mcp --pet ` +- Preview shows the exact command and the equivalent intended MCP JSON shape before applying. +- Configure requires an explicit button click in the UI. +- Removal requires an explicit button click in the UI and uses: + - `claude mcp remove --scope user openpets` +- Remove is enabled only when Claude reports an `openpets` MCP entry. If the entry cannot be verified as OpenPets-managed, the UI must warn that it will remove any Claude MCP server named `openpets`. +- Config operations are conservative and idempotent where Claude exposes enough detail: + - If Claude reports an `openpets` MCP entry and a detail command/output lets OpenPets verify the command/args match, Configure reports already configured / no change. + - If Claude only reports `openpets` as present but does not expose reliable command/arg detail, Configure reports `Configured / needs manual verification` rather than replacing it. + - If an `openpets` MCP entry exists but differs or cannot be verified, OpenPets must not automatically remove it. The UI must show a separate Replace action with a strong warning before remove-then-add. + - If Replace removes an entry but add fails, OpenPets shows a clear failure and the action journal contains the previous detected summary and intended restore command; no OAuth/session config restoration is attempted. +- Backups/restore behavior is defined and implemented for the files OpenPets directly edits. + - Because Phase 07 writes via the Claude CLI rather than directly editing `~/.claude.json`, OpenPets should not create a misleading full backup of that internal file by default. + - OpenPets records a local configuration action journal with timestamp, command preview, selected pet, and previous detected status where available. + - Restore behavior for Phase 07 is removal of the OpenPets MCP server entry through `claude mcp remove --scope user openpets`; it does not attempt to restore OAuth/session internals. +- Doctor/check reports: + - Whether `claude` is found. + - Whether `claude --version` works. + - Whether `claude mcp list` works. + - Whether an `openpets` MCP entry appears present. + - What command OpenPets expects. + - That Claude Code may need restart/reload for MCP changes to take effect. +- UI is CSP-safe and uses Electron IPC handlers with sender checks. +- Renderer/main IPC is narrow: renderer sends only action names and `{ selectedPetId?: string }`; main constructs argv, revalidates selected pet against installed non-broken pets, and enforces sender checks for the Agent Setup window. +- Configure/remove/replace operations are serialized so concurrent clicks cannot run overlapping Claude CLI commands. +- Automated checks cover command construction, pet id argument handling, status parsing, and timeout/error classification. +- `pnpm check` passes. + +## Proposed files/directories + +- `packages/claude/src/index.ts` + - Export Claude Code setup/detection helpers. +- `packages/claude/src/claude-code.ts` + - `detectClaudeCode`, `buildClaudeMcpAddCommand`, `buildClaudeMcpRemoveCommand`, `parseClaudeMcpList`, status types. +- `packages/claude/src/check-claude-code.ts` + - Node-based contract checks for command construction/status parsing. +- `packages/claude/package.json` + - Include build/check script updates. +- `apps/desktop/src/agent-setup.ts` + - Desktop orchestration for running Claude commands, timeouts, and local action journal. +- `apps/desktop/src/windows.ts` + - Replace Agent Setup placeholder with real Claude setup UI and preload access. +- `apps/desktop/preload.cjs` + - Expose narrow `openpetsAgentSetup` methods to renderer, separate from broader app/window APIs. +- `apps/desktop/src/app-state.ts` or adjacent state helper + - Persist lightweight agent setup status/action journal if needed. +- `docs/phases/phase-07-claude-detection-configuration.md` + +## Technical approach + +### Scope: Claude Code user-scope MCP only + +Phase 07 configures the universal MCP path already built in Phase 06: + +```text +Claude Code → @open-pets/mcp → @open-pets/client → desktop IPC → pet lease +``` + +Use a single MCP server name: + +```text +openpets +``` + +Use user scope first because OpenPets is a personal companion integration and should not silently create team/project files. Project-scoped `.mcp.json` and per-project pet choices can be documented later. + +### Detection + +Detection runs from the desktop main process, never from the sandboxed renderer. + +Best-effort command sequence: + +1. Resolve `claude` from `PATH` by running `claude --version`. +2. If `claude` is not found, try common platform locations only as non-invasive hints: + - macOS GUI apps may have a reduced `PATH`; include common Homebrew/npm paths such as `/opt/homebrew/bin`, `/usr/local/bin`, and inherited `PATH` entries. + - Windows may expose `claude.cmd`; command resolution should try `claude` and `claude.cmd` where appropriate. +3. Run `claude mcp list` if version works. +4. If an official/detail command is available and works (for example `claude mcp get openpets` or a future JSON output), use it to verify command/args. Otherwise treat list output as present/absent only. +5. Parse output conservatively for an `openpets` entry. +6. Return a structured result to the UI. + +Each child process should: + +- Have a short timeout, initially 3 seconds. +- Kill the child process on timeout and classify the result as timeout, not as a generic error. +- Capture bounded stdout/stderr. +- Avoid shell interpolation by using `spawn`/`execFile`-style argv arrays. +- Never include secrets in UI text. + +### Command construction + +Centralize command construction in `@open-pets/claude` so tests can verify it without Electron: + +Default pet: + +```bash +claude mcp add --scope user openpets -- npx -y @open-pets/mcp +``` + +Explicit pet: + +```bash +claude mcp add --scope user openpets -- npx -y @open-pets/mcp --pet snoopy +``` + +Removal: + +```bash +claude mcp remove --scope user openpets +``` + +Pet id values come from installed OpenPets pet ids in app state, not free-form UI entry. This avoids quoting/shell-injection complexity and aligns with the product rule that agents do not install/remove pets. + +### Preview + +Before applying, show: + +- Human summary. +- Exact argv-style command. +- Equivalent intended MCP JSON shape: + +```json +{ + "mcpServers": { + "openpets": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@open-pets/mcp"] + } + } +} +``` + +With explicit pet: + +```json +"args": ["-y", "@open-pets/mcp", "--pet", "snoopy"] +``` + +### Applying changes + +Use Claude Code's official CLI rather than directly editing `~/.claude.json`: + +- Configure new entry: `claude mcp add --scope user openpets -- ...` +- Update existing differing or unverifiable entry: only through a separate explicit Replace action with warning text. +- Remove OpenPets entry: `claude mcp remove --scope user openpets`. + +If configure/remove commands fail before making changes, show the sanitized stderr/stdout summary and leave state unchanged. + +Exception: Replace is a two-step remove-then-add flow. If remove succeeds but add fails, Claude config has already changed; the UI must state that the previous `openpets` entry was removed and show the intended restore/add command from the action journal. + +The UI should include a **Copy command** fallback so users can apply configuration manually if Claude CLI invocation from the Electron app fails because of PATH, permissions, or shell environment differences. + +### Action journal and restore + +Because user-scope MCP config lives inside Claude's internal `~/.claude.json`, which may include OAuth/session/private state, Phase 07 should not copy the whole file into OpenPets backups by default. + +Instead, persist an OpenPets-local action journal entry for each attempted configuration/removal: + +- timestamp +- action: configure/update/replace/remove +- selected pet id or default +- command argv preview +- detected previous OpenPets status, if known +- success/failure and sanitized message + +Location: + +```text +/agent-setup-actions.json +``` + +Journal entries must be bounded and sanitized: + +- Keep only the latest 20 entries. +- Store argv arrays and OpenPets status labels. +- Store sanitized output summaries capped at 500 characters. +- Do not store raw Claude config files, OAuth/session data, full stdout/stderr, home-directory paths, tokens, or environment variables. + +During implementation, verify current `claude mcp` CLI syntax against official docs/help output before wiring writes. If a detail/get command or output format is unavailable or unexpected, fail closed: classify the entry as present but unverifiable and require manual review/Replace rather than assuming ownership. + +Restore path in Phase 07 is explicit uninstall/remove of the OpenPets MCP entry via Claude CLI. + +### UI shape + +Agent Setup can stay lightweight and inline-data-URL based for now, matching existing Pet Manager/Settings style: + +- Header: Configure Agents +- Claude Code card +- Status badge and details +- Pet selection dropdown: Default pet + installed non-broken pets +- Preview box +- Buttons: Refresh / Doctor, Configure, Remove +- If an existing `openpets` MCP entry is present but unverifiable/different, show a separate Replace action with stronger warning copy instead of silently changing it. +- Copy command fallback. +- Note: restart Claude Code if MCP changes do not appear immediately. + +## Risks and tradeoffs + +- Claude Code config behavior can change. Mitigation: use official CLI commands where possible and keep parsing conservative. +- `claude mcp list` output may not be stable. Mitigation: use it for status hints only; failed parsing reports needs manual check rather than corrupting config. +- Existing user config could be lost if OpenPets blindly removes an `openpets` entry. Mitigation: no automatic replacement; require explicit Replace with warning and action journal. +- User-scope config is personal and may contain secrets. Mitigation: do not directly edit or back up full `~/.claude.json` in Phase 07. +- `npx -y @open-pets/mcp` may use a published package in real installs, while local dev uses workspace packages. Mitigation: Phase 07 config preview targets the final product command; manual dev verification can inspect command preview without requiring published package behavior. +- Windows may need command resolution differences. Mitigation: command discovery tries platform variants, uses argv arrays, and includes Windows samples in checks. + +## Security/privacy notes + +- No shell string execution for user-controlled values. +- Pet choice is constrained to installed pet ids from app state. +- Renderer gets only narrow IPC methods for agent setup. +- Main process revalidates selected pet id and never trusts renderer-supplied command/argv. +- Sanitize child-process output before showing it in UI; bound max displayed length. +- Do not expose Claude config file contents or OAuth/session material in UI or logs. +- No silent configuration changes; user must click Configure/Remove. + +## Test/check plan + +- `packages/claude` contract checks: + - command argv for default pet. + - command argv for explicit pet. + - remove argv. + - equivalent JSON preview. + - MCP list parsing for present/missing/error-ish samples. + - unverifiable existing entry classification. + - Windows/macOS command discovery/path sample helpers where factored. + - invalid/free-form pet id rejection when using helper directly. +- Desktop checks: + - Agent setup IPC renderer sender restrictions. + - Command result timeout/error classification helper if factored separately. + - Action journal redaction/bounds if factored separately. +- Workspace: + - `pnpm check` + +## Manual verification guide + +After implementation: + +1. Run `pnpm check`. +2. Start desktop: `pnpm --filter @open-pets/desktop dev`. +3. Open tray → **Configure Agents...**. +4. Confirm Claude Code status appears and does not crash whether Claude is installed or not. +5. Choose default pet and inspect preview. +6. Choose an installed non-default pet and confirm preview adds `--pet `. +7. Use Copy command to verify the manual fallback text is correct. +8. Click Configure only if you are comfortable modifying your Claude Code user MCP config. +9. Run `claude mcp list` separately and confirm `openpets` appears. +10. If using an unpublished local package, do not expect a real Claude session to load `npx -y @open-pets/mcp` until the package is available; treat command/config preview and `claude mcp list` as the Phase 07 verification target. +11. Restart/open Claude Code and confirm OpenPets MCP tools appear only if the package is published or your dev environment routes the package name locally. +12. Click Remove and confirm `claude mcp list` no longer shows `openpets`. +13. Optional safer test: create a fake `claude` executable earlier in `PATH` that records argv and returns sample `mcp list` output; launch the desktop app from that terminal so the modified `PATH` is inherited; use it to verify missing Claude, successful add, failed add, existing entry, replace warning, and remove flows without touching real Claude config. + +Expected results: + +- Detection and doctor statuses are actionable. +- Preview matches the command OpenPets runs. +- Configure/remove are explicit and idempotent. +- OpenPets does not edit Claude config silently. + +## Oracle plan review + +Reviewed. Oracle found the phase boundary and official-CLI direction sound, but flagged two blockers: + +- Idempotency/update detection was underspecified because `claude mcp list` may not reliably prove command/args match. +- Remove-then-add could lose an existing user `openpets` config if add fails. + +Oracle also requested clearer remove semantics, platform command discovery, stricter renderer/main IPC rules, action journal schema/location, operation locking/timeouts, safer manual verification, and not promising real MCP tools appear before package publishing/dev override exists. + +## Oracle feedback disposition + +Fixed: + +- Weakened idempotency to verified-detail-only and present/absent otherwise. +- Added explicit Replace action for unverifiable/different existing entries; no automatic remove-then-add. +- Defined Remove warning semantics for unverifiable entries. +- Added macOS GUI PATH and Windows `claude.cmd` discovery considerations. +- Tightened renderer/main IPC boundary and main-process pet revalidation. +- Added action journal location/schema/redaction/bounds. +- Added `replace` to the action journal action schema and clarified Replace add-failure state. +- Added operation serialization and timeout kill behavior. +- Added Copy command fallback and safer fake-CLI/manual verification guidance. +- Clarified that real Claude MCP tools require published package or dev package routing. + +Fixed after re-review: + +- Clarified that Replace can change Claude config if remove succeeds and add fails, and must show restore/add guidance rather than claiming state is unchanged. +- Required implementation-time verification of current `claude mcp` CLI syntax/help and fail-closed behavior for unavailable/unexpected detail output. +- Clarified Agent Setup preload should be narrow/separate and fake-CLI testing requires launching desktop from a terminal with modified `PATH`. diff --git a/docs/phases/phase-08-claude-enhanced-hooks.md b/docs/phases/phase-08-claude-enhanced-hooks.md new file mode 100644 index 00000000..debdd967 --- /dev/null +++ b/docs/phases/phase-08-claude-enhanced-hooks.md @@ -0,0 +1,371 @@ +# Phase 08: Claude Enhanced Hooks + +## Goal + +Make Claude Code feel polished by adding safe global Claude hooks that send automatic OpenPets reactions and short local speech through `@open-pets/claude` and `@open-pets/client`. + +This phase builds on Phase 07's Claude Code detection/MCP configuration UI. Phase 08 should let the user install, doctor, and uninstall OpenPets-managed Claude hooks with explicit confirmation and backups. + +## Non-goals + +- No direct changes to Claude MCP configuration beyond what Phase 07 already provides. +- No Cursor, VS Code, Windsurf, OpenCode, or Antigravity hooks. +- No model-generated speech. +- No displaying user prompt text, code, logs, file paths, command text, transcript contents, or tool input in pet speech. +- No blocking/denying Claude actions. OpenPets hooks are decorative/status-only and should fail open. +- No shell scripts, Unix-only sockets, `nc`, `/tmp` socket paths, or Bun runtime dependency. +- No durable persistence of Claude session payloads or transcripts. + +## User-visible/manual outcome + +From **Configure Agents...**, the Claude Code card gains enhanced hook controls: + +- Hook status: not installed / installed / needs update / error. +- Preview of the exact hook entries OpenPets will add to Claude user settings. +- Install hooks with explicit confirmation and backup. +- Doctor/check hooks. +- Uninstall OpenPets-managed hooks only. + +In a real Claude Code session, the pet reacts automatically: + +- User prompt submitted → thinking, occasional short thinking speech. +- Tool use → editing/running/testing reaction, usually silent. +- Permission request → waiting reaction + clear approval message. +- Notification → waiting/attention reaction, silent by default. +- Stop → success reaction + occasional short success speech. +- Stop failure → error reaction + occasional short error speech. + +## Acceptance criteria + +- `@open-pets/claude` exposes a Node/npm/npx-friendly hook entry point, for example: + - `open-pets-claude hook` + - final published/dev preview command may be displayed as `npx -y @open-pets/claude hook` until packaging decides global/local binary details. +- Hook command reads Claude hook JSON from stdin with a strict byte limit and timeout-friendly behavior. +- Hook command never blocks Claude intentionally: + - returns exit code 0 for handled, ignored, and degraded OpenPets-unavailable cases. + - only uses non-zero exit for unrecoverable CLI/runtime invocation errors before hook handling begins. +- Hook command stdout is always empty, including `UserPromptSubmit`, because Claude may ingest stdout as context. +- Installed hook entries must not synchronously block Claude for decorative status updates: + - use Claude hook `"async": true` where supported. + - keep timeout short, initially 3 seconds. + - if async hooks are not supported by the installed Claude version, doctor/install must warn and fail closed rather than installing cold-start `npx` hooks on high-frequency events. + - future packaging may replace `npx` with a faster local/bundled binary, but Phase 08 should not rely on synchronous `npx` for `PreToolUse`. +- Hook command sends OpenPets events through `@open-pets/client` only; no direct socket/manual JSON writes. +- Hook mapping handles the selected Claude hook events and ignores all others safely: + - `UserPromptSubmit` → `thinking` + selective speech. + - `PreToolUse` → `editing`, `testing`, `running`, or `working` based on `tool_name` and safe classification of `tool_input`. + - `PermissionRequest` → `waiting` + fixed approval speech. + - `Notification` → `waiting` by default, no speech. + - `Stop` → `success` + selective speech. + - `StopFailure` → `error` + selective speech. +- If Claude event names differ on an installed Claude version, unknown events are ignored safely and surfaced in doctor output where possible. +- Speech uses local static message pools/templates only. +- Speech safety rules are enforced: + - max 140 characters. + - single-line only. + - no code blocks/code-like snippets. + - no URLs. + - no absolute/relative path-like content. + - no secret-looking values. + - never include raw user prompt/tool input/assistant response/transcript content. +- Speech is throttled/selective: + - no speech on `PreToolUse` or `Notification` by default. + - permission speech always allowed subject to a short cooldown to avoid duplicates. + - lifecycle speech has a cooldown, initially at least 20 seconds per category. + - throttle state is ephemeral and stored outside Claude settings, with bounded size and no payload content. +- Hooks route through the default OpenPets desktop route in Phase 08: + - Because Claude hooks do not reliably know the active MCP lease id, they call `react`/`say` without `leaseId` and therefore affect the desktop default pet. + - Future lease-aware Claude routing is explicitly deferred until a reliable session↔lease correlation is available. +- Global hook installation targets Claude Code user settings: + - default path `~/.claude/settings.json` only for Phase 08. + - do not honor `CLAUDE_CONFIG_DIR` unless a later phase verifies it is official/current Claude Code behavior. + - creates a timestamped backup before writing. + - validates settings is a JSON object before modifying. + - settings path must be missing or a regular file; abort on symlink/special file surprises. + - create parent directory with restrictive permissions where practical. + - write temp file with restrictive permissions where practical before atomic rename. + - preserves unrelated settings and unrelated hooks. + - installs only clearly OpenPets-managed command hooks. + - uninstall/update matching requires the `--openpets-managed` marker; package-name-only matching is not enough. + - idempotent reinstall/update. + - atomic write where practical. +- Uninstall removes only OpenPets-managed hook commands and leaves unrelated user/project hooks intact. +- Agent Setup UI shows hook status, preview, install/update, doctor, and uninstall controls with explicit warning that hooks execute commands from Claude Code, are global user-scope across Claude projects, and route to the OpenPets default pet in Phase 08. +- Agent Setup provides a local dev command toggle so manual testing can install Claude MCP/hooks against this checkout's built `dist` files instead of published `npx` packages: + - production MCP: `npx -y @open-pets/mcp ...` + - local MCP: `node /packages/mcp/dist/index.js ...` + - production hooks: `npx -y @open-pets/claude hook --openpets-managed` + - local hooks: `node /packages/claude/dist/cli.js hook --openpets-managed` +- Automated checks cover hook payload parsing, event mapping, speech safety, throttling decisions, settings merge/uninstall behavior, and command preview. +- `pnpm check` passes. + +## Proposed files/directories + +- `packages/claude/package.json` + - Add `bin` for `open-pets-claude`. + - Ensure check builds and validates hooks. +- `packages/claude/src/cli.ts` + - CLI dispatcher for `hook`, `doctor-hooks`, maybe install/uninstall helpers used by desktop/tests. +- `packages/claude/src/hooks.ts` + - Hook payload parsing, event mapping, client calls, speech selection/throttling. +- `packages/claude/src/hook-messages.ts` + - Static local speech pools. +- `packages/claude/src/hook-settings.ts` + - Claude settings path resolution, backup, merge, preview, uninstall, doctor helpers. +- `packages/claude/src/check-claude-hooks.ts` + - Contract checks. +- `packages/claude/src/index.ts` + - Export hook helpers for desktop. +- `apps/desktop/src/agent-setup.ts` + - Add hook status/preview/install/doctor/uninstall orchestration and action journal entries. +- `apps/desktop/src/windows.ts` + - Add hook section to Claude Code card. +- `apps/desktop/preload.cjs` + - Add narrow hook actions through `openpetsAgentSetup`. +- `docs/phases/phase-08-claude-enhanced-hooks.md` + +## Technical approach + +### Hook command behavior + +Claude Code command hooks receive JSON on stdin. The OpenPets hook command should: + +1. Read stdin up to a strict limit, initially 64 KiB. +2. Parse JSON defensively. +3. Determine `hook_event_name` and safe metadata such as `tool_name`. +4. Map to an OpenPets reaction and optional static speech. +5. Send via `createOpenPetsClient({ connectTimeoutMs: 500, responseTimeoutMs: 500 })`. +6. Swallow OpenPets-unavailable errors and exit 0. + +The command should write debug output only to stderr when `OPENPETS_DEBUG=1`; stdout must stay empty so Claude does not receive unintended context. + +### Event mapping + +Initial mapping: + +```text +UserPromptSubmit → react thinking; maybe say thinking pool +PreToolUse Edit → react editing +PreToolUse Write → react editing +PreToolUse MultiEdit → react editing +PreToolUse Bash with test-ish command → react testing +PreToolUse Bash otherwise → react running +PreToolUse other tool → react working +PermissionRequest → say "Approval needed" with waiting reaction +Notification → react waiting; no speech +Stop → say success pool with success reaction +StopFailure → say error pool with error reaction +unknown → no-op +``` + +Tool input is used only for classification, never copied into speech. + +### Speech pools + +Use local, short, safe strings. Example categories: + +```text +thinking: "Thinking it through", "Let me check", "On it" +success: "Done", "That worked", "All set" +error: "Something failed", "Needs another look", "Hit a snag" +permission: "Approval needed" +``` + +Selection should be deterministic enough for tests but varied at runtime, for example injectable random source in tests. + +### Throttling + +Throttle state should not include hook payload content. Store only timestamps/counters by category in an OpenPets-owned file, for example: + +Use a pure Node cross-platform path because the hook runs outside Electron: + +```text +macOS/Linux: ${XDG_STATE_HOME:-~/.local/state}/openpets/claude-hook-throttle.json +Windows: %LOCALAPPDATA%\OpenPets\claude-hook-throttle.json +fallback: os.tmpdir()/openpets-/claude-hook-throttle.json +``` + +Requirements: + +- Bounded JSON object. +- Atomic best-effort write. +- Safe if missing/corrupt. +- No hard failure if throttle storage cannot be read/written. + +### Settings install/update/uninstall + +Use Claude Code user settings: + +```text +~/.claude/settings.json +``` + +Hook config shape should follow current Claude docs: + +```json +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "npx -y @open-pets/claude hook --openpets-managed", + "timeout": 3, + "async": true, + "asyncRewake": false + } + ] + } + ] + } +} +``` + +Use one OpenPets-managed command per event with the stable `--openpets-managed` marker in the command so uninstall can safely identify owned hooks. + +Install algorithm: + +1. Resolve settings path to `~/.claude/settings.json`. +2. Read existing file if present; missing file becomes `{}`. +3. Refuse symlink/special-file settings paths; allow missing or regular file only. +4. Validate top-level JSON object. +5. Validate `hooks` is absent or object; if malformed, abort and ask user to fix manually. +6. Verify Claude hook async support from current docs/known behavior and local checks where practical. If no reliable local check exists, install may proceed based on documented support but doctor must warn when support is uncertain. +7. Create backup before write: `settings.json.openpets-backup-YYYYMMDD-HHMMSS.json`. +8. Remove previous OpenPets-managed hook commands. +9. Add current OpenPets-managed async hook entries. +10. Atomic write temp file + rename. + +Uninstall algorithm: + +1. Resolve/read/validate settings. +2. Backup before write. +3. Remove only hook commands containing the `--openpets-managed` marker. +4. Prune empty arrays/objects created by OpenPets where safe. +5. Atomic write. + +Doctor should report: + +- settings path. +- whether file exists and parses. +- whether hooks object is valid. +- installed/up-to-date/needs update/not installed. +- backup path from last install/uninstall if applicable. +- whether OpenPets desktop IPC is reachable. +- reminder that Claude hooks require trusted workspaces and Claude restart/reload if settings were changed. + +### Agent Setup UI + +Add an **Enhanced Claude hooks** section to the existing Claude Code card: + +- Status badge. +- Preview JSON snippet. +- Buttons: Doctor hooks, Install/Update hooks, Uninstall hooks. +- Warning copy: Claude hooks execute command hooks automatically; OpenPets-managed hooks are status-only, fail open, and do not inspect/store code content. +- Local dev command toggle shared with the MCP setup preview/actions. + +## Risks and tradeoffs + +- Claude hook schemas/events can change. Mitigation: parse defensively, ignore unknowns, document event contract, keep doctor actionable. +- Hooks are command execution. Mitigation: explicit confirmation, preview, OpenPets marker, uninstall, backups, no silent edits. +- Direct settings edits can corrupt user config if careless. Mitigation: validate JSON object, backup, atomic writes, preserve unrelated keys, abort on malformed hooks. +- `npx -y @open-pets/claude hook` may be slow for frequent hooks. Mitigation: no speech on high-frequency tool events; hook timeout 3 seconds; future packaging can switch command to bundled/local binary. +- Hooks may be skipped in untrusted Claude workspaces. Mitigation: doctor/manual guide documents trust requirement. +- Session-to-MCP-lease routing is ambiguous. Mitigation: Phase 08 explicitly routes hook events to default pet; MCP per-project pet routing remains available through tools/leases. + +## Security/privacy notes + +- Never include raw Claude payload content in speech. +- Do not read transcript files. +- Do not store hook payloads, prompts, tool inputs, assistant messages, cwd, transcript paths, or session ids in durable logs. +- Debug logs, if enabled, must be minimal and sanitized. +- Hook stdout must stay empty; use stderr for debug only when `OPENPETS_DEBUG=1`. +- Hook commands must fail open and avoid blocking user work. +- Settings installer must preserve unrelated hooks and avoid removing anything not OpenPets-managed. +- Hook settings must be async/background; do not install synchronous cold-start `npx` hooks for high-frequency events. +- Speech safety filters should reuse or match MCP speech safety rules. + +## Test/check plan + +- `packages/claude` checks: + - hook event mapping fixtures for each accepted event. + - malformed/oversized stdin handling. + - speech safety rejects code/path/url/secret/multiline/overlength content. + - throttling allows/blocks expected categories without storing payload content. + - settings install preview/merge preserves unrelated settings and hooks. + - settings reinstall is idempotent. + - settings uninstall removes only OpenPets-managed hooks. + - malformed settings/hook object aborts safely. + - symlink/special-file settings path aborts safely. + - generated hook config includes `--openpets-managed`, `timeout: 3`, and `async: true`. + - published/local command previews for MCP and hooks. +- Desktop checks: + - Agent Setup hook action validation/sender restrictions where practical. +- Workspace: + - `pnpm check` + +## Manual verification guide + +After implementation: + +1. Run `pnpm check`. +2. Start desktop: `pnpm --filter @open-pets/desktop dev`. +3. Open tray → **Configure Agents...**. +4. In Claude Code, confirm MCP is configured from Phase 07 or configure it now. +5. Inspect Enhanced Claude hooks preview. + For local checkout testing, enable **Use local dev commands** and confirm previews use `node .../packages/mcp/dist/index.js` and `node .../packages/claude/dist/cli.js` instead of `npx`. + Local dev mode writes absolute checkout paths into Claude config; uninstall or replace it before moving/deleting the checkout. +6. Click Doctor hooks and confirm it reports settings status clearly. +7. Click Install/Update hooks only if okay modifying `~/.claude/settings.json`. +8. Confirm a backup file was created next to Claude settings. +9. Confirm malformed settings are reported safely by temporarily testing against a fake settings path in checks or a controlled test profile, not by corrupting your real settings. +10. Confirm unrelated hook entries remain after install/update. +11. Confirm reinstall is idempotent. +12. Start/restart Claude Code in a trusted workspace. +13. Invoke a hook command with a stdin fixture directly, for example a `UserPromptSubmit` fixture, and confirm it exits 0 with empty stdout even if OpenPets desktop is unavailable. + Example after build: `printf '{"hook_event_name":"UserPromptSubmit","prompt":"hello"}' | node packages/claude/dist/cli.js hook --openpets-managed`. +14. Submit a real prompt and confirm the pet reacts thinking with occasional safe speech. +15. Run/edit/test from Claude and confirm reactions change without noisy speech. +16. Trigger a permission request and confirm waiting + approval message. +17. Finish a response and confirm success speech/reaction. +18. If possible, trigger a failing turn and confirm error reaction/speech. +19. Quit OpenPets desktop and confirm Claude hooks do not break Claude Code. +20. Return to Agent Setup and click Uninstall hooks. +21. Confirm OpenPets-managed hook entries are removed, uninstall is idempotent, and unrelated Claude settings remain. + +Expected results: + +- Hooks are explicit, reversible, and backed up. +- Backups may contain user-sensitive Claude settings; keep them local/private. +- Pet reactions feel automatic and non-spammy. +- Speech is short, local, safe, and never includes private prompt/tool content. +- Broken/missing OpenPets desktop app does not break Claude Code. + +## Oracle plan review + +Reviewed. Oracle found the phase boundary sound but flagged one blocker: + +- Decorative hooks cannot rely on synchronous cold-start `npx`, especially for frequent `PreToolUse`; hook entries must be async/background or use a fast local binary. + +Oracle also requested: marker in examples, no `CLAUDE_CONFIG_DIR` until verified, UI warnings about global/default-pet routing, pure Node throttle path, regular-file/no-symlink settings hardening, selected-events wording, and expanded manual verification. + +## Oracle feedback disposition + +Fixed: + +- Required `async: true` hook entries with short timeout and fail-closed install if async support is unavailable/uncertain. +- Added `--openpets-managed` marker to preview/config examples. +- Removed `CLAUDE_CONFIG_DIR` support from Phase 08 scope. +- Added UI warning requirement for global user-scope hooks and Phase 08 default-pet routing. +- Defined pure Node cross-platform throttle-state path. +- Added settings path hardening: missing/regular file only, abort symlink/special files, restrictive permissions where practical. +- Clarified Phase 08 handles selected Claude events and ignores others safely. +- Expanded tests/manual verification for malformed settings, unrelated hooks preservation, idempotency, unavailable desktop, stdin fixture invocation, and uninstall idempotency. + +Fixed after re-review: + +- Required empty hook stdout for all events. +- Clarified async support verification may rely on documented support with doctor warning if no reliable local check exists. +- Tightened uninstall/update matching to require `--openpets-managed` marker. +- Added `asyncRewake: false` to generated config, stdin fixture command example, and backup privacy note. diff --git a/docs/phases/phase-08c-test-harness-regression-pack.md b/docs/phases/phase-08c-test-harness-regression-pack.md new file mode 100644 index 00000000..ec43c227 --- /dev/null +++ b/docs/phases/phase-08c-test-harness-regression-pack.md @@ -0,0 +1,192 @@ +# Phase 08C: Test Harness and Regression Pack + +## Goal + +Add a lightweight, explicit test workflow for OpenPets before Phase 09 onboarding glues together pet management, MCP routing, Claude setup, and hooks. + +The project already has useful `check-*.ts` contract checks. This phase standardizes how those checks are run, adds a top-level `pnpm test`, and expands regression coverage around the riskiest recent features without introducing a heavy test stack prematurely. + +## Non-goals + +- No full Electron UI automation yet. +- No Playwright/Spectron-style end-to-end suite. +- No large migration to a test framework unless a clear gap appears. +- No broad refactors of feature code solely to satisfy tests. +- No network-dependent tests. +- No tests that mutate real Claude user settings, real pet installs, or real desktop app state. + +## User-visible/manual outcome + +Developers can run: + +```bash +pnpm test +pnpm check +``` + +and get clear regression coverage for core OpenPets behavior. `pnpm check` remains the pre-commit/phase gate; `pnpm test` becomes the focused regression test command. + +## Acceptance criteria + +- Root package has a `test` script. +- These packages get `test` scripts in this phase because they already have meaningful contract checks: + - `apps/desktop` + - `packages/client` + - `packages/mcp` + - `packages/claude` +- Packages without meaningful runtime checks yet may keep `test` as type/build-only or omit it with a documented reason: + - `packages/cli` + - `packages/pet-format` +- Existing `check-*.ts` files are either: + - included in package `test`, or + - clearly kept in `check` with `test` delegating to them. +- Package tests run against fresh built artifacts. Either package `test` builds first, or root `pnpm test` builds before invoking tests. Avoid stale `dist` false positives. +- Prefer `check = typecheck + build + test` at each package so `check` cannot drift away from regression tests. +- `pnpm check` still passes. +- `pnpm test` passes. +- Tests are deterministic and do not require: + - a running Electron app. + - a real Claude installation. + - network access. + - writes to real `~/.claude/settings.json`. + - writes to real OpenPets user data, except via isolated temp directories or pure in-memory checks. +- Tests using temp directories clean them up and do not depend on the current working directory except explicit repo-relative fixture paths. +- Claude settings install/uninstall tests must pass explicit temp settings paths; they must never call install/uninstall against the default real Claude settings path. +- Regression coverage includes at least: + - MCP tool contract and safe speech validation. + - MCP lease status/fallback behavior where currently factored. + - Claude MCP command previews for published/local dev modes. + - Claude hook event mapping. + - Claude hook stdout/privacy/speech safety expectations. + - Claude settings install/update/uninstall on temp fixture files. + - Local dev command path validation failure cases where practical. + - Desktop lease manager behavior. + - Desktop IPC protocol validation. + - Pet zip safety/catalog validation checks. +- Test output names should make it obvious what failed. +- `docs/implementation-process.md` or the phase spec documents the new test/check distinction. + +## Proposed files/directories + +- `package.json` + - Add root `test` script. +- Workspace package `package.json` files: + - Add package-level `test` scripts where appropriate. +- Existing check files: + - `apps/desktop/src/check-*.ts` + - `packages/client/src/check-*.ts` + - `packages/mcp/src/check-*.ts` + - `packages/claude/src/check-*.ts` +- Optional new fixture/check files: + - `packages/claude/src/check-claude-local-dev.ts` if not folded into existing checks. +- `docs/testing.md` + - Short developer-facing description of `test` vs `check`, package expectations, and isolation rules. +- `docs/phases/phase-08c-test-harness-regression-pack.md` + +## Technical approach + +### Keep it simple first + +Continue using Node-executed TypeScript-built contract checks for now: + +```text +pnpm typecheck && pnpm build && node dist/check-*.js +``` + +Reasons: + +- This matches the current repo pattern. +- It keeps dependencies minimal. +- The current risky logic is mostly pure Node contract logic, not browser UI. +- We can still add Vitest later when richer assertions/mocking become painful. + +### Define command semantics + +Recommended command meanings: + +```text +pnpm test = deterministic regression tests only +pnpm check = typecheck + build + tests + static/smoke checks required before commits +``` + +Package `check` should call package `test` after typecheck/build when practical: + +```text +check = typecheck + build + test +test = node dist/check-*.js ... +``` + +If a package needs `test` to build first for standalone use, keep that behavior explicit and avoid stale `dist` artifacts. The implementation should avoid duplicating long command lists more than necessary. + +### Avoid real user config/state + +Claude settings tests must use temp files/directories. Desktop state tests should use existing pure helpers or isolated temp paths only. If a test cannot avoid real Electron `app.getPath("userData")`, keep it out of `pnpm test` and document it as manual/Electron-only. + +### Regression priorities + +Add or strengthen tests around bugs found during manual verification: + +- Local dev mode should not accidentally use published `npx` commands. +- Local hook command paths are shell-quoted safely. +- Local dev mode validates missing/invalid dist paths where practical. +- Hook commands produce empty stdout. +- Hook CLI direct invocation produces empty stdout for normal and malformed fixture payloads. +- Explicit pet rendering should not regress to giant installed-pet image data URLs where this can be checked without Electron. +- Benign IPC client disconnect errors should not fail response handling where factored. + +## Risks and tradeoffs + +- Adding a framework now may slow progress. Mitigation: standardize existing checks first. +- Pure Node checks cannot catch every Electron UI issue. Mitigation: keep manual verification for tray/windows and add UI automation later. +- Too many duplicated scripts can become hard to maintain. Mitigation: keep `test`/`check` scripts simple and consistent. +- Tests that touch real Claude/OpenPets state would be risky. Mitigation: temp fixtures only. + +## Security/privacy notes + +- Tests must not read or write real `~/.claude/settings.json`. +- Tests must not print secrets, local tokens, or full user config payloads. +- Fixtures should be synthetic and minimal. +- Any local path assertions should avoid requiring a specific user home path. + +## Test/check plan + +- Run `pnpm test`. +- Run `pnpm check`. +- Confirm no test writes to real Claude settings. +- Confirm temp fixtures are cleaned up. +- Confirm test failures are understandable by temporarily reviewing script names/output. + +## Manual verification guide + +After implementation: + +1. Run: + + ```bash + pnpm test + pnpm check + ``` + +2. Confirm both pass. +3. Confirm no real Claude settings were modified. +4. Confirm no desktop app launch is required for tests. +5. Confirm the command split is understandable: + - `test` = deterministic regression tests. + - `check` = full pre-commit validation. + +## Oracle plan review + +Reviewed. Oracle approved deferring Vitest and standardizing existing Node contract checks first. + +Oracle requested clearer package scope, fresh built artifact behavior, temp-dir cleanup/isolation requirements, explicit no-real-Claude-settings guard, `check` calling `test` to prevent drift, and direct hook CLI stdout assertion. + +## Oracle feedback disposition + +Fixed: + +- Listed packages expected to get `test` scripts now and packages allowed to defer. +- Required tests to run against fresh built artifacts and preferred `check = typecheck + build + test`. +- Added temp-dir cleanup/current-working-directory isolation acceptance criteria. +- Added explicit guard that Claude settings install/uninstall tests must use temp settings paths. +- Added `docs/testing.md` to proposed files. +- Added direct hook CLI stdout assertion to regression priorities. diff --git a/docs/phases/phase-09-first-run-onboarding.md b/docs/phases/phase-09-first-run-onboarding.md new file mode 100644 index 00000000..880d6a36 --- /dev/null +++ b/docs/phases/phase-09-first-run-onboarding.md @@ -0,0 +1,335 @@ +# Phase 09: First-run Onboarding + +## Goal + +Connect the existing pet manager, agent setup, default pet, and settings pieces into a clear first-run onboarding flow. + +Phase 09 should make a fresh OpenPets install feel ready: the default pet appears, the user can optionally browse/install pets, configure Claude from the real Agent Setup flow, and finish with onboarding completion persisted. + +## Non-goals + +- No new agent integrations beyond the existing Claude Code setup/hook controls. +- No new catalog install mechanics beyond the existing Pet Manager install flow. +- No packaging/release installer work. +- No account system, telemetry, analytics, or network calls beyond the existing catalog fetch. +- No forced pet install; the bundled default pet remains enough to complete onboarding. +- No silent edits to Claude or other agent settings; existing confirmation/backup behavior remains required. +- No destructive onboarding reset UI beyond developer/manual reset instructions for this phase. + +## User-visible/manual outcome + +On a fresh app state, OpenPets opens a first-run onboarding window after startup. + +The onboarding window has a simple wizard shape: + +1. **Welcome** + - Explains that OpenPets lives in the tray/menu bar. + - Confirms the bundled default pet is already available. + - Provides a Continue button. +2. **Pets** + - Shows the current default pet and a short explanation that installing more pets is optional. + - Provides an action to open the real Pet Manager. + - Provides Skip/Continue. + - If the catalog is unavailable, onboarding remains completable and points the user to try later from Pet Manager. +3. **Agents** + - Shows Claude Code as the supported first integration for this MVP checkpoint. + - Provides an action to open the real Agent Setup window. + - Explains that setup is confirmation-based and can be skipped. +4. **Ready** + - Confirms OpenPets is ready. + - Offers quick actions to open Pet Manager or Agent Setup. + - Finish persists onboarding completion and closes the onboarding window. + +The tray remains usable during onboarding. Closing onboarding without finishing should not mark onboarding complete; it should be possible to reopen onboarding from the tray until completed. + +## Acceptance criteria + +- App state persists onboarding completion, for example `preferences.onboardingCompleted` or equivalent versioned state. +- State normalization handles missing/old state by treating onboarding as incomplete. +- Fresh app state opens onboarding automatically after app initialization. +- The default pet appears early when `openDefaultPetOnLaunch` is enabled, independent of onboarding completion. +- Onboarding is implemented as a normal sandboxed/context-isolated Electron task window with the existing preload/security posture. +- Onboarding uses dedicated IPC handlers with sender validation; only the onboarding renderer can complete onboarding. +- Onboarding IPC sender validation must use the main-process `taskWindows` / `webContents.id` mapping, not renderer-declared DOM attributes. +- If the shared preload exposes broad APIs, existing main-process IPC sender checks must deny pet install/remove, preference mutation, and agent setup mutation from the onboarding window. +- Onboarding cannot directly mutate pet installs or agent configuration except by opening the existing Pet Manager / Agent Setup flows. +- Onboarding includes actions to: + - continue through steps, + - open Pet Manager, + - open Agent Setup, + - finish onboarding, + - close without completing. +- Onboarding completion persists and prevents automatic reopening on the next app launch. +- Finishing onboarding closes the onboarding window and refreshes the tray immediately so **Continue Setup...** disappears without restart. +- Until onboarding is completed, the tray exposes a way to reopen onboarding, for example **Continue Setup...**. +- After onboarding is completed, the tray no longer shows the onboarding continuation item by default. +- Catalog failure does not block onboarding completion. +- Agent detection/configuration errors do not block onboarding completion. +- No automated test touches real Claude settings or real user data. +- Automated checks cover onboarding state normalization and completion persistence behavior. +- `pnpm test` passes. +- `pnpm check` passes. + +## Proposed files/directories + +- `apps/desktop/src/app-state.ts` + - Add persisted onboarding completion flag with backwards-compatible normalization. + - Add a narrow completion helper, e.g. `completeOnboarding()`. +- `apps/desktop/src/windows.ts` + - Add `onboarding` task window kind and HTML. + - Add IPC handlers for onboarding snapshot/completion and opening Pet Manager / Agent Setup from onboarding. + - Reuse existing CSP, sandbox, context isolation, navigation blocking, and sender validation patterns. +- `apps/desktop/src/main.ts` + - Decide when to open onboarding automatically after startup. +- `apps/desktop/src/tray.ts` + - Add **Continue Setup...** while onboarding is incomplete. + - Refresh menu after onboarding completion. +- `apps/desktop/preload.cjs` + - Expose a narrow `openpetsOnboarding` bridge only for onboarding actions. +- `apps/desktop/src/check-onboarding-state.ts` + - Contract checks for state migration/completion through extracted pure normalization/completion helpers or injected temp state paths. + - Do not import Electron-bound `app-state.ts` directly from plain Node tests if that couples tests to Electron `app.getPath`. +- `docs/phases/phase-09-first-run-onboarding.md` + +## Technical approach + +### State model + +Extend the existing versioned app state with onboarding completion. + +Preferred minimal shape: + +```ts +preferences: { + defaultPetId: string; + openDefaultPetOnLaunch: boolean; + speechBubblesEnabled: boolean; + petScale: number; + onboardingCompleted: boolean; +} +``` + +Default/missing value should be `false`, so existing installs see onboarding once after upgrading. If that is too disruptive during development, the implementation may use a developer reset/manual test path, but the production behavior should remain first-run based. + +Add narrow exported helpers: + +```ts +isOnboardingCompleted(): boolean +completeOnboarding(): OpenPetsStateV1 +``` + +`completeOnboarding()` should only set the completion flag and persist via existing atomic state write behavior. + +### Startup behavior + +Startup order should stay stable: + +1. Initialize state. +2. Install internal UI handlers. +3. Create tray. +4. Install default pet display handlers. +5. Start local IPC. +6. Show default pet if preference says so. +7. Open onboarding if incomplete. +8. Refresh tray. + +Opening onboarding should not delay local IPC startup or default pet display. If opening the window fails, log the error and keep the app usable from tray. + +### Window and IPC + +Add an `onboarding` task window kind rather than a separate dashboard architecture. + +The onboarding renderer should be static inline HTML loaded through the same data URL pattern as existing task windows. It should use the existing preload bridge, not Node integration. + +Suggested bridge: + +```ts +window.openpetsOnboarding = { + getSnapshot(): Promise<{ defaultPetName: string; onboardingCompleted: boolean }>; + complete(): Promise; + openPetManager(): Promise; + openAgentSetup(): Promise; +} +``` + +Keep the snapshot lightweight: default pet/onboarding state only. Phase 09 does not need to duplicate the full Pet Manager or Agent Setup UIs inside onboarding; opening the real windows is enough and avoids parallel state/config logic. Do not expose Claude settings paths, Claude config previews, or detailed agent status in onboarding. + +Sender validation should allow onboarding access only through the existing main-process window-kind mapping from `webContents.id` to `taskWindows`. Renderer-declared values like `data-openpets-view="onboarding"` may be useful for the preload bridge but must not be the trust boundary. + +### Onboarding UI details + +The UI should be intentionally small and clear, not a full dashboard: + +- One card/wizard window, roughly the same size as other task windows. +- Step indicators for Welcome / Pets / Agents / Ready. +- Primary and secondary actions per step. +- Clear skip language: pet installs and agent setup can be done later. +- Explicit reminder that Claude setup may edit user-level Claude settings only after confirmation and backups. + +### Reopen behavior + +Before completion: + +- App startup opens onboarding automatically. +- Tray shows **Continue Setup...**. +- Closing the window keeps completion false. + +After completion: + +- App startup does not auto-open onboarding. +- Tray omits **Continue Setup...**. +- Pet Manager, Agent Setup, and Settings remain available normally. + +### Reset/manual testing behavior + +Phase 09 does not need an end-user reset UI. Manual verification can reset onboarding by deleting the dev Electron user-data directory or editing/removing the dev state file documented by app logs. + +If a small developer-only helper is cheaper and safer, it may be added only as a documented internal check command, not as product UI. + +## Risks and tradeoffs + +- **Onboarding could become a second implementation of Pet Manager/Agent Setup.** Avoid this by using onboarding as a guide that opens the real windows. +- **Existing dev installs may see onboarding after the state schema changes.** This is acceptable for first-run behavior; manual instructions should include how to mark complete or reset state. +- **Catalog/agent setup failures can make onboarding feel blocked.** The flow must make those steps optional and completable. +- **Window focus could be annoying at startup.** The default pet and tray remain primary; onboarding should be a normal window and not repeatedly refocus if closed. +- **State tests may be coupled to Electron `app`.** Prefer extracting pure normalization/completion helpers if needed instead of brittle Electron test setup. + +## Security/privacy notes + +- Onboarding must not silently configure agents or install pets. +- Claude setup remains routed through the existing Agent Setup controls with explicit confirmation and backups. +- No onboarding payload should include Claude config contents beyond what existing Agent Setup already exposes. +- Renderer stays sandboxed/context-isolated with no Node integration. +- CSP should block external resources and navigation. +- IPC sender validation must prevent other windows from invoking onboarding completion if practical within the current data URL sender model. + +## Test/check plan + +- Add deterministic contract coverage for: + - missing/old state normalizes `onboardingCompleted` to `false`, + - completion helper persists `true`, + - preference updates preserve onboarding completion, + - onboarding completion does not alter installed pets/default pet/agent configuration state. +- Run: + +```bash +pnpm test +pnpm check +``` + +Manual verification should use a fresh or reset dev app state. + +## Manual verification guide + +1. Reset the dev app state by deleting the OpenPets dev user data directory shown in the app startup log, or remove/edit `openpets-state.json` so onboarding is incomplete. +2. Run: + +```bash +pnpm dev:desktop +``` + +3. Confirm the default pet appears if `openDefaultPetOnLaunch` is enabled. +4. Confirm the onboarding window opens automatically. +5. On Welcome, click Continue. +6. On Pets, click the Pet Manager action and confirm the real Pet Manager opens. Close or leave it open, then continue/skip. +7. On Agents, click the Agent Setup action and confirm the real Configure Agents window opens with Claude controls. Do not apply config unless intentionally testing Claude setup. Continue/skip. +8. On Ready, click Finish. +9. Quit OpenPets and launch again with `pnpm dev:desktop`. +10. Confirm onboarding does not auto-open after completion. +11. Reset onboarding state again, relaunch, close the onboarding window without finishing, and confirm **Continue Setup...** is available from the tray and reopens onboarding. +12. Simulate catalog unavailable behavior if practical, for example by disconnecting network or using an invalid catalog URL if supported by the dev environment; confirm onboarding can still be completed. +13. Confirm that a machine without Claude Code configured, or with Claude setup errors, can still complete onboarding. +14. Confirm Claude settings are unchanged unless existing Agent Setup configure/install buttons were explicitly used. + +Expected results: + +- Onboarding never blocks app/tray/default pet usage. +- Skipping pet install and agent setup still allows finishing. +- Completion persists across restart. +- No Claude settings are modified unless you explicitly use existing Agent Setup configure/install buttons. + +## Oracle plan review + +Reviewed by Oracle for architecture fit, scope, security/privacy, state migration, testing, and cross-platform risks. + +Blockers: none. + +Should-fix feedback: + +- Ensure onboarding IPC sender validation uses main-process `taskWindows` / `webContents.id` mapping, not renderer-declared DOM values. +- Keep onboarding preload/API narrow; if shared preload exposes broad APIs, existing IPC sender checks must deny install/remove/preferences from onboarding. +- Add acceptance criteria that Finish closes onboarding and refreshes tray so **Continue Setup...** disappears immediately. +- Avoid importing Electron-bound `app-state.ts` directly from Node tests; extract pure normalization/completion helpers or inject temp state paths. +- Keep onboarding snapshot minimal: default pet/onboarding state only; do not duplicate Claude detection/status or expose Claude settings paths in onboarding. +- Manual verification should include Claude unavailable/catalog unavailable cases and confirm no Claude settings changed unless Agent Setup buttons are explicitly used. + +Nice-to-have feedback: + +- Add a small “Start using OpenPets”/Finish action on Ready that closes onboarding. +- Place tray **Continue Setup...** above manager/settings while incomplete for discoverability. +- Consider a developer-only env var to force onboarding incomplete for manual testing. + +Verdict: implementation-ready after sender-validation/preload and state-test clarifications. Scope is appropriate as one phase because it connects existing surfaces instead of rebuilding pet/agent setup logic. + +## Oracle feedback disposition + +Fixed: + +- Added explicit main-process `webContents.id` sender-validation requirement. +- Added shared-preload/broad-API guard requirement. +- Added finish-close/tray-refresh acceptance criterion. +- Clarified state tests should use extracted pure helpers or injected temp state paths, not brittle Electron-bound imports. +- Reduced onboarding snapshot to default pet/onboarding state only and forbade exposing Claude config paths/previews in onboarding. +- Added manual checks for catalog unavailable, Claude unavailable/error states, and no Claude settings mutation unless existing Agent Setup controls are explicitly used. + +Accepted nice-to-have: + +- Ready/Finish action and discoverable tray placement are included in implementation guidance. + +Deferred: + +- Developer-only env var to force onboarding incomplete. Manual reset via dev user-data/state file is enough for Phase 09 unless implementation testing shows reset friction is high. + +## Oracle implementation review + +Initial implementation review: + +Blockers: none. + +Should-fix feedback: + +- Remove onboarding access to the broad `openpets:get-state` handler; onboarding should use only its minimal snapshot. +- Return a minimal completion result instead of the full app state from onboarding completion. +- Wrap automatic onboarding startup so a window creation failure does not exit the tray app. +- Improve onboarding state tests so completion remains true after preference-like updates, and prefer temp/injected persistence coverage if practical. +- Show the current default pet on the Pets step, not only Welcome. + +Nice-to-have feedback: + +- Disable Finish while completion is in flight. +- Step indicators/back navigation can be considered later if cheap. +- Manual Electron verification is still required. + +Final Oracle re-check: + +- Blockers: none. +- Remaining should-fix: none from the previous review. +- Verified previous should-fix items are resolved. +- Verdict: proceed to manual Electron verification before user acceptance. + +## Oracle implementation feedback disposition + +Fixed: + +- Added root `pnpm dev:desktop` script so the manual verification command works from the workspace root. +- Removed onboarding from `openpets:get-state` allowlist. +- Onboarding completion now returns `{ onboardingCompleted: true }` instead of full app state. +- Wrapped startup onboarding open in a try/catch so tray/default-pet startup continues on failure. +- Added state-check coverage that onboarding completion remains true after a preference-like update while other preferences are preserved. +- Added current default pet display to the Pets step. +- Disabled the Finish button while completion is in flight. + +Deferred: + +- Temp-path full app-state persistence test. The current app-state module is Electron-bound through `app.getPath`; Phase 09 covers the extracted pure onboarding normalization/completion contract and leaves broader state-store injection for a later hardening/refactor if needed. +- Clickable/back-capable step indicators. Forward-only onboarding keeps Phase 09 simpler and matches the approved scope. diff --git a/docs/phases/phase-10a-desktop-package-hardening.md b/docs/phases/phase-10a-desktop-package-hardening.md new file mode 100644 index 00000000..be93056b --- /dev/null +++ b/docs/phases/phase-10a-desktop-package-hardening.md @@ -0,0 +1,346 @@ +# Phase 10A: Desktop Package Hardening + +## Goal + +Add a reliable current-platform desktop packaging checkpoint for the OpenPets tray app and document the MVP validation matrix, while explicitly deferring the unresolved packaged Claude command distribution path to Phase 10B. + +Phase 10A should prove that the packaged desktop app itself can launch and run the core local experience: tray, default pet, onboarding, Pet Manager, settings, local state, assets, and local IPC startup/cleanup behavior. + +## Non-goals + +- No real packaged Claude end-to-end demo unless the user already has published `@open-pets/*` packages available. +- No npm package publishing. +- No bundled packaged MCP/Claude resource command mode. +- No signed/notarized public release. +- No app store work. +- No auto-update system. +- No start-at-login feature. +- No new pet/catalog/agent features. +- No broad UI redesign. + +Phase 10B should choose the Claude distribution path: + +- publish `@open-pets/mcp` and `@open-pets/claude`, or +- bundle unpacked MCP/Claude command resources inside the packaged app and point Agent Setup at those paths. + +## User-visible/manual outcome + +From a clean checkout, the user can run: + +```bash +pnpm test +pnpm check +pnpm package:desktop:dir +``` + +Then launch the generated current-platform packaged app and verify: + +- OpenPets starts as a tray/menu bar app. +- The default pet appears and uses packaged assets. +- First-run onboarding can complete. +- Pet Manager opens and can show installed pets/catalog state. +- Settings opens and can update preferences. +- The app writes local IPC discovery state in the user-specific runtime location. +- Quit exits cleanly. + +The phase also creates `docs/mvp-validation.md` with a platform matrix, unsigned-app notes, package commands, cleanup notes, and the remaining Phase 10B packaged-Claude gap. + +## Acceptance criteria + +- A desktop packaging tool/config exists for current-platform local validation. +- Packaging is Node/pnpm based and does not require Bun. +- Root scripts exist: + - `pnpm package:desktop:dir` + - optionally `pnpm package:desktop` if installer packaging is cheap after `--dir` passes. +- Desktop package scripts exist for package/build/check flow. +- Root package script performs a topological build of desktop dependencies before packaging, not only `apps/desktop` TypeScript. +- Packaging includes required runtime files: + - compiled desktop `dist/**`, + - `preload.cjs`, + - `assets/tray-icon.png`, + - bundled/default pet runtime assets or generated-renderer code, + - workspace runtime dependencies required by the desktop app, including built `@open-pets/claude` and `@open-pets/client` files because Agent Setup imports Claude helpers at startup, + - package metadata needed by Electron. +- Packaging must not leave pnpm symlinks that escape the packaged app, especially symlinks back to `packages/*` in the checkout. +- Packaging excludes obvious repo bulk/secrets/user data: + - `v1/`, + - `web/`, + - `docs/phases/`, + - `.env*`, + - Claude settings/backups, + - app user data, + - build caches. +- Package content checks verify expected config/files and guard against obvious inclusion mistakes where practical. +- A post-package content check verifies the generated package directory contains main/preload/assets/runtime workspace deps and does not contain forbidden repo bulk/secrets where practical. +- Packaged resource path assumptions are audited and fixed if needed: + - tray icon loading, + - preload loading, + - built-in pet loading, + - internal data URLs/task windows, + - local IPC discovery file paths. +- Renderer security remains unchanged: + - `nodeIntegration: false`, + - `contextIsolation: true`, + - `sandbox: true`, + - CSP/navigation guards retained. +- Local dev command mode for Agent Setup remains unavailable or clearly dev-only in packaged builds. +- Published/npx Claude commands remain documented as requiring real published packages; packaged Claude end-to-end is explicitly deferred to Phase 10B. +- In Phase 10A packaged app verification, do not apply Claude MCP/hooks unless `@open-pets/*` packages are actually published and intentionally being tested. +- `docs/mvp-validation.md` documents: + - platform validation matrix for macOS/Windows/Linux, + - current-platform package command, + - unsigned local-app warnings, + - how to launch the packaged output, + - cleanup/uninstall notes, + - which MVP demo rows are verified in Phase 10A vs deferred to Phase 10B. +- `pnpm test` passes. +- `pnpm check` passes. +- `pnpm package:desktop:dir` succeeds on the current platform, or the exact blocker and next action are documented. + +## Proposed files/directories + +- `package.json` + - Add root packaging scripts. +- `apps/desktop/package.json` + - Add package scripts and packaging dev dependency, likely `electron-builder`. +- `apps/desktop/electron-builder.yml` + - Define local validation packaging config: stable app id, product name, output directory, explicit files/resources, disabled publish, and signing/notarization deferral. +- `.gitignore` + - Ensure package output directory is ignored. +- `apps/desktop/src/assets.ts` + - Harden packaged resource path lookup if needed. +- `apps/desktop/src/built-in-pet.ts`, `apps/desktop/src/pet-window.ts`, or related files + - Harden packaged built-in pet asset lookup only if package testing exposes a failure. +- `apps/desktop/src/check-packaging-contract.ts` + - Deterministic packaging config/content guard where practical. +- `docs/mvp-validation.md` + - MVP package/manual validation checklist and matrix. +- `docs/phases/phase-10a-desktop-package-hardening.md` + +## Technical approach + +### Packaging tool + +Use `electron-builder` for Phase 10A unless implementation testing reveals a blocker. + +Initial target should be unpacked/current-platform packaging first: + +```bash +pnpm package:desktop:dir +``` + +Installer targets can be added only after `--dir` works and if the config is straightforward. The phase can still pass with `--dir` as the stable packaging checkpoint if installers need more platform-specific work. + +### Build order + +Packaging must not assume only `apps/desktop` needs building. + +Root scripts should build relevant workspace dependencies before packaging. For example: + +```json +{ + "package:desktop:dir": "pnpm build && pnpm --filter @open-pets/desktop package:dir" +} +``` + +If full workspace build is too slow but reliable dependency filtering is easy, it can be narrowed later. + +### Package contents + +Start with a conservative `electron-builder` file list that packages only the desktop app runtime. Do not package the entire repository. + +Candidate includes: + +- `dist/**` +- `preload.cjs` +- `assets/**` +- built workspace runtime dependencies needed by desktop imports, especially `@open-pets/claude` and `@open-pets/client` +- `package.json` + +If built-in pet assets live outside those paths or are resolved through `app.getAppPath()`, include them explicitly and adjust path helpers. If the bundled default pet is generated/rendered from compiled code rather than external files, document that in the packaging contract. + +The generated package must not depend on symlinks that escape to the source checkout. If pnpm workspace layout causes symlink leakage, use an explicit staging/deploy step rather than shipping checkout-relative links. + +### pnpm workspace handling + +Do not add root `.npmrc` hoisting/linker changes preemptively. + +If `electron-builder` fails because of pnpm workspace dependency layout: + +1. Prefer explicit file/resource inclusion and package-local fixes. +2. Consider a staging/deploy step. +3. Only add root pnpm linker changes if necessary, with documentation and another `pnpm check` run. + +### Claude packaging limitation + +Phase 10A must not pretend packaged Claude works while packages are private/unpublished. + +Agent Setup published mode may still show `npx -y @open-pets/...`, but `docs/mvp-validation.md` should state that a packaged end-to-end Claude demo requires Phase 10B unless those packages have been published. + +Local dev command mode should stay disabled in packaged builds, because absolute checkout paths are not suitable for a distributed app. + +### Validation docs + +`docs/mvp-validation.md` should include a table like: + +```text +Area Phase 10A status +Tray/default pet packaged Verify now +Onboarding packaged Verify now +Pet Manager packaged Verify now +Claude MCP via npx Deferred unless packages published +Claude hooks via npx Deferred unless packages published +Packaged bundled commands Phase 10B +``` + +## Risks and tradeoffs + +- **This is not full MVP distribution yet.** It intentionally defers packaged Claude commands to avoid hiding a publish/bundle decision. +- **electron-builder + pnpm may need staging.** Avoid global linker changes unless required. +- **Unsigned apps may show OS warnings.** Accept this for local validation and document it. +- **Cross-platform validation cannot be completed from one OS.** Define the matrix now; mark non-current platforms unverified until tested. +- **Packaging can reveal path assumptions.** Fix concrete packaged-mode failures only. + +## Security/privacy notes + +- Do not weaken renderer sandbox/context isolation/CSP. +- Do not introduce TCP/HTTP control surfaces. +- Do not include secrets, local app data, Claude settings, or backups in package output. +- Keep local IPC local-only and token-gated. +- Claude config mutation remains confirmation-based through existing Agent Setup behavior. +- Unsigned package output is for local MVP validation, not public trust distribution. + +## Test/check plan + +Run: + +```bash +pnpm test +pnpm check +pnpm package:desktop:dir +``` + +Add/extend deterministic checks for: + +- packaging config exists, +- package scripts exist, +- required runtime files are included by config or copied resources, +- obviously forbidden repo/user-data patterns are not included by config, +- generated package output contains main/preload/assets and workspace runtime deps after `package:desktop:dir`, +- generated package output does not contain symlinks escaping the package directory, +- root packaging command is available. + +Manual verification covers actual packaged app launch and resource loading. + +## Manual verification guide + +1. Run: + +```bash +pnpm test +pnpm check +pnpm package:desktop:dir +``` + +2. Launch the generated app from the package output. +3. Confirm tray icon appears. +4. Confirm default pet appears and can be shown/hidden/paused/resumed. +5. Complete or reset onboarding and confirm it behaves correctly in packaged mode. +6. Open Pet Manager and confirm installed pets/catalog state renders. +7. Open Settings and toggle a preference; restart packaged app and confirm persistence. +8. Confirm local IPC discovery is created in a per-user location while the app runs and cleaned/stale-safe on quit as documented. + - While the packaged app is running, use an existing checkout client smoke/status command if available to verify discovery, token, and status endpoint against the packaged app. +9. Open Agent Setup and confirm local dev mode is unavailable/clearly dev-only in packaged builds. +10. Confirm docs clearly mark packaged Claude MCP/hooks as deferred to Phase 10B unless npm packages are published. Do not apply Claude MCP/hooks from the packaged app during Phase 10A unless intentionally testing published packages. +11. If catalog/fixture is available, install a pet and set it as default in packaged mode; if unavailable, mark catalog install as unverified and record the catalog error. +12. Quit from tray and confirm clean exit. + +## Oracle plan review + +Reviewed by Oracle after user chose to split broad Phase 10 into Phase 10A/10B. + +Blocker: + +- Even with Claude end-to-end deferred, desktop imports `@open-pets/claude` through Agent Setup, and that imports `@open-pets/client`. Phase 10A must explicitly include those workspace runtime deps' built files or use staging/deploy; packaging only desktop `dist`, preload, assets, and package metadata is likely insufficient. + +Should-fix feedback: + +- Require no symlinks escaping the packaged app, especially pnpm workspace symlinks to `packages/*`. +- Make electron-builder config requirements explicit: stable `appId`, `productName`, output dir, `publish` disabled, signing/notarization deferred, output ignored. +- Add post-package content checks, not only config checks. +- Add manual local IPC smoke step from checkout against packaged app. +- Strengthen Agent Setup warning: do not apply Claude MCP/hooks in Phase 10A packaged app unless packages are actually published. +- Pet Manager verification should include install/set-default if catalog/fixture is available, or explicitly mark catalog install unverified if unavailable. + +Nice-to-have feedback: + +- Keep Phase 10A to `electron-builder --dir` first. +- Prefer staging/deploy over root linker changes if pnpm fights builder. +- Clarify bundled default pet assets if generated rather than file assets. + +Verdict: original packaged-Claude blocker is resolved by the split. Phase 10A is nearly implementation-ready after making workspace runtime dependency inclusion a hard acceptance criterion. + +## Oracle feedback disposition + +Fixed: + +- Added hard acceptance criterion for packaged workspace runtime deps, especially `@open-pets/claude` and `@open-pets/client`. +- Added no-escaping-symlink acceptance criterion. +- Made electron-builder config expectations explicit. +- Added post-package content check requirement. +- Added local IPC smoke verification step. +- Strengthened packaged Agent Setup warning not to apply Claude MCP/hooks unless packages are actually published. +- Added Pet Manager install/set-default manual check when catalog/fixture is available, with explicit unverified note if unavailable. +- Clarified default pet assets may be generated/runtime code rather than external files. + +Accepted: + +- Start with `--dir` packaging first; installers are optional after `--dir` passes. +- Prefer staging/deploy over root pnpm linker changes if needed. + +Deferred: + +- Packaged Claude end-to-end command distribution remains Phase 10B. + +## Oracle implementation review + +Reviewed by Oracle after implementation. + +Blockers: none. + +Should-fix feedback: + +- Manually verify the packaged GUI before closing Phase 10A: tray, default pet, onboarding, Pet Manager, Settings, IPC smoke, quit cleanup. +- Tighten or explicitly disposition package over-inclusion. Current unpacked app may include workspace package `src/`, `tsconfig.json`, built check scripts, and source maps. This is acceptable for local inspectable Phase 10A if documented, but does not fully match runtime-only packaging intent. +- Extend package output contract checks to third-party runtime deps such as `yauzl` and transitive deps. + +Nice-to-have feedback: + +- Document `npmRebuild: false` assumption as acceptable because current deps are JS-only/no native modules. +- Keep `asar: false` for Phase 10A only and revisit for release hardening. +- Exact script assertions in packaging checks are acceptable at current scale, though less brittle assertions may be useful later. + +Verdict: acceptable for Phase 10A after manual packaged-app verification. + +## Oracle implementation feedback disposition + +Fixed: + +- Added package output assertions for `yauzl` and its runtime helpers/transitive dependencies. +- Documented Phase 10A package over-inclusion as an intentional local/inspectable packaging tradeoff in `docs/mvp-validation.md`. +- Documented `npmRebuild: false` as safe only while runtime deps are JavaScript-only. + +Deferred: + +- Runtime-only ASAR packaging. Phase 10A intentionally uses unpacked output for inspectable package checks; release hardening should revisit ASAR/runtime-only output before public distribution. + +Needs manual verification: + +- Packaged GUI launch and full Phase 10A manual checklist must be completed by the user before committing/closing this phase. + +Final Oracle re-check: + +- Blockers: none. +- Remaining implementation/spec should-fix: none. +- Phase gate still remaining: user manual packaged GUI verification before closing Phase 10A. +- Verdict: proceed to user manual verification; do not close/commit phase until that checklist passes. diff --git a/docs/phases/phase-10b-packaged-claude-command-distribution.md b/docs/phases/phase-10b-packaged-claude-command-distribution.md new file mode 100644 index 00000000..c0ae7182 --- /dev/null +++ b/docs/phases/phase-10b-packaged-claude-command-distribution.md @@ -0,0 +1,333 @@ +# Phase 10B: Packaged Claude Command Distribution + +## Goal + +Make the packaged OpenPets desktop app capable of configuring Claude Code without relying on unpublished `npx @open-pets/*` packages. + +Phase 10B should add a packaged/bundled command mode that points Claude MCP and Claude hooks at JavaScript entry points shipped inside the packaged app resources. + +## Non-goals + +- No npm publishing. +- No public signed/notarized release. +- No auto-update. +- No changing the public MCP tool set. +- No new agent integrations beyond Claude. +- No model-generated speech. +- No removing the existing published `npx` command mode for future published packages. +- No weakening Claude configuration confirmation/backups. + +## User-visible/manual outcome + +In the packaged app, Agent Setup no longer asks users to configure private/unpublished `npx` packages by default. + +Instead, packaged Agent Setup previews and installs commands like: + +```text +node /app/node_modules/@open-pets/mcp/dist/index.js +node /app/node_modules/@open-pets/claude/dist/cli.js hook --openpets-managed +``` + +The exact path is platform-specific, but it must be inside the packaged app output/resources and must be a regular file. + +In dev/unpackaged mode, the existing behavior remains: + +- published mode: `npx -y @open-pets/...` +- local dev mode: built checkout `dist` paths + +## Acceptance criteria + +- `@open-pets/desktop` packages the runtime packages required for bundled Claude commands: + - `@open-pets/mcp`, + - `@open-pets/claude`, + - `@open-pets/client`, + - required third-party runtime dependencies. +- Agent Setup chooses command mode by environment: + - packaged app: bundled packaged mode, + - dev app: published/local dev toggle remains available. +- The Agent Setup renderer/preload accepts and renders bundled command mode snapshots without treating them as invalid. +- Packaged Agent Setup checks Node availability for Claude-launched commands, or clearly disables configure/install with a warning if Node is unavailable/too old. +- Packaged mode command previews use `node ` and never `npx -y @open-pets/...` unless the user is explicitly in published mode in dev. +- Packaged MCP command points at a regular file shipped inside package resources. +- Packaged hook command points at a regular file shipped inside package resources. +- Packaged command paths are not source-checkout paths and do not use `app.getAppPath()` assumptions that fail after packaging. +- Packaged commands are validated before install/doctor/configure actions: + - path exists, + - path is a regular file, + - real path is inside packaged app resources/app directory, + - symlink escapes are rejected, + - `.asar` paths are rejected unless the file is explicitly unpacked/readable as a regular file, + - command contains no newline/null/unsupported shell characters. +- Missing bundled resources produce an actionable Agent Setup status/error, not an unhandled IPC failure. +- Claude MCP configure/replace/remove still uses `claude mcp ...` and still requires explicit user button actions. +- Claude hook install/update/uninstall still writes only OpenPets-managed hooks and still creates backups. +- Doctor/preview text clearly labels packaged bundled mode and explains commands point into the installed OpenPets app. +- If packaged bundled command resources are missing, Agent Setup shows an actionable error and disables configure/install actions rather than falling back silently to unpublished `npx` packages. +- Existing contract checks cover packaged bundled command files in package output. +- Existing Claude code/hooks contract checks cover bundled command preview/matching behavior. +- `pnpm test` passes. +- `pnpm check` passes. +- `pnpm package:desktop:dir` passes. + +## Proposed files/directories + +- `packages/claude/src/claude-code.ts` + - Add command mode support for packaged bundled MCP command paths. +- `packages/claude/src/hook-settings.ts` + - Add command mode support for packaged bundled hook CLI command paths. +- `packages/claude/src/check-claude-code.ts` + - Add bundled command preview/status checks. +- `packages/claude/src/check-claude-hooks.ts` + - Add bundled hook settings checks. +- `apps/desktop/src/agent-setup.ts` + - Select bundled mode in `app.isPackaged`. + - Validate packaged resource paths and surface errors. + - Check/warn for Node availability for packaged bundled commands. + - Keep dev local toggle unavailable in packaged app. +- `apps/desktop/preload.cjs` + - Accept/render `commandMode: "bundled"` snapshots and label bundled mode clearly. +- `apps/desktop/package.json` + - Add `@open-pets/mcp` as a desktop runtime dependency so packaged output includes it. +- `apps/desktop/src/check-packaging-contract.ts` + - Require packaged MCP/Claude/client entry files and MCP runtime deps. +- `docs/mvp-validation.md` + - Update Phase 10B status and manual Claude packaged validation steps. +- `docs/phases/phase-10b-packaged-claude-command-distribution.md` + +## Technical approach + +### Command mode model + +Extend `OpenPetsCommandMode` from: + +```ts +"published" | "local" +``` + +to: + +```ts +"published" | "local" | "bundled" +``` + +Semantics: + +- `published`: npm/npx package names. +- `local`: checkout-relative built `dist` files for development. +- `bundled`: package-relative files shipped in the packaged app's `node_modules/@open-pets/*/dist` directories. + +For current unpacked Phase 10A packaging (`asar: false`), `bundled` paths can be resolved from `@open-pets/claude`'s own `import.meta.url` in packaged node_modules: + +```text +.../Resources/app/node_modules/@open-pets/claude/dist +.../Resources/app/node_modules/@open-pets/mcp/dist/index.js +``` + +This mirrors the current `local` sibling-package resolution, but should be named `bundled` so the UI does not imply a fragile source checkout path. + +### Desktop mode selection + +In desktop Agent Setup: + +- `app.isPackaged` should force `bundled`. +- Dev mode keeps current published/local toggle. +- UI should show the dev checkbox disabled in packaged app, with text indicating packaged bundled commands are used. + +### Hook settings + +Bundled hook settings should still use the OpenPets marker: + +```text +node "/@open-pets/claude/dist/cli.js" hook --openpets-managed +``` + +Uninstall must continue to remove any OpenPets-managed hooks by marker, including old published/local commands. + +Install/update should replace old OpenPets-managed published/local commands with bundled commands when running from packaged app. + +### MCP settings + +Bundled MCP settings should configure Claude with: + +```json +{ + "type": "stdio", + "command": "node", + "args": ["/@open-pets/mcp/dist/index.js"] +} +``` + +If a pet is selected: + +```json +"args": ["/@open-pets/mcp/dist/index.js", "--pet", "pet-id"] +``` + +### Packaging contract + +Package output checks should assert: + +- `node_modules/@open-pets/mcp/dist/index.js` exists. +- `node_modules/@open-pets/claude/dist/cli.js` exists. +- `node_modules/@open-pets/client/dist/index.js` exists. +- required MCP third-party dependencies exist (`@modelcontextprotocol/sdk`, `zod`, etc.) if included by builder. +- no symlink escapes package output. + +### Security/safety + +Path validation should reject: + +- missing files, +- directories/symlinks when a regular file is expected, +- paths whose `realpath` is outside the packaged app resource/app root in bundled mode, +- `.asar` paths unless the target is explicitly unpacked/readable as a regular file, +- command strings containing newline/null. + +Do not silently fall back to `npx` in packaged app if bundled resources are missing; that would reintroduce the Phase 10A blocker. + +## Risks and tradeoffs + +- **Installed app path moves.** Claude settings will contain absolute paths into the installed app; if the user moves/deletes OpenPets, Agent Setup doctor should report needs update/error and the user should reinstall/update config. +- **ASAR disabled in Phase 10A.** Bundled commands rely on regular files. If ASAR is enabled later, MCP/Claude command files must move to `asarUnpack` or `extraResources`. +- **Node availability.** Commands use `node`, so users still need Node available for Claude to launch MCP/hooks. This matches the project's Node/npm/npx direction and avoids bundling a separate runtime in this phase. +- **Cross-platform paths.** Windows paths with spaces must be handled through Claude MCP arg arrays and shell-quoted hook command strings. + +## Security/privacy notes + +- No silent Claude configuration changes. +- Backups remain required before Claude settings hook writes. +- Bundled commands are local files shipped with OpenPets, not remote downloads. +- No TCP/HTTP control plane is introduced. +- Speech/privacy rules from earlier phases remain unchanged. +- Uninstall removes only OpenPets-managed hooks by marker. + +## Test/check plan + +Run: + +```bash +pnpm test +pnpm check +pnpm package:desktop:dir +``` + +Automated coverage: + +- bundled MCP preview command shape, +- bundled hook command shape, +- parse/classify expected bundled Claude MCP entries, +- bundled paths with spaces/backslashes in MCP arg arrays and hook shell quoting, +- bundled command path validation for existing and missing files, +- renderer/preload snapshot validation accepts `commandMode: "bundled"`, +- package output includes MCP/Claude/client runtime entries and MCP runtime deps. + +## Manual verification guide + +1. Run: + +```bash +pnpm test +pnpm check +pnpm package:desktop:dir +``` + +2. Launch the packaged app. +3. Open Agent Setup. +4. Confirm command preview uses `node `, not `npx -y @open-pets/mcp`. +5. Confirm hook preview uses `node hook --openpets-managed`. +6. Configure Claude MCP only if you are ready to update your user Claude settings; verify backup/config behavior as in earlier phases. +7. Install/update hooks only if you are ready to update your user Claude settings; verify backup/config behavior as in earlier phases. +8. Run Claude Code and confirm MCP/hook behavior if desired. +9. Use Agent Setup to remove MCP/hooks after testing if you do not want packaged paths left in Claude settings. + +## Oracle plan review + +Reviewed by Oracle. + +Blocker: + +- The original plan omitted renderer/preload bundled-mode support. Current `apps/desktop/preload.cjs` validates command mode as only `published` or `local`, so a bundled snapshot would be rejected. The spec must include preload/renderer handling. + +Should-fix feedback: + +- Add explicit Node prerequisite handling because packaged bundled commands require `node` on Claude's PATH. +- Validate bundled paths with `realpath` against the packaged app root, not simple string prefix. Reject missing files, directories, symlinks, `.asar` paths unless unpacked, newline/NUL/quote/shell-dangerous chars. +- Ensure missing bundled resources produce actionable Agent Setup status/error instead of unhandled IPC failure. +- Add cross-platform tests for paths with spaces/backslashes, MCP args arrays, hook shell quoting, and bundled `claude mcp get` parsing. +- Extend package contract checks to require `@open-pets/mcp/dist/index.js` plus runtime deps like `@modelcontextprotocol/sdk` and `zod`. +- Update docs to state Claude settings contain absolute OpenPets app paths; moving/deleting/updating the app may require Agent Setup replace/update/remove. + +Nice-to-have feedback: + +- Centralize command-mode/path resolution helpers. +- Show shortened paths in UI while writing full absolute paths to Claude settings. +- Add future ASAR note for `asarUnpack` or `extraResources`. + +Verdict: architecture is sound, but not implementation-ready until preload bundled-mode support and path/quoting/test clarifications are added. + +## Oracle feedback disposition + +Fixed: + +- Added `apps/desktop/preload.cjs` to proposed files and acceptance criteria for bundled-mode renderer support. +- Added Node prerequisite warning/disable requirement. +- Strengthened realpath/path validation requirements. +- Added missing-resource error-state requirement. +- Added cross-platform path/quoting tests to test plan. +- Added MCP runtime deps to package contract requirements. +- Added absolute packaged path docs requirement. + +Accepted: + +- Centralize command/path helpers where practical during implementation. +- Show shortened paths in UI while writing full paths if cheap. + +Deferred: + +- ASAR support remains future release hardening; Phase 10B should note that ASAR requires `asarUnpack`/`extraResources` for externally executed JS files. + +## Oracle implementation review + +Reviewed by Oracle after implementation. + +Blockers: none. + +Should-fix feedback: + +- Prevent broken writes when `node` is unavailable. `detectClaudeCodeStatus()` reports “Node required”, but action handlers must also guard configure/replace/install-hooks in bundled mode. +- Keep cleanup available on bundled-resource errors. `remove` and `uninstall-hooks` should not be blocked by missing bundled MCP/hook resources. +- Add stronger Windows/path tests for hook command quoting, MCP args arrays, and text parsing of bundled paths with spaces. +- Reject symlink command files or document reliance on package checks. +- Manual packaged Agent Setup verification remains required. + +Nice-to-have feedback: + +- Make bundled tests explicitly depend on built `@open-pets/mcp` or avoid relying on stale sibling `dist` state. +- Clarify Node PATH check is best-effort because Claude's runtime environment may differ. +- Keep ASAR/`extraResources` deferred as documented. + +Verdict: architecture is sound and package contract is substantially adequate; fix node/action guard and cleanup-path issues before final acceptance. + +## Oracle implementation feedback disposition + +Fixed: + +- Added action-level bundled Node guard before configure/replace/install-hooks. +- Moved cleanup actions so `remove` and `uninstall-hooks` remain available even if bundled command resources are missing. +- Added quoted-path parsing and Windows-style path display checks for Claude MCP command handling. +- Runtime bundled command validation now rejects symlink entry files in addition to using `realpath` containment checks. + +Accepted: + +- Manual packaged Agent Setup verification remains a phase gate. + +Deferred: + +- ASAR/`extraResources` release hardening remains deferred. + +Final Oracle re-check: + +- Blockers: none. +- Remaining should-fix: none from the prior Oracle review. +- Manual packaged Agent Setup UI verification remains as the phase gate before closing. +- Verdict: proceed to user manual packaged verification for bundled MCP preview/configure, hook preview/install, real Claude behavior if desired, and cleanup/remove. diff --git a/docs/phases/phase-10c-asar-runtime-packaging.md b/docs/phases/phase-10c-asar-runtime-packaging.md new file mode 100644 index 00000000..3263690f --- /dev/null +++ b/docs/phases/phase-10c-asar-runtime-packaging.md @@ -0,0 +1,257 @@ +# Phase 10C: ASAR Runtime Packaging + +## Goal + +Move the desktop package from fully unpacked local-validation output to a more release-like ASAR package while keeping Claude's externally executed MCP and hook commands available as regular unpacked files. + +Phase 10C should reduce package over-inclusion from Phase 10A/10B and prove the packaged app still works with bundled Claude commands. + +## Non-goals + +- No public signed/notarized release. +- No installer targets beyond the current `--dir` package checkpoint unless trivial. +- No npm publishing. +- No auto-update. +- No start-at-login. +- No new agent integrations. +- No changing Claude hook/MCP behavior except paths needed for ASAR packaging. + +## User-visible/manual outcome + +The current-platform package still builds with: + +```bash +pnpm package:desktop:dir +``` + +The generated app uses ASAR for the Electron app contents, but still includes unpacked files that Claude can execute with external `node`: + +```text +app.asar +app.asar.unpacked/node_modules/@open-pets/mcp/dist/index.js +app.asar.unpacked/node_modules/@open-pets/claude/dist/cli.js +``` + +Agent Setup packaged previews should point at `app.asar.unpacked/...` paths, not `app.asar/...` paths and not source checkout paths. + +## Acceptance criteria + +- `electron-builder` ASAR packaging is enabled. +- Externally executed bundled command files are unpacked: + - `@open-pets/mcp/dist/index.js`, + - `@open-pets/claude/dist/cli.js`, + - package metadata (`package.json`) needed for ESM/package resolution, + - all runtime files and transitive dependencies those commands need when launched by external `node`. +- Packaged Agent Setup bundled MCP preview points at a regular file outside `app.asar`, preferably under `app.asar.unpacked`. +- Packaged hook preview points at a regular file outside `app.asar`, preferably under `app.asar.unpacked`. +- Desktop runtime imports from ASAR still work for the Electron app itself. +- Package contract checks assert: + - `app.asar` exists, + - externally executed command entry files exist under unpacked resources, + - unpacked command package metadata exists, + - external `node` can execute packaged MCP/Claude command entry points in safe smoke modes, + - generated bundled command preview paths do not contain `.asar` except `.asar.unpacked`, + - no symlinks escape package output, + - forbidden repo/user-data patterns remain absent. +- Runtime bundled path resolution handles both dev/unpacked and packaged ASAR layouts. +- Claude bundled path validation still rejects missing files, symlinks, and true `app.asar` paths, while allowing `app.asar.unpacked` paths. +- `docs/mvp-validation.md` is updated to describe ASAR + unpacked command resources. +- `pnpm test` passes. +- `pnpm check` passes. +- `pnpm package:desktop:dir` passes. + +## Proposed files/directories + +- `apps/desktop/electron-builder.yml` + - Enable ASAR and configure unpacking for externally executed OpenPets command resources. +- `packages/claude/src/claude-code.ts` + - Resolve bundled MCP path to ASAR-unpacked location when running from ASAR. +- `packages/claude/src/hook-settings.ts` + - Resolve bundled Claude CLI path to ASAR-unpacked location when running from ASAR. +- `packages/claude/src/check-claude-code.ts` + - Cover ASAR-to-unpacked path mapping. +- `packages/claude/src/check-claude-hooks.ts` + - Cover ASAR-to-unpacked hook CLI path mapping. +- `apps/desktop/src/check-packaging-contract.ts` + - Validate ASAR/unpacked package output and bundled command files. +- `docs/mvp-validation.md` + - Update packaging notes. +- `docs/phases/phase-10c-asar-runtime-packaging.md` + +## Technical approach + +### Builder config + +Enable ASAR: + +```yaml +asar: true +``` + +Use `asarUnpack` for externally executed command resources and their module metadata/dependency closure. Initial candidate: + +```yaml +asarUnpack: + - node_modules/@open-pets/mcp/** + - node_modules/@open-pets/claude/** + - node_modules/@open-pets/client/** + - node_modules/@modelcontextprotocol/** + - node_modules/zod/** +``` + +If runtime smoke testing shows additional MCP SDK transitive dependencies are required for external `node`, include them. Prefer a minimal external command runtime subtree, but correctness is more important than micro-optimizing package size in this phase. + +### Path mapping + +When `@open-pets/claude` runs inside Electron from `app.asar`, its `import.meta.url` may include `app.asar`. Bundled command paths for Claude must map to `app.asar.unpacked`. + +Add a pure, tested path mapper with behavior: + +```text +.../Resources/app.asar/node_modules/@open-pets/claude/dist/index.js +=> +.../Resources/app.asar.unpacked/node_modules/@open-pets/claude/dist/cli.js +.../Resources/app.asar.unpacked/node_modules/@open-pets/mcp/dist/index.js +``` + +In dev/non-ASAR mode, continue resolving sibling workspace package paths as today. + +Validation must reject true `app.asar` command paths but allow `app.asar.unpacked` paths. + +### Validation + +Package output contract should locate either: + +- ASAR + `app.asar.unpacked` resources from Phase 10C. + +Phase 10C should prefer ASAR and fail if `app.asar` is missing after package output. + +Because forbidden-file checks cannot simply walk inside `app.asar` as a directory, use an ASAR library/tool or verify through builder file config plus unpacked tree checks. + +Post-package smoke checks should execute: + +```bash +node /node_modules/@open-pets/mcp/dist/index.js --version +node /node_modules/@open-pets/claude/dist/cli.js hook --openpets-managed +``` + +The hook smoke must use isolated/missing discovery input so it does not contact a real desktop app. + +## Risks and tradeoffs + +- **Dependency completeness:** external `node` running unpacked MCP/hook files may need dependencies that were left inside `app.asar`. Package contract and manual Claude tests must catch this. +- **Path mapping:** ASAR path rewrites are easy to get subtly wrong on Windows. Keep mapping string-based and covered by tests. +- **Package size:** unpacking command packages duplicates some files. Accept for this MVP unless it becomes excessive. +- **Future installers/signing:** signing/notarization remains deferred. + +## Security/privacy notes + +- Do not weaken renderer sandbox/context isolation/CSP. +- Do not include user data, Claude settings, backups, or secrets in package output. +- Claude config changes remain explicit/backup-protected. +- Externally executed command files must be shipped by OpenPets, not downloaded dynamically. + +## Test/check plan + +Run: + +```bash +pnpm test +pnpm check +pnpm package:desktop:dir +``` + +Automated coverage: + +- ASAR path maps to ASAR-unpacked command path. +- True `app.asar` command paths are rejected while `app.asar.unpacked` command paths are allowed. +- Package output contains `app.asar` and unpacked command resources. +- Packaged command resources are regular files, not symlinks. +- External `node` smoke-executes packaged MCP/Claude command files. + +## Manual verification guide + +Manual verification is provided after implementation. + +## Oracle plan review + +Reviewed by Oracle. + +Blockers: none. + +Should-fix feedback: + +- Make `asarUnpack` dependency closure explicit; external `node` cannot resolve modules left inside `app.asar`. +- Include package metadata in unpacked resources because ESM packages need `package.json`, `type`, and exports metadata. +- Add post-package smoke checks that actually run packaged MCP and Claude CLI entry points with safe inputs. +- Change validation semantics from rejecting any `.asar` to rejecting true `app.asar` while allowing `app.asar.unpacked`. +- Add pure/tested path mapper for `app.asar` → `app.asar.unpacked`, including Windows/backslash and spaces cases. +- Update package contract for ASAR-era layout: `app.asar` exists and command files are regular non-symlink files under `app.asar.unpacked`. +- If forbidden-file checks cannot walk ASAR contents, inspect ASAR with a tool/library or explicitly verify builder config plus unpacked tree checks. + +Nice-to-have feedback: + +- Prefer a minimal external command runtime subtree, but do not over-optimize before smoke checks pass. +- Document ASAR as packaging hygiene, not a security boundary. +- Keep installer/signing deferred. + +Verdict: implementation-ready after tightening unpacked dependency/metadata and smoke-test requirements. + +## Oracle feedback disposition + +Fixed: + +- Added explicit unpacked dependency and package metadata requirements. +- Added external node smoke-test requirements for MCP and Claude command entry points. +- Clarified validation should reject true `app.asar` but allow `app.asar.unpacked`. +- Added pure ASAR path mapper requirement and ASAR-era package contract requirements. +- Clarified forbidden-file checks must account for ASAR not being a normal directory. + +Accepted: + +- Keep installer/signing deferred. +- Document ASAR as packaging hygiene, not a security boundary in implementation docs. + +## Oracle implementation review + +Reviewed by Oracle after implementation. + +Blocker: + +- The ASAR-unpacked command target was mapped from `app.asar` to `app.asar.unpacked`, but the bundled validation root was still derived from `import.meta.url` under `app.asar`. Valid unpacked command paths could be rejected as outside root. + +Should-fix feedback: + +- Add a test for validation/root containment behavior, not only the string mapper. +- Manual packaged Agent Setup UI verification remains required. +- Consider cleaning `dist-electron` before packaging to avoid stale output masking regressions. + +Nice-to-have feedback: + +- Narrow `asarUnpack` later; `node_modules/**` is acceptable for MVP because command smokes prove dependency closure. +- Make `mapAsarPathToUnpacked` match `app.asar` as a path segment only. + +Verdict: close, but fix ASAR containment-root issue before acceptance. + +## Oracle implementation feedback disposition + +Fixed: + +- Bundled validation roots now pass through the same `app.asar` → `app.asar.unpacked` mapper as command target paths. +- `mapAsarPathToUnpacked` now matches `app.asar` as a path segment and leaves `app.asar.unpacked` unchanged. +- Added mapper coverage for path-segment behavior and already-covered command smoke tests validate packaged command files under `app.asar.unpacked`. + +Accepted: + +- `node_modules/**` remains unpacked for this MVP phase to keep external command dependency closure reliable. + +Needs manual verification: + +- Packaged Agent Setup UI must be manually verified after implementation. + +Final Oracle re-check: + +- Blockers: none. +- Remaining should-fix: none from the prior review. +- Manual packaged Agent Setup/Claude verification remains the phase gate before closing. +- Verdict: ASAR-to-unpacked path mapping and containment validation look correct; package command smokes passed; proceed to manual packaged verification. diff --git a/docs/phases/phase-10d-package-polish-clean-output.md b/docs/phases/phase-10d-package-polish-clean-output.md new file mode 100644 index 00000000..a4ad596c --- /dev/null +++ b/docs/phases/phase-10d-package-polish-clean-output.md @@ -0,0 +1,164 @@ +# Phase 10D: Package Polish and Clean Output + +## Goal + +Polish the desktop package metadata and make package output deterministic by cleaning stale output before each package build. + +This phase targets the warnings/noise surfaced during Phase 10A-10C packaging, without taking on signing, notarization, installers, or new product features. + +## Non-goals + +- No code signing or notarization. +- No installer targets beyond the current `--dir` package checkpoint. +- No app icon format generation unless it is trivial and safe. +- No npm publishing. +- No auto-update. +- No changes to Claude MCP/hooks behavior. + +## User-visible/manual outcome + +Packaging should be cleaner and less fragile: + +- desktop package metadata includes description/author fields used by packagers, +- packaging scripts remove stale `dist-electron` output before rebuilding, +- package contract checks enforce clean-output behavior and metadata presence, +- documentation notes remaining unsigned/default-icon limitations. + +## Acceptance criteria + +- `apps/desktop/package.json` includes package metadata expected by `electron-builder`, at minimum: + - `description`, + - `author`. +- Package scripts clean `apps/desktop/dist-electron` before running `electron-builder --dir`. +- Both `package` and `package:dir` clean `apps/desktop/dist-electron` before running `electron-builder` because both write that output directory. +- Cleanup uses a repository-owned helper that resolves and verifies the exact target before deleting; it does not accept path/env arguments. +- Root `pnpm package:desktop:dir` still performs a full workspace build before packaging. +- Package contract checks assert metadata presence and clean-output script behavior. +- Package contract checks verify clean-output behavior with a stale sentinel or equivalent helper contract, not only script text. +- `docs/mvp-validation.md` documents any remaining package warnings, including unsigned app and default app icon if still present. +- `pnpm test` passes. +- `pnpm check` passes. +- `pnpm package:desktop:dir` passes. + +## Proposed files/directories + +- `apps/desktop/package.json` + - Add metadata and clean-output package script. +- `apps/desktop/src/check-packaging-contract.ts` + - Assert metadata and package script behavior. +- `apps/desktop/scripts/clean-package-output.cjs` + - Cross-platform cleanup helper for `dist-electron`. +- `docs/mvp-validation.md` + - Update package warning notes. +- `docs/phases/phase-10d-package-polish-clean-output.md` + +## Technical approach + +Use only Node/pnpm commands. Add a tiny CommonJS cleanup helper instead of shell-specific `rm -rf` or brittle inline `node -e` quoting: + +```text +apps/desktop/scripts/clean-package-output.cjs +``` + +The helper should: + +1. Resolve `apps/desktop/dist-electron` relative to its own location. +2. Verify the basename is `dist-electron` and parent is the desktop package root. +3. Delete with `fs.rmSync(target, { recursive: true, force: true })`. +4. Accept no path/env overrides. + +Package scripts should run the helper before `electron-builder`: + +```json +"package": "pnpm build && node scripts/clean-package-output.cjs && electron-builder", +"package:dir": "pnpm build && node scripts/clean-package-output.cjs && electron-builder --dir && node dist/check-packaging-contract.js --output" +``` + +Do not add icon generation in this phase unless the existing PNG can be safely referenced without platform-specific conversion issues. + +## Risks and tradeoffs + +- Cleaning output removes previous packaged builds, which is intended for deterministic packaging. +- App icon polish may need platform-specific `.icns` / `.ico` generation; defer if not trivial. +- Metadata should not imply public distribution readiness while signing/notarization are still deferred. + +## Security/privacy notes + +- Cleaning only targets `apps/desktop/dist-electron`. +- Do not include user data, Claude settings, backups, or secrets. +- No changes to Claude config mutation behavior. + +## Test/check plan + +Run: + +```bash +pnpm test +pnpm check +pnpm package:desktop:dir +``` + +## Manual verification guide + +Manual verification is provided after implementation. + +## Oracle plan review + +Reviewed by Oracle. + +Blockers: none. + +Should-fix feedback: + +- Use a real cross-platform cleanup helper instead of inline `node -e`; inline example was invalid/brittle. +- Cleanup helper must resolve and verify exact target `apps/desktop/dist-electron` before deleting, with no path/env override. +- Acceptance should verify clean behavior, not only script text. +- Clarify both `package` and `package:dir` clean output. +- Add cleanup helper path to proposed files. + +Nice-to-have feedback: + +- Keep icon generation deferred and document default Electron icon warning. +- Metadata should not imply public release readiness while signing/notarization are deferred. +- Avoid overly brittle full-string script assertions where possible. + +Verdict: implementation-ready after cleanup helper/verification clarifications. + +## Oracle feedback disposition + +Fixed: + +- Replaced inline cleanup approach with a dedicated helper. +- Added exact-target verification/no-override requirements. +- Added clean-behavior verification requirement. +- Clarified both package scripts clean output. +- Added helper file to proposed files. + +Accepted: + +- Icon generation remains deferred and will be documented as a known package warning. + +## Oracle implementation review + +Reviewed by Oracle after implementation. + +Blockers: none. + +Should-fix: none. + +Nice-to-have feedback: + +- Document that desktop `pnpm test` may delete `apps/desktop/dist-electron` because it exercises the cleanup helper. +- Consider making contract script assertions less exact over time; current checks are acceptable. + +Verdict: implementation is sound. Cleanup is scoped/cross-platform, metadata is appropriate, docs accurately defer default icon/signing, and validation passed. + +## Oracle implementation feedback disposition + +Fixed: + +- Added docs note that desktop package tests may remove `apps/desktop/dist-electron` while exercising the cleanup helper. + +Accepted: + +- Current script assertions remain as-is for now. diff --git a/docs/phases/phase-10e-app-icon-package-identity.md b/docs/phases/phase-10e-app-icon-package-identity.md new file mode 100644 index 00000000..addd1c06 --- /dev/null +++ b/docs/phases/phase-10e-app-icon-package-identity.md @@ -0,0 +1,153 @@ +# Phase 10E: App Icon Package Identity + +## Goal + +Replace the default Electron packaged-app icon with OpenPets-owned package icon assets while keeping tray icon behavior unchanged. + +This phase removes the remaining default-icon packaging warning for the current macOS package and documents Windows/Linux icon follow-up if needed. + +## Non-goals + +- No signing/notarization. +- No installer targets beyond `--dir`. +- No visual redesign of the app UI. +- No new marketing/brand system. +- No replacing the small tray icon unless packaging requires shared source art. + +## User-visible/manual outcome + +The packaged app no longer uses Electron's default app icon. On macOS package output, `OpenPets.app` has an OpenPets MVP app icon. If generated from the current 64×64 source, the icon is temporary and may look soft at large sizes. + +## Acceptance criteria + +- A package app icon asset exists under desktop assets/build resources. +- `electron-builder` config points at the app icon. +- macOS `pnpm package:desktop:dir` no longer emits the default Electron icon warning, or the warning remains explicitly documented if generated icon quality is rejected. +- The tray icon remains loaded from `assets/tray-icon.png` and still renders in the tray. +- Package contract checks assert app icon assets/config exist. +- Package contract checks assert tray icon code/config still points at `assets/tray-icon.png`. +- Icon generation must not leave temporary `icon.iconset/` directories in the repository. +- `docs/mvp-validation.md` updates the default-icon note. +- `pnpm test` passes. +- `pnpm check` passes. +- `pnpm package:desktop:dir` passes. + +## Proposed files/directories + +- `apps/desktop/assets/app-icon.icns` + - macOS app icon generated from OpenPets-owned source art. +- `apps/desktop/electron-builder.yml` + - Add macOS icon config. +- `apps/desktop/src/check-packaging-contract.ts` + - Assert icon config and asset exist. +- `docs/mvp-validation.md` + - Update icon warning notes. +- `docs/phases/phase-10e-app-icon-package-identity.md` + +## Technical approach + +Use the existing `assets/tray-icon.png` as the source for a simple MVP app icon if it produces acceptable results. The current PNG is 64×64, so generated `.icns` will be basic and can be visually improved later with higher-resolution brand artwork. + +Generate `.icns` on macOS using built-in tools if available, using a temporary directory outside the repo or cleaning it before completion: + +```bash +mkdir -p icon.iconset +sips -z ... +iconutil -c icns icon.iconset +``` + +If high-quality `.icns` generation is not reliable from the 64×64 source, do not fake a polished icon. Either commit an MVP/temporary `.icns` and document soft quality, or document the asset limitation and defer high-resolution icon work. + +Configure electron-builder: + +```yaml +mac: + icon: assets/app-icon.icns +``` + +Keep Windows/Linux icons deferred unless existing tooling can produce `.ico`/PNG assets safely without extra dependencies. + +## Risks and tradeoffs + +- Source tray icon is only 64×64, so the app icon may look soft at large sizes. +- `.icns` generation is macOS-specific; acceptable because current packaging validation is on macOS. +- Windows/Linux icon polish may need separate assets and should be deferred unless trivial. + +## Security/privacy notes + +- Icon generation must use repository assets only. +- No user data or config is touched. + +## Test/check plan + +Run: + +```bash +pnpm test +pnpm check +pnpm package:desktop:dir +``` + +Check package output logs for default Electron icon warning absence. +If practical, inspect the generated `.icns` with `iconutil -c iconset` or `file`. + +## Manual verification guide + +Manual verification is provided after implementation and should include checking the packaged `.app` Finder/Dock icon and confirming the tray icon still appears. + +## Oracle plan review + +Reviewed by Oracle. + +Blockers: none. + +Should-fix feedback: + +- Do not describe an upscaled 64×64 `.icns` as polished; acceptance should allow MVP/temporary quality or deferral if bad. +- Add contract checks for `assets/app-icon.icns`, `mac.icon`, and tray icon still using `assets/tray-icon.png`. +- Add manual verification for packaged Finder/Dock icon and tray icon. +- Ensure temporary `icon.iconset/` is not left in repo. + +Nice-to-have feedback: + +- Build-resource directory may be better later; `assets/app-icon.icns` is acceptable now. +- Document Windows/Linux icon assets as future work. +- Inspect `.icns` with `iconutil` or `file` if feasible. + +Verdict: implementation-ready; frame as MVP identity cleanup, not final branding. + +## Oracle feedback disposition + +Fixed: + +- Spec now frames generated 64×64-derived icon as MVP/temporary and allows deferral if quality is unacceptable. +- Added contract check requirements for app icon, mac icon config, and unchanged tray icon path. +- Added manual verification requirements for packaged app icon and tray icon. +- Added temporary iconset cleanup requirement. +- Added optional `.icns` inspection note. + +Accepted: + +- Windows/Linux icon assets are future work, not Phase 10E failure. + +## Oracle implementation review + +Reviewed by Oracle after implementation. + +Blockers: none. + +Should-fix: none. + +Nice-to-have feedback: + +- Contract checks could eventually inspect packaged `.app` icon metadata; current `electron-builder` success and warning disappearance are enough for MVP. +- Future branding should replace the upscaled 64×64-derived `.icns` with high-resolution source art. + +Verdict: implementation is acceptable. Asset/config/checks/docs align with MVP package identity scope, tray behavior is guarded, and remaining signing/platform icon work is correctly deferred. + +## Oracle implementation feedback disposition + +Accepted: + +- Packaged `.app` metadata inspection remains future hardening. +- High-resolution branding remains future polish. diff --git a/docs/phases/phase-11-mvp-closeout-release-readiness.md b/docs/phases/phase-11-mvp-closeout-release-readiness.md new file mode 100644 index 00000000..a4635d47 --- /dev/null +++ b/docs/phases/phase-11-mvp-closeout-release-readiness.md @@ -0,0 +1,194 @@ +# Phase 11: MVP Closeout and Release Readiness + +## Goal + +Turn the current packaged OpenPets MVP into a clear, repeatable release-readiness checkpoint. + +This phase should consolidate the validation work from Phase 10A-10E into one broader MVP closeout pass: document exactly what is ready, what remains unverified, how to manually validate the package, and fix any small readiness gaps found while doing that review. + +## Non-goals + +- No public distribution release. +- No signing, notarization, installer generation, auto-update, or publishing. +- No new agent integrations beyond the current Claude-focused MVP. +- No major UI redesign or new onboarding/product feature. +- No Windows/Linux implementation work beyond documenting unverified status and known follow-up. +- No broad refactors unless required to make existing validation reliable. + +## User-visible/manual outcome + +The user has one authoritative MVP readiness guide and can run one documented local package validation flow to decide whether the current macOS unpacked package is MVP-ready for local dogfooding. + +If small readiness bugs are found during this pass, they are fixed in this phase. Larger items are explicitly documented as post-MVP/release follow-up instead of being silently expanded into this phase. + +## Acceptance criteria + +- `docs/mvp-validation.md` becomes the authoritative MVP closeout checklist rather than a Phase 10A-centric note. +- The checklist clearly separates: + - automated validation commands, + - macOS manual validation steps, + - cross-platform unverified gaps, + - signing/notarization/distribution deferrals, + - cleanup/uninstall guidance, + - known limitations and post-MVP follow-ups. +- The docs capture the current packaged Claude command expectations using `node` plus `app.asar.unpacked` paths. +- The docs make clear that Claude configuration can contain absolute packaged-app paths and must be replaced/removed after moving or deleting the app. +- Package validation remains local-only and does not mutate real Claude settings during automated tests. +- Any small readiness issues discovered while reviewing the validation path are fixed if they are low-risk and fit this phase. +- Any larger readiness issues are recorded as known limitations/follow-ups, not implemented silently. +- `pnpm test` passes. +- `pnpm check` passes. +- `pnpm package:desktop:dir` passes after tests/checks, so manual package validation uses fresh package output. +- Oracle implementation review is completed and feedback is dispositioned. + +## Proposed files/directories + +- `docs/mvp-validation.md` + - Rework into MVP closeout/readiness guide. +- `docs/phases/phase-11-mvp-closeout-release-readiness.md` + - This phase spec, Oracle reviews, and dispositions. +- Potentially small targeted package/test/doc files if validation reveals a contained readiness gap. + +## Technical approach + +1. Audit the current MVP validation doc against the current package behavior from Phase 10A-10E. +2. Reorganize `docs/mvp-validation.md` into a release-readiness structure: + - status summary, + - automated command checklist, + - macOS packaged-app manual checklist, + - Agent Setup/Claude packaged command expectations, + - cleanup/rollback notes, + - platform/signing limitations, + - post-MVP follow-up list. +3. Run the full local validation commands in this order, because desktop package tests may delete `apps/desktop/dist-electron` while exercising the cleanup helper: + + ```bash + pnpm test + pnpm check + pnpm package:desktop:dir + ``` + +4. If validation finds small issues that are directly related to readiness clarity or package contract reliability, fix them in scope. Examples in scope: + - documentation corrections, + - missing package contract assertions, + - tiny validation-script fixes that keep existing behavior unchanged. +5. If validation finds behavior bugs, cross-platform failures, signing/installer/public distribution needs, or Claude workflow changes, record them as known limitations/follow-ups or ask the user before expanding scope. +6. Send the resulting diff and validation results to Oracle for implementation review. + +## Risks and tradeoffs + +- A closeout pass can easily become an unbounded bug-fix phase. Scope is intentionally limited to documentation clarity and small readiness fixes. +- Cross-platform package behavior remains unverified without Windows/Linux machines. The honest MVP outcome is to document that gap, not claim support that was not tested. +- The current package is unsigned and uses absolute paths for packaged Claude commands. That is acceptable for local MVP validation but not for public distribution. +- Generated app icon quality is temporary because it is derived from a 64×64 source asset. + +## Security/privacy notes + +- Automated tests and package checks must keep using temp fixtures and must not modify real Claude settings or real user OpenPets state. +- MVP docs should warn users before they use Agent Setup actions that mutate Claude settings. +- Speech/privacy rules from earlier phases remain unchanged: short safe pet messages only, no code/logs/secrets/URLs/path-like content. +- Cleanup guidance should distinguish OpenPets app data from Claude configuration so users do not assume deleting the app removes agent integrations. + +## Test/check plan + +Run in this order: + +```bash +pnpm test +pnpm check +pnpm package:desktop:dir +``` + +If any targeted code/test changes are made, also run the most specific affected package checks first where useful. + +## Manual verification guide + +Manual verification is provided after implementation. It should ask the user to run the revised `docs/mvp-validation.md` macOS checklist against the packaged app and confirm whether the MVP closeout passes locally. + +The revised checklist must explicitly cover: + +1. Launching the packaged app from `apps/desktop/dist-electron/`. +2. Tray icon/menu presence. +3. Default pet show/hide, pause/resume, drag, and persistence basics. +4. Onboarding presence/completion or documented reset path. +5. Pet Manager installed/catalog state rendering. +6. Settings preference toggle and restart persistence. +7. Local IPC smoke with `pnpm --filter @open-pets/client smoke:status` while the packaged app is running. +8. Agent Setup preview showing bundled `node .../app.asar.unpacked/...` commands, not unpublished `npx -y @open-pets/*` commands. +9. Claude configure/install as an optional mutating step only, with backups and remove/uninstall cleanup called out. +10. Quitting OpenPets from the tray. + +## MVP scope statement + +Phase 11 validates a macOS local `--dir` dogfooding checkpoint only. + +The readiness guide must clearly document these prerequisites and limitations: + +- `pnpm` workspace checkout is required for developer validation commands. +- Packaged Claude bundled commands require `node` on Claude's PATH. +- The package is unsigned and not notarized. +- Windows/Linux package behavior, installers, auto-update, and public distribution are unverified/deferred. +- Claude settings may contain absolute paths into the packaged app and should be replaced/removed after moving or deleting the app. +- OpenPets app data cleanup and Claude config cleanup are separate. + +## Oracle plan review + +Reviewed by Oracle. + +Blockers: none. + +Should-fix feedback: + +- Run `pnpm test` and `pnpm check` before `pnpm package:desktop:dir`, because tests may delete package output. +- Bound “small readiness fixes” with examples and require deferral/user approval for larger behavior/platform/signing/Claude workflow changes. +- Make the manual checklist explicit in the spec/docs. +- Separate safe Agent Setup preview validation from optional mutating Claude configure/install steps. +- State MVP scope honestly as macOS local `--dir` dogfooding only. +- Include prerequisites and limitations: Node on PATH for Claude bundled commands, workspace/pnpm requirement, unsigned warnings, absolute Claude paths, ASAR unpacked resources. + +Nice-to-have feedback: + +- Add a concise readiness status table. +- Include artifact identity details such as output path, app version, date/commit/manual tester fields. +- Add cleanup/data-loss notes distinguishing OpenPets app data from Claude config. + +Verdict: not implementation-ready until command-order, checklist, and scope-boundary issues are fixed. + +## Oracle feedback disposition + +Fixed: + +- Validation command order now runs `pnpm test`, `pnpm check`, then `pnpm package:desktop:dir`. +- In-scope small readiness fixes are bounded to docs, package contract assertions, and tiny validation-script fixes; larger behavior/platform/signing/Claude workflow changes require deferral or user approval. +- Manual checklist requirements are explicit in the spec. +- Claude Agent Setup preview is separated from optional mutating Claude configure/install validation. +- MVP scope is explicitly macOS local `--dir` dogfooding only. +- Prerequisites and limitations are explicitly listed. + +Accepted for implementation: + +- Add readiness status table. +- Add artifact identity fields where useful. +- Add cleanup/data-loss notes distinguishing OpenPets app data from Claude config. + +## Oracle implementation review + +Reviewed by Oracle after implementation. + +Blockers: none. + +Should-fix: none. + +Nice-to-have feedback: + +- Add a commit/SHA field to Artifact identity when doing an actual release-readiness run. +- Add typical macOS app-data path as a convenience while keeping the logged-path guidance. + +Verdict: implementation-ready for manual user verification. The docs are honest about macOS-only local `--dir` readiness, clearly separate safe vs mutating Claude steps, document absolute packaged Claude paths and cleanup/data-loss risks, and preserve the workflow gate before closing MVP readiness. + +## Oracle implementation feedback disposition + +Accepted: + +- Commit/SHA artifact fields can be filled during an actual release-readiness run. +- Typical platform app-data paths can be added as future documentation polish; the current logged-path guidance is the source of truth and avoids platform-path drift. diff --git a/docs/phases/phase-12-actual-pet-animation-polish.md b/docs/phases/phase-12-actual-pet-animation-polish.md new file mode 100644 index 00000000..5a5f4a8b --- /dev/null +++ b/docs/phases/phase-12-actual-pet-animation-polish.md @@ -0,0 +1,253 @@ +# Phase 12: Actual Pet Animation Polish + +## Goal + +Replace the placeholder-feeling built-in pet experience with an actual animated pet renderer and premium compact chat bubble that feel alive in the packaged app. + +This phase should make the default OpenPets experience look like a real desktop companion instead of a CSS/wireframe prototype, while preserving the stable packaging, IPC, Claude, and pet-install safety work already completed. + +## Non-goals + +- No public marketplace/catalog redesign. +- No signing, notarization, installer, or distribution changes. +- No new agent integrations. +- No broad Pet Manager/Agent Setup UI redesign unless needed to preview the actual pet. +- No complex physics simulation. +- No audio. +- No unvalidated remote pet format expansion that weakens install safety. + +## User-visible/manual outcome + +When the user launches OpenPets, the default pet appears as an actual animated sprite/visual companion, not a simple CSS mascot or static image. + +The pet should: + +- idle with a high-quality loop, +- switch to directional drag/move animation based on mouse drag direction, +- react visibly to pause/resume, +- show premium compact speech/reaction bubbles cleanly, +- stay transparent/framed like a desktop pet, +- remain draggable and non-disruptive, +- package correctly in the macOS local app. + +## Acceptance criteria + +- A real bundled default pet visual asset exists in `apps/desktop/assets/` or another packaged desktop asset location. +- Bundled pet asset contract is explicit: + - source file: `apps/desktop/assets/default-pet-spritesheet.webp`, copied from the real catalog material for Claude (`https://zip.openpets.dev/pets/claude-f187e74a/claude.zip` / `web/public/pets/claude-f187e74a/spritesheet.webp`), + - no remote runtime art fetch, + - known frame layout/dimensions documented in code comments or constants: 1536×1872, 8 columns × 9 rows, 192×208 frames, + - at minimum supports `idle`, `run-left`, and `run-right` visual states, + - no SVG-coded/generated substitute for the default pet. +- The built-in default pet renderer uses the asset instead of the CSS-only mascot. +- The pet renderer supports at least an idle animation loop for the bundled pet. +- Dragging the pet changes animation/direction based on mouse movement: + - dragging right uses a right-facing/running-right visual state, + - dragging left uses a left-facing/running-left visual state, + - stopping drag settles back to idle without jitter. +- Drag-direction detection does not rely on renderer pointer events inside `-webkit-app-region: drag`. +- Installed-pet rendering uses the same current catalog sprite contract as the built-in default pet: 1536×1872 WebP, 8 columns × 9 rows, 192×208 frames, with idle/run-right/run-left rows animated instead of showing the full spritesheet. +- Pause/resume visibly affects animation without breaking window transparency or input behavior. +- Speech/reaction bubble styling is polished enough for MVP: premium compact text, readable typography, subtle depth, clean pointer, constrained dimensions, gentle entrance/exit, not huge, not wireframe, and does not expose unsafe message content beyond existing filtering. +- Pet window sizing/clamping accounts for the actual visual asset and bubble space. +- The tray icon and packaged app icon behavior remain unchanged. +- Package contract checks cover the new bundled pet asset(s). +- Package contract checks verify the source asset exists, is non-empty, and remains covered by packaged `assets/**` inclusion. +- `pnpm test` passes. +- `pnpm check` passes. +- `pnpm package:desktop:dir` passes. +- Oracle implementation review is completed and feedback is dispositioned. + +## Proposed files/directories + +- `apps/desktop/assets/` + - Add bundled default pet sprite/asset file(s). +- `apps/desktop/src/pet-window.ts` + - Replace CSS-only built-in pet with asset-backed rendering. + - Add animation-state CSS/JS for bundled pet. + - Add drag-direction animation state handling driven from safe main-process window movement signals, not renderer pointer events in draggable regions. + - Polish bubble/reaction presentation. +- `apps/desktop/pet-preload.cjs` + - Optional narrow preload for pet windows if needed to receive main-process motion events and update DOM state without enabling Node in the renderer. +- `apps/desktop/src/built-in-pet.ts` + - Document or reference bundled pet asset expectations if useful. +- `apps/desktop/src/display.ts` + - Adjust default pet window sizing if the actual pet needs more space. +- `apps/desktop/src/check-packaging-contract.ts` + - Assert bundled pet asset(s) are present and packaged. +- `docs/mvp-validation.md` + - Update manual validation checklist to include actual animated pet quality checks. +- `docs/phases/phase-12-actual-pet-animation-polish.md` + - This phase spec, reviews, and dispositions. + +## Technical approach + +1. Choose a safe MVP asset strategy: + - Use a repository-owned bundled WebP/PNG sprite asset from the existing catalog materials. + - Do not use SVG-coded/generated substitute art for the default pet. + - Initial target contract: one local 1536×1872 sprite sheet with 8 columns × 9 rows, 192×208 frames, and rows/states for `idle`, `run-left`, and `run-right`. + - Do not fetch remote art at runtime. +2. Implement asset-backed built-in pet rendering: + - Keep the pet window sandboxed/context-isolated. + - Keep CSP strict and allow only the needed local/data image sources (`img-src file: data:` for file-backed images). + - If renderer-side state JS is required, prefer a narrow preload listening for whitelisted IPC messages. Do not enable Node integration, remote scripts, eval, or broad renderer privileges. + - Preserve transparent frameless always-on-top behavior. +3. Add MVP animation: + - Use CSS sprite/background animation or a small controlled frame loop. + - Avoid heavy dependencies. + - Respect pause/resume with `animation-play-state` or equivalent. + - During drag, infer horizontal drag direction from main-process `BrowserWindow` movement deltas because Electron draggable regions do not reliably deliver renderer pointer events. + - Send only whitelisted motion states (`idle`, `run-left`, `run-right`) to the pet renderer, via narrow preload IPC or another bounded safe bridge. + - Use simple thresholding/debouncing so the pet does not flicker between left/right on tiny movements. + - Return to idle after drag stops or after a short no-movement timeout. + - Add `prefers-reduced-motion` handling where practical. +4. Polish the pet and chat bubble presentation: + - Better shadow/depth around the pet image. + - Speech bubble with small premium typography, constrained width/height, subtle entrance/exit, pointer styling, and layered shadow instead of harsh borders. + - Bubble copy should feel like a compact companion status, not a toast/card: short line length, balanced wrapping, soft radius, optical alignment to the pet, and no giant empty surface. + - Bubble content must be rendered as escaped text/textContent only, never `innerHTML` from message content. + - Apply shared bubble styling to both built-in and installed pet windows so the default and installed experiences do not diverge badly. + - No `transition: all`; animate specific transform/opacity properties only. +5. Preserve installed pet behavior: + - Current installed catalog pets provide `spritesheet.webp` using the 1536×1872 / 8×9 / 192×208 contract. + - Render installed pets through the same CSS sprite-frame approach as the built-in default pet, not as a full-sheet image. + - Do not show a decorative fallback square/box behind valid installed pets. + - If future metadata parsing is added, keep it strict and backward-compatible. +6. Update contract checks/docs and run full validation. + +## Risks and tradeoffs + +- Art quality can consume too much time. This phase should improve the default from wireframe to delightful MVP, not solve final branding. +- Sprite animation format changes can break installed pets if not backward-compatible. Phase 12 assumes the current catalog/v1 1536×1872 / 8×9 / 192×208 spritesheet contract; future custom formats need explicit validation/metadata work. +- `-webkit-app-region: drag` prevents reliable renderer pointer events. Drag animation must be driven from main-process window movement or a narrow custom drag architecture. +- Window transparency, draggable regions, and speech bubbles are platform-sensitive; keep the implementation simple and test packaged output. +- Chat bubble polish can expand into broad UI redesign. Keep scope to the pet window bubble only. +- Drag-direction animation can be jittery if based on every pointer event. Use thresholds and smoothing rather than instant direction flips. +- Larger pet/bubble sizing can move the pet unexpectedly; clamp to work area and avoid stealing focus. + +## Security/privacy notes + +- Bundled pet assets must be local repository assets; no runtime remote fetch. +- Installed pet safety rules remain: no script execution, zip-slip prevention, file limits, size limits. +- Speech safety rules remain unchanged: short messages only; no code/logs/secrets/URLs/path-like content. +- Renderer CSP must remain restrictive. +- Chat bubble message content must use escaped text/textContent only and must not introduce `innerHTML` injection paths. + +## Test/check plan + +Run: + +```bash +pnpm test +pnpm check +pnpm package:desktop:dir +``` + +Also run focused desktop checks during implementation if package contract or pet-window code changes. + +## Manual verification guide + +Manual verification is provided after implementation. It should include: + +1. Run `pnpm dev:desktop` and confirm the default pet is an actual animated visual, not CSS/wireframe. +2. Confirm the pet remains transparent, frameless, draggable, and non-focus-stealing. +3. Drag the pet left and right and confirm the animation/direction changes with drag direction, then settles back to idle without flicker. +4. Confirm pause/resume visibly pauses/resumes animation. +5. Trigger or inspect speech/reaction bubble behavior and confirm it feels premium: compact, readable, visually attached to the pet, no oversized wireframe card, no jarring animation. + + Suggested speech command while OpenPets is running: + + ```bash + pnpm --filter @open-pets/client smoke:say "Working on it" thinking + ``` + + Suggested reaction command: + + ```bash + pnpm --filter @open-pets/client smoke:react success + ``` + +6. If a catalog/installed pet is available, set it as default and confirm static installed-pet rendering still works with the shared polished bubble. +7. Run `pnpm package:desktop:dir`, launch the packaged app, and repeat the default pet checks. + +## Oracle plan review + +Reviewed by Oracle. + +Blockers: + +- Drag-direction plan was not implementable as initially written because the current pet window uses `-webkit-app-region: drag`, which makes renderer pointer events unreliable, and there was no preload/IPC bridge. + +Should-fix feedback: + +- Define the bundled pet asset contract: filenames, format, dimensions/frame layout/states, ownership/source, and fallback behavior. +- Be explicit about CSP and avoid broad renderer scripting privileges. +- Require bubble text to use escaped text/textContent only. +- Clarify that polished bubble styling applies to installed pets too. +- Add concrete manual bubble triggers using client smoke commands. +- Add manual verification for installed-pet fallback/static rendering. +- Specify package contract checks for new assets in ASAR-era packaging. +- If changing default pet window size, verify clamping/persistence. + +Nice-to-have feedback: + +- Prefer CSS sprites or animated image formats over custom JS frame loops unless JS is needed for drag state. +- Add lightweight asset checks for existence, size, and dimensions if easy. +- Add `prefers-reduced-motion` support. +- Use real catalog pet material rather than generated/SVG-coded art when available. + +Verdict: not implementation-ready until drag-direction architecture and asset/CSP/manual-verification requirements are tightened. + +## Oracle feedback disposition + +Fixed: + +- Drag-direction architecture now explicitly avoids renderer pointer events in draggable regions and uses main-process window movement deltas plus a narrow safe bridge/preload if needed. +- Added bundled pet asset contract requirements, including local checked-in WebP source, known frame layout/states, and no SVG-coded/generated substitute. +- Added CSP requirements for file/data image sources and no Node integration/remote/eval/broad renderer privileges. +- Required bubble content to be escaped/text-only and shared across built-in and installed pet windows. +- Added concrete manual `smoke:say` and `smoke:react` triggers. +- Added installed-pet fallback/static rendering manual verification. +- Added package contract expectations for source asset existence/non-empty and packaged `assets/**` inclusion. +- Added clamp/persistence verification if window sizing changes. + +Accepted: + +- Prefer CSS sprites or animated image formats when practical. +- Add lightweight asset checks where easy. +- Add `prefers-reduced-motion` handling where practical. +- Prefer real catalog pet material over generated art. + +## Oracle implementation review + +Reviewed by Oracle after implementation. + +Blockers: none. + +Should-fix: none before manual verification. + +Key checks: + +- Drag-direction architecture uses main-process window movement, not renderer pointer events in `-webkit-app-region: drag`. +- Pet preload is narrow: only listens for whitelisted `openpets:pet-motion` states and mutates `dataset`. +- Renderer remains sandboxed/context-isolated with Node disabled. +- Bubble content is escaped text, not unsafe HTML. +- Installed pet fallback remains static/safe with shared bubble styling. +- Packaging config includes `pet-preload.cjs` and assets; contract checks cover source asset/preload/config. + +Nice-to-have feedback: + +- Add an ASAR-content/package-output assertion for `pet-preload.cjs` and `default-pet-spritesheet.webp` later if convenient. +- If manual testing shows drag jitter, tune the `3px` threshold / `180ms` idle timeout. +- Future installed-pet animation metadata remains out of Phase 12. + +Verdict: implementation is acceptable for manual verification. Focus manual testing on packaged sprite loading, left/right drag animation, idle settle, bubble quality, pause/resume, installed-pet fallback, and no focus/drag regressions. + +## Oracle implementation feedback disposition + +Accepted: + +- ASAR-content assertions can be added as future package hardening; current source/config/package validation passed. +- Additional bundled asset hygiene checks can be added if bundled pet assets become more complex. +- Drag threshold/idle timing will be tuned only if manual testing shows jitter. +- Richer installed-pet animation metadata is a future phase, not Phase 12. diff --git a/docs/phases/phase-13a-pet-manager-ui-polish.md b/docs/phases/phase-13a-pet-manager-ui-polish.md new file mode 100644 index 00000000..66a9ee84 --- /dev/null +++ b/docs/phases/phase-13a-pet-manager-ui-polish.md @@ -0,0 +1,240 @@ +# Phase 13A: Pet Manager UI/UX Polish + +## Goal + +Make the Pet Manager feel like the first polished OpenPets desktop window and establish a reusable visual direction for later Settings, Agent Setup, and Onboarding polish. + +This phase focuses on one window only: Pet Manager. + +## Non-goals + +- No Settings, Agent Setup, or Onboarding redesign. +- No new install/remove/default product behavior beyond small UI affordances needed for clarity. +- No new pet catalog backend, marketplace, or publishing flow. +- No remote image rendering in the renderer. +- No framework migration; keep the current vanilla preload-driven UI. +- No weakening zip/catalog/install safety. +- No public distribution/signing work. + +## User-visible/manual outcome + +Opening **Manage Pets** from the tray shows a premium compact desktop UI instead of a wireframe list. + +The user should be able to quickly understand: + +- which pet is currently default, +- which pets are installed, +- which catalog pets can be installed, +- what actions are safe/available, +- whether catalog data is live, fixture, loading, empty, or errored. + +## Acceptance criteria + +- Pet Manager copy no longer says or feels like placeholder/wireframe. +- Pet Manager has a polished header, clear current-default summary, installed-pets section, catalog/search section, and error/status surfaces. +- Installed cards clearly distinguish default, built-in/protected, broken, and removable pets. +- Catalog cards clearly distinguish installable, installed, default, and unavailable/broken states. +- Buttons have clear hierarchy and disabled/busy states; no tiny ambiguous controls. +- Search has a polished empty state when no catalog pets match. +- Catalog status explains live vs fixture vs error without looking like debug text. +- The design uses consistent spacing, typography, radii, shadows, badges, and compact desktop proportions. +- No external images are introduced into the Pet Manager renderer. +- Pet install, remove, and set-default behavior remains unchanged and safe. +- Pet Manager can still render if catalog fetch falls back to fixture or fails. +- Pet Manager rendering continues to use DOM APIs and `textContent` for all catalog/state strings; no `innerHTML` for catalog-provided content. +- No CSP `img-src` expansion, no ``, and no CSS `background-image` using catalog preview URLs in Phase 13A. +- Existing visible placeholder copy is removed from Pet Manager window definitions and user-facing text. +- `pnpm --filter @open-pets/desktop build` passes. +- `pnpm --filter @open-pets/desktop test` passes. +- If shared task-window styles are changed, verify Settings/Agent Setup/Onboarding are not obviously broken. +- Designer review is completed before final manual verification. +- Oracle implementation review is completed and feedback is dispositioned. + +## Proposed files/directories + +- `apps/desktop/src/windows.ts` + - Update Pet Manager HTML structure and Pet Manager-specific/shared styling. + - Remove placeholder Pet Manager description. +- `apps/desktop/preload.cjs` + - Refactor `renderPetManager` / `renderCatalog` card DOM for polished UI states. + - Add small helper functions for status labels, card metadata, action rows, and empty states. +- `docs/phases/phase-13a-pet-manager-ui-polish.md` + - This phase spec, reviews, and dispositions. +- Potentially `docs/mvp-validation.md` + - Add a short Pet Manager visual verification note if useful. + +## Technical approach + +1. Keep scope to Pet Manager: + - Require Pet Manager selectors under `body[data-openpets-view="pet-manager"]` unless intentionally changing shared task-window tokens. + - Avoid broad restyling of Settings/Agent Setup/Onboarding unless shared base styles must be adjusted carefully. + - Remove the existing placeholder copy from `taskWindowDefinitions.pet-manager.description` and visible Pet Manager text. +2. Improve information architecture: + - Add a hero/header area with concise product copy. + - Add a current-default summary card derived from `state.preferences.defaultPetId` and installed pet state. + - Keep installed pets and catalog as separate sections. +3. Polish cards: + - Installed cards: pet name, id/source metadata, status badges, concise description, broken reason if present, primary/default action, secondary/remove action. + - Catalog cards: pet name, id metadata, description, installed/default badges, install or set-default/remove actions. +4. Improve state surfaces: + - Catalog source/status pill for live/fixture/error. + - Empty installed/catalog search states with plain helpful copy. + - Inline global error surface that is visually distinct but not alarming for non-destructive errors. +5. Improve interaction polish: + - Button hierarchy: primary install/set-default, secondary remove/protected. + - Busy state remains button-level/card-level without changing backend operation semantics. + - Use specific CSS transitions only; no `transition: all`. + - Add subtle card hover/focus/press feedback without distracting animations. +6. Preserve security constraints: + - No remote images in Pet Manager renderer. + - Do not add `img-src` to the Pet Manager CSP, do not add ``, and do not use catalog preview URLs as CSS backgrounds in this phase. + - Continue using textContent/DOM APIs for user/catalog strings. + - Keep renderer CSP restrictive. + +## Risks and tradeoffs + +- Pet Manager currently shares task-window CSS with other windows. Over-broad changes could accidentally regress Settings/Agent Setup/Onboarding. +- Preview thumbnails would be desirable but can create CSP/cache/security work. Defer image previews unless implemented through a safe later design. +- Pure vanilla DOM UI can become messy. Keep helpers small and avoid a framework rewrite. +- Catalog can contain many pets; keep cards reasonably compact and avoid heavy DOM work or animations. + +## Security/privacy notes + +- Pet Manager still must not execute pet content or scripts. +- Catalog strings must be inserted with `textContent`, not `innerHTML`. +- Install/remove/default actions must keep the existing IPC allowlist and validation. +- No remote image URLs should be added to CSP for this phase. +- No `innerHTML` should be used for catalog-provided or state-provided strings. + +## Test/check plan + +Run: + +```bash +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +``` + +If shared styles or packaging-sensitive files change unexpectedly, also run: + +```bash +pnpm check +``` + +## Manual verification guide + +Manual verification is provided after implementation. It should include: + +1. Run `pnpm dev:desktop`. +2. Open **Manage Pets** from the tray. +3. Confirm the window feels polished, compact, and not wireframe. +4. Confirm current default pet is obvious. +5. Search the catalog and confirm empty/search states look good. +6. Confirm live/fixture/error catalog status is understandable; simulate unavailable catalog only if feasible without expanding scope. +7. Install a catalog pet if available and confirm busy state during install. +8. Set an installed catalog pet as default. +9. Remove a removable pet and confirm busy state during remove. +10. Confirm broken/protected/default button states are understandable. +11. Open Settings/Agent Setup/Onboarding quickly to confirm shared styles were not obviously broken if shared styles changed. + +## Oracle plan review + +Reviewed by Oracle. + +Blockers: none. + +Should-fix feedback: + +- Tighten style isolation under `body[data-openpets-view="pet-manager"]` unless intentionally changing shared tokens. +- Add explicit acceptance that catalog/state strings use DOM APIs/`textContent`, no `innerHTML` for catalog content. +- Add manual verification for catalog unavailable/error/fixture status if feasible, busy install/remove states, set default, remove, and shared window regression check. +- Make “no remote image rendering” concrete: no CSP `img-src` expansion, no ``, no catalog preview CSS backgrounds. +- Explicitly remove existing Pet Manager placeholder copy. + +Nice-to-have feedback: + +- Add a small Pet Manager visual checklist to `docs/mvp-validation.md`. +- Define a few grouped design tokens/comments without building a full design system. +- Add compact empty states for no installed pets beyond built-in and no catalog matches. +- Keep helper extraction modest; avoid a mini framework. + +Verdict: implementation-ready after small spec tightening. + +## Oracle feedback disposition + +Fixed: + +- Added selector isolation requirement under `body[data-openpets-view="pet-manager"]`. +- Added explicit `textContent`/DOM API requirement and `innerHTML` prohibition for catalog/state strings. +- Added concrete no-remote-image constraints. +- Added placeholder-copy removal as an explicit implementation item. +- Expanded manual verification for catalog status, busy states, set-default/remove actions, and shared-window regression checks. + +Accepted: + +- Add Pet Manager visual checklist to `docs/mvp-validation.md` if implementation changes warrant it. +- Use modest grouped design tokens/comments only if helpful. +- Add compact empty states for catalog/no-extra-installed states. +- Keep helper extraction modest. + +## Designer implementation review + +Reviewed by Designer after implementation. + +Blockers: none. + +Should-fix: none. + +Nice-to-have feedback: + +- Make Pet Manager empty states span the full grid width. +- Make the current default card heading slightly larger to reinforce hierarchy. + +Verdict: approved. The visual direction is a substantial step up from wireframe and establishes a good baseline for future windows. + +## Designer implementation feedback disposition + +Fixed: + +- Empty states now span all grid columns. +- Current default card heading is larger than regular card headings. + +## Oracle implementation review + +Reviewed by Oracle after implementation and follow-up fixes. + +Initial should-fix feedback: + +- Restore remove affordance for a removable current default pet. +- Show broken state on catalog cards. +- Avoid appending empty actions rows for current-default catalog cards. +- Fix current-default remove busy-state restore so a newly rendered protected default does not get an enabled dead Protected button. + +Final review: + +Blockers: none. + +Should-fix: none. + +Nice-to-have feedback: + +- Manually verify Settings/Agent Setup/Onboarding because shared button/card/search styles changed. +- Consider shortening catalog error pill text if it feels too debuggy. +- Future: centralize small DOM helpers if Pet Manager grows further. + +Verdict: approved for manual verification. Removable defaults remain actionable, broken catalog states are visible, empty action rows are avoided, busy-state restoration is fixed, and CSP/textContent/no-remote-image constraints remain intact. + +## Oracle implementation feedback disposition + +Fixed: + +- Current default card now includes badges, broken reason, and Remove/Protected action. +- Catalog cards now show Broken badge and broken reason for installed broken pets. +- Empty catalog card action rows are no longer appended. +- Current-default remove busy restore no longer runs after successful re-render, preventing stale enabled Protected buttons. + +Accepted: + +- Shared-window visual smoke check is included in manual verification. +- Catalog error pill copy can be adjusted later if it feels too debug-like in manual testing. +- DOM helper centralization is deferred until more UI windows are polished. diff --git a/docs/phases/phase-13b-onboarding-greeting-polish.md b/docs/phases/phase-13b-onboarding-greeting-polish.md new file mode 100644 index 00000000..08baf974 --- /dev/null +++ b/docs/phases/phase-13b-onboarding-greeting-polish.md @@ -0,0 +1,270 @@ +# Phase 13B: Onboarding Greeting Polish + +## Goal + +Turn the first onboarding screen into a polished OpenPets greeting inspired by `lfs/greeting.png`, without copying the screenshot literally. + +This phase should create a native onboarding welcome experience that feels premium, playful, and consistent with the Phase 13A Pet Manager polish direction. + +## Non-goals + +- No full onboarding redesign beyond the greeting/welcome step unless a small structural change is required to keep navigation coherent. +- No Agent Setup, Settings, or Pet Manager changes. +- No copying the screenshot pixel-for-pixel. +- No using the screenshot as the final UI background. +- No remote image loading. +- No new onboarding business logic. +- No new pet install/configuration behavior. + +## User-visible/manual outcome + +On first launch, the onboarding welcome screen should feel like a real product greeting: + +- clear OpenPets identity, +- friendly “AI coding companion” positioning, +- playful pet/mascot atmosphere, +- strong primary Next action, +- subtle progress dots, +- polished copy that is not generic placeholder text. + +## Acceptance criteria + +- Onboarding step 0 has a polished greeting layout inspired by `lfs/greeting.png` but implemented as native HTML/CSS. +- The welcome copy is concise and product-quality; avoid awkward/generic wording. +- The greeting uses available local assets only. Current discovered local assets are: + - `apps/desktop/assets/default-pet-spritesheet.webp` + - `apps/desktop/assets/tray-icon.png` + - `lfs/greeting.png` as reference only, not packaged directly unless explicitly approved. +- The implemented greeting may use extracted local WebP assets copied from `web/public` into `apps/desktop/assets/`: + - `apps/desktop/assets/onboarding-logo.webp` from `web/public/openpets.webp` + - `apps/desktop/assets/onboarding-pets.webp` from `web/public/petland.webp` +- `lfs/greeting.png` must not appear in runtime code, packaged assets, CSS URLs, or CSP assumptions. +- Packaged image assets must be embedded as data URLs generated from local packaged files; onboarding CSP must remain `img-src data:` with no remote or `file:` image sources. +- If a hero image/illustration is needed, use a safe local packaged asset or a CSS/native composition; do not fetch remote images. +- Onboarding CSP remains restrictive; no remote image/script/style sources. +- Dynamic text continues to use DOM APIs/textContent in preload. +- Existing onboarding steps still work: Welcome → Pets → Agents → Ready → Finish. +- Existing onboarding buttons still work. +- Window remains usable at current task-window size and does not require custom native titlebar/window controls. +- Greeting fits in the increased 900×760 default task window without clipping the primary action, and remains usable at the 720×520 minimum window size. +- Keyboard/tab access still reaches Next and later Finish controls. +- Progress dots/steps still communicate the current step accessibly. +- `pnpm --filter @open-pets/desktop build` passes. +- `pnpm --filter @open-pets/desktop test` passes. +- If any runtime asset is added/referenced, `pnpm package:desktop:dir` passes or packaged asset loading is otherwise verified. +- If shared task-window styles are touched, Pet Manager, Settings, and Agent Setup are manually checked for obvious visual regressions. +- Designer review is completed before manual verification. +- Oracle implementation review is completed and feedback is dispositioned. + +## Proposed files/directories + +- `apps/desktop/src/windows.ts` + - Update onboarding welcome markup and onboarding-specific scoped styles. + - Keep styles under `body[data-openpets-view="onboarding"]` where possible. +- `apps/desktop/preload.cjs` + - Keep existing step navigation; adjust only if new welcome markup needs small class/state handling. +- `apps/desktop/assets/` + - Add `onboarding-logo.webp` and `onboarding-pets.webp` if using the real WebP greeting assets. Do not package `lfs/greeting.png` as-is. +- `docs/phases/phase-13b-onboarding-greeting-polish.md` + - This phase spec, reviews, and dispositions. + +## Technical approach + +1. Use `lfs/greeting.png` as art direction, not implementation: + - light airy background, + - pixel/game feel, + - strong OpenPets brand block, + - mascot/pet visual area, + - progress dots and primary Next button. + - The file must remain reference-only and should not be copied into `apps/desktop/assets` or loaded by the app. +2. Build native onboarding welcome UI: + - Keep the existing task window shell and Electron titlebar. + - Use scoped onboarding CSS for the welcome step. + - Use bundled local WebP assets when they materially improve fidelity to the reference. + - Generate data URLs from packaged local asset files in the main process before creating the onboarding data URL, avoiding fragile `data:` document → `file:` image loading. +3. Copy direction, not copy text: + - Proposed headline direction: “Your AI coding companion” or similar. + - Proposed body direction: “OpenPets lives in your tray and gives your coding agents a friendly desktop companion.” + - Keep copy short and human. +4. Preserve onboarding flow: + - Step indicators still reflect current step. + - Next continues to Pets. + - Existing later steps remain functional even if visually less polished for now. +5. Preserve security: + - No remote images. + - If local packaged image assets are used, onboarding CSP must include only `img-src data:` and no remote or `file:` image sources. + - No `innerHTML` for dynamic text. + - Keep CSP restrictive. + +## Risks and tradeoffs + +- A single polished greeting may make later onboarding steps feel less polished. Acceptable for this phase; the goal is to confirm the welcome styling direction first. +- Using `lfs/greeting.png` directly would be fast but wrong for product UI: it includes screenshot/window chrome and fixed text. Build native UI instead. +- Without additional uploaded hero art, the visual will be inspired rather than identical. If user provides separated logo/mascot/scene assets later, a follow-up can swap them in. +- Data-URL task windows plus local file assets are easy to break in packaged apps. If assets are used, embed them as data URLs generated from packaged local files and verify package output. + +## Security/privacy notes + +- No remote asset loading. +- No user data or agent configuration is changed by this phase. +- Onboarding completion semantics stay unchanged. +- Renderer remains sandboxed/context-isolated through the existing task-window setup. + +## Test/check plan + +Run: + +```bash +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +``` + +If shared task-window styles are changed unexpectedly, also run: + +```bash +pnpm check +``` + +If runtime image assets are added or referenced, also run: + +```bash +pnpm package:desktop:dir +``` + +## Manual verification guide + +Manual verification is provided after implementation. It should include: + +1. Run `pnpm dev:desktop`. +2. Reset onboarding state if needed by deleting the logged OpenPets app data state file/directory. +3. Confirm the first onboarding screen looks like a polished greeting inspired by `lfs/greeting.png`, not a literal screenshot paste. +4. Confirm copy feels good and not awkward. +5. Confirm the primary action is visible and not clipped at the default task-window size. +6. Confirm keyboard tab order reaches Next and later Finish controls. +7. Confirm progress dots/steps still show the current step clearly. +8. Confirm Next advances to Pets, then Agents, then Ready. +9. Confirm Finish still completes onboarding. +10. If shared styles changed, quickly inspect Pet Manager, Settings, and Agent Setup for obvious visual regressions. + +## Oracle plan review + +Reviewed by Oracle. + +Blockers: none. + +Should-fix feedback: + +- Clarify that `lfs/greeting.png` is reference-only and must not appear in runtime code, packaged assets, CSS URLs, or CSP assumptions. +- Clarify asset strategy and CSP requirements if existing local packaged assets are used. +- Add package/manual verification if runtime assets are added or referenced. +- Add shared-style regression criteria because `createTaskWindowStyles()` is shared. +- Add fit/accessibility checks for default/minimum window sizes, keyboard/tab access, and progress dots/steps. + +Nice-to-have feedback: + +- Require designer review against the direction of `greeting.png`, not literal fidelity. +- Add a short note to `docs/mvp-validation.md` if this becomes part of first-run validation. +- Prefer CSS/native composition unless asset use is strongly justified. + +Verdict: not implementation-ready until asset/CSP/package and shared-style verification requirements are tightened. + +## Oracle feedback disposition + +Fixed: + +- Spec now states `lfs/greeting.png` is reference-only and must not be loaded or packaged. +- Local WebP assets are allowed when copied from `web/public` into desktop assets, embedded as data URLs, covered by explicit `img-src data:`, and package-verified. +- Added package validation requirement if runtime image assets are added/referenced. +- Added shared-window regression manual checks if shared styles are touched. +- Added fit/accessibility checks for default window size, keyboard access, and progress indicators. + +Accepted: + +- Designer review should compare against the reference direction, not literal screenshot fidelity. +- `docs/mvp-validation.md` can be updated if implementation makes onboarding greeting part of MVP validation. +- CSS/native composition is preferred unless asset use is clearly worth it. + +## Designer implementation review + +Reviewed by Designer after implementation. + +Blockers: none. + +Should-fix: none. + +Nice-to-have feedback: + +- Add a small transition to soften the visual jump between the light welcome step and later dark onboarding steps. + +Verdict: approved. The native CSS composition captures the direction of `lfs/greeting.png` without copying the screenshot or adding asset/CSP risk. + +## Designer implementation feedback disposition + +Fixed: + +- Added a reduced-motion-aware body color transition. + +## Oracle implementation review + +Reviewed by Oracle after implementation and follow-up fixes. + +Initial should-fix feedback: + +- Ensure hidden panels stay hidden because the welcome hero display rule appears after the generic hidden rule. +- Add `aria-current="step"` to the active progress indicator. + +Final review: + +Blockers: none. + +Should-fix: none. + +Nice-to-have feedback: + +- Manually verify default-size fit and 720×520 minimum-size usability in real Electron rendering. +- Manually inspect Pet Manager, Settings, and Agent Setup because shared body/button styles changed. +- Future: replace emoji pet tokens with real brand/mascot assets if provided. + +Verdict: approved for manual verification. Hidden panels are protected, progress state has `aria-current`, no runtime image/CSP risk was introduced, and scope remains limited to the onboarding welcome/greeting step. + +## Oracle implementation feedback disposition + +Fixed: + +- Hidden onboarding panels now use `display: none !important` to avoid cascade surprises. +- Active progress indicator now receives `aria-current="step"`, removed from inactive indicators. +- Added reduced-motion guard for body transition/button transforms. + +Accepted: + +- Manual verification includes default-size fit and shared-window smoke checks. +- Real brand/mascot assets can replace emoji tokens in a later phase if provided. + +## Asset-based implementation review + +Reviewed by Designer and Oracle after switching from CSS/emoji composition to bundled WebP assets. + +Designer verdict: approved. + +- Blockers: none. +- Should-fix: none. +- Nice-to-have: cache generated asset data URLs so repeat onboarding opens do not synchronously reread WebP files. + +Oracle verdict: approved for manual verification. + +- Blockers: none. +- Should-fix: none. +- Nice-to-have: tighten CSP/package assertions, optionally assert packaged asset contents, remove/exclude `.DS_Store`, and manually launch packaged onboarding once. + +## Asset-based implementation feedback disposition + +Fixed: + +- Increased the default task window size to 900×760 while retaining a 720×520 minimum size. +- Cached generated asset data URLs in the main process after the first read. +- Package contract now asserts the onboarding asset filenames and `img-src data:` CSP in `windows.ts`. + +Accepted: + +- Manual verification should check both the increased default size and minimum-size usability. +- Packaged-app launch verification is useful before release; `pnpm package:desktop:dir` already passes for this phase. diff --git a/docs/phases/phase-13c-pet-manager-gallery.md b/docs/phases/phase-13c-pet-manager-gallery.md new file mode 100644 index 00000000..e69d5d77 --- /dev/null +++ b/docs/phases/phase-13c-pet-manager-gallery.md @@ -0,0 +1,238 @@ +# Phase 13C: Pet Manager Gallery + +## Goal + +Redesign the Pet Manager into a polished pet installation/gallery screen inspired by `lfs/pets.png`, while preserving the existing real catalog, install, set-default, and remove behavior. + +## Non-goals + +- No changes to catalog source, pet zip validation, installation security, or state schema. +- No remote image loading beyond existing catalog preview URLs already used by catalog metadata. +- No new pet categories in persisted data; filters can be UI-derived from current pet text/ids. +- No using `lfs/pets.png` as a runtime image or packaged asset. +- No full redesign of Agent Setup, Settings, or onboarding beyond shared task-window sizing/styles if required. + +## User-visible/manual outcome + +Opening Pet Manager shows a light, polished two-column install page: + +- OpenPets brand art at top-left, +- search and simple gallery filter pills, +- install/selectable pet cards with pixel-art previews where available, +- a large right-side selected pet detail panel, +- focused action buttons that install, set default, remove, or indicate selected/protected/broken state, +- existing catalog/install behavior still works. + +## Acceptance criteria + +- Pet Manager visually follows `lfs/pets.png` direction: light background, two-column gallery/detail layout, pixel-game product feel, rounded cards, strong primary actions. +- `lfs/pets.png` remains reference-only and is not loaded by app code or copied into desktop assets. +- Use existing bundled/local assets where safe, including `apps/desktop/assets/onboarding-logo.webp` for brand identity. Do not embed the large `default-pet-spritesheet.webp` in the Pet Manager data URL. +- Catalog pet preview images may be shown only from validated `CatalogPetV2.preview` values returned by main-process catalog data. The renderer must not independently construct remote image URLs. +- Pet Manager CSP must be exactly scoped for required image sources, e.g. `img-src data: https://openpets.dev`; no broad `https:`, `*`, or `file:` sources. +- Do not use mutable `InstalledPetState.source.preview` for remote rendering unless it is revalidated against the same catalog preview rules. +- Catalog previews are spritesheets; the UI must crop/display a single-frame thumbnail/preview, not show the whole sheet. +- Image failures/offline state must degrade gracefully: no broken image icon, neutral/blank preview surface, and install/default/remove actions remain usable. +- All catalog/state strings are inserted with DOM APIs/textContent, not `innerHTML`. +- Existing Pet Manager operations continue to work: + - install catalog pet, + - set installed pet as default, + - remove non-protected installed pet, + - protected built-in/default pet cannot be removed, + - broken installed pets cannot be selected as default. +- Search continues to filter catalog/gallery pets. +- Quick filters are limited to `All` and `Installed`; do not add category filters like `Cute`, `Helpers`, or `Robots` until backed by real catalog metadata. +- The large detail panel does not include decorative metadata pills like `Free`, `Open source`, or `Works in terminal`; those are unnecessary until backed by real catalog metadata. +- Detail actions are limited to real Pet Manager operations: `Install`, `Set Default`, and `Remove`, with disabled/status variants such as `Selected`, `Protected`, `Broken`, or `Installing…`. +- Keyboard users can tab to search, filter pills, pet cards/actions, and detail actions. +- The Pet Manager opens at 1160×780 and remains usable at 720×520 with vertical scrolling/responsive stacking. +- `pnpm --filter @open-pets/desktop build` passes. +- `pnpm --filter @open-pets/desktop test` passes. +- `pnpm package:desktop:dir` passes because Pet Manager CSP/asset packaging contracts change. +- Designer review is completed before manual verification. +- Oracle implementation review is completed and feedback is dispositioned. + +## Proposed files/directories + +- `apps/desktop/src/windows.ts` + - Update Pet Manager markup and scoped CSS. + - Add Pet Manager image CSP for data/local catalog previews as needed. +- `apps/desktop/preload.cjs` + - Replace the current section-card renderer with gallery/detail rendering. + - Keep IPC calls and validation boundaries unchanged. +- `apps/desktop/src/check-packaging-contract.ts` + - Update assertions if the Pet Manager starts depending on bundled image assets/CSP. +- `docs/phases/phase-13c-pet-manager-gallery.md` + - This spec, reviews, and dispositions. + +## Technical approach + +1. Keep backend/state/install behavior unchanged. +2. Build one normalized UI list from installed pets plus catalog pets: + - installed/default state comes from `OpenPetsStateV1`, + - catalog data comes from `getCatalog()`. +3. Render a left gallery with: + - search, + - filter pills (`All`, `Installed`) derived from installed/default state, + - cards showing thumbnail, name, subtitle, and selected/install state. +4. Render a right detail panel for the selected pet: + - title/description, + - large preview image, + - concise install/default/protected/broken status, + - small preview tiles reusing available image material, + - primary action (`Install`, `Selected`, or `Set Default`) and secondary action (`Remove` or `Protected`) where applicable. +5. Use safe image behavior: + - bundled default uses data URL or local packaged asset strategy already established, + - catalog previews use only validated `CatalogPetV2.preview` URLs from main-process catalog data, scoped to `https://openpets.dev`, + - card/mini preview images crop a single sprite frame from spritesheets using CSS background strategy, + - the detail spotlight animates the pet sprite itself without decorative platform/fallback art, + - image error handling is attached with DOM listeners in preload (`addEventListener("error", ...)`), not inline attributes, + - failures hide the broken image and leave a neutral preview surface. +6. Preserve accessibility: + - cards are buttons or contain focusable buttons, + - selected card uses `aria-pressed` or `aria-current`, + - detail status updates avoid inaccessible-only visual changes. + +## Risks and tradeoffs + +- Catalog previews are remote URLs; they can fail offline. The UI must remain usable with neutral/blank preview surfaces. +- This is a bigger Pet Manager UI rewrite than Phase 13A; keep install/state/security code unchanged to limit risk. +- Without real per-animation preview images, the Idle/Happy/Wave tiles will reuse available pet art rather than showing true animation states. +- Category filters are intentionally omitted until catalog metadata includes real tags. + +## Security/privacy notes + +- No new persisted data. +- No broad install behavior changes; narrow stale-target cleanup is allowed when a previously failed/manual install leaves files on disk while state says the pet is not installed. +- No direct use of `lfs/pets.png` at runtime. +- Keep Electron renderer sandbox/context isolation unchanged. +- CSP should allow only the image sources needed for data bundled assets and exact existing catalog preview origin (`https://openpets.dev`); no remote script/style execution and no `file:` images. +- Removing a removable current-default pet must preserve existing safe fallback behavior to the built-in pet. + +## Test/check plan + +Run: + +```bash +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +``` + +```bash +pnpm package:desktop:dir +``` + +## Manual verification guide + +1. Run `pnpm dev:desktop`. +2. Open Pet Manager from the tray. +3. Confirm the page resembles `lfs/pets.png` direction: light two-column gallery/detail layout, not a literal screenshot paste. +4. Confirm the default/bundled pet is selected by default and shows a large detail panel. +5. Type in search and confirm gallery cards filter. +6. Click `All` and `Installed` and confirm results update gracefully. +7. Click a catalog pet and confirm the detail panel updates. +8. Install a catalog pet and confirm it becomes installed/selectable without breaking the UI. +9. Set an installed pet as default and confirm selected/default state updates. +10. Remove a non-protected installed pet and confirm it disappears/falls back safely. +11. If the current default is a removable installed pet, remove it and confirm OpenPets falls back safely to the built-in/default pet. +12. Simulate offline/catalog error or use fixture/error state if available; confirm the gallery remains usable and image failures show neutral preview surfaces with no broken image icon. +13. Confirm a broken installed pet, if present, is shown but cannot be selected as default. +14. Confirm an installed pet missing from the current catalog still appears and remains manageable. +15. Resize to 720×520 and confirm the layout remains usable with scrolling. +16. Quickly inspect onboarding, Settings, and Agent Setup for obvious shared-style regressions. + +## Oracle plan review + +Reviewed by Oracle. + +Blockers: none. + +Should-fix feedback: + +- Clarify remote preview policy: only validated `CatalogPetV2.preview` from main-process catalog data; exact CSP such as `img-src data: https://openpets.dev`; no broad `https:`, `*`, or `file:`; no mutable `InstalledPetState.source.preview` without revalidation. +- Require previews to crop a single sprite frame, not show full spritesheets. +- Use DOM `addEventListener("error", ...)` for image failures, not inline `onerror` attributes. +- Add offline/image-failure behavior: no broken image icon, visible fallback thumbnail/card/detail, install/default/remove still usable. +- Preserve current-default removal semantics explicitly: removable current default can still be removed and safely falls back to built-in. +- Require `textContent`/DOM APIs for all catalog/state strings; no `innerHTML`. +- Make `pnpm package:desktop:dir` required if CSP/assets/packaging contract changes for gallery images. + +Nice-to-have feedback: + +- Use `loading="lazy"`, `decoding="async"`, and `referrerpolicy="no-referrer"` for remote preview images. +- Keep gallery model helpers small. +- Add manual checks for catalog offline/fixture/error, broken installed pet, and installed pet missing from current catalog. +- Add package contract checks for Pet Manager CSP and bundled logo/default thumbnail assets if used. + +Verdict: not implementation-ready until image/CSP rules and spritesheet cropping/fallback behavior are tightened. + +## Oracle feedback disposition + +Fixed: + +- Added exact remote preview policy and CSP constraints. +- Added single-frame spritesheet crop requirement. +- Replaced inline `onerror` language with DOM listener requirement. +- Added offline/image-failure fallback acceptance and manual checks. +- Added current-default removal fallback acceptance. +- Added DOM/textContent requirement. +- Made `pnpm package:desktop:dir` required for this phase. +- Added manual checks for broken/missing/catalog error states. + +Accepted: + +- Use lazy/async/no-referrer attributes for remote preview images. +- Keep gallery helpers small and avoid framework-like preload code. +- Add package contract checks for Pet Manager CSP and bundled assets when implemented. + +## Implementation review + +Reviewed by Designer and Oracle after implementation. + +Validation passed: + +```bash +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +pnpm package:desktop:dir +``` + +Initial Designer should-fix feedback: + +- Add keyboard-visible focus styling for Pet Manager cards. +- Adjust spritesheet crop/container sizing to avoid distorting the first-frame preview. + +Initial Oracle should-fix feedback: + +- Fix nested interactive keyboard behavior where card-level Enter/Space handling could intercept nested action button keyboard activation. +- Tighten packaging contract CSP assertions so onboarding and Pet Manager CSPs are checked separately and exactly. + +Final Designer verdict: approved. Previous UI/accessibility feedback resolved. + +Final Oracle verdict: approved for closeout. Previous security/CSP/data-integrity feedback resolved. + +## Implementation feedback disposition + +Fixed: + +- Added `.pm-pet-card:focus-visible` styling. +- Adjusted thumbnail/mini sprite containers to match the 8×9 spritesheet frame aspect more closely. +- Guarded card keydown handling with `event.target !== card` so nested action buttons keep normal keyboard activation. +- Tightened package contract checks to assert exact onboarding and Pet Manager CSP strings. +- Strengthened catalog preview URL validation with `URL` parsing, exact protocol/host/no credentials/no port/path/`.webp` checks. +- Updated filter rendering so active visual state stays in sync and detail prefers visible filtered pets. + +## Manual follow-up fixes + +Fixed after manual testing: + +- Pet Manager now opens at 1160×780 while other task windows keep their prior default sizing. +- Removed the large embedded default spritesheet from Pet Manager HTML to avoid Electron `ERR_INVALID_URL` from oversized data URLs. +- Replaced visible `` sprite cropping with validated, preloaded CSS background sprites so previews do not show broken image icons. +- Removed decorative platform/fallback art from the detail spotlight; the selected pet sprite is the focus. +- Added install cleanup for stale target directories before renaming a validated temp install directory into place. +- Added Pet Manager `no-referrer` meta for CSS background preview requests. + +Accepted: + +- Idle/Happy/Wave mini tiles currently reuse the available first-frame crop; true per-state animation previews can wait for explicit animation metadata. diff --git a/docs/phases/phase-13d-agent-setup-polish.md b/docs/phases/phase-13d-agent-setup-polish.md new file mode 100644 index 00000000..6d26f707 --- /dev/null +++ b/docs/phases/phase-13d-agent-setup-polish.md @@ -0,0 +1,327 @@ +# Phase 13D: Agent Setup Polish + +## Goal + +Redesign the Agent Setup window into a polished **Integrations** hub that matches the new OpenPets visual direction while preserving all existing Claude MCP and hook behavior. + +The first screen is a grid of integrations. Claude is the only functional integration in this phase; future integrations are visible but inert/disabled. Claude can be quickly installed when safe, or opened into a Claude detail/configuration view. + +## Non-goals + +- No changes to Claude MCP command semantics. +- No changes to Claude hook install/uninstall behavior beyond UI copy/styling. +- No new persisted state or settings schema. +- No hiding command/JSON previews; they remain available for trust and manual fallback. +- No remote assets or new network calls. +- No committing real Claude settings or user data in tests. +- No functional implementation for Cursor, OpenCode, VS Code, Windsurf, or Zed in this phase. + +## User-visible/manual outcome + +Opening **Configure Agents** shows a polished integrations grid: + +- cards for Claude, Cursor, OpenCode, VS Code, Windsurf, and Zed using bundled icons, +- quick install/configure controls on the Claude card, +- disabled/coming-soon controls on future integration cards, +- a Claude detail/configuration view reachable from the Claude card, +- command/MCP JSON previews and enhanced Claude hooks controls in the Claude detail view, +- clear status badges and warning/result copy, +- polished buttons with proper icons and visual hierarchy, +- existing actions still work: refresh, copy command, configure, replace, remove, doctor hooks, install/update hooks, uninstall hooks. + +## Acceptance criteria + +- Agent Setup uses a polished light UI consistent with Pet Manager and onboarding. +- Layout is usable at the current task window size and responsive at 720×520 with scrolling. +- First view is an integrations grid, not a split Claude configuration view. +- Bundled integration SVG icons from `apps/desktop/assets/integrations/` are used through data URLs. +- Claude grid quick Install only runs existing safe `configure` when `canConfigure && !busy`. +- If Claude is not directly installable (already configured, not detected, mismatched entry, busy), the card action opens Claude details instead of mutating state. +- Hook install/update is never triggered from the grid quick action; hooks remain in Claude detail with visible global-setting warning. +- Non-Claude integration cards are disabled/inert and do not make IPC calls or persist state. +- Claude status states remain accurate: detected, not detected, configured, needs setup, error. +- Hook status states remain accurate: installed, needs update, not installed, error. +- Existing enable/disable rules are preserved: + - Configure only when `canConfigure` and not busy, + - Replace only when `canReplace` and not busy, + - Remove only when `canRemove` and not busy, + - Hook uninstall disabled when hooks are not installed, + - all relevant actions disabled while busy. +- Preview strings are inserted with DOM APIs/textContent, not `innerHTML`. +- Command and JSON previews remain selectable/copyable and never execute automatically. +- Warnings make replace/remove/global-hooks risk clear without overwhelming the page. +- Buttons use proper inline SVG icons, not ASCII symbols. +- Exact CSP/asset policy is explicit: + - if no images are used, keep the current restrictive CSP without `img-src`, + - if bundled logos/icons are used, allow only `img-src data:`, + - do not allow `https://openpets.dev`, broad `https:`, `file:`, `*`, remote fonts, or remote assets in Agent Setup. +- Replace, Remove, Hook Install/Update, and Hook Uninstall keep visible risk copy and require an explicit user click. +- The global Claude hooks warning remains visible near hook actions because hooks modify user-level Claude settings. +- Command-mode behavior is preserved: packaged builds remain forced to bundled mode by existing validation, and UI polish must not allow packaged users to switch to local/published mode or bypass mode validation. +- Inline SVG icons are created through static markup or DOM `createElementNS`; no `innerHTML` for icons or dynamic labels. +- Action result/status copy is exposed in a visible status region so busy/result changes are understandable. +- `pnpm --filter @open-pets/desktop build` passes. +- `pnpm --filter @open-pets/desktop test` passes. +- Designer review is completed for visual polish. +- Oracle implementation review is completed and feedback is dispositioned. + +## Proposed files/directories + +- `apps/desktop/src/windows.ts` + - Replace Agent Setup markup with integrations hub plus Claude detail structure. + - Add scoped Agent Setup CSS under `body[data-openpets-view="agent-setup"]`. +- `apps/desktop/preload.cjs` + - Render status classes, mode controls, button icons, and preview copy into the new structure. + - Preserve existing IPC calls and action binding behavior. +- `docs/phases/phase-13d-agent-setup-polish.md` + - This spec, reviews, and dispositions. + +## Technical approach + +1. Keep `agent-setup.ts` business logic unchanged. +2. Build an integrations shell: + - `.integrations-view`: logo/title plus integration card grid, + - `.claude-detail-view`: Claude status, pet routing, command mode, MCP actions, previews, hooks, and back navigation. +3. In preload: + - map `status.state` and `hookStatus.status` to CSS classes, + - render action buttons with inline SVG icons, + - keep `textContent` for all dynamic status/details/previews, + - keep current button disable logic. +4. Make mode selection clear: + - Packaged/published command is visually primary/recommended in normal use, + - Local dev remains a checkbox/toggle only when available, + - Packaged mode copy explains `node` PATH requirement when relevant. +5. Make preview pane stable: + - fixed-height code panes with internal scroll, + - no layout jumps when preview text changes. + +## Risks and tradeoffs + +- This page touches trusted Claude configuration actions; visual polish must not reduce clarity about side effects. +- Hook settings are global for the Claude user, so the UI must keep that warning visible. +- Too much hiding of command/JSON preview would reduce trust; keep previews visible but visually secondary. +- Button icons improve scannability but labels remain required for clarity. + +## Security/privacy notes + +- No new execution paths. +- No new remote content. +- No `innerHTML`. +- If using the bundled logo, CSP is `img-src data:` only for Agent Setup. +- Existing output sanitization and path redaction remain in `agent-setup.ts`. +- Existing Claude settings backup behavior remains unchanged. +- Existing IPC sender allow-list remains unchanged. + +## Test/check plan + +Run: + +```bash +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +``` + +If Agent Setup CSP/assets/package contracts change, also run: + +```bash +pnpm package:desktop:dir +``` + +If shared task-window styles are changed broadly, also run: + +```bash +pnpm check +``` + +## Manual verification guide + +1. Run `pnpm dev:desktop`. +2. Open Configure Agents from the tray. +3. Confirm the page looks consistent with Pet Manager polish. +4. Confirm Claude status, details, and available actions render clearly. +5. Toggle local dev mode if available and confirm previews update. +6. Change pet routing and confirm command/JSON previews update. +7. Copy command and confirm result text updates. +8. Run Refresh/Doctor and confirm status refreshes. +9. If safe on your machine, verify Configure/Replace/Remove still respect existing enable/disable states and confirmation intent. +10. Verify hook Doctor/Install/Uninstall buttons preserve current behavior and warnings remain visible. +11. Verify status variants where possible: + - Claude not detected, + - needs setup, + - configured, + - mismatched existing entry / Replace available, + - Remove available, + - hook installed, + - hook needs update, + - hook not installed, + - hook error. +12. For mutation tests, use safe/temp Claude settings or confirm backups are created before changing real user settings. +13. If packaged mode is available, confirm the UI does not expose local/published mode switching and uses bundled command behavior. +14. Resize to 720×520 and confirm the window remains usable with scrolling. + +## Oracle plan review + +Reviewed by Oracle. + +Blockers: none. + +Should-fix feedback: + +- Specify exact Agent Setup CSP/asset policy: no images means current CSP; bundled logo means `img-src data:` only; no remote assets or copied Pet Manager preview CSP. +- Tighten trust UX for destructive/global actions: Replace, Remove, Hook Install/Update, and Hook Uninstall keep visible risk copy and require explicit click; hook global warning remains visible. +- Preserve command-mode behavior: packaged app remains forced to bundled mode, dev can toggle local/published only through existing validation. +- Inline SVG icons must not use `innerHTML`; use static markup or DOM `createElementNS`. +- Expand manual verification for status variants and safe/temp Claude settings. + +Nice-to-have feedback: + +- Consider wider Agent Setup sizing if two columns/previews feel cramped. +- Add `aria-live`/status styling criteria for action result and busy states. +- Run `pnpm package:desktop:dir` if CSP/assets/package contracts change. + +Verdict: directionally sound after these clarifications; proceed with implementation after disposition. + +## Oracle feedback disposition + +Fixed: + +- Added exact CSP/asset policy and disallowed remote Agent Setup assets. +- Added explicit trust UX criteria for Replace/Remove/hook actions and global hook warning placement. +- Added command-mode preservation criteria for packaged vs dev behavior. +- Added no-`innerHTML` icon requirement. +- Expanded manual verification matrix. +- Added package validation requirement when CSP/assets/package contracts change. + +Accepted: + +- Consider Agent Setup-specific sizing during implementation if 900×760 is cramped. +- Add visible status/result region and styling for busy/result changes. + +## Integrations hub plan update + +User direction changed during implementation: Agent Setup should become an integrations page/hub, not just a Claude split-view setup page. + +Oracle reviewed the updated plan. + +Blockers: none. + +Should-fix feedback: + +- Claude quick Install is narrowly defined: only existing non-destructive `configure` when `canConfigure && !busy`. +- If Claude is not directly installable, open details instead of mutating anything. +- Hook install/update remains only in Claude detail with visible global hook warning. +- Other integration cards are inert/disabled; no fake success or IPC calls. +- Agent Setup CSP remains `img-src data:` only; package contract should cover bundled SVG assets and no remote Agent Setup image sources. +- No `innerHTML`; use static shell/DOM APIs. +- Update docs for grid-first integrations hub and Claude-only functional scope. + +Disposition: fixed in this updated spec and implementation plan. + +## Implementation review + +Reviewed by Designer and Oracle after implementation. + +Validation passed: + +```bash +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +pnpm package:desktop:dir +``` + +Designer verdict: approved. + +Designer should-fix feedback: + +- Add accessible labels/live updates for command, MCP JSON, and hooks preview regions because they update when pet routing or command mode changes. + +Oracle verdict: approved. + +Oracle should-fix feedback: none. + +Oracle nice-to-have feedback: + +- Tighten the packaging contract Agent Setup CSP assertion to specifically check `createAgentSetupHtml`. +- Consider hiding/rewording disabled local dev mode in packaged builds later. + +Integrations hub follow-up review: + +- Designer verdict: approved. Nice-to-have: focus management and view transition polish. +- Oracle verdict: approved. Nice-to-have: after Claude quick Install, surface result in detail view; broaden package-contract remote-asset assertion. + +## Implementation feedback disposition + +Fixed: + +- Added `aria-label` and `aria-live="polite"` to command, MCP JSON, and hooks preview regions. +- Tightened the package contract to assert Agent Setup's data-only CSP within `createAgentSetupHtml` specifically. +- Added grid → Claude detail → back focus management. +- Claude grid quick Install now opens Claude detail after configure so the action result is visible. +- Tightened package contract to reject any `https?:` reference inside `createAgentSetupHtml` and added bundled integration SVG safety checks. + +Accepted: + +- Packaged-build local dev mode copy can be refined later; existing command-mode validation and disabled state preserve safety. +- View transition polish can wait until the hub/detail interaction is manually reviewed. + +## Claude detail redesign update + +User feedback after manual review: the Claude detail page was too dense and confusing, and mismatched/custom `openpets` MCP entries should not appear as scary errors by default. + +Updated UX direction: + +- Claude detail is a single-column page with three clear areas: + - **Connection**: status, pet routing, and only the actions that apply now. + - **Optional Claude hooks**: hook status/actions with a global-settings warning. + - **Advanced details**: collapsed command/MCP JSON/hooks JSON previews for inspection/manual fallback. +- Existing/custom `openpets` MCP entries are treated as installed-but-custom/installed-but-unverified in the UI. +- `configure` still refuses to overwrite existing custom/unverified entries; `replace` remains explicit and separate. +- Remove and hook actions stay visible only in the Claude detail view, never from the grid quick action. + +Review results: + +- Designer approved the simplified single-column detail flow with no blockers or should-fix issues. +- Oracle approved safety/correctness with one should-fix: bundled-mode custom-entry warning must mention that Remove deletes the `openpets` MCP entry. + +Disposition: + +- Fixed bundled-mode warning copy to mention Remove risk. +- Calmed the backend `configure` refusal message for existing custom/unverified entries while preserving overwrite protection. + +Validation passed after fixes: + +```bash +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +``` + +## Claude detail loading/action refinement + +User feedback after manual review: + +- Custom config repair should be a primary action labeled **Replace configuration**. +- MCP command/JSON preview belongs inside the Connection/MCP section. +- Hooks JSON preview belongs inside the Hooks section. +- Slow actions need immediate loading feedback; replace/remove/hook install/check/remove take several seconds. + +Implemented: + +- Renamed and promoted Replace to a primary **Replace configuration** button. +- Moved MCP advanced preview into the Connection card. +- Moved hooks advanced preview into the Hooks card. +- Added per-action loading labels/spinner and disabled surrounding controls while Claude actions run. + +Review results: + +- Designer approved with no blockers or should-fix issues. +- Oracle found two loading-state should-fix issues: copy command should not leave controls disabled, and failed actions must restore prior disabled states instead of blindly enabling everything. + +Disposition: + +- Copy command now bypasses the busy wrapper. +- Busy handling now stores/restores prior disabled states on failure. + +Follow-up fixes: + +- Back to integrations remains enabled while a Claude action is loading. +- Claude MCP status detection now allows a slower cold `claude mcp list` by using a longer timeout and retrying once after an initial timeout, so first open is less likely to show a transient timeout error that Refresh immediately fixes. diff --git a/docs/phases/phase-13e-onboarding-flow-polish.md b/docs/phases/phase-13e-onboarding-flow-polish.md new file mode 100644 index 00000000..e9d6f572 --- /dev/null +++ b/docs/phases/phase-13e-onboarding-flow-polish.md @@ -0,0 +1,188 @@ +# Phase 13E: Onboarding Flow Polish + +## Goal + +Finish the first-run onboarding flow beyond the polished welcome screen so the Pets, Integrations, and Ready steps feel like a direct continuation of the welcome screen and guide users into the real product windows. + +Phase 13B polished only the first welcome/greeting step. This phase polishes the remaining onboarding steps and makes the primary action on those steps open Pet Manager or Integrations. + +## Non-goals + +- No new pet installation behavior. +- No new Claude MCP or hooks behavior. +- No new persisted onboarding sub-step state. +- No remote assets or new network calls. +- No changes to runtime use of `lfs/greeting.png`; it remains reference-only. +- No committing real user data or Claude settings. + +## User-visible/manual outcome + +On first launch, onboarding should feel complete: + +- Step 0 remains the polished OpenPets welcome. +- Step 1 explains choosing/managing pets and makes **Open Pet Manager** the primary action. +- Step 2 explains connecting coding agents and makes **Open Integrations** the primary action. +- Step 3 clearly explains that setup is done and where to reopen Pet Manager/Integrations later. +- The user can still skip/continue without installing a pet or configuring Claude. +- Pets/Integrations step copy makes focus behavior clear: if the user opens the product window, they should return to onboarding to continue. + +## Acceptance criteria + +- Onboarding still has four clear steps: Welcome, Pets, Integrations, Ready. +- Later steps use the same light polished visual language as the welcome/Pet Manager/Integrations work, not the older dark generic card style. +- Onboarding does not auto-open Pet Manager or Integrations on step entry. +- Open Pet Manager and Open Integrations are primary actions; Continue remains secondary beside them. +- If Pet Manager or Integrations is already open, clicking the primary action reuses/focuses the existing window through the existing `openTaskWindow` behavior and does not create duplicates. +- Manual open is best-effort: if a window open fails, onboarding shows a readable status/error and the primary button remains usable. +- Each open step has a dedicated status line with clear states: instruction text, `Opening…`, `Opened — return here to continue.`, and failure text. +- Manual buttons remain available: + - Open Pet Manager / Continue, + - Open Integrations / Continue, + - Start using OpenPets, + - optional Ready-step shortcuts. +- Step labels/copy/buttons use current product naming: “Integrations” rather than “Agent Setup” everywhere user-facing. Internal IPC/function names may remain unchanged. +- Onboarding completion behavior remains unchanged: Finish marks onboarding completed and closes the onboarding window. +- Closing onboarding before Finish still leaves onboarding incomplete and available later. +- Auto-opening Pet Manager/Integrations does not mutate pet catalog, Claude MCP config, or hook settings by itself. +- Existing IPC sender allow-list remains local-only and only allows onboarding to open internal task windows. +- Onboarding CSP remains `img-src data:` only; no remote assets. +- Dynamic text/status updates use DOM APIs/textContent, not `innerHTML`. +- Keyboard/tab access still reaches all actions. +- Window remains usable at 900×760 and 720×520 with scrolling if needed. +- `pnpm --filter @open-pets/desktop build` passes. +- `pnpm --filter @open-pets/desktop test` passes. +- Designer review is completed for the polished later-step UX. +- Oracle implementation review is completed and feedback is dispositioned. + +## Proposed files/directories + +- `apps/desktop/src/windows.ts` + - Update onboarding markup and scoped styles for Pets, Integrations, Ready steps. + - Rename visible Agent Setup copy to Integrations where appropriate. +- `apps/desktop/preload.cjs` + - Trigger one-time Pet Manager/Integrations auto-open when entering steps 1 and 2. + - Keep manual buttons and completion behavior. +- `apps/desktop/src/check-packaging-contract.ts` + - Update assertions only if onboarding contract/copy/assets need coverage. +- `docs/phases/phase-13e-onboarding-flow-polish.md` + - This spec, reviews, and dispositions. + +## Technical approach + +1. Keep the existing four-step onboarding model. +2. Replace the older dark generic card markup for steps 1–3 with polished light cards: + - Pets: short explanation, “Pet Manager opened” status, Open/Continue actions. + - Integrations: short explanation, “Integrations opened” status, Open/Continue actions. + - Ready: clear final call-to-action and shortcuts. +3. In preload, keep `showStep(step)` focused on navigation only; do not auto-open child windows. +4. Clicking Open Pet Manager calls `onboardingApi.openPetManager()` and updates a dedicated status line. +5. Clicking Open Integrations calls `onboardingApi.openAgentSetup()` and updates a dedicated status line. +6. Continue buttons advance without opening windows. +7. Do not make open actions install/configure anything; they only open internal windows. + +## Risks and tradeoffs + +- Opening child windows can steal focus from onboarding. This now only happens after an explicit user click on the primary action. +- Onboarding step copy/status must explicitly say that the product window opened and the user should return to onboarding to continue. +- Pet Manager/Integrations are separate task windows, so users may need to return to onboarding manually after reviewing them. The onboarding copy should make that clear. + +## Security/privacy notes + +- No new external IPC or network behavior. +- Auto-open only calls existing internal window-opening IPC from the onboarding renderer. +- No automatic Claude configuration, hook installation, pet installation, file writes, or user settings mutation beyond final onboarding completion. +- Existing CSP restrictions remain. + +## Test/check plan + +Run: + +```bash +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +``` + +If onboarding CSP/assets/package contracts change, also run: + +```bash +pnpm package:desktop:dir +``` + +## Manual verification guide + +1. Run `pnpm dev:desktop`. +2. Reset onboarding state if needed by deleting the OpenPets app data directory. +3. Confirm Welcome still looks polished. +4. Click Next and confirm Pet Manager does not auto-open. +5. Confirm the Pets step visually matches the welcome screen direction and has Open Pet Manager as the primary action with Continue secondary beside it. +6. Click Open Pet Manager and confirm it opens/focuses Pet Manager and updates the status line. +7. Return to onboarding and click Continue; confirm Integrations does not auto-open. +8. Confirm the Integrations step visually matches the welcome screen direction and has Open Integrations as the primary action with Continue secondary beside it. +9. Click Open Integrations and confirm it opens/focuses Integrations and updates the status line. +10. Confirm neither auto-open action installs pets, configures Claude, installs hooks, or removes anything. +11. Confirm Ready step copy and shortcuts are clear. +12. Click Start using OpenPets and confirm onboarding closes and does not reopen on next launch. +13. Confirm closing before Finish keeps onboarding incomplete. +14. Resize to 720×520 and confirm all steps remain usable. + +## Oracle plan review + +Reviewed by Oracle. + +Blockers: none. + +Should-fix feedback: + +- Clarify focus/window behavior because auto-open will likely steal focus from onboarding. +- Define once-only semantics precisely: entering steps repeatedly should not reopen/refocus unless the user clicks the manual button. +- Require dedicated status lines for `Opening…`, success, and failure. +- Rename visible Agent Setup copy to Integrations; internal names may stay. +- Confirm auto-open only happens after user advances, not on initial welcome render. +- Add manual verification for existing-window reuse/focus without duplicates. + +Nice-to-have feedback: + +- Add a return-to-onboarding hint in step copy. +- Add package-contract assertions if CSP/copy/assets change materially. +- Manually check small displays and multi-window behavior. + +Verdict: implementation-ready after tightening focus/status/once-only criteria. + +## Oracle feedback disposition + +Fixed: + +- Added focus-stealing/return-to-onboarding copy requirements. +- Defined once-only semantics per onboarding renderer session and manual button behavior. +- Added dedicated auto-open status line requirements. +- Required user-facing Integrations naming. +- Clarified no auto-open on initial welcome render. +- Expanded manual verification for reuse/focus/no duplicates. + +Accepted: + +- Package-contract assertions are only needed if CSP/assets/contracts materially change. +- Small display and multi-window behavior are included in manual verification. + +## Revised UX direction + +After an initial implementation pass, user feedback was that auto-open felt wrong and later steps did not visually match the first welcome page closely enough. + +Updated direction: + +- Do not auto-open Pet Manager or Integrations on step entry. +- Make **Open Pet Manager** / **Open Integrations** the primary action. +- Keep **Continue** as the secondary action next to the primary action. +- Ensure the later onboarding cards use the same light background, rounded glass card, clouds, typography, and visual direction as the welcome screen. + +## Implementation notes + +Implemented: + +- Reworked Pets, Integrations, and Ready steps into polished light cards. +- Renamed user-facing Agent Setup copy/buttons to Integrations. +- Added dedicated auto-open status lines for Pet Manager and Integrations. +- Added one-time auto-open behavior for steps 1 and 2 after user navigation. +- Preserved manual open buttons and finish behavior. + +Validation/reviews pending. diff --git a/docs/phases/phase-13f-settings-tray-polish.md b/docs/phases/phase-13f-settings-tray-polish.md new file mode 100644 index 00000000..ba9de4bf --- /dev/null +++ b/docs/phases/phase-13f-settings-tray-polish.md @@ -0,0 +1,207 @@ +# Phase 13F: Settings and Tray Polish + +## Goal + +Polish the remaining basic desktop surfaces so Settings and tray/menu copy match the Phase 13 visual/product direction. + +## Non-goals + +- No new settings schema. +- No new pet behavior beyond existing preference/reset actions. +- No new Integrations/Claude behavior. +- No remote assets or network calls. +- No changes to onboarding flow beyond label consistency if needed. + +## User-visible/manual outcome + +- Settings opens as a polished light OpenPets window instead of the older dark generic card layout. +- Settings groups current controls into clear cards/rows with better copy and consistent button/toggle styling. +- Tray menu uses current product labels, especially **Integrations** instead of **Configure Agents**. +- Tray menu order is clearer and still exposes setup, pet visibility, pause/resume, Pet Manager, Integrations, Settings, and Quit. + +## Acceptance criteria + +- Settings uses a light background/card style consistent with Onboarding, Pet Manager, and Integrations. +- Existing Settings controls are preserved: + - Open default pet on app launch, + - Speech bubbles enabled, + - Pet scale coming later, + - Reset default pet position. +- Settings controls remain wired to the existing preload/state behavior. +- Settings result/error feedback remains visible. +- Settings remains usable at 900×760 and minimum 720×520. +- No remote assets are added; Settings CSP remains restrictive and does not need `img-src` unless images are added. +- Tray menu user-facing copy uses current naming: + - `Continue Setup...` can remain, + - `Manage Pets...` remains acceptable, + - `Configure Agents...` becomes `Integrations...`, + - Quit and pause/show/hide labels remain clear. +- Tray menu order remains: + - `OpenPets` disabled header, + - separator, + - conditional `Continue Setup...` plus separator when onboarding is incomplete, + - `Default Pet: Built-in Pet`, + - `Show/Hide Default Pet`, + - `Pause/Resume All Pets`, + - separator, + - `Manage Pets...`, + - `Integrations...`, + - `Settings...`, + - separator, + - `Quit OpenPets`. +- Tray actions still call the same internal functions/windows. +- Agent setup window title/user-facing definition uses **Integrations** (`OpenPets — Integrations`) while preserving internal `agent-setup` identifiers. +- Settings includes visible `aria-live` status feedback for successful preference saves and reset actions, plus existing error feedback. +- Settings-specific light styles are scoped to `body[data-openpets-view="settings"]`; avoid broad edits to global `button`, `.card`, or `.setting-row` behavior. +- Settings has clear keyboard/focus behavior: labels toggle checkboxes, reset button is reachable, focus states are visible. +- `pnpm --filter @open-pets/desktop build` passes. +- `pnpm --filter @open-pets/desktop test` passes. +- Designer review is completed for Settings polish. +- Oracle implementation review is completed and feedback is dispositioned. + +## Proposed files/directories + +- `apps/desktop/src/windows.ts` + - Redesign Settings markup and scoped Settings CSS. +- `apps/desktop/preload.cjs` + - Adjust only if markup IDs/classes require it; preserve behavior. +- `apps/desktop/src/tray.ts` + - Update menu labels/order only. +- `docs/phases/phase-13f-settings-tray-polish.md` + - This spec, reviews, and dispositions. + +## Technical approach + +1. Keep `createSettingsHtml()` simple and data-free: no assets, no remote content. +2. Add scoped CSS under `body[data-openpets-view="settings"]` to avoid broad regressions. +3. Use native checkboxes/buttons with polished row/card styling; do not invent custom persistence. +4. Preserve existing element IDs used by preload: + - `open-default-pet-on-launch`, + - `speech-bubbles-enabled`, + - `pet-scale-value`, + - `reset-default-pet-position`. +5. Update tray label `Configure Agents...` to `Integrations...`; keep `openTaskWindow("agent-setup")` internally. +6. Update any visible `Configure Agents` window title/heading copy to Integrations while preserving internal task kind names. + +## Risks and tradeoffs + +- Settings shares global task-window CSS; keep new CSS scoped to Settings to avoid impacting other polished views. +- Tray label changes should not imply new functionality; use Integrations because that is now the actual window experience. + +## Security/privacy notes + +- No new IPC channels. +- No new external content. +- Existing preference update IPC sender allow-list remains Settings-only. +- Reset position remains the existing local state action. + +## Test/check plan + +Run: + +```bash +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +``` + +If shared styles unexpectedly affect multiple windows, also run/manual-check the app via: + +```bash +pnpm dev:desktop +``` + +## Manual verification guide + +1. Run `pnpm dev:desktop`. +2. Open Settings from the tray and confirm it matches the light OpenPets polish direction. +3. Toggle “Open default pet on app launch” and confirm it persists after closing/reopening Settings. +4. Toggle “Speech bubbles enabled” and confirm it persists after closing/reopening Settings. +5. Toggle each checkbox twice in the same open Settings window and confirm controls re-enable after each save. +6. Restart the desktop app and confirm both toggles persist; for “Open default pet on app launch,” confirm startup behavior matches the setting. +7. Show the default pet, move it, click Reset default pet position twice, and confirm it returns near the bottom-right of the primary display and the button re-enables after each click. +8. Confirm Settings status/success text updates after toggles and reset, and errors still display if something fails. +9. Resize Settings to 720×520 and confirm no clipped controls, usable scrolling, and visible status/error feedback. +10. Open tray menu and confirm `Integrations...` appears instead of `Configure Agents...` in the expected order. +11. Click every tray menu item once where safe: Continue Setup (if visible), Show/Hide, Pause/Resume, Manage Pets, Integrations, Settings. +12. Click `Integrations...` and confirm the Integrations window opens with the user-facing title/copy updated. +13. Smoke-check Pet Manager and Onboarding for obvious style regressions. + +## Oracle plan review + +Reviewed by Oracle. + +Blockers: + +- Settings preload ID inventory omitted `pet-scale-value`, which current preload requires. + +Should-fix feedback: + +- Define exact tray order. +- Update all user-facing “Configure Agents” surfaces, including agent window title/definition. +- Clarify Settings feedback behavior with visible `aria-live` success/status text or narrow to errors only. +- Add explicit minimum-size manual checks. +- Add restart persistence verification for settings toggles and startup behavior. +- Strengthen reset-position manual verification by moving/showing the pet first. +- Require Settings-specific scoped light overrides, not broad global style edits. + +Nice-to-have feedback: + +- Add keyboard/focus accessibility acceptance. +- Replace stale Settings definition copy. +- Smoke-test all tray menu items after reorder/rename. + +Verdict: conditionally approved after spec fixes. + +## Oracle feedback disposition + +Fixed: + +- Added `pet-scale-value` to required ID inventory. +- Added exact tray order. +- Required user-facing Integrations naming for tray and window title/definition. +- Required visible Settings status/success feedback. +- Added minimum-size, restart persistence, startup behavior, reset-position, and full tray smoke manual checks. +- Required Settings-specific scoped styling and keyboard/focus criteria. + +Accepted: + +- Settings definition copy will be updated as part of the user-facing Integrations/Settings polish. + +## Implementation notes + +Implemented: + +- Redesigned Settings as scoped light UI with header, grouped panels, polished rows, focus states, and status feedback. +- Preserved existing Settings controls and IDs, including `pet-scale-value`. +- Added `settings-status` as an `aria-live` region for saving/reset feedback. +- Updated user-facing agent setup title/heading to Integrations while preserving internal `agent-setup` IDs. +- Renamed tray `Configure Agents...` to `Integrations...` without changing the underlying action. + +Follow-up Settings changes: + +- Removed the Speech bubbles toggle because speech bubbles are now always enabled. +- Added a Launch OpenPets at login toggle backed by Electron login item settings on supported platforms. +- Kept unsupported platforms disabled with explanatory copy. + +Validation passed: + +```bash +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +``` + +Designer review: approved with no blockers or should-fix issues. + +Oracle implementation review: + +- Blocker: Settings controls were not re-enabled after successful saves/resets. +- Should-fix: failure status text could remain stuck on “Saving…”/“Resetting…”. +- Should-fix: manual verification should include repeated toggle/reset checks. +- Nice-to-have: add `role="status"` to Settings status. + +Disposition: + +- Fixed controls re-enabling in `renderSettings()`. +- Fixed failure status text for save/reset errors. +- Added repeated toggle/reset manual checks. +- Added `role="status"` to Settings status. diff --git a/docs/phases/phase-14-pet-scale-setting.md b/docs/phases/phase-14-pet-scale-setting.md new file mode 100644 index 00000000..ad6be355 --- /dev/null +++ b/docs/phases/phase-14-pet-scale-setting.md @@ -0,0 +1,168 @@ +# Phase 14: Pet Scale Setting + +## Goal + +Make the existing `petScale` preference real: Settings should let users choose pet size, and the desktop pet should update without layout shift or cropping. + +## Non-goals + +- No new pet animation states. +- No new pet window transparency/drag behavior beyond preserving current behavior. +- No new persisted schema version. +- No custom per-pet scale; scale applies to the default desktop pet renderer. +- No changes to MCP speech safety or bubble content. + +## User-visible/manual outcome + +- Settings shows a usable Pet scale control instead of “coming later.” +- Changing scale updates the visible default pet shortly after save. +- The pet remains unclipped at supported scale values. +- Chat bubble remains an overlay above the pet and does not move the pet. +- Reset position still places the pet near the bottom-right using the window bounds. + +## Acceptance criteria + +- Settings exposes pet scale choices: Small, Medium, Large. +- Default scale is **Medium**, matching the current polished pet size after the bugfix. +- Persisted `preferences.petScale` accepts only supported numeric values and normalizes invalid/old values to the default. +- `validatePreferencePatch()` accepts only supported `petScale` values from Settings IPC; unsupported/non-finite values are rejected. +- Current and newly created state files use the new default scale. +- Changing scale via Settings saves through existing `openpets:update-preferences` IPC. +- Changing scale refreshes the default pet content immediately if it is visible. +- Unrelated preference updates, including launch/speech toggles and tray show/hide writes, do not reset the selected scale. +- Scale applies to built-in and installed default pet sprites. +- Scale is passed explicitly through the default-pet render path; explicit agent pet windows are not accidentally changed unless deliberately passed a scale later. +- Pet shell/card dimensions, sprite transform, and bubble offset all derive from the same scale constant/value. +- Bubble vertical position is derived from scale and stays close to the pet without shifting the pet. +- Pet and bubble do not get cropped at supported scales. +- Existing IDs needed by Settings/preload remain stable or are intentionally updated together. +- `pnpm --filter @open-pets/desktop build` passes. +- `pnpm --filter @open-pets/desktop test` passes. + +## Proposed files/directories + +- `apps/desktop/src/app-state.ts` + - Normalize/validate `petScale`. + - Change default scale to Medium. +- `apps/desktop/src/pet-window.ts` + - Use persisted `petScale` for sprite and bubble layout. +- `apps/desktop/src/windows.ts` + - Replace disabled “Pet scale” row with a real select/segmented control. + - Refresh pet content after scale updates. +- `apps/desktop/preload.cjs` + - Render and bind scale control. +- `docs/phases/phase-14-pet-scale-setting.md` + - This spec, reviews, and dispositions. + +## Technical approach + +1. Define supported scale values/default in a single pure source of truth, `app-state-core.ts`, and import/reuse where practical: + - Small: `0.44`, + - Medium: `0.56`, + - Large: `0.72`. +2. Make Medium (`0.56`) the default. +3. Preserve old/corrupt state safety by normalizing any unsupported `petScale` to Medium. Old persisted `petScale: 1` should normalize to Medium because `1` was never the actual post-bugfix visual render scale. +4. In Settings, add a `select id="pet-scale"` or equivalent simple control. +5. On scale change, call existing `updatePreferences({ petScale })` and show status feedback. +6. In main process preference update handler, compare previous vs next `petScale` and call `refreshDefaultPetContent()` only when scale actually changes. +7. In pet renderer creation, read `state.preferences.petScale` and pass/use it for: + - sprite transform scale, + - scaled shell size, + - installed card size, + - bubble bottom offset. + +## Risks and tradeoffs + +- Window size stays fixed at 220×260 for simplicity. Large scale is capped to fit within that viewport with the current bubble max height. +- Position persistence stores top-left window position, not pet visual anchor. Changing scale may visually change the pet’s bottom/right footprint but should not jump the window unexpectedly. +- Existing old state files with `petScale: 1` will normalize to Medium, preserving the current corrected default size while allowing users to choose a smaller Small option. + +## Security/privacy notes + +- No new IPC channels. +- Existing Settings-only preference update IPC is reused. +- Scale input is validated in the main process/state normalization. +- No remote assets or code execution changes. + +## Test/check plan + +Run: + +```bash +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +``` + +## Manual verification guide + +1. Run `pnpm dev:desktop`. +2. Open Settings and confirm Pet scale offers Small, Medium, Large. +3. With the pet visible, switch between Small/Medium/Large and confirm the visible pet updates. +4. Change scale twice in the same Settings session and confirm controls stay enabled. +5. Toggle launch/speech settings and confirm the selected scale does not reset. +6. Test all scales with the built-in pet and with an installed pet set as default. +7. Trigger short and long speech bubbles/reactions at each scale, especially Large, and confirm bubble/pet are not cropped and the pet does not move. +8. Change scale while the pet is hidden, then show the pet and confirm the selected scale applies. +9. Confirm Reset default pet position still moves the pet near bottom-right at Small and Large. +10. Restart the app and confirm selected scale persists. +11. If manually editing state for verification, confirm fresh state defaults to Medium, old `petScale: 1` normalizes to Medium, and invalid values normalize to Medium. + +## Oracle plan review + +Reviewed by Oracle. + +Blockers: + +- Proposed Large `0.9` likely cannot meet no-cropping in the current fixed 220×260 window with max-height bubble. +- Settings IPC validation for `petScale` must be explicit because current `validatePreferencePatch()` only accepts launch/speech preferences. + +Should-fix feedback: + +- Define scale constants/default in one main-process source of truth. +- Clarify that old `petScale: 1` normalizes to the default because it was not the actual post-bugfix visual scale. +- Ensure unrelated preference updates do not reset scale. +- Pass scale explicitly through the default-pet path so explicit agent pet windows are not accidentally affected. +- Require derived dimensions/offsets to use the same scale. +- Refresh pet content only after real scale changes. + +Manual verification gaps: + +- Test built-in and installed default pets. +- Test long speech/reaction bubbles at each scale. +- Test fresh state, old `petScale: 1`, invalid values. +- Test repeated scale changes in one Settings session. +- Test scale while pet hidden, then show pet. +- Test reset position at Small and Large. + +Verdict: conditionally approved after fixing scale sizing. + +## Oracle feedback disposition + +Fixed: + +- Reduced Large to `0.84` to fit the fixed viewport with bubble. +- Added explicit Settings IPC validation requirement. +- Added one-source-of-truth scale constants requirement. +- Documented `petScale: 1` migration to the default. +- Added unrelated preference preservation, explicit default-pet scale path, derived dimension, and refresh-on-real-change criteria. +- Expanded manual verification matrix. + +## Implementation notes + +Implemented: + +- Added supported scale constants in `app-state.ts`: Small `0.44`, Medium `0.56`, Large `0.72`. +- Moved scale constants/normalization into pure `app-state-core.ts` so lightweight tests can cover scale normalization without importing Electron. +- Changed default/invalid scale normalization to Medium. +- Added real Settings Pet scale select and status feedback. +- Added `petScale` validation to Settings preference IPC. +- Refreshes default pet content only when scale changes. +- Pet renderer derives shell size, sprite transform, installed-card size, and bubble offset from the selected scale. +- Explicit agent pet windows keep the Medium/default render scale for now; default pet windows use the saved preference. + +Validation passed: + +```bash +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +``` diff --git a/docs/phases/phase-15-codex-pet-import.md b/docs/phases/phase-15-codex-pet-import.md new file mode 100644 index 00000000..778092d6 --- /dev/null +++ b/docs/phases/phase-15-codex-pet-import.md @@ -0,0 +1,44 @@ +# Phase 15 — Codex Pet Import + +## Goal + +Let users import locally generated Codex pets from `~/.codex/pets/` directly in Pet Manager. + +## Implemented + +- Added a **Codex** tab next to All and Installed in Pet Manager. +- Added main-process discovery for `~/.codex/pets//pet.json` and `spritesheet.webp`. +- Codex pets are shown with local `data:image/webp` previews so the Pet Manager CSP does not need broader local-file image access. +- Local previews are only inlined when the raw spritesheet bytes are small enough and the Codex tab remains under a total raw preview-byte cap; oversized valid pets can still be imported but show an empty preview. +- Added Import action for Codex-only pets. +- If a pet exists in both the catalog and Codex, the Pet Manager uses Codex metadata/preview and Import uses the local Codex copy. +- Import copies only validated `pet.json` metadata and `spritesheet.webp` into OpenPets user data under the normal installed-pets directory. +- Imported Codex pets are recorded in app state with `source.kind = "codex"`. +- Missing `~/.codex/pets/` is treated as an empty Codex tab, not an error. +- Discovery scans at most 100 sorted Codex pet directories per render. + +## Safety Rules + +- Pet id must be safe and match the folder name. +- `spritesheetPath` must be exactly `spritesheet.webp`. +- `pet.json` and `spritesheet.webp` have size limits. +- The Codex root, source path, and pet files must stay inside `~/.codex/pets/`; root, pet directories, and files cannot be symlinks. +- Destination path must stay inside OpenPets pets storage. +- Import writes fresh regular files into OpenPets storage rather than copying links. +- Existing installed pets cannot be imported again. +- Import shares the same per-pet operation lock as catalog install/remove/default changes. + +## Automated Checks + +- `check-codex-pets.ts` covers Codex metadata validation and preview-size gating. +- Desktop test script includes the Codex check. + +## Manual Verification + +1. Ensure local pets exist under `~/.codex/pets//pet.json` and `spritesheet.webp`. +2. Open Pet Manager. +3. Confirm the Codex tab appears. +4. Confirm Codex pets show previews and descriptions. +5. Import a Codex pet. +6. Confirm it moves to Installed and can be set as default. +7. Confirm removing it deletes the OpenPets copy, not the original Codex folder. diff --git a/docs/phases/phase-16-claude-hook-pet-routing.md b/docs/phases/phase-16-claude-hook-pet-routing.md new file mode 100644 index 00000000..a3660a2e --- /dev/null +++ b/docs/phases/phase-16-claude-hook-pet-routing.md @@ -0,0 +1,84 @@ +# Phase 16 — Claude Hook Pet Routing + +## Goal + +When a Claude integration is configured with a selected pet, Claude hook speech/reactions should route to the same selected pet as the MCP server instead of always using the default desktop pet. + +## Non-goals + +- Per-project Claude hook installation. Current Claude hooks remain global user settings. +- Changing MCP lease behavior. +- Removing the default-pet fallback when no hook pet is configured. + +## User-visible/manual outcome + +- Installing/updating Claude hooks from Integrations with pet `fixer` writes hook commands containing `--pet fixer`. +- Prompt-submit hook speech appears on the fixer pet, matching the selected Claude integration pet. +- If no selected pet is configured for hooks, hook events continue targeting the default pet. + +## Acceptance criteria + +- `open-pets-claude hook --openpets-managed --pet ` validates `` and passes it to hook handling. +- Hook handling acquires a short OpenPets lease for the configured pet and passes that `leaseId` into `say`/`react`. +- Integrations hook doctor/install uses the selected pet when checking/writing hooks. +- Hook status text makes the target explicit. +- Invalid hook `--pet` values are rejected at CLI boundary. + +## Proposed files/directories + +- `packages/claude/src/cli.ts` +- `packages/claude/src/hooks.ts` +- `packages/claude/src/hook-settings.ts` +- `packages/claude/src/check-claude-hooks.ts` +- `apps/desktop/src/agent-setup.ts` + +## Technical approach + +- Extend the Claude hook CLI with `--pet` parsing using the same pet-id validation as MCP setup. +- Include `--pet ` in generated Claude hook commands when a selected pet exists. +- During hook runtime, acquire a short lease for the configured pet and pass the returned lease id to the OpenPets client call. +- Do not release hook leases immediately; rely on the existing short TTL so the explicit pet remains visible briefly after the hook event. +- Preserve default-pet behavior when no `--pet` is present. + +## Risks and tradeoffs + +- Hooks are global in `~/.claude/settings.json`, so installing hooks for `fixer` makes hook events target `fixer` globally. Per-project hook routing is deferred. +- If the selected pet is missing or broken, local IPC falls back to default pet using existing lease fallback behavior. + +## Security/privacy notes + +- Hook `--pet` is validated against the safe pet-id pattern before use. +- Hook speech validation still rejects code, paths, URLs, and secret-like content. +- No hook payload content is logged by default. + +## Test/check plan + +- `pnpm --filter @open-pets/claude build` +- `pnpm --filter @open-pets/claude test` +- `pnpm --filter @open-pets/desktop build` +- `pnpm --filter @open-pets/desktop test` +- `pnpm --filter @open-pets/mcp test` + +## Manual verification guide + +1. Open Integrations. +2. Select `fixer` for Claude. +3. Update/install optional Claude hooks. +4. Confirm `~/.claude/settings.json` hook commands include `--pet fixer`. +5. Start Claude in a project using the `--pet fixer` MCP config. +6. Submit a prompt. +7. Confirm hook speech/reaction appears on the fixer pet, not the default pet. + +## Oracle plan review + +Skipped as emergency bugfix; implementation review requested immediately after the targeted fix. + +## Oracle feedback disposition + +Implementation review via Oracle found no blocking issues. + +- Fixed: Added CLI-level regression coverage for `open-pets-claude hook --pet fixer` and invalid `--pet bad/pet`. +- Fixed: Hook status message now states whether hook events target the selected pet or default pet. +- Fixed: Hook CLI validates `--pet` at the boundary instead of relying only on IPC fallback. +- Fixed: Hook action journal commands include `--pet ` when applicable. +- Accepted: Hook leases are not immediately released; they rely on the existing 15s TTL to keep the explicit pet visible after hook events. diff --git a/docs/phases/phase-17-openpets-cli-project-setup.md b/docs/phases/phase-17-openpets-cli-project-setup.md new file mode 100644 index 00000000..aea1189a --- /dev/null +++ b/docs/phases/phase-17-openpets-cli-project-setup.md @@ -0,0 +1,298 @@ +# Phase 17 — OpenPets CLI Project Setup + +## Goal + +Add a real npm-distributed OpenPets CLI that lets users configure the current project to use a selected pet with Claude, using one simple command. + +Primary command: + +```bash +npx @open-pets/cli configure --pet fixer +``` + +This should configure both Claude MCP and Claude hooks for the current project. No extra MCP/hooks questions. + +## Non-goals + +- App-installed `openpets` shim / Settings “Install CLI” button. Defer to a later phase. +- Shared/team project config by default. +- Supporting agents other than Claude. +- Remote/custom catalog flags. +- `openpets install `; defer pet installation CLI to a later phase. +- Exposing pet install/remove/default controls through MCP tools. +- Building a complex TUI. Keep interaction simple. + +## User-visible/manual outcome + +From a project directory, users can run: + +```bash +npx @open-pets/cli configure --pet fixer +``` + +Expected result: + +- Claude MCP is configured locally for the current project with OpenPets `--pet fixer`. +- Claude project-local hooks are configured with `--project-local --pet fixer`. +- Starting Claude from that project makes MCP tools and hook speech/reactions target fixer. + +Users can also run: + +```bash +npx @open-pets/cli configure --agent claude --pet fixer --cwd /path/to/project --yes +``` + +If `--pet` is omitted, the CLI lists installed pets and prompts for one. + +## Acceptance criteria + +- `packages/cli` exposes a real executable bin, `openpets`. +- `openpets configure --pet ` runs non-interactively for Claude using current working directory. +- `--agent claude` is accepted; unsupported agents fail clearly. +- `--cwd ` targets another project directory; default is `process.cwd()`. +- `--yes` is accepted as a compatibility/no-op flag; there is no final confirmation prompt in Phase 17. +- If `--pet` is missing, CLI queries installed pets from the running OpenPets desktop app and prompts the user to choose. +- CLI configures Claude MCP using local project scope, private to the current user. +- CLI configures Claude hooks in `/.claude/settings.local.json`, private to the project/user. +- Claude MCP command is installed while spawning `claude` with `cwd` set to the resolved target project. +- Long-lived generated Claude MCP/hook commands use a stable package command, not a temporary `npx @open-pets/cli` install path. +- Hook commands include `--pet `. +- Existing non-OpenPets hooks in `.claude/settings.local.json` are preserved. +- Existing OpenPets-managed hooks are replaced safely. +- CLI fails with clear instructions if OpenPets desktop app is not running. +- CLI fails clearly if Claude Code is unavailable on `PATH`. +- CLI validates selected pet ids and rejects unsafe values. + +## Proposed files/directories + +- `packages/cli/package.json` +- `packages/cli/src/index.ts` +- `packages/cli/src/check-cli-contract.ts` +- `packages/client/src/index.ts` +- `apps/desktop/src/local-ipc.ts` +- `apps/desktop/src/local-ipc-protocol.ts` +- `apps/desktop/src/check-local-ipc-protocol.ts` +- `packages/claude/src/hook-settings.ts` (reuse project-local hook writer by passing settings path) + +## Technical approach + +### CLI command shape + +```bash +openpets configure [--agent claude] [--pet ] [--cwd ] [--yes] +``` + +Defaults: + +- `--agent claude` +- `--cwd process.cwd()` +- configure both MCP and hooks +- local/private project config + +### Installed pet discovery + +Add a narrow local IPC/client method for the user-run CLI: + +```ts +pets.list +``` + +Return only safe display data: + +```ts +{ + ok: true, + pets: [ + { id, displayName, installed: true, builtIn, broken } + ], + defaultPetId +} +``` + +The CLI uses this to validate `--pet` and power the picker. + +### Stable generated commands + +Do not write temp/cache paths from `npx @open-pets/cli` into Claude config. Instead make the CLI package self-contained and expose wrapper subcommands: + +```bash +openpets mcp --pet fixer +openpets hook --openpets-managed --project-local --pet fixer +``` + +Generated long-lived commands should use the package version being configured: + +```bash +npx -y @open-pets/cli@0.0.0 mcp --pet fixer +npx -y @open-pets/cli@0.0.0 hook --openpets-managed --project-local --pet fixer +``` + +The `mcp` wrapper starts the existing OpenPets MCP server implementation. The `hook` wrapper delegates to the existing Claude hook handler. + +This avoids storing an absolute path into npm's temporary `npx` cache. + +### Claude MCP configuration + +Use Claude’s own CLI for local project MCP setup when available: + +```bash +claude mcp add-json openpets '' --scope local +``` + +JSON shape: + +```json +{ + "type": "stdio", + "command": "npx", + "args": ["-y", "@open-pets/cli@0.0.0", "mcp", "--pet", "fixer"], + "env": {} +} +``` + +Spawn `claude mcp add-json` with `cwd` set to the resolved target project directory so Claude's local MCP scope attaches to the intended project. + +Do not directly mutate `~/.claude.json` in this phase; delegate that to `claude mcp add-json`. + +### Claude project-local hooks + +Write hooks directly to: + +```text +/.claude/settings.local.json +``` + +Use existing `installClaudeHooks(settingsPath, commandMode, selectedPetId)` with a CLI-appropriate command mode/path if possible. If needed, add a small command builder option so the npm CLI writes hook commands that call the installed npm Claude CLI: + +```bash +npx -y @open-pets/cli@0.0.0 hook --openpets-managed --project-local --pet fixer +``` + +Project-local hooks are private and should not require committing repo files. + +Before writing hooks: + +- resolve and validate the target project directory; +- reject symlinked `/.claude` directories; +- ensure `/.claude/settings.local.json` resolves inside the target project; +- preserve existing non-OpenPets settings and hooks. + +### Interactive picker + +If `--pet` is missing: + +- list usable installed non-broken pets, including built-in unless explicitly disallowed later +- use a simple stdin/stdout numbered picker +- no extra MCP/hooks questions + +### Preflight and idempotency + +Before writing anything: + +- verify OpenPets desktop is reachable and supports `pets.list`; +- verify the selected pet is installed and usable; +- verify Claude Code is available on PATH; +- verify target project path and hook path safety; +- build the version-pinned wrapper commands. + +If any preflight fails, do not write MCP or hooks. + +If a local Claude MCP entry named `openpets` already exists, `configure` should replace it by invoking Claude's add/update flow for the same server name when supported by `claude mcp add-json`; if Claude refuses, fail clearly before writing hooks. + +## Risks and tradeoffs + +- Adding public local IPC pet listing is a new surface. Keep it read-only and minimal. +- Invoking `claude` CLI can fail if Claude Code is unavailable on PATH. CLI should print the exact failure and next step. +- Project-local hooks and Claude local MCP scope are stored in different places by Claude design. +- Npm `npx` hook commands may run package resolution at hook time. This is acceptable for npm-first CLI but may be slower than future app-installed shim. +- Generated commands are version-pinned for stability; users may need to rerun configure after upgrading OpenPets CLI. + +## Security/privacy notes + +- Do not expose filesystem install/remove/default mutation over MCP. +- Validate project path and pet id before writes. +- Only write inside `/.claude/settings.local.json` for hooks. +- Reject unsafe project-local hook paths, including symlinked `.claude` directories. +- Preserve non-OpenPets settings/hooks. +- Do not log prompt/hook payloads or secrets. + +## Test/check plan + +- Unit/contract check for CLI arg parsing and generated Claude config. +- Contract check that existing hooks are preserved and OpenPets-managed hooks are replaced. +- Mocked `claude` binary check for exact `add-json` argv, `--scope local`, JSON shape, and spawned cwd. +- Noninteractive `--pet` check; missing `--pet` non-TTY failure/picker behavior. +- Generated command version pinning check. +- Project hook writer rejects symlinked `.claude` / unsafe cwd. +- IPC protocol check for `pets.list` validation/result shape. +- MCP public tool list remains exactly `openpets_status`, `openpets_say`, `openpets_react`. +- Package build/test: + +```bash +pnpm --filter @open-pets/cli build +pnpm --filter @open-pets/cli test +pnpm --filter @open-pets/client test +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +pnpm --filter @open-pets/claude test +``` + +## Manual verification guide + +1. Run OpenPets desktop. +2. In a test project, run: + + ```bash + npx @open-pets/cli configure --pet fixer + ``` + +3. Confirm command succeeds without MCP/hooks prompts. +4. Confirm Claude local MCP entry for the project targets `--pet fixer`. +5. Confirm `/.claude/settings.local.json` contains OpenPets hooks with `--pet fixer`. +6. Start Claude in that project. +7. Confirm `/mcp` shows OpenPets connected. +8. Submit a prompt and confirm hook bubble appears on fixer. +9. Call `openpets_status` and confirm actual target is fixer. + +## Oracle plan review + +Oracle reviewed the Phase 17 plan and recommended revision before implementation. + +## Oracle feedback disposition + +- Fixed: Changed long-lived generated commands from temporary absolute package paths to self-contained, version-pinned `npx -y @open-pets/cli@ mcp/hook ...` wrappers. +- Fixed: Removed final confirmation prompt from Phase 17; `--yes` is accepted as no-op compatibility only. +- Fixed: Spec now requires spawning `claude mcp add-json` with `cwd` set to the target project. +- Fixed: Added project-local hook path safety requirements for symlinked `.claude` and inside-project resolution. +- Fixed: Deferred `openpets install ` out of Phase 17 to avoid expanding mutation/security scope. +- Fixed: Added preflight/idempotency requirements before writing MCP/hooks. +- Fixed: Added version pinning, mocked Claude CLI tests, project hook safety tests, noninteractive CLI tests, and MCP public tool-list regression to test plan. + +## Implementation notes + +- Added `openpets` bin in `@open-pets/cli`. +- Added `configure`, `mcp`, and `hook` CLI command paths. +- Added read-only `pets.list` local IPC/client method. +- `configure --pet ` writes config without requiring the desktop app to be running; omitting `--pet` still queries the running desktop app for interactive pet selection. +- Claude MCP config is written through `claude mcp add-json openpets --scope local` with `cwd` set to the target project. +- Project-local hooks are prepared before MCP mutation and written only after MCP configuration succeeds. +- Generated MCP/hook commands use version-pinned `npx -y @open-pets/cli@` wrappers. +- CLI-generated npm hook timeout is `10` seconds to tolerate `npx` startup. +- Project-local hooks include the internal `--project-local` marker so global OpenPets hooks can detect project-specific OpenPets hooks and skip themselves, preventing duplicate default/project pet reactions. +- `--force` / `--replace` removes any existing local Claude MCP `openpets` entry before adding the new one. +- Hidden maintainer flag `--local-dev` writes local `node /packages/cli/dist/index.js ...` commands for pre-release testing; it is intentionally omitted from user help. + +## Oracle implementation review + +Oracle approved the implementation after one revision pass. + +- Fixed: Avoid partial config by parsing/merging hook settings before MCP mutation and writing hooks only after MCP succeeds. +- Fixed: Reject symlinked `.claude`, non-file `settings.local.json`, malformed hook event arrays, and unsafe project-local hook paths. +- Fixed: Added `publishConfig.access = public` for npm-distributed packages. +- Fixed: Added `pets.list` response shape validation. +- Fixed: Added mocked Claude CLI cwd/argv/JSON test. +- Fixed: Increased CLI-generated hook timeout and added MCP wrapper signal forwarding. +- Fixed: Added global-hook duplicate prevention using explicit project-local hook marker detection. +- Fixed: Hardened project-local hook detection to reject symlinks/non-files, cap settings size, and ensure the settings path stays under the Claude project dir. +- Fixed: Hook CLI boundaries reject `--pet` with a missing value. +- Accepted: Generated commands are pinned to the package version; release must publish `@open-pets/cli`, `@open-pets/client`, `@open-pets/claude`, and `@open-pets/mcp` at the same version. diff --git a/docs/phases/phase-18-reaction-sprite-animation-mapping.md b/docs/phases/phase-18-reaction-sprite-animation-mapping.md new file mode 100644 index 00000000..e1f82359 --- /dev/null +++ b/docs/phases/phase-18-reaction-sprite-animation-mapping.md @@ -0,0 +1,352 @@ +# Phase 18 — Reaction Sprite Animation Mapping + +## Goal + +Make OpenPets reactions actually drive the pet spritesheet animation state, not just temporary reaction bubble text. + +OpenPets currently uses the universal Codex/OpenPets spritesheet layout for `idle`, `run-right`, and `run-left` only. The missing piece is mapping **all** universal spritesheet rows to reachable reaction/emotion states so no available Codex animation row is unused. + +This phase should make hooks like `thinking`, `editing`, `testing`, `waiting`, `success`, `error`, and `notification/attention` visibly animate the pet using the correct spritesheet rows for all built-in, installed catalog, and imported Codex pets that use the shared spritesheet format. + +## Non-goals + +- No new sprite art generation. +- No remote runtime art fetch. +- No broad pet format redesign. +- No new public MCP tools. +- No broad agent hook redesign. A narrow hook/reaction adjustment is allowed if needed to make every universal spritesheet row reachable. +- No physics/drag architecture rewrite beyond preserving existing drag left/right behavior. +- No support for arbitrary custom spritesheet layouts without explicit future metadata. + +## User-visible/manual outcome + +When Claude or MCP sends a reaction, the pet visibly changes animation row: + +- prompt/thinking shows the thinking/review animation, +- edit/write activity shows an active work animation, +- bash test activity shows a waiting/testing animation, +- permission requests show waiting, +- notifications/attention can wave, +- successful completion jumps/celebrates, +- failures show the failed/error animation, +- dragging still uses left/right run animations. + +Speech bubbles still work, but they are no longer the only visible feedback. + +## Acceptance criteria + +- A single universal spritesheet contract is documented and encoded in desktop code: + - 1536×1872 WebP, + - 8 columns × 9 rows, + - 192×208 frames, + - rows matching the Codex/OpenPets state layout below. +- The desktop renderer supports these universal rows: + + | Universal row | Codex state | Frames | Duration | Intended use | + | --- | --- | --- | --- | --- | + | 0 | `idle` | 6 | 5500ms | Neutral idle. | + | 1 | `running-right` | 8 | 1060ms | Drag/move right. | + | 2 | `running-left` | 8 | 1060ms | Drag/move left. | + | 3 | `waving` | 4 | 700ms | Friendly attention/click/optional greeting. | + | 4 | `jumping` | 5 | 840ms | Success/celebration. | + | 5 | `failed` | 8 | 1220ms | Error/failure/warning. | + | 6 | `waiting` | 6 | 1010ms | Waiting/permission/testing if no dedicated testing row. | + | 7 | `running` | 6 | 820ms | Working/editing/running active work. | + | 8 | `review` | 6 | 1030ms | Thinking/reviewing. | + +- Every universal animation row is reachable either through drag, hook behavior, or a public reaction value: + + | Universal state | Reachability requirement | + | --- | --- | + | `idle` | Default/no active reaction, and message-only speech. | + | `running-right` | Drag/move right via existing `run-right` motion IPC. | + | `running-left` | Drag/move left via existing `run-left` motion IPC. | + | `waving` | Reachable through a `waving` reaction and used for Claude `Notification`/attention. | + | `jumping` | Reachable through `success` and `celebrating`. | + | `failed` | Reachable through `error`. | + | `waiting` | Reachable through `waiting` and `testing`. | + | `running` | Reachable through `working`, `editing`, and `running`. | + | `review` | Reachable through `thinking`. | + +- Public reaction values should be expanded only as needed to make universal rows properly addressable: + - add `waving` so row 3 is directly usable, + - keep `warning` out of this phase because it maps to `failed` and does not unlock a new row, + - do not add `sleeping` in this phase because the universal sheet has no unique sleeping row and v1 maps it to `idle`. +- Current and new OpenPets reactions map to universal spritesheet states: + + | Reaction | Spritesheet state | + | --- | --- | + | `idle` | `idle` | + | `thinking` | `review` | + | `working` | `running` | + | `editing` | `running` | + | `running` | `running` | + | `testing` | `waiting` | + | `waiting` | `waiting` | + | `waving` | `waving` | + | `success` | `jumping` | + | `error` | `failed` | + | `celebrating` | `jumping` | + +- Claude hook reactions are updated only where needed for row reachability: + - `Notification` maps to `waving` instead of `waiting`, because notification is an attention event and row 3 must be used, + - `PermissionRequest` remains `waiting`, because permission is a blocking/waiting state. + +- Drag motion remains higher-priority while dragging: + - the existing motion IPC values stay `run-right` and `run-left`, + - `run-right` maps to universal spritesheet state `running-right`, + - `run-left` maps to universal spritesheet state `running-left`, + - stopping drag returns to the current reaction state if one is active, otherwise idle. +- Reaction-only calls no longer only show the reaction word in a bubble; they also switch the sprite animation for the transient display lifetime. +- Message + reaction calls show the message in the bubble while still switching sprite animation to the reaction-mapped row. +- Message-only calls show the message while the sprite remains idle unless drag motion overrides it. +- One-shot states (`waving`, `jumping`, `failed`) run 2 loops and then stop instead of repeating for the full 4-second bubble lifetime. +- Long-running states (`idle`, `waiting`, `running`, `review`, `running-left`, `running-right`) keep looping while active. +- Hook speech still displays safe short text, but the paired hook reaction controls the animation row. +- Built-in/prefilled speech text must use consistent sentence-style capitalization. The first letter should be uppercase, and the phrase should read as a short proper sentence/status, not a mix of lowercase/title-case styles. +- All installed pets, imported Codex pets, and the built-in default pet use the same universal mapping. +- If an installed pet is missing or invalid, existing broken/fallback behavior remains unchanged. +- `prefers-reduced-motion` still disables sprite animation loops where practical. +- Tests/contract checks cover: + - universal row constants, + - exhaustive `reactionToSpriteState satisfies Record` mapping, + - every universal row has at least one reachable trigger, + - `waving` is accepted by desktop IPC, the client protocol, and MCP `openpets_react`, + - Claude `Notification` maps to `waving`, + - built-in hook speech pools use consistent sentence-style capitalization, with every phrase matching at least `^[A-Z]`, + - derived universal spritesheet dimensions equal `frameWidth * columns = 1536` and `frameHeight * rows = 1872`, + - generated HTML/CSS includes all mapped reaction states for both `.sprite` and `.installed-sprite`, + - existing `run-left`/`run-right` motion states still override reaction state and map to `running-left`/`running-right`, + - reduced-motion selectors cover both built-in and installed sprite elements, + - `docs/mapping.md` is updated to no longer claim reactions are bubble-only. + +## Proposed files/directories + +- `apps/desktop/src/pet-window.ts` + - Add universal spritesheet state constants for all nine rows. + - Add reaction-to-sprite-state mapping. + - Render CSS selectors for reaction animation states, not only motion states. + - Preserve drag left/right motion publisher behavior. +- `apps/desktop/pet-preload.cjs` + - If needed, extend the existing narrow bridge to handle reaction state separately from drag motion state. +- `apps/desktop/src/default-pet-controller.ts` + - Ensure transient reaction state survives refresh/reload and clears after `transientDisplayMs`. +- `apps/desktop/src/agent-pet-controller.ts` + - Same for explicit/agent pet windows. +- `apps/desktop/src/check-packaging-contract.ts` or a new focused check file + - Add mapping/contract assertions and wire the focused check into `apps/desktop/package.json` `test` if new. +- `packages/client/src/protocol.ts` and `apps/desktop/src/local-ipc-protocol.ts` + - Add `waving` to allowed reactions so the waving row is reachable. +- `packages/claude/src/hooks.ts` + - Map Claude `Notification` to `waving` instead of `waiting`. +- `packages/claude/src/hook-messages.ts` + - Normalize all prefilled hook speech capitalization. +- `packages/mcp/src/tools.ts` + - Verify MCP reaction schema exposes any newly allowed reaction values. +- `docs/mapping.md` + - Update from “bubble-only” behavior to the new animation mapping. +- `docs/phases/phase-18-reaction-sprite-animation-mapping.md` + - This spec, Oracle review, and feedback disposition. + +## Technical approach + +1. **Make the spritesheet contract explicit in code.** + - Replace the current partial `defaultPetSprite.states` with a universal constant matching the Codex/OpenPets v1 mapping. + - Use clear names: `idle`, `running-right`, `running-left`, `waving`, `jumping`, `failed`, `waiting`, `running`, `review`. + - Keep frame sizes/columns/rows unchanged. + - This phase assumes all installed catalog/Codex pets are known-good universal-format spritesheets; it does **not** add WebP dimension parsing/validation. Future arbitrary pet formats should add strict metadata/dimension validation in a separate phase. + +2. **Separate sprite state from bubble text.** + - `PetTransientDisplay` already has `reaction?: string` and `message?: string`. + - Narrow `PetTransientDisplay.reaction` to `OpenPetsReaction` if practical; otherwise validate/fallback unknown strings to `idle` before mapping. + - Continue showing explicit `message` text when provided. + - For reaction-only events, show a stable randomized short status line from a dedicated reaction message pool instead of the raw lowercase reaction id. + - Additionally derive a sprite state from `reaction`. + - If no reaction is active, sprite state is `idle` unless drag motion overrides it. + +3. **Make all rows reachable.** + - Add `waving` as an allowed reaction so row 3 is available through `openpets_react` and hook notifications. + - Remap Claude `Notification` from `waiting` to `waving`, document it in `docs/mapping.md`, and update Claude hook tests. + - Do not add fake use of `sleeping`; it has no unique universal row. + - Do not add `warning`; it does not unlock a new sprite row. + +4. **Normalize built-in speech text.** + - All prefilled hook speech in `hook-messages.ts` should start with an uppercase letter and use consistent short sentence/status style. + - Add a direct test over all hook speech pools requiring every phrase to match at least `^[A-Z]`. + - Examples should be like `Thinking it through`, `Approval needed`, `Something failed`, not a mixed set of lowercase/title-case fragments. + +5. **Render data attributes for both reaction state and drag motion.** + - Use something like: + + ```html + + ``` + + - Prefer keeping `pet-preload.cjs` motion-only. Initialize reaction state in generated HTML with `data-reaction-state` or CSS variables. + - CSS chooses drag state when `data-motion-state` is `run-left`/`run-right`; otherwise it uses reaction state. + - If preload must handle reaction state, it must whitelist valid universal sprite states and ignore anything else. + +6. **Preserve existing drag behavior.** + - Keep main-process movement detection because `-webkit-app-region: drag` makes renderer pointer events unreliable. + - Do not regress the previous fix where explicit pets animate while dragged. + - Drag should temporarily override reaction animation, then settle back to reaction state until the transient display clears. + +7. **Keep transient lifetime unchanged.** + - Reaction/message display still clears after 4 seconds. + - When it clears, animation returns to idle unless the pet is being dragged. + +8. **Use universal mapping for every pet path.** + - Built-in fallback spritesheet and installed pet spritesheets use the same renderer code path or same generated CSS rules. + - Codex-imported pets are already installed as `spritesheet.webp`; no separate Codex code path should be required. + +9. **Update docs and tests.** + - Update `docs/mapping.md` so it reflects the new truth. + - Add a contract check that would fail if reactions remain bubble-only. + - Add concrete checks against the generated CSS/source so built-in and installed pet paths cannot drift. + +## Risks and tradeoffs + +- The universal mapping assumes all current catalog/Codex pets share the Codex/OpenPets 8×9 layout. This is a product decision for now; arbitrary future pet formats need metadata and validation in a later phase. +- Because this phase does not parse WebP dimensions, a malformed manually-installed spritesheet can still render incorrectly; existing missing/oversized-file protections remain the safety boundary. +- `testing -> waiting` is imperfect but follows the existing v1 universal mapping. We can later add distinct test art only if the spritesheet contract changes. +- Success/error animations are temporary and will clear after 4 seconds. This is consistent with current transient display behavior, but if users expect success/error to persist longer, that should be a separate product decision. +- CSS selector precedence between drag state and reaction state must be simple and tested to avoid subtle bugs. +- Reloading pet HTML to change transient display is simple but can restart sprite animation. That is acceptable for this phase unless visual flicker is severe. + +## Security/privacy notes + +- No new external input is trusted beyond existing validated reaction/message values. +- Message safety rules remain unchanged. +- Renderer remains sandboxed/context-isolated with strict CSP. +- No remote script or image fetch is added. +- Installed pet file validation remains unchanged; this phase only changes how already-installed spritesheets are animated. + +## Test/check plan + +Run focused checks during implementation: + +```bash +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +pnpm --filter @open-pets/claude test +pnpm --filter @open-pets/mcp test +``` + +If shared reaction constants move or are added to a shared package, also run affected package build/tests. + +Before implementation review, run at least: + +```bash +pnpm --filter @open-pets/desktop build +pnpm --filter @open-pets/desktop test +``` + +## Manual verification guide + +After implementation: + +1. Run `pnpm dev:desktop`. +2. Show the default pet. +3. Trigger each MCP reaction and confirm animation row changes visibly. Use available smoke commands if present, for example `pnpm --filter @open-pets/client smoke:react thinking`; otherwise trigger through Claude MCP tools: + - `thinking` -> review/thinking row, + - `working` -> active running/work row, + - `editing` -> active running/work row, + - `running` -> active running/work row, + - `testing` -> waiting row, + - `waiting` -> waiting row, + - `waving` -> waving row, + - `success` -> jumping row, + - `error` -> failed row, + - `celebrating` -> jumping row. +4. Trigger Claude `Notification`; confirm the pet waves on both default and project/agent pets, and confirm any generated text is properly capitalized. +5. Trigger `openpets_say` with both a message and reaction; confirm bubble shows the message while sprite uses the reaction animation. Also trigger message-only speech and confirm the bubble appears while the sprite stays idle. +6. Drag the pet left/right while a reaction is active; confirm drag overrides to `running-left`/`running-right`, then returns to the active reaction until it clears. +7. Set an installed/Codex pet as default and repeat representative reactions (`thinking`, `success`, `error`, `waving`). +8. Configure a project pet and confirm explicit/non-default pet reactions animate too. +9. Verify reactions clear back to idle after about 4 seconds. +10. Review all built-in/hook-generated speech phrases and confirm they start with uppercase letters and use a consistent short sentence/status style. + +## Oracle plan review + +Reviewed by Oracle. + +Blockers: none. + +Should-fix feedback: + +- Clarify universal sprite state naming versus existing motion IPC names (`run-left`/`run-right`). +- Require exhaustive typed reaction-to-sprite mapping and safe fallback if `PetTransientDisplay.reaction` remains a string. +- Clarify that this phase assumes known-good universal spritesheets and does not add WebP dimension validation. +- Make the test harness concrete and wire any new check into desktop `test`. +- Ensure built-in and installed sprite CSS paths both get all mapped states. +- Cover reduced-motion behavior for installed sprites as well as built-in sprites. +- Specify message-only behavior. +- Prefer keeping the preload motion-only; if expanded, whitelist reaction states. +- Fix the “Phase 18/previous fix” typo. + +Follow-up review after user requested all rows reachable and speech capitalization: + +- Make `Notification` → `waving` non-optional. +- Keep `warning` out of scope because it does not unlock a new row. +- Add protocol-level acceptance criteria for `waving` across desktop IPC, client, and MCP. +- Make speech capitalization test precise: all hook speech pool phrases should start with `[A-Z]`. +- Assert derived universal spritesheet dimensions equal `1536×1872`. + +Nice-to-have feedback: + +- Factor universal constants/CSS generation to avoid drift. +- Add concrete smoke-command manual verification examples. +- Consider future WebP dimension checks if non-universal pets become possible. +- Document `waving` as defined but currently unused unless deliberately added. + +## Oracle feedback disposition + +Fixed: + +- Added explicit naming guidance: motion IPC remains `run-left`/`run-right`, mapped to universal rows `running-left`/`running-right`. +- Added exhaustive typed mapping acceptance criterion using `Record`. +- Clarified validation scope: no WebP dimension parsing in this phase; known-good universal catalog/Codex format is assumed. +- Required concrete desktop contract check wiring if a new check file is added. +- Required generated CSS/source checks for both `.sprite` and `.installed-sprite`. +- Added reduced-motion coverage for installed sprite elements. +- Added message-only behavior criterion. +- Added preference to keep preload motion-only; any reaction preload handling must whitelist state values. +- Fixed the “Phase 18/previous fix” wording. +- Made `Notification` → `waving` explicit and required. +- Kept `warning` out of scope. +- Added protocol/MCP acceptance criteria for `waving`. +- Added precise hook speech capitalization test requirement. +- Added derived spritesheet dimension assertion requirement. + +Accepted: + +- Implementation should factor constants/CSS generation where practical to avoid drift. +- Manual verification now includes concrete `smoke:react` examples where available. +- Future WebP dimension validation remains deferred. +- `waving` is now explicitly used by `Notification` and by public `openpets_react`. + +## Oracle implementation review + +Reviewed by Oracle after implementation. + +Blocking feedback: + +- Commit must include untracked `docs/mapping.md` because desktop packaging contract checks now require it. + +Non-blocking feedback: + +- Fix docs wording that still implied `waiting` covers notification. +- Clarify `openpets_react` docs so it says reactions drive animation, not only bubbles. +- Add base `background-position: 0 var(--sprite-row-y)` so reduced-motion still shows the selected reaction row instead of always showing idle. + +Verdict: safe to commit after including docs and fixing the small docs/reduced-motion issues. + +## Oracle implementation feedback disposition + +Fixed: + +- `docs/mapping.md` is part of this phase output and must be committed. +- Updated `waiting` wording in `docs/mapping.md` so notification maps to `waving` only. +- Updated `openpets_react` wording to say it drives animation and may show reaction text. +- Added base sprite `background-position` for built-in and installed sprite elements. +- Reran desktop build/test after fixes. diff --git a/docs/phases/phase-19-opencode-integration.md b/docs/phases/phase-19-opencode-integration.md new file mode 100644 index 00000000..5d609771 --- /dev/null +++ b/docs/phases/phase-19-opencode-integration.md @@ -0,0 +1,305 @@ +# Phase 19 — OpenCode Integration Plan + +## Goal + +Add full OpenPets support for OpenCode with parity to the current Claude Code integration, without over-splitting implementation work. + +Target parity means: + +- OpenPets MCP tools available in OpenCode. +- OpenPets instructions installed into OpenCode instructions. +- OpenCode activity drives pet reactions and short safe speech. +- CLI project setup can route a selected pet to a project. +- Desktop Integrations can detect/configure/remove/doctor OpenCode setup. + +## Current Claude integration to mirror + +Claude support has four layers: + +1. **MCP tools** + - Public tools stay exactly `openpets_status`, `openpets_say`, `openpets_react`. + - MCP server: `packages/mcp/src/server.ts`. + - CLI wrapper: `packages/cli/src/index.ts` → `openpets mcp --pet `. + - Claude command/config helpers: `packages/claude/src/claude-code.ts`. + - Desktop setup: `apps/desktop/src/agent-setup.ts`. + +2. **Instructions/memory** + - Claude writes `~/.claude/openpets.md` and imports it from `~/.claude/CLAUDE.md`. + - Implementation: `apps/desktop/src/claude-memory.ts`. + - Safety pattern: managed markers, preserve user content, private file modes, no symlink writes, max file size. + +3. **Hooks/events** + - Claude hooks write managed command entries to `~/.claude/settings.json` or project-local `.claude/settings.local.json`. + - Implementation: `packages/claude/src/hook-settings.ts`, `packages/claude/src/hooks.ts`, `packages/claude/src/hook-messages.ts`. + - Current mapping: + - `UserPromptSubmit` → `thinking` + throttled speech. + - `PreToolUse Edit/Write/MultiEdit` → `editing`. + - `PreToolUse Bash` test command → `testing`, otherwise `running`. + - other `PreToolUse` → `working`. + - `PermissionRequest` → `waiting` + forced short speech. + - `Notification` → `waving`. + - `Stop` → `success` + throttled speech. + - `StopFailure` → `error` + throttled speech. + +4. **CLI project setup** + - `openpets configure --agent claude --pet --cwd ` configures project-local Claude MCP and hooks. + - Explicit `--pet` works without desktop app; omitted `--pet` uses local IPC `pets.list`. + +## OpenCode source findings + +OpenCode has enough native extension surface for parity, but OpenCode support should use config + plugin, not Claude-style hooks. + +- **MCP config** + - OpenCode config has `mcp` at `v1/opencode/packages/opencode/src/config/config.ts` lines 220-229. + - Local MCP shape is in `v1/opencode/packages/opencode/src/config/mcp.ts` lines 5-19: + - `{ type: "local", command: string[], environment?, enabled?, timeout? }`. + - `opencode mcp add` is interactive, so automated OpenPets setup should directly edit JSON/JSONC config with the same shape. + +- **Config locations** + - Global config uses OpenCode global config dir, with `OPENCODE_CONFIG_DIR` override support. + - Project configs include `opencode.json`, `opencode.jsonc`, `.opencode/opencode.json`, `.opencode/opencode.jsonc`. + - OpenCode's own MCP add path resolution checks `opencode.json` before `opencode.jsonc`, then `.opencode/*`: `v1/opencode/packages/opencode/src/cli/cmd/mcp.ts` lines 399-415. + - OpenCode writes config with `jsonc-parser`: `v1/opencode/packages/opencode/src/cli/cmd/mcp.ts` lines 417-431. + +- **Instructions** + - OpenCode config supports `instructions: string[]`: `v1/opencode/packages/opencode/src/config/config.ts` lines 238-240. + - OpenCode config merge concatenates instruction arrays without duplicates: lines 54-59. + +- **Plugins/events** + - OpenCode plugin hook surface: `v1/opencode/packages/plugin/src/index.ts` lines 222-333. + - Useful hooks include `event`, `chat.message`, `tool.execute.before`, `tool.execute.after`, `command.execute.before`. + - Do **not** rely on `permission.ask`; permissions should be handled through plugin `event` for bus event `permission.asked`. + - OpenCode awaits plugin hooks directly, so plugin hooks must return immediately and run OpenPets calls fire-and-forget. + +## Non-goals + +- Do not fork or modify `v1/opencode/`. +- Do not add new public MCP tools. +- Do not expose pet install/remove/default controls through MCP or OpenCode plugin tools. +- Do not add a network listener or external SSE watcher unless plugin-based events prove insufficient. +- Do not send prompts, raw commands, code, logs, tool output, URLs, secrets, or file paths to pet speech. +- Do not write project-local absolute local/bundled paths by default. + +## Recommended subphase sequence + +This should be **4 subphases**, not 6. That keeps risk separated without making the project feel fragmented. + +### Phase 19A — OpenCode Foundation + +**Goal:** Add OpenCode config helpers, previews, safe write primitives, and shared speech/event safety. No plugin runtime or UI yet. + +**Scope:** + +- Add `packages/opencode` or equivalent module. +- Build helpers for: + - best-effort OpenCode detection; + - global/project config path discovery; + - JSON/JSONC read/write; + - MCP/instructions/plugin previews; + - installed/missing/stale/error status classification. +- Extract shared speech categories, message picking, speech validation, and throttling from Claude into a neutral reusable module. +- Keep Claude behavior unchanged. +- Define exact config target behavior: + - follow OpenCode's own existing file order: `opencode.json`, `opencode.jsonc`, `.opencode/opencode.json`, `.opencode/opencode.jsonc`; + - if no project config exists, create `.opencode/opencode.jsonc`; + - published mode is default for project config; + - local/bundled absolute paths are explicit dev/global-only, never default project config. +- Add safety primitives: + - max config size; + - reject symlinked file and parent dirs; + - no writes on invalid JSON/JSONC; + - backup before update/replace/remove; + - temp-file + rename atomic writes. + +**Acceptance criteria:** + +- OpenCode MCP preview shape is: + +```json +{ + "mcp": { + "openpets": { + "type": "local", + "command": ["npx", "-y", "@open-pets/cli@0.0.0", "mcp", "--pet", "fixer"], + "enabled": true + } + } +} +``` + +- Tests cover JSON/JSONC parsing, candidate ordering, idempotency, stale entries, invalid config, backups, max size, symlink rejection, and speech safety. +- Claude checks still pass unchanged. + +**Checks:** + +- `pnpm --filter @open-pets/opencode check` +- `pnpm --filter @open-pets/claude check` + +### Phase 19B — OpenCode Plugin Runtime + +**Goal:** Add the OpenCode plugin that reacts to OpenCode activity. No desktop UI yet. + +**Scope:** + +- Implement a valid OpenCode server plugin package/entry. +- Package contract must satisfy OpenCode loader expectations: + - npm package export/main compatible with OpenCode; + - default export exposes the OpenCode plugin shape; + - file/local plugin has a stable `id`; + - local/bundled dynamic import smoke test exists. +- Plugin hooks must return immediately. +- OpenPets calls run fire-and-forget with internal `.catch()` and debug-only logging. +- Use short client timeouts, but do not await them in OpenCode hooks. +- Lease strategy: + - acquire a lease when configured with `pet`; + - pass `leaseId` to `say`/`react`; + - prefer cached lease with safe fallback, or per-event acquisition if simpler and non-blocking. +- Event mapping: + - `chat.message` → `thinking` + throttled speech. + - `tool.execute.before` edit/write/patch tool names → `editing`. + - `tool.execute.before` shell/bash tool with test-like category → `testing`. + - `tool.execute.before` shell/bash tool otherwise → `running`. + - other tools → `working`. + - plugin `event` with `permission.asked` → `waiting` + approval-needed speech. + - stable session completion/error events, if verified during implementation → `success` / `error`. + - avoid raw prompt/command/output text in speech. + +**Acceptance criteria:** + +- Plugin can be imported from built output. +- Plugin hook functions return without awaiting OpenPets IPC. +- Unit tests cover event classification and fire-and-forget failure swallowing. +- Manual OpenCode config can load the plugin and trigger reactions. + +**Checks:** + +- `pnpm --filter @open-pets/opencode check` +- Manual plugin load smoke test. + +### Phase 19C — CLI Project Setup for OpenCode + +**Goal:** Extend `openpets configure` to support project-local OpenCode setup. + +**Scope:** + +- Add `--agent opencode` alongside existing `--agent claude`. +- Keep Claude CLI behavior unchanged. +- With explicit `--pet`, configuration can run without desktop app. +- Without `--pet`, use local IPC pet picker as Claude does. +- Write project config entries for: + - `mcp.openpets`; + - OpenPets instructions file, e.g. `.opencode/openpets.md`; + - OpenPets plugin spec/options. +- Print exact files changed and restart guidance. +- Warn that `.opencode/opencode.jsonc` can be committed and may contain the selected pet id. +- Do not require `opencode` binary on PATH to write project config; warn if not found. + +**Acceptance criteria:** + +- `openpets configure --agent opencode --pet fixer --local-dev --cwd ` writes expected project config offline. +- Re-running is idempotent. +- `--force` replaces only OpenPets-managed entries. +- Unknown agents still fail clearly. +- Claude project setup tests still pass. + +**Checks:** + +- `pnpm --filter @open-pets/cli check` +- `pnpm --filter @open-pets/opencode check` +- `pnpm --filter @open-pets/claude check` + +### Phase 19D — Desktop Integration, Packaging, Docs, Hardening + +**Goal:** Add OpenCode to Desktop Integrations and close out docs/packaging/cross-platform hardening. + +**Scope:** + +- Add OpenCode card next to Claude Code. +- Desktop OpenCode setup target is explicitly **global OpenCode config**, not project config. +- If project setup is desired from desktop, defer to a later phase with a project directory selector. +- Global setup can: + - detect OpenCode best-effort; + - install/replace/remove global MCP entry; + - install/update global OpenPets instructions; + - install/update/remove global OpenPets plugin; + - show previews and copy manual snippets; + - show clear status and backups. +- Preserve Claude UI/actions. +- Package bundled OpenCode plugin/CLI resources safely: + - no true `app.asar` paths; + - no symlinked bundled entry; + - dynamic-import smoke test for bundled plugin path. +- Update README and mapping docs. +- Manual verification covers: + - invalid config; + - custom `OPENCODE_CONFIG_DIR`; + - OpenCode absent; + - removal safety; + - packaged bundled plugin; + - project/global precedence; + - macOS/Windows/Linux path differences where available. + +**Acceptance criteria:** + +- Desktop UI clearly says OpenCode desktop setup is global. +- No global config write occurs without explicit user action. +- Remove only removes OpenPets-managed entries. +- OpenCode absent on PATH does not prevent showing config status/previews. +- Packaged app can locate bundled OpenCode plugin and CLI resources. +- Docs explain exact files touched and CLI project setup vs desktop global setup. +- `pnpm check` passes. + +**Checks:** + +- `pnpm --filter @open-pets/desktop check` +- `pnpm --filter @open-pets/opencode check` +- `pnpm check` + +## Security/privacy requirements for every subphase + +- Never include prompt text, command text, file paths, tool output, code, logs, URLs, or secrets in pet speech. +- Classify tools using tool names and coarse categories only. +- Validate pet ids with the same strict regex as Claude. +- Reject symlinked config/instruction/plugin write targets. +- Keep writes inside the selected project or OpenCode config dir. +- Back up before update/replace/remove once writes are implemented. +- No new TCP/HTTP surface. +- Plugin failures must never break or delay OpenCode; hooks must return immediately. + +## Oracle plan review + +Oracle reviewed the first all-in-one Phase 19 plan and found the architecture viable but too broad and not implementation-ready. + +### Blockers found + +- Desktop target scope was ambiguous: global vs project setup. +- `permission.ask` assumption was wrong; use `event` hook for `permission.asked`. +- Plugin hooks are awaited by OpenCode; OpenPets work must be fire-and-forget, not merely timeout-bounded. +- Plugin package/path contract was underspecified. +- Config precedence and write targets needed tightening. +- Data-loss protections needed hard requirements. +- `.opencode` is not private; project config can be committed. +- Manual verification was too happy-path-only. + +## Oracle feedback disposition + +- **Fixed:** Split one large phase into 4 subphases, not 6. +- **Fixed:** Desktop setup is global-only unless a later project picker is added. +- **Fixed:** Permission mapping uses `event`/`permission.asked`, not `permission.ask`. +- **Fixed:** Plugin hooks must return immediately and run OpenPets calls fire-and-forget. +- **Fixed:** Added plugin package/path contract and bundled import smoke requirement. +- **Fixed:** Aligned existing config candidate order with OpenCode's own `mcp add` order. +- **Fixed:** Added invalid JSONC, max size, symlink, backup, and atomic write requirements. +- **Fixed:** Removed “private-ish” wording for `.opencode` and added commit warning. +- **Fixed:** Combined desktop, packaging, docs, and hardening into one closeout phase to avoid oversplitting. + +## Implementation closeout + +Phase 19A–19D are implemented in the v2 workspace: + +- `packages/opencode` provides OpenCode config helpers, strict managed-entry classification, safe global/project writes, and the OpenCode plugin runtime. +- `packages/cli` supports `openpets configure --agent opencode` for project-local setup. +- Desktop Integrations supports global OpenCode setup/removal/preview/copy, with packaged CLI resource checks and published plugin configuration. +- README and mapping docs explain OpenCode project-local vs desktop-global setup and safe speech constraints. + +Validation completed with `pnpm check` after @oracle implementation review. diff --git a/docs/phases/phase-19a-opencode-foundation.md b/docs/phases/phase-19a-opencode-foundation.md new file mode 100644 index 00000000..8ec5f447 --- /dev/null +++ b/docs/phases/phase-19a-opencode-foundation.md @@ -0,0 +1,283 @@ +# Phase 19A — OpenCode Foundation + +## Goal + +Add the non-UI foundation for OpenCode integration: + +- OpenCode config discovery and safe JSON/JSONC helpers. +- OpenCode MCP/instructions/plugin previews and status classification. +- Shared speech safety utilities reusable by Claude hooks and the future OpenCode plugin. + +This phase should not install a working OpenCode plugin, change Desktop Integrations UI, or extend `openpets configure --agent opencode` yet. It prepares the safe primitives for those later phases. + +## Non-goals + +- No OpenCode runtime plugin implementation. +- No Desktop Integrations OpenCode card. +- No CLI `--agent opencode` user-facing command yet. +- No writes to real user OpenCode config during tests. +- No changes to public MCP tools; they remain exactly `openpets_status`, `openpets_say`, `openpets_react`. +- No fork or modification of `v1/opencode/`. + +## User-visible/manual outcome + +No direct user-visible feature is expected yet. + +Developers should be able to run checks and see that OpenPets can safely build and classify OpenCode config previews in isolated temp fixtures. + +## Acceptance criteria + +- Add an OpenCode integration package/module, likely `packages/opencode`, with build/check scripts. +- Provide pure helpers for: + - best-effort OpenCode executable detection data shape; + - global/project OpenCode config candidate paths; + - JSON/JSONC parsing and update planning; + - OpenPets MCP config preview; + - OpenPets instruction config preview; + - OpenPets plugin config preview; + - installed/missing/stale/error status classification. +- OpenCode project config candidate order for existing files follows OpenCode's own MCP add behavior: + 1. `opencode.json` + 2. `opencode.jsonc` + 3. `.opencode/opencode.json` + 4. `.opencode/opencode.jsonc` +- If no project config exists, helpers should plan creation of `.opencode/opencode.jsonc`. +- Status helpers must scan all relevant project config files, not only the chosen write target, because OpenCode can merge top-level and `.opencode` configs. +- Multiple or conflicting OpenPets entries must be classified explicitly instead of silently choosing one. +- Global config discovery is read/status-only in 19A and must define: + - `OPENCODE_CONFIG_DIR` override support; + - default config directory derived from platform/XDG conventions; + - candidate files `config.json`, `opencode.json`, and `opencode.jsonc`; + - no global writes by default in this phase. +- Published mode is the default for project config previews: + +```json +{ + "mcp": { + "openpets": { + "type": "local", + "command": ["npx", "-y", "@open-pets/cli@0.0.0", "mcp", "--pet", "fixer"], + "enabled": true + } + } +} +``` + +- Local-dev previews may use `node /.../cli/dist/index.js`, but project-local absolute paths must not be the default. +- The CLI package version must be an injected parameter to preview builders. `packages/opencode` must not import `@open-pets/cli`, so the later CLI dependency on `@open-pets/opencode` does not create a cycle. +- No-pet MCP preview is valid and means the default OpenPets MCP target: + +```json +{ + "type": "local", + "command": ["npx", "-y", "@open-pets/cli@0.0.0", "mcp"], + "enabled": true +} +``` + +- Config safety helpers must support: + - max config size cap; + - rejecting symlinked config files; + - rejecting symlinked relevant parent directories where practical; + - no write plan on invalid JSON/JSONC; + - no write plan on invalid OpenCode field types such as `mcp: []`, `instructions: "x"`, or `plugin: {}`; + - backup-before-write path planning; + - temp-file + rename write planning. +- Write planning must require a validated project root and reject project-root symlink/escape cases. +- Backup and temp paths must use unique names, avoid overwrite, use exclusive temp-file creation, and keep temp files in the same directory as the target. +- File mode should be private where meaningful on the current platform. +- Existing unrelated OpenCode config keys are preserved by update helpers. +- Existing non-OpenPets MCP/plugin/instruction entries are preserved. +- Managed ownership rules must be explicit: + - current managed MCP entry = exact expected OpenPets command shape for the selected mode/pet; + - stale managed MCP entry = recognizable OpenPets package/path/command but outdated; + - custom/foreign `mcp.openpets` = present but not managed; classify separately and do not overwrite/remove by default; + - current managed instruction entry = expected OpenPets instruction path plus managed file block; + - stale managed instruction entry = recognizable OpenPets path/block but outdated; + - custom/foreign OpenPets-like instruction/plugin entries = classify separately and do not overwrite/remove by default. +- Existing OpenPets-managed entries are idempotently recognized. +- Stale OpenPets-managed entries can be classified as `needs_update` without overwriting in this phase's public surface. +- Shared speech utilities are extracted or introduced so Claude and OpenCode can share: + - speech categories; + - message picking; + - validation rules. +- Throttling extraction is optional in 19A. If included, it must include exact parity tests for Claude state paths, categories, cooldowns, and OpenCode namespacing. Otherwise, defer throttling to Phase 19B. +- Claude hook behavior remains unchanged after extraction/introduction. +- Speech validation still rejects: + - empty or >140 character messages; + - newlines; + - code-like content; + - URLs; + - path-like content; + - secret-looking assignments. +- Tests must not mutate real Claude settings, real OpenCode config, or real OpenPets user data. + +## Proposed files/directories + +Likely new files: + +- `packages/opencode/package.json` +- `packages/opencode/tsconfig.json` +- `packages/opencode/src/index.ts` +- `packages/opencode/src/opencode-config.ts` +- `packages/opencode/src/opencode-previews.ts` +- `packages/opencode/src/opencode-status.ts` +- `packages/opencode/src/check-opencode-foundation.ts` + +Possible new shared files/package: + +- `packages/agent-events/package.json` +- `packages/agent-events/src/index.ts` +- or a smaller shared module inside an existing package if adding a package is unnecessary. + +If a shared package is added, it must not depend on Claude, OpenCode, CLI, desktop, or MCP packages. + +Likely changed files: + +- `pnpm-workspace.yaml` if a new package is added. +- `packages/claude/src/hooks.ts` +- `packages/claude/src/hook-messages.ts` +- `packages/claude/src/check-claude-hooks.ts` +- root/package check wiring if needed. + +## Technical approach + +### OpenCode config facts from `v1/opencode/` + +- `Config.Info.mcp` exists in `v1/opencode/packages/opencode/src/config/config.ts` lines 220-229. +- Local MCP config shape is in `v1/opencode/packages/opencode/src/config/mcp.ts` lines 5-19: + - `type: "local"` + - `command: string[]` + - optional `environment`, `enabled`, `timeout`. +- OpenCode's interactive `mcp add` resolves project config candidates in `v1/opencode/packages/opencode/src/cli/cmd/mcp.ts` lines 399-415. +- OpenCode's `mcp add` uses `jsonc-parser` update edits in `v1/opencode/packages/opencode/src/cli/cmd/mcp.ts` lines 417-431. +- OpenCode config supports `instructions: string[]` in `v1/opencode/packages/opencode/src/config/config.ts` lines 238-240. +- OpenCode config supports `plugin` array in `v1/opencode/packages/opencode/src/config/config.ts` line 159. + +### Config helpers + +Use OpenCode-specific config shapes, not Claude shapes. + +MCP preview should produce: + +```ts +{ + type: "local", + command: ["npx", "-y", "@open-pets/cli@", "mcp", "--pet", petId], + enabled: true, +} +``` + +Instructions preview should plan: + +- project: `.opencode/openpets.md` plus `instructions: [".opencode/openpets.md"]`; +- global: a safe OpenCode config dir path decided in later desktop phase. + +Plugin preview should be intentionally minimal in 19A because the runtime contract is finalized in Phase 19B. It may build the planned config slot and preserve existing entries, but installed/stale plugin classification should be limited to exact/recognizable OpenPets specs already known. Ambiguous plugin entries must be classified as custom/foreign and left untouched. + +### Safe write model + +This phase can implement actual helper functions for isolated fixture writes, but tests must only write to temp directories. + +Rules: + +- Refuse to parse/write oversized config files. +- Refuse symlinked config files. +- Refuse symlinked relevant parent directories, including top-level config parents and `.opencode`, when creating or updating config. +- Refuse symlinked or escaping project roots. +- On parse errors, return an error status and do not produce a write operation. +- On invalid known field types, return an error status and do not produce a write operation. +- For update helpers, create backups before replacing/removing/updating. +- Use an exclusive temp file in the same directory and rename for final write. +- Generate backup/temp names that do not overwrite existing files. + +### Shared speech utilities + +Keep Claude behavior equivalent. Move pure validation and message selection first; avoid over-refactoring hook runtime. + +The shared module should be neutral, not named Claude-specific, so Phase 19B can import it from the OpenCode plugin without circular dependencies. + +If throttling is moved in this phase, throttle storage must remain namespaced by agent/integration so Claude and OpenCode do not overwrite each other's cooldown state. If this makes 19A too broad, leave throttling in Claude for now and add a clear Phase 19B follow-up. + +## Risks and tradeoffs + +- Adding a new package increases workspace wiring but keeps OpenCode code isolated. +- Direct JSONC editing is necessary because OpenCode's `mcp add` is interactive, but it creates data-loss risk; mitigate with parse guards, backups, and atomic writes. +- Plugin config shape may need adjustment in Phase 19B after runtime smoke tests; keep plugin preview centralized so future correction is one-file. +- Shared speech extraction can accidentally change Claude behavior; keep tests focused on parity. + +## Security/privacy notes + +- Do not read or write real user OpenCode/Claude files in tests. +- Do not expose prompt text, command text, code, logs, file paths, URLs, secrets, or tool output through shared speech utilities. +- Validate pet ids with the same strict rules as Claude. +- Treat config paths as untrusted; reject symlinks and oversized files. +- Preserve unrelated user config. + +## Test/check plan + +- `pnpm --filter @open-pets/opencode check` +- `pnpm --filter @open-pets/claude check` +- `pnpm --filter @open-pets/desktop check` if packaging/check contracts are touched. + +Specific tests/checks: + +- Project candidate selection with each possible existing config file. +- No existing config plans `.opencode/opencode.jsonc`. +- Status scanning sees duplicate/conflicting OpenPets entries across top-level and `.opencode` configs. +- Global discovery covers `OPENCODE_CONFIG_DIR`, platform/XDG default path, `config.json`, `opencode.json`, and `opencode.jsonc`. +- MCP preview with and without selected pet. +- Local-dev preview uses `node` command only when explicitly requested. +- JSONC update preserves unrelated fields and comments where practical. +- Invalid known field types return error and no write plan: `mcp: []`, `instructions: "x"`, `plugin: {}`. +- Existing matching OpenPets entries classify as installed. +- Existing stale OpenPets entries classify as needs update. +- Custom/foreign `mcp.openpets` classifies separately and is not treated as managed. +- Non-OpenPets entries are preserved. +- Invalid JSONC returns error and no write plan. +- Oversized config returns error. +- Symlinked config, symlinked top-level parent, symlinked `.opencode`, or symlinked/escaping project root returns error. +- Backup and temp names are unique and do not overwrite existing files. +- Temp writes use exclusive creation in the same directory as the target. +- Backup/temp write helpers write only inside temp fixtures. +- Shared speech validator parity with Claude hook expectations. +- If throttling is extracted, Claude throttle path/category/cooldown parity is covered. +- Cross-platform path fixtures cover POSIX paths, Windows-style paths, spaces in paths, `XDG_CONFIG_HOME`, and `OPENCODE_CONFIG_DIR`. +- Claude hook checks still pass. + +## Manual verification guide + +After implementation, manually verify: + +1. Run `pnpm --filter @open-pets/opencode check`. +2. Run `pnpm --filter @open-pets/claude check`. +3. If desktop/package files changed, run `pnpm --filter @open-pets/desktop check`. +4. Run any fixture/manual check with temp `HOME`, `XDG_CONFIG_HOME`, and `OPENCODE_CONFIG_DIR`. +5. Inspect generated fixture outputs from tests, if any, and confirm OpenCode config shape matches the expected MCP/instructions/plugin previews. +6. Confirm no real `~/.config/opencode`, `~/.claude`, or OpenPets user state changed. + +## Oracle plan review + +Oracle reviewed the initial Phase 19A spec and found the subphase cut good, but not fully implementation-ready until config ownership, precedence, and write contracts were tightened. + +Blockers raised: + +- Global config scope was contradictory. +- Write target order and full load/status scanning were underspecified. +- Managed ownership rules were missing. +- JSON/JSONC write contract needed invalid type, symlink, atomicity, backup, and mode details. +- No-pet MCP preview behavior was undefined. +- Package dependency/version source could create cycles. +- Shared speech throttling extraction was too broad. +- Plugin preview/status was premature without a finalized plugin runtime contract. + +## Oracle feedback disposition + +- **Fixed:** Defined global config discovery/status as read-only in 19A with `OPENCODE_CONFIG_DIR`, default path, and candidate files. +- **Fixed:** Required status helpers to scan all relevant project config files and classify multiple/conflicting entries. +- **Fixed:** Added explicit managed/current/stale/custom ownership rules for MCP, instructions, and plugin-like entries. +- **Fixed:** Added invalid known field type errors, project-root checks, parent symlink rejection, unique backups/temp files, exclusive same-directory temp writes, and private mode requirement where meaningful. +- **Fixed:** Defined no-pet MCP preview as default OpenPets MCP target without `--pet`. +- **Fixed:** Required package version injection so `packages/opencode` does not import `@open-pets/cli`. +- **Fixed:** Narrowed shared speech extraction to categories/messages/validation; throttling is optional only with parity tests, otherwise deferred. +- **Fixed:** Limited plugin preview/status to minimal exact/recognizable specs and custom/foreign preservation until Phase 19B finalizes runtime contract. diff --git a/docs/phases/phase-19b-opencode-plugin-runtime.md b/docs/phases/phase-19b-opencode-plugin-runtime.md new file mode 100644 index 00000000..89cd5210 --- /dev/null +++ b/docs/phases/phase-19b-opencode-plugin-runtime.md @@ -0,0 +1,254 @@ +# Phase 19B — OpenCode Plugin Runtime + +## Goal + +Add the OpenCode runtime plugin that turns OpenCode activity into OpenPets reactions and short safe speech. + +This phase builds on Phase 19A's foundation package and shared speech validation. It should make the plugin importable/testable and ready for manual OpenCode config, but it should not add Desktop UI or `openpets configure --agent opencode` yet. + +## Non-goals + +- No Desktop Integrations OpenCode card. +- No CLI `configure --agent opencode` setup flow. +- No writes to real user OpenCode config. +- No OpenCode source changes under `v1/opencode/`. +- No new public MCP tools. +- No external SSE watcher or new TCP/HTTP listener. +- No pet install/remove/default controls. + +## User-visible/manual outcome + +Developers can manually add the plugin to an OpenCode config and see OpenPets react to OpenCode activity. + +Expected manual plugin spec shape: + +```jsonc +{ + "plugin": [ + ["@open-pets/opencode", { "pet": "fixer" }] + ] +} +``` + +For local development, a file URL or absolute built plugin path can be used after build, but app/desktop setup is still deferred. + +## Acceptance criteria + +- `@open-pets/opencode` exposes an OpenCode-compatible server plugin entrypoint. +- Package export contract supports OpenCode's loader expectations: + - npm package can resolve a server plugin entrypoint through an explicit package export such as `./server` without replacing the helper `.` export; + - default export is an object with `server()`; + - file/local plugin entry includes a stable `id`; + - built output can be dynamically imported in tests; + - package-level resolution of `@open-pets/opencode/server` is covered by tests. +- Plugin options accept: + - optional `pet` id using the same strict pet id validation as Claude/OpenCode foundation; + - optional debug flag or rely on `OPENPETS_DEBUG=1`. +- Plugin hooks must return immediately. OpenPets IPC work must run fire-and-forget and never be awaited by OpenCode plugin hooks. +- The `event` hook must be a synchronous non-throwing wrapper. OpenCode calls `event` with `void hook.event(...)` and does not await/catch it, so `event` must catch synchronous errors and schedule async work internally. +- OpenPets calls must be best-effort: + - catch all errors internally; + - debug-only sanitized logging; + - short client timeouts; + - never throw back into OpenCode. +- If `pet` is configured, plugin reactions/speech should acquire or reuse an OpenPets lease and pass `leaseId` to `react`/`say`. +- Lease handling must not block OpenCode hooks. It can be a cached in-flight/background lease or per-event fire-and-forget acquisition, but hook functions themselves must remain synchronous or already-resolved async functions. +- Event mapping must cover: + - `chat.message` → `thinking` + throttled speech. + - `tool.execute.before` edit/write/patch tool names → `editing`. + - `tool.execute.before` shell/bash tool names with test-like command/category when safely inferable → `testing`. + - `tool.execute.before` shell/bash tool names otherwise → `running`. + - other non-OpenPets tools → `working`. + - plugin `event` with `permission.asked` → `waiting` + approval-needed speech. + - plugin `event` with `session.status` idle, if stable and useful → `success` sparingly. + - plugin `event` with `session.error` → `error`. +- The plugin must ignore OpenPets MCP tool calls to avoid self-reaction loops, including likely OpenCode MCP tool names such as: + - `openpets_openpets_status` + - `openpets_openpets_say` + - `openpets_openpets_react` +- Speech must use shared `@open-pets/agent-events` validation and message pools. +- Speech must not include prompt text, command text, tool args, tool output, code, logs, file paths, URLs, or secrets. +- Throttling for OpenCode speech must be namespaced separately from Claude, e.g. `opencode-hook-throttle.json` or equivalent. +- Tests cover event classification, speech safety, self-tool suppression, fire-and-forget behavior, import shape, and failure swallowing. + +## Proposed files/directories + +Likely changed/new files: + +- `packages/opencode/package.json` +- `packages/opencode/src/plugin.ts` +- `packages/opencode/src/opencode-plugin-runtime.ts` +- `packages/opencode/src/check-opencode-plugin.ts` +- `packages/opencode/src/index.ts` +- `packages/opencode/src/check-opencode-foundation.ts` + +Possible shared utility additions: + +- `packages/agent-events/src/index.ts` for reusable throttling primitives, if needed. + +## Technical approach + +### OpenCode plugin contract + +From `v1/opencode`: + +- Plugin config specs are strings or `[string, options]`: `v1/opencode/packages/opencode/src/config/plugin.ts` lines 12-17. +- Plugin loader imports the resolved module and reads the default export: `v1/opencode/packages/opencode/src/plugin/loader.ts` lines 118-128. +- A server plugin must default-export an object with a `server()` function: `v1/opencode/packages/opencode/src/plugin/shared.ts` lines 272-303. +- File plugins need an `id`: `v1/opencode/packages/opencode/src/plugin/shared.ts` lines 306-316. +- Hook type surface includes `event`, `chat.message`, `tool.execute.before`, `tool.execute.after`, and `command.execute.before`: `v1/opencode/packages/plugin/src/index.ts` lines 222-333. + +Implementation should export something compatible with: + +```ts +export default { + id: "open-pets-opencode", + server: async (_input, options) => ({ + "chat.message": () => { /* schedule, return immediately */ }, + "tool.execute.before": () => { /* schedule, return immediately */ }, + event: () => { /* sync non-throwing schedule wrapper */ }, + }), +}; +``` + +The exact TypeScript types can be local structural types to avoid adding a heavy dependency on OpenCode internals. If `@opencode-ai/plugin` is added as a dev/type dependency, it must not introduce runtime/package issues. + +### Hook latency rule + +OpenCode awaits plugin hooks in several paths: + +- `Plugin.trigger` awaits hook promises: `v1/opencode/packages/opencode/src/plugin/index.ts` lines 258-269. +- Tool execution calls `tool.execute.before` before running tools: `v1/opencode/packages/opencode/src/session/prompt.ts` lines 428-433. + +Therefore every plugin hook must schedule OpenPets work and then return immediately. Do not `await client.react`, `await client.say`, or `await client.acquireLease` directly inside a hook. + +Use a small scheduler/helper such as: + +```ts +function fireAndForget(work: () => Promise): void { + void work().catch(debugLog); +} +``` + +Hook functions may be `async` for OpenCode compatibility, but they must not await the scheduled work. + +Exception: the `event` hook should not be `async`, because OpenCode does not await/catch it. + +### OpenPets client behavior + +Use `createOpenPetsClient({ connectTimeoutMs: 500, responseTimeoutMs: 500 })` or a similarly short timeout. + +When `pet` is configured: + +- Acquire a lease in the background. +- Cache the lease id while valid if simple. +- Use the lease id for `say`/`react` once available. +- If lease acquisition fails, fall back to default target or no lease without surfacing errors. + +### Event classification + +Keep classification pure and testable. + +Proposed pure functions: + +- `classifyOpenCodeToolReaction(toolName, args): OpenPetsReaction | undefined` +- `classifyOpenCodeBusEvent(event): { reaction?, speechCategory?, forceSpeech? } | undefined` +- `shouldIgnoreOpenPetsTool(toolName): boolean` + +Tool classification should rely on tool names and safe coarse categories. It may inspect a shell command only to decide whether it looks test-like, and must never send the command text to speech. + +Expected tool name patterns: + +- edit/write/patch: names containing `edit`, `write`, `patch`, `apply_patch`. +- shell/bash: names containing `bash`, `shell`, `terminal`, or OpenCode's shell tool id if known. +- OpenPets MCP tools: suppress names ending in or equal to `openpets_status`, `openpets_say`, `openpets_react`, including server-prefixed forms. + +Bus event classification: + +- `permission.asked` from permission bus → `waiting`, `permission` speech. +- `session.error` → `error` speech category. +- `session.status` with idle may map to `success`, but avoid noisy success spam by throttling. + +### Speech/throttling + +Use shared messages and validator from `@open-pets/agent-events`. + +OpenCode speech categories should match Claude categories for now: + +- `thinking` +- `success` +- `error` +- `permission` + +Throttle storage must be separate from Claude. If file-backed throttling is added in this phase, it must use only OpenCode-specific path names and best-effort writes. + +## Risks and tradeoffs + +- OpenCode plugin APIs may evolve; keep plugin-specific runtime isolated in `packages/opencode`. +- Fire-and-forget scheduling avoids blocking OpenCode but means pet reactions may be dropped if Node exits immediately. +- Lease caching adds state; if too complex, prefer simple background per-event lease acquisition for correctness. +- Mapping session idle to success may be noisy. It should be throttled or omitted if unstable during implementation. +- Adding runtime client dependency to `@open-pets/opencode` is expected in this phase; avoid depending on CLI or desktop. + +## Security/privacy notes + +- Never send prompt text, command text, tool args, tool output, code, logs, file paths, URLs, or secrets to pet speech. +- Debug logging must sanitize paths and secrets. +- Plugin must ignore OpenPets MCP tools to avoid loops. +- Plugin must catch all OpenPets client errors. +- No new local server, TCP listener, or HTTP endpoint is added. +- `pet` option must be strictly validated before use. + +## Test/check plan + +- `pnpm --filter @open-pets/opencode check` +- `pnpm --filter @open-pets/agent-events check` +- `pnpm --filter @open-pets/claude check` +- `pnpm check` after implementation review fixes. + +Specific tests: + +- Dynamic import of built plugin entry returns default object with `id` and `server()`. +- Dynamic import of package export `@open-pets/opencode/server` returns default object with `id` and `server()`. +- `server()` returns hooks for `chat.message`, `tool.execute.before`, and `event`. +- Hook calls schedule work and return before a deliberately unresolved fake client promise completes. +- `event` hook is synchronous, non-throwing, and does not produce unhandled rejections when classification/scheduling fails. +- OpenPets client errors are swallowed. +- Configured pet id is validated; invalid pet option fails plugin setup safely. +- `chat.message` maps to `thinking` without using prompt text. +- edit/write/patch tools map to `editing`. +- shell/bash test-like tools map to `testing`; non-test shell maps to `running`. +- OpenPets MCP tools are ignored. +- `permission.asked` maps to `waiting` + permission speech. +- `session.error` maps to `error`. +- Speech validator rejects unsafe generated messages. +- Throttle state is OpenCode-namespaced if implemented. + +## Manual verification guide + +After implementation and review: + +1. Run `pnpm --filter @open-pets/opencode check`. +2. Run `pnpm check`. +3. Start OpenPets desktop locally. +4. Build packages. +5. Manually add the local built OpenCode plugin to a test OpenCode project config. +6. Start OpenCode in that test project. +7. Submit a prompt and confirm the selected/default pet reacts with `thinking`. +8. Run an edit/write action and confirm `editing`. +9. Run a test-like shell action and confirm `testing`. +10. Trigger a permission request if practical and confirm `waiting` / approval speech. +11. Confirm OpenPets MCP tool calls do not trigger recursive reactions. +12. Confirm no prompt text, command text, file path, or tool output appears in pet speech. + +## Oracle plan review + +Oracle reviewed the initial Phase 19B spec and found two plan blockers: + +- Package export contract needed to explicitly support OpenCode's `./server` entry resolution while preserving helper exports. +- The `event` hook must not be `async`/rejecting because OpenCode invokes it with `void hook.event(...)` and does not await/catch it. + +## Oracle feedback disposition + +- **Fixed:** Added explicit `@open-pets/opencode/server` export requirement and package-level import test. +- **Fixed:** Required `event` to be synchronous, non-throwing, and internally catch/schedule async work. diff --git a/docs/phases/phase-19c-opencode-cli-project-setup.md b/docs/phases/phase-19c-opencode-cli-project-setup.md new file mode 100644 index 00000000..aa63db48 --- /dev/null +++ b/docs/phases/phase-19c-opencode-cli-project-setup.md @@ -0,0 +1,274 @@ +# Phase 19C — OpenCode CLI Project Setup + +## Goal + +Extend the OpenPets CLI so users can configure a project for OpenCode with one command: + +```bash +npx @open-pets/cli configure --agent opencode --pet fixer +``` + +This writes project-local OpenCode config for OpenPets MCP, OpenPets instructions, and the OpenPets OpenCode plugin. + +## Non-goals + +- No Desktop Integrations OpenCode UI. +- No global OpenCode setup from desktop. +- No project directory picker UI. +- No OpenCode runtime plugin changes beyond using Phase 19B's plugin package/export. +- No new public MCP tools. +- No OpenCode source changes under `v1/opencode/`. +- No real user config writes in tests. + +## User-visible/manual outcome + +From a project directory, users can run: + +```bash +npx @open-pets/cli configure --agent opencode --pet fixer +``` + +Expected result: + +- OpenCode project config contains `mcp.openpets`. +- OpenCode project config includes OpenPets instruction file `.opencode/openpets.md`. +- OpenCode project config includes OpenPets plugin spec/options targeting `fixer`. +- Starting OpenCode from that project gives OpenPets MCP tools and plugin-driven reactions for `fixer`. + +If `--pet` is omitted, the CLI uses the same installed-pet picker as Claude setup and therefore requires OpenPets desktop to be running. + +## Acceptance criteria + +- `parseConfigureArgs` accepts `--agent opencode` and still accepts/keeps `--agent claude` behavior unchanged. +- Unsupported agents still fail clearly. +- `openpets configure --agent opencode --pet --cwd ` runs without requiring OpenPets desktop. +- `openpets configure --agent opencode` without `--pet` uses local IPC pet listing/picker like Claude. +- OpenCode setup does not require `opencode` binary on `PATH`; if detection is added, it is warning-only. +- Project path validation rejects symlinked project roots. +- OpenCode config writes use Phase 19A helpers: + - project config candidate selection; + - all existing project config candidate scanning before writing; + - JSON/JSONC parsing/updating; + - backup/temp/atomic write safety; + - symlink and escape rejection. +- If no project OpenCode config exists, create `.opencode/opencode.jsonc`. +- Existing unrelated OpenCode config keys are preserved. +- Existing non-OpenPets MCP/plugin/instruction entries are preserved. +- Existing matching OpenPets entries are idempotent. +- Existing stale managed OpenPets entries are updated. +- Custom/foreign `mcp.openpets` or OpenPets-like plugin/instruction entries must not be overwritten. In this phase, fail clearly and tell the user to edit/remove the custom entry manually. +- `--force` may replace stale managed OpenPets entries, but must not overwrite custom/foreign entries. +- Written project config uses published mode by default: + +```jsonc +{ + "mcp": { + "openpets": { + "type": "local", + "command": ["npx", "-y", "@open-pets/cli@0.0.0", "mcp", "--pet", "fixer"], + "enabled": true + } + }, + "instructions": [".opencode/openpets.md"], + "plugin": [["@open-pets/opencode@0.0.0", { "pet": "fixer" }]] +} +``` + +- With `--local-dev`, generated MCP config may use `node mcp --pet fixer`, but the plugin spec should remain package-based unless a safe local plugin file path policy is implemented in a later phase. +- The package-based plugin spec must be version-pinned to the same package version used for generated MCP commands, e.g. `@open-pets/opencode@`. +- The managed instruction file `.opencode/openpets.md` is written with OpenPets managed markers. +- Instruction file writes must be safe: + - reject symlinked `.opencode/openpets.md`; + - reject oversized instruction files; + - preserve user content outside managed markers; + - upsert the managed OpenPets block if the expected path exists without a managed block; + - backup before destructive update; + - temp-file + rename atomic write; + - no instruction write if config planning fails. +- CLI output prints: + - configured agent (`OpenCode`); + - target project path; + - selected pet id/name; + - config file path changed; + - instruction file path changed; + - a warning that `.opencode/opencode.jsonc` and `.opencode/openpets.md` can be committed and contain the selected pet id; + - restart guidance for OpenCode. +- Tests cover offline explicit-pet setup, idempotency, preserving unrelated config, custom/foreign conflict refusal, symlink rejection, and Claude regression. + +## Proposed files/directories + +Likely changed files: + +- `packages/cli/package.json` +- `packages/cli/src/index.ts` +- `packages/cli/src/check-cli-contract.ts` +- `packages/opencode/src/opencode-config.ts` +- `packages/opencode/src/opencode-previews.ts` +- `packages/opencode/src/opencode-status.ts` +- `packages/opencode/src/check-opencode-foundation.ts` + +Possible new file: + +- `packages/opencode/src/opencode-project-setup.ts` + +## Technical approach + +### CLI flow + +Keep existing Claude flow intact and branch in `configureProject` by `options.agent`: + +```ts +if (options.agent === "claude") return configureClaudeProject(options) +if (options.agent === "opencode") return configureOpenCodeProject(options) +``` + +Update `ConfigureOptions.agent` to `"claude" | "opencode"`. + +### OpenCode project setup helper + +Prefer putting most OpenCode-specific write logic in `packages/opencode`, not in the CLI, so desktop Phase 19D can reuse the same primitives. + +Potential API: + +```ts +prepareOpenCodeProjectSetup({ + projectDir, + petId, + cliVersion, + commandMode, + cliEntryPath, +}): PreparedOpenCodeProjectSetup + +writePreparedOpenCodeProjectSetup(prepared): OpenCodeProjectSetupResult +``` + +The helper should: + +1. Validate project root. +2. Select config write target. +3. Read and classify **all existing project config candidates** because OpenCode can merge top-level and `.opencode` configs. +4. Fail on custom/foreign/conflicting OpenPets entries anywhere in those candidates. +5. Read the selected write target or `{}`. +6. Classify selected-target OpenPets entries for idempotent updates. +7. Add/update: + - `mcp.openpets`; + - `instructions` containing `.opencode/openpets.md` once; + - `plugin` containing version-pinned `@open-pets/opencode@` once with `{ pet }`. +8. Plan `.opencode/openpets.md` managed block upsert. +9. Plan safe config writes via Phase 19A helpers. +10. Execute writes only after all config and instruction write plans have succeeded. + +The setup must be two-phase: validate/classify/plan all writes first, then execute. If any plan fails, write nothing. + +### Instruction file content + +Use the same guidance as Claude memory, adapted for OpenCode: + +- OpenPets MCP tools may be available. +- Use `openpets_say` for meaningful short status/personality messages. +- Keep messages brief, user-facing, and non-sensitive. +- Do not include code, logs, secrets, URLs, or file paths. +- Use `openpets_react` for visual feedback. +- Use `openpets_status` only when checking availability or target pet. +- Do not spam every internal step. + +### Conflict policy + +Status helpers from Phase 19A distinguish `installed`, `needs_update`, `custom`, and `conflict`. + +For Phase 19C: + +- `installed`: leave as-is unless generated content differs only in managed block, then refresh instruction block. +- `needs_update`: update managed entries. +- `not_installed`: install entries. +- `custom` / `conflict`: fail clearly and do not write config. + +This avoids overwriting user-owned `openpets` entries. + +If the expected instruction path is present but lacks the managed block, treat it as an instruction `needs_update`: upsert the managed block while preserving existing file content outside managed markers. + +### Offline behavior + +Reuse existing `resolveConfiguredPet` behavior: + +- explicit `--pet` validates syntax only and does not require desktop; +- omitted `--pet` queries installed pets through local IPC. + +## Risks and tradeoffs + +- Project `.opencode/opencode.jsonc` and `.opencode/openpets.md` can be committed. CLI must warn clearly. +- Direct JSONC editing risks data loss. Use existing parse guards, backups, temp writes, and no-write-on-error policy. +- Package-based plugin spec assumes `@open-pets/opencode` is published alongside CLI. This is correct for published mode; local plugin path setup is deferred. +- `--local-dev` only affects MCP command in this phase. Plugin local-dev path is deferred to avoid unsafe path/config churn. + +## Security/privacy notes + +- Do not write outside the project root. +- Reject symlinked project roots and unsafe config paths. +- Preserve unrelated OpenCode config. +- Do not overwrite custom/foreign OpenPets-like entries. +- Do not perform partial writes; if any plan fails, no config or instruction file should be written. +- Do not expose prompts, code, logs, URLs, paths, or secrets in instruction text beyond generic warnings. +- Tests must use temp directories only. + +## Test/check plan + +- `pnpm --filter @open-pets/opencode check` +- `pnpm --filter @open-pets/cli check` +- `pnpm --filter @open-pets/claude check` +- `pnpm check` after implementation review fixes. + +Specific tests: + +- `parseConfigureArgs(["--agent", "opencode", "--pet", "fixer"])` works. +- Unsupported agent still throws. +- Offline explicit-pet OpenCode setup writes config without calling local IPC. +- Missing `--pet` still calls pet picker/listing. +- New project creates `.opencode/opencode.jsonc` and `.opencode/openpets.md`. +- Existing `opencode.json` is preferred over `.opencode/opencode.jsonc` as write target. +- Existing unrelated config keys/MCP/plugin/instructions are preserved. +- Conflicts across multiple project config candidate files are detected before writing. +- Re-running setup is idempotent. +- Stale managed OpenPets entries are updated. +- Custom `mcp.openpets` refuses without writing. +- Custom OpenPets-like plugin/instruction refuses without writing. +- Existing `.opencode/openpets.md` without managed block gets managed block added while preserving user text. +- Instruction symlink/oversized file is rejected without config writes. +- Symlink project/config paths are rejected. +- Claude CLI tests still pass. + +## Manual verification guide + +After implementation and review: + +1. Run `pnpm --filter @open-pets/cli check`. +2. Run `pnpm check`. +3. In a temporary project, run: + +```bash +node /path/to/packages/cli/dist/index.js configure --agent opencode --pet fixer --cwd /tmp/openpets-opencode-test --local-dev +``` + +4. Confirm `.opencode/opencode.jsonc` contains `mcp.openpets`, `.opencode/openpets.md` instruction path, and `@open-pets/opencode` plugin spec. +5. Confirm `.opencode/openpets.md` contains OpenPets managed markers. +6. Re-run the command and confirm config remains idempotent. +7. Add a custom `mcp.openpets` entry and confirm setup refuses without overwriting. +8. Confirm real user OpenCode config was not touched. + +## Oracle plan review + +Oracle reviewed the initial Phase 19C spec and found blockers: + +- Must scan all project config candidates before writing, not only selected write target. +- Instruction file write safety was under-specified. +- Partial-write/data-loss sequencing was undefined. +- Published plugin spec should be version-pinned. +- Existing expected instruction path without managed block needed a preserve-and-upsert policy. + +## Oracle feedback disposition + +- **Fixed:** Required scanning/classifying all existing project config candidates before selecting a write target. +- **Fixed:** Added safe instruction file write requirements: symlink/size rejection, preserve user content, backup, temp+rename, no write if config planning fails. +- **Fixed:** Required two-phase plan-all-then-execute sequencing to avoid partial writes. +- **Fixed:** Required version-pinned `@open-pets/opencode@` plugin spec. +- **Fixed:** Clarified expected instruction path without managed block is `needs_update` and should upsert the managed block while preserving existing content. diff --git a/docs/phases/phase-19d-opencode-desktop-hardening.md b/docs/phases/phase-19d-opencode-desktop-hardening.md new file mode 100644 index 00000000..b37b13b7 --- /dev/null +++ b/docs/phases/phase-19d-opencode-desktop-hardening.md @@ -0,0 +1,305 @@ +# Phase 19D — OpenCode Desktop Integration, Packaging, Docs, Hardening + +## Goal + +Add OpenCode to the Desktop Integrations window for global OpenCode setup, package the OpenCode runtime safely, and finish OpenCode docs/hardening. + +This is the final OpenCode integration phase after: + +- 19A: config foundation; +- 19B: plugin runtime; +- 19C: CLI project setup. + +## Non-goals + +- No desktop project directory picker. +- No desktop project-local OpenCode setup. Desktop setup is explicitly global. +- No OpenCode source changes under `v1/opencode/`. +- No new public MCP tools. +- No new network listener or external SSE watcher. +- No pet install/remove/default controls via OpenCode. + +## User-visible/manual outcome + +The Integrations window shows an OpenCode card next to Claude Code. + +Users can open OpenCode details and: + +- See best-effort OpenCode detection/status. +- Select pet routing. +- Install/update/remove global OpenPets OpenCode setup. +- Inspect/copy global MCP/instructions/plugin config preview. +- See exact global files that would be touched. + +Desktop copy must clearly state that OpenCode desktop setup is **global**. Users who want per-project setup should use: + +```bash +openpets configure --agent opencode --pet +``` + +## Acceptance criteria + +- Integrations hub includes an enabled OpenCode card with status and Configure/Install actions. +- Claude integration UI/actions continue to work unchanged. +- OpenCode detail view clearly says setup is global, not project-local. +- OpenCode global setup uses Phase 19A/19C primitives where possible: + - global config path discovery; + - safe JSON/JSONC update planning; + - managed instruction block upsert; + - custom/foreign conflict refusal; + - backup/temp/atomic writes. +- Global setup must have its own explicit helper contract because Phase 19C helpers are project-root scoped: + - validate global config dir/candidate parents before reading; + - scan all global candidates before writing; + - refuse custom/foreign entries anywhere; + - preserve the effective owner file for global `plugin`/`instructions` arrays because higher-precedence arrays can shadow lower arrays; + - if managed OpenPets entries live in a stale higher-precedence overlay while user arrays live lower, migrate managed entries to the user array owner and clean the stale managed overlay; + - refuse if user `plugin` and `instructions` arrays live in different global config files, or if a higher-precedence empty array shadows lower user entries; + - otherwise choose the single managed owner file if one exists; + - fail if managed entries span multiple global files; + - otherwise write to the highest-precedence existing global config file; + - only create `opencode.jsonc` when no global config exists. +- No global OpenCode config write happens without explicit user action. +- OpenCode absent on `PATH` does not block preview/status; it only affects the detection message. +- Desktop global setup writes/updates: + - global config `mcp.openpets`; + - global OpenPets instructions file; + - global `plugin` spec for version-pinned `@open-pets/opencode@`. +- Global setup uses bundled command mode when packaged and local mode in dev where appropriate. +- Published plugin specs use the `@open-pets/opencode` package version, or the shared release version if the workspace moves to one synchronized release version. Do not derive the plugin package version from an unrelated Electron app-only version. +- Dev/local absolute-path command mode is developer-only and must only be used behind the existing local-dev toggle. Real/default global setup uses published packages in dev and bundled CLI command mode in packaged desktop. +- Bundled MCP command is explicit: `node /node_modules/@open-pets/cli/dist/index.js mcp --pet `. +- Desktop packaged dependencies must include `@open-pets/cli` for bundled global MCP setup. +- Because OpenCode will spawn `node`, bundled setup must warn/block clearly if `node` is unavailable on PATH, matching the Claude packaged command policy. +- Plugin config remains published/version-pinned `@open-pets/opencode@` in this phase. The packaged desktop may contain/import `@open-pets/opencode` for previews/smoke checks, but global OpenCode itself will resolve the published plugin package unless a future bundled plugin path policy is added. +- UI/docs must explain that OpenCode may need npm/network access to resolve the published OpenCode plugin package unless it is already cached/installed. +- Bundled command paths must point outside true `app.asar` into `app.asar.unpacked` resources. +- Packaged resources include `@open-pets/opencode`, `@open-pets/agent-events`, `@open-pets/client`, CLI/MCP dependencies, and plugin server export. +- Packaged smoke checks verify: + - `@open-pets/opencode/dist/plugin.js` exists; + - `@open-pets/opencode/package.json` exists; + - `@open-pets/agent-events/dist/index.js` exists; + - dynamic import of the packaged OpenCode plugin server works. +- Remove action removes only OpenPets-managed global entries/blocks and preserves unrelated OpenCode config. +- Removal writes config first, then cleans the now-unused instruction block/file. If instruction cleanup fails after config removal, stale unused OpenPets text is safer than leaving config pointing at missing instructions. +- Managed-entry signatures are: + - MCP: `mcp.openpets` with `type: "local"`, `enabled: true`, and command matching the OpenPets CLI published/local/bundled command shapes for `mcp` plus optional `--pet `; + - instructions: exactly `/openpets.md` for desktop global setup and a file containing the OpenPets managed markers; + - plugin: `@open-pets/opencode` or `@open-pets/opencode@`, optionally as `[spec, { pet }]`. + - Any OpenPets-like entry that does not match these signatures is custom/foreign and must be refused rather than overwritten or removed. +- Config, instruction, temp, and backup files use private `0600` modes where supported; created config/instruction directories use private `0700` modes where supported. +- Docs explain: + - CLI project setup vs desktop global setup; + - files touched; + - how to remove/reconfigure; + - privacy/speech constraints. +- `pnpm check` passes. + +## Release checklist + +- Confirm the version-pinned `@open-pets/opencode@` written by desktop global setup has been published to npm before shipping a packaged desktop release that advertises OpenCode setup. + +## Proposed files/directories + +Likely changed files: + +- `apps/desktop/package.json` +- `apps/desktop/src/agent-setup.ts` +- `apps/desktop/src/windows.ts` +- `apps/desktop/preload.cjs` +- `apps/desktop/src/check-packaging-contract.ts` +- `packages/opencode/src/opencode-project-setup.ts` +- `packages/opencode/src/opencode-config.ts` +- `packages/opencode/src/check-opencode-foundation.ts` +- `README.md` +- `docs/mapping.md` + +Possible new files: + +- `apps/desktop/src/opencode-global-setup.ts` + +## Technical approach + +### Desktop scope + +Desktop setup is global only. + +Use OpenCode global config discovery from Phase 19A: + +- `OPENCODE_CONFIG_DIR` if present; +- platform/XDG default config dir; +- candidate files `config.json`, `opencode.json`, `opencode.jsonc`. + +If existing global `plugin` or `instructions` arrays are present, write OpenPets into the effective owner file for those arrays so OpenPets does not create a higher-precedence array that shadows user entries. If those user arrays are split across files, or if a higher-precedence empty array already shadows lower user entries, refuse with a manual consolidation message. If no array owner exists, update an existing managed owner, otherwise write the highest-precedence existing global config file (`opencode.jsonc`, then `opencode.json`, then `config.json`). If no global config exists, create global `opencode.jsonc` in the OpenCode config dir. + +### Global setup data model + +Add OpenCode status/action data alongside existing Claude snapshot, without breaking Claude fields. + +Possible approach: + +- keep `getAgentSetupSnapshot()` returning Claude fields for compatibility; +- add `opencodeStatus`, `opencodePreview`, `opencodeInstructionStatus`, `opencodePluginStatus` fields; +- add OpenCode-specific actions such as: + - `opencode-install`; + - `opencode-remove`; + - `opencode-refresh` handled by snapshot reload; + - `opencode-copy-config` in preload only. + +Avoid overloading Claude `configure` / `remove` semantics if it makes state ambiguous. + +### Global config writes + +Use a two-phase plan-all-then-execute model: + +1. Validate global config dir/path safety. +2. Read/classify global candidates. +3. Refuse custom/foreign OpenPets-like entries. +4. Plan config update. +5. Plan instruction file upsert. +6. Execute instruction write before config write. + +Instruction upsert must preserve content outside managed markers. + +### Removal + +Removal should: + +- remove `mcp.openpets` only if managed; +- remove OpenPets instruction path from `instructions` only if managed; +- remove OpenPets plugin spec only if managed; +- remove only OpenPets managed block from instruction file; +- preserve unrelated config and user text. + +If entries are custom/foreign, show a warning and do not remove. + +Removal must also use two-phase destructive write safety: + +1. Validate/read all global candidates safely. +2. Classify exact managed entries. +3. Refuse custom/foreign entries. +4. Fail if managed entries span multiple global files. +5. Plan all config/instruction writes before executing. +6. Backup + atomic-write config and instruction changes. +7. Preserve user text outside OpenPets managed instruction markers. + +### UI + +Add enabled OpenCode card in the integrations grid. + +OpenCode detail can be simpler than Claude's initial UI but must include: + +- status card; +- pet routing select; +- global scope warning; +- install/update button; +- remove button; +- refresh button; +- config preview JSON; +- instruction file path/details; +- action result. + +### Packaging + +Desktop `package.json` should include workspace dependencies needed at runtime: + +- `@open-pets/cli`; +- `@open-pets/opencode`; +- `@open-pets/agent-events` if not pulled transitively in packaged output. + +Packaging checks should assert these are built and present. + +### Docs + +Update docs with OpenCode support: + +- `openpets configure --agent opencode --pet fixer` for project setup. +- Desktop OpenCode setup is global. +- OpenPets speech safety and MCP tool usage remain unchanged. + +## Risks and tradeoffs + +- Desktop global config can affect all OpenCode projects. UI must make this explicit. +- Global config path conventions may vary; use Phase 19A helpers and avoid guessing beyond tested defaults. +- Removal must not delete user-owned OpenCode settings. +- Packaging plugin imports can fail if `exports`/asar paths are wrong; add smoke checks. +- Adding OpenCode fields to the existing Claude-focused snapshot can make preload code complex; keep integration-specific DOM code separated where practical. + +## Security/privacy notes + +- Never write global config without explicit user action. +- Preserve unrelated global OpenCode config. +- Do not overwrite custom/foreign OpenPets-like entries. +- Do not create network listeners. +- Keep speech guidance: no code, logs, secrets, URLs, or file paths in pet speech. +- Packaged paths must not point into true `app.asar` for executable/plugin resources. + +## Test/check plan + +- `pnpm --filter @open-pets/opencode check` +- `pnpm --filter @open-pets/desktop check` +- `pnpm check` + +Specific checks: + +- Agent Setup HTML contains enabled OpenCode card/details and global warning. +- Preload binds OpenCode install/remove/refresh without breaking Claude bindings. +- Desktop backend reports OpenCode status when binary is absent. +- Global setup uses temp fixtures in tests/checks only. +- Temp `OPENCODE_CONFIG_DIR` global install/update/remove. +- All global candidates are scanned before writing. +- Managed owner in lower-priority candidate is updated, not duplicated. +- Custom/foreign conflict refusal. +- Symlinked global config dir/candidate/instruction file rejection. +- Remove preserves unrelated config and user instruction text. +- Managed entries spanning multiple global candidate files fail safely. +- Invalid JSON/JSONC or oversized global candidates block setup/removal writes. +- Stale managed MCP/plugin/pet/version entries update cleanly. +- Packaged generated MCP command contains `app.asar.unpacked`, never true `app.asar`. +- Packaged Node-on-PATH block/warning path exists in desktop install handling and remains a manual packaged-app verification item. +- Global removal creates backups and uses temp/atomic writes. +- Copy preview action copies the OpenCode config preview without requiring writes. +- Packaging contract checks built `@open-pets/opencode` and `@open-pets/agent-events` resources. +- Packaged smoke imports OpenCode server plugin export. +- Docs mention CLI project setup vs desktop global setup. + +## Manual verification guide + +After implementation and review: + +1. Run `pnpm check`. +2. Run `pnpm dev:desktop`. +3. Open Integrations. +4. Confirm Claude still works as before. +5. Confirm OpenCode card is enabled. +6. Open OpenCode detail. +7. Confirm it says desktop setup is global. +8. Install OpenCode global integration in a temp `OPENCODE_CONFIG_DIR` environment if practical. +9. Confirm global config has OpenPets MCP/instructions/plugin entries. +10. Confirm remove preserves unrelated config and removes only managed OpenPets entries. +11. Package dir build if practical and confirm packaged smoke checks pass. + +## Oracle plan review + +Oracle reviewed the initial Phase 19D spec and found blockers: + +- Global setup safety contract was under-specified. +- Removal needed a two-phase destructive-write contract. +- Bundled MCP command/package path was ambiguous. +- Packaged plugin behavior was unclear: published plugin spec vs bundled desktop copy. +- Tests/checks needed explicit global setup/removal fixture coverage. + +## Oracle feedback disposition + +- **Fixed:** Added explicit global helper contract for safe all-candidate scanning, owner selection, custom refusal, and `opencode.jsonc` creation. +- **Fixed:** Added two-phase removal safety contract with backups/atomic writes and user text preservation. +- **Fixed:** Defined bundled MCP command via packaged `@open-pets/cli` and Node-on-PATH requirement. +- **Fixed:** Clarified plugin config remains published/version-pinned while packaged plugin resources are for desktop imports/smoke checks in this phase. +- **Fixed:** Added explicit global setup/removal fixture coverage requirements. + +## Implementation closeout + +- Desktop OpenCode global setup, removal, preview, and copy actions are implemented in the Integrations window. +- Automated checks now cover OpenCode global install/remove status transitions, JSONC preview planning, bundled `app.asar.unpacked` command shape, strict managed-entry classification, packaged resource presence, and plugin smoke import. +- `pnpm check` passed after implementation review and strict managed-entry hardening. +- Remaining release-time manual item: confirm the version-pinned `@open-pets/opencode@` is published before shipping a desktop build that advertises OpenCode global setup. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 00000000..127a4b97 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,291 @@ +# OpenPets Desktop Release Guide + +This guide is for an AI agent creating a new OpenPets desktop release from a local macOS machine. The release flow builds Electron artifacts locally, creates a draft GitHub Release, and uploads the assets. + +## Repository and app + +- GitHub repo: `alvinunreal/openpets` +- Desktop app: `apps/desktop` +- Release script: `apps/desktop/scripts/release-local.mjs` +- Root command: `pnpm release:desktop` +- Update checker expects GitHub release tags like `v2.0.0`. + +## What the release script does + +`pnpm release:desktop -- --yes` performs these checks/actions: + +1. Requires macOS. +2. Requires `pnpm` and `gh`. +3. Requires GitHub CLI auth for `github.com`. +4. Requires `origin` to point to `alvinunreal/openpets`. +5. Requires a clean git working tree. +6. Requires the current branch to have an upstream. +7. Requires local `HEAD` to match the upstream branch. +8. Requires desktop version to be stable semver and not `0.0.0`. +9. Requires tag/release `v` to not already exist. +10. Runs build/checks. +11. Builds release artifacts. +12. Generates `SHA256SUMS`. +13. Creates a draft GitHub Release. +14. Uploads top-level whitelisted artifacts only. + +Draft releases are not visible to the app update checker until published. + +## Default release assets + +Default command: + +```bash +pnpm release:desktop -- --yes +``` + +Default build matrix: + +- macOS DMG: x64 + arm64 +- Windows NSIS installer: x64 +- Linux AppImage: x64 + +Expected main artifacts look like: + +```txt +OpenPets--mac-x64.dmg +OpenPets--mac-arm64.dmg +OpenPets--win-x64-setup.exe +OpenPets--linux-x86_64.AppImage +SHA256SUMS +``` + +Optional flags: + +```bash +pnpm release:desktop -- --yes --include-mac-zip +pnpm release:desktop -- --yes --include-win-portable +pnpm release:desktop -- --yes --include-linux-deb +pnpm release:desktop -- --yes --include-linux-targz +pnpm release:desktop -- --yes --include-optional +pnpm release:desktop -- --yes --include-experimental-arm +``` + +`--include-optional` includes mac zip, Windows portable, Linux deb, and Linux tar.gz x64 targets. + +`--include-experimental-arm` adds Windows ARM64 and Linux ARM64 artifacts. Only use this if those artifacts can be tested. + +## Full release procedure + +### 1. Choose the next version + +Use stable semver only: + +```txt +2.0.0 +2.0.1 +2.1.0 +3.0.0 +``` + +Do not use `0.0.0` or prerelease tags unless the release script is intentionally changed. + +### 2. Bump package versions + +Update the workspace package versions together so bundled packages report the same release version. + +Files to update: + +```txt +package.json +apps/desktop/package.json +packages/agent-events/package.json +packages/claude/package.json +packages/cli/package.json +packages/client/package.json +packages/mcp/package.json +packages/opencode/package.json +packages/pet-format/package.json +``` + +Set each top-level `version` field to the chosen version, for example: + +```json +"version": "2.0.1" +``` + +### 3. Install/update lockfile if needed + +Run: + +```bash +pnpm install +``` + +If `pnpm-lock.yaml` changes, include it in the version bump commit. + +### 4. Run checks before committing + +Run: + +```bash +pnpm build +pnpm --filter @open-pets/desktop check +``` + +Fix any failures before continuing. + +### 5. Commit and push the version bump + +Check status: + +```bash +git status --short +``` + +Commit the version bump and any intentional release changes: + +```bash +git add package.json apps/desktop/package.json packages/*/package.json pnpm-lock.yaml +git commit -m "release desktop v" +git push +``` + +Only add files that are intentionally part of the release. Do not accidentally include unrelated worktree changes. + +### 6. Confirm GitHub CLI auth + +Run: + +```bash +gh auth status --hostname github.com +``` + +If not authenticated: + +```bash +gh auth login +``` + +### 7. Run a dry run first + +Run: + +```bash +pnpm release:desktop -- --dry-run +``` + +This should pass preflight, build artifacts, generate checksums, and stop before creating the GitHub Release. + +If it fails because the tree is dirty, inspect: + +```bash +git status --short +``` + +The release script requires a clean tree before release creation. + +### 8. Create the draft GitHub Release and upload assets + +For the recommended default release: + +```bash +pnpm release:desktop -- --yes +``` + +For a fuller x64 release with optional artifacts: + +```bash +pnpm release:desktop -- --yes --include-optional +``` + +The script creates a draft release named/tagged: + +```txt +v +``` + +Example: + +```txt +v2.0.1 +``` + +### 9. Smoke test before publishing + +Before publishing the draft release, manually test at least: + +- macOS DMG on the current Mac. +- Windows installer on a Windows machine or VM. +- Linux AppImage on a Linux machine or VM. + +Unsigned release warnings are expected until code signing/notarization is configured: + +- macOS may show Gatekeeper warnings. +- Windows may show SmartScreen warnings. + +### 10. Publish the draft release + +After smoke testing, publish the draft release on GitHub. + +The app update checker will only see the release after it is published. + +## Common failure modes + +### Version is `0.0.0` + +Fix `apps/desktop/package.json` and the other workspace package versions. + +### Dirty working tree + +The release script refuses to create releases from a dirty checkout. Commit, stash, or revert changes first. + +### HEAD is not pushed + +Push the current branch before releasing: + +```bash +git push +``` + +### Tag or release already exists + +Use a new version, or manually inspect GitHub releases/tags before proceeding. + +### Partial GitHub upload failure + +If the script creates the draft release but upload fails: + +1. Inspect the draft release on GitHub. +2. Upload missing artifacts manually with: + +```bash +gh release upload v --repo alvinunreal/openpets +``` + +3. Or delete the draft release/tag and rerun after fixing the issue. + +## Manual packaging smoke commands + +These do not create a GitHub Release: + +```bash +pnpm --filter @open-pets/desktop build +node apps/desktop/scripts/clean-package-output.cjs +pnpm --dir apps/desktop exec electron-builder --mac dmg --x64 --publish never +pnpm --dir apps/desktop exec electron-builder --mac dmg --arm64 --publish never +pnpm --dir apps/desktop exec electron-builder --win nsis --x64 --publish never +pnpm --dir apps/desktop exec electron-builder --linux AppImage --x64 --publish never +``` + +Artifacts are written to: + +```txt +apps/desktop/dist-electron/ +``` + +## Important notes for future agents + +- Do not publish from an uncommitted local state. +- Do not use `--skip-checks` with `--yes`; the script rejects this. +- Do not upload the entire `dist-electron` directory manually. Upload only final top-level artifacts and `SHA256SUMS`. +- Keep the tag format as `v`. +- Keep `publish: null` in `electron-builder.yml`; GitHub release upload is handled by the local script. +- Windows icon is `apps/desktop/assets/app-icon.ico`. +- macOS icon is `apps/desktop/assets/app-icon.icns`. +- The Windows/macOS artifacts are currently unsigned unless signing config is added later. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 00000000..1c3cbc89 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,57 @@ +# OpenPets testing + +OpenPets currently uses lightweight Node contract checks instead of a full test framework. + +## Commands + +```bash +pnpm test +pnpm check +``` + +- `pnpm test` builds the workspace and runs deterministic regression tests. +- `pnpm check` is the full pre-commit/phase gate: typecheck, build, then package tests/checks. + +Package-level `pnpm test` commands generally run built `dist` artifacts and assume the package was built first. Use root `pnpm test` or package `pnpm check` when you need a fresh build included automatically. + +Package-level `check` scripts should include package tests after typecheck/build so regression coverage cannot drift away from validation. + +## Package expectations + +Packages with runtime contract coverage should expose `test` scripts: + +- `apps/desktop` +- `packages/client` +- `packages/mcp` +- `packages/claude` + +Packages without meaningful runtime checks yet may omit `test` until they gain behavior beyond type/build validation: + +- `packages/cli` +- `packages/pet-format` + +## Isolation rules + +Tests must not require: + +- a running Electron app +- a real Claude installation +- network access +- writes to real `~/.claude/settings.json` +- writes to real OpenPets user data + +Use temp directories/files for settings and fixtures. Clean them up after the test. Do not depend on the process current working directory except for explicit repo-relative fixture paths. + +## What belongs in tests + +Good fits for the current harness: + +- IPC protocol contracts +- MCP tool contracts +- lease manager behavior +- Claude MCP command previews +- Claude hook event mapping and speech safety +- Claude settings merge/install/uninstall against temp files +- zip safety and catalog validation + +Electron tray/window behavior remains manually verified until a later UI automation phase. diff --git a/docs/workflow.md b/docs/workflow.md new file mode 100644 index 00000000..f374b7fb --- /dev/null +++ b/docs/workflow.md @@ -0,0 +1,214 @@ +# OpenPets 2.0 workflow + +This document defines the day-to-day working workflow for OpenPets 2.0. + +The goal is to build in clear phases with excellent DX, strong review, and manual confirmation after every meaningful checkpoint. + +## Source of truth documents + +- `task.txt` — product decisions, agreed scope, architecture direction, and discussion history. +- `docs/implementation-process.md` — high-level implementation phases and process rules. +- `docs/workflow.md` — practical working workflow for each phase. +- `docs/phases/phase-XX-name.md` — detailed spec for one phase. + +## Core rule + +Do not jump straight from product notes to code. + +For each implementation phase: + +1. Write a dedicated phase spec in `docs/phases/`. +2. Send the phase spec to Oracle for plan review. +3. Fix or disposition Oracle feedback in the spec. +4. Ask the user to approve the phase spec. +5. Implement only the approved phase. +6. Run relevant automated checks. +7. Send the implementation diff to Oracle for code/maintainability review. +8. Fix or disposition Oracle feedback. +9. Give the user exact manual verification steps. +10. Wait for user confirmation before continuing. + +## Phase spec location + +Phase specs should live here: + +```text +docs/phases/ +``` + +Naming pattern: + +```text +docs/phases/phase-00-workspace-foundation.md +docs/phases/phase-01-tray-desktop-shell.md +docs/phases/phase-02-default-pet-window.md +docs/phases/phase-03-local-state-pet-manager-basics.md +``` + +Use two-digit phase numbers so files sort naturally. + +## Phase spec template + +Each phase spec should use this structure: + +```md +# Phase XX: Name + +## Goal + +## Non-goals + +## User-visible/manual outcome + +## Acceptance criteria + +## Proposed files/directories + +## Technical approach + +## Risks and tradeoffs + +## Security/privacy notes + +## Test/check plan + +## Manual verification guide + +## Oracle plan review + +## Oracle feedback disposition +``` + +The spec should be concrete enough that implementation can proceed without rediscovering the whole plan, but not so detailed that it becomes fake certainty. + +## Oracle plan review packet + +When sending a phase spec to Oracle for review, include: + +- The phase spec path. +- The relevant product context from `task.txt` if needed. +- What decision or plan should be reviewed. +- Known uncertainties. +- Specific risks where Oracle should focus. + +Oracle should review for: + +- Architecture fit. +- Maintainability. +- Security/privacy issues. +- Data-loss risks. +- Cross-platform risks. +- Scope size. +- Missing acceptance criteria. +- Missing manual verification steps. + +## Oracle feedback disposition + +After Oracle reviews a phase spec or implementation, record feedback disposition. + +Use these categories: + +```text +Fixed +Deferred +Rejected +Needs user decision +``` + +Do not silently ignore Oracle feedback. + +If feedback expands scope significantly, ask the user before expanding the phase. + +## User approval before implementation + +After Oracle plan review and spec updates, ask the user to approve the phase spec. + +Do not start implementation until the user approves the phase spec. + +The approval question should be direct: + +```text +Approve Phase XX for implementation? +``` + +## Implementation rules + +During implementation: + +- Implement only the approved phase. +- Do not add unrelated refactors. +- Do not silently expand scope. +- If the phase becomes too large, stop and propose a split. +- Keep changes aligned with `task.txt`, `docs/implementation-process.md`, and the approved phase spec. +- Prefer simple, reliable, cross-platform behavior over cleverness. +- Preserve v1 as reference material only; do not blindly copy v1 assumptions. + +## Implementation review packet + +After implementation, send Oracle: + +- Summary of implemented changes. +- Changed file list or diff summary. +- Test/check results. +- Known limitations. +- Deviations from the approved spec. +- Specific concerns where Oracle should focus. + +Then fix or disposition Oracle feedback before asking the user to manually confirm. + +## Manual user verification + +After Oracle implementation review is resolved, give the user: + +- What changed. +- Commands to run. +- Manual actions to take. +- Expected results. +- Known limitations. +- A clear confirmation question. + +Example: + +```text +Please verify Phase 01: + +1. Run `pnpm dev:desktop`. +2. Confirm the OpenPets tray icon appears. +3. Open Manage Pets, Configure Agents, and Settings from the tray. +4. Confirm each placeholder window opens. +5. Quit from the tray and confirm the app exits cleanly. + +Does Phase 01 pass on your machine? +``` + +Do not proceed to the next numbered phase, or any meaningful user-visible sub-phase, until the user confirms. + +## Recommended initial phase sequence + +Start with these phase specs: + +1. `phase-00-workspace-foundation.md` +2. `phase-01-tray-desktop-shell.md` +3. `phase-02-default-pet-window.md` +4. `phase-03-local-state-pet-manager-basics.md` +5. `phase-04-v2-pet-catalog-installation.md` +6. `phase-05-local-ipc-client.md` +7. `phase-06-mcp-integration.md` +8. `phase-07-claude-detection-configuration.md` +9. `phase-08-claude-enhanced-hooks.md` +10. `phase-09-first-run-onboarding.md` +11. `phase-10-mvp-hardening-packaging.md` + +Phases can be split later if they become too large. + +Likely split candidates: + +- Phase 04: catalog browsing vs zip install/validation. +- Phase 08: Claude hook reactions vs speech/polish. +- Phase 10: hardening vs packaging/release validation. + +## Current phase tracking + +Track the active phase in the relevant `docs/phases/phase-XX-name.md` file and in conversation with the user. + +Do not hardcode the current next action in this workflow document; it should stay reusable across phases. diff --git a/package.json b/package.json new file mode 100644 index 00000000..413b6b61 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "openpets-v2-workspace", + "version": "2.0.0", + "private": true, + "description": "OpenPets 2.0 workspace", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/alvinunreal/openpets.git" + }, + "type": "module", + "packageManager": "pnpm@11.0.8", + "engines": { + "node": ">=20" + }, + "scripts": { + "dev:desktop": "pnpm --filter @open-pets/desktop dev", + "package:desktop:dir": "pnpm build && pnpm --filter @open-pets/desktop package:dir", + "package:desktop": "pnpm build && pnpm --filter @open-pets/desktop package", + "release:desktop": "node apps/desktop/scripts/release-local.mjs", + "test": "pnpm build && pnpm -r --if-present test", + "check": "pnpm -r check", + "typecheck": "pnpm -r typecheck", + "build": "pnpm -r build" + }, + "devDependencies": { + "typescript": "^6.0.3" + } +} diff --git a/packages/agent-events/package.json b/packages/agent-events/package.json new file mode 100644 index 00000000..9ad1a9de --- /dev/null +++ b/packages/agent-events/package.json @@ -0,0 +1,35 @@ +{ + "name": "@open-pets/agent-events", + "version": "2.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/alvinunreal/openpets.git", + "directory": "packages/agent-events" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "test": "node dist/check-agent-events.js", + "check": "pnpm typecheck && pnpm build && pnpm test", + "typecheck": "tsc --noEmit", + "build": "tsc" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^6.0.3" + } +} diff --git a/packages/agent-events/src/check-agent-events.ts b/packages/agent-events/src/check-agent-events.ts new file mode 100644 index 00000000..135f92c4 --- /dev/null +++ b/packages/agent-events/src/check-agent-events.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; + +import { hookSpeechPools, pickHookSpeech, validateHookSpeech } from "./index.js"; + +assert.equal(pickHookSpeech("thinking", () => 0), "Thinking it through"); +assert.equal(pickHookSpeech("success", () => 0.99), "Nice, finished"); + +for (const [category, messages] of Object.entries(hookSpeechPools)) { + for (const message of messages) { + assert.match(message, /^[A-Z]/, `${category} hook speech must start with uppercase: ${message}`); + assert.equal(validateHookSpeech(message), message); + } +} + +for (const unsafe of ["", "a".repeat(141), "line\nbreak", "const x = 1", "https://example.com", "/Users/alvin/project", "api_key=secret"]) { + assert.throws(() => validateHookSpeech(unsafe)); +} + +console.error("Agent event speech validation passed."); diff --git a/packages/agent-events/src/index.ts b/packages/agent-events/src/index.ts new file mode 100644 index 00000000..a805e286 --- /dev/null +++ b/packages/agent-events/src/index.ts @@ -0,0 +1,23 @@ +export type HookSpeechCategory = "thinking" | "success" | "error" | "permission"; + +export const hookSpeechPools: Record = { + thinking: ["Thinking it through", "Let me check", "On it", "Working it out"], + success: ["Done", "That worked", "All set", "Nice, finished"], + error: ["Something failed", "Needs another look", "Hit a snag", "Not quite there"], + permission: ["Approval needed"], +}; + +export function pickHookSpeech(category: HookSpeechCategory, random: () => number = Math.random): string { + const pool = hookSpeechPools[category]; + return pool[Math.max(0, Math.min(pool.length - 1, Math.floor(random() * pool.length)))] ?? pool[0] ?? "Working"; +} + +export function validateHookSpeech(message: string): string { + if (message.length < 1 || message.length > 140) throw new Error("Hook speech length is invalid."); + if (/\r|\n/.test(message)) throw new Error("Hook speech must be single line."); + if (/```|\b(function|const|let|var|class|import|export)\b|[{};]/.test(message)) throw new Error("Hook speech looks code-like."); + if (/https?:\/\/|www\./i.test(message)) throw new Error("Hook speech must not contain URLs."); + if (/(^|\s)(?:~|\.{1,2}|[A-Za-z]:)?[\\/][^\s]+/.test(message)) throw new Error("Hook speech must not contain paths."); + if (/\b(api[_-]?key|secret|password|token)\s*[:=]/i.test(message)) throw new Error("Hook speech must not contain secrets."); + return message; +} diff --git a/packages/agent-events/tsconfig.json b/packages/agent-events/tsconfig.json new file mode 100644 index 00000000..762e7118 --- /dev/null +++ b/packages/agent-events/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/claude/package.json b/packages/claude/package.json new file mode 100644 index 00000000..454c3e17 --- /dev/null +++ b/packages/claude/package.json @@ -0,0 +1,42 @@ +{ + "name": "@open-pets/claude", + "version": "2.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/alvinunreal/openpets.git", + "directory": "packages/claude" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "open-pets-claude": "dist/cli.js" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "test": "node dist/check-claude-code.js && node dist/check-claude-hooks.js", + "check": "pnpm typecheck && pnpm build && pnpm test", + "typecheck": "tsc --noEmit", + "build": "tsc" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^6.0.3" + }, + "dependencies": { + "@open-pets/agent-events": "workspace:*", + "@open-pets/client": "workspace:*" + } +} diff --git a/packages/claude/src/check-claude-code.ts b/packages/claude/src/check-claude-code.ts new file mode 100644 index 00000000..d5527882 --- /dev/null +++ b/packages/claude/src/check-claude-code.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; + +import { buildClaudeMcpGetCommand, buildClaudeMcpPreview, classifyClaudeMcpStatus, formatCommandForDisplay, getBundledMcpEntryPath, getLocalMcpEntryPath, mapAsarPathToUnpacked, parseClaudeMcpGetOutput, parseClaudeMcpListOutput, validateOpenPetsPetArg } from "./claude-code.js"; + +const defaultPreview = buildClaudeMcpPreview(); +assert.deepEqual(defaultPreview.add.args, ["mcp", "add", "--scope", "user", "openpets", "--", "npx", "-y", "@open-pets/mcp"]); +assert.deepEqual(defaultPreview.remove.args, ["mcp", "remove", "--scope", "user", "openpets"]); +assert.deepEqual(defaultPreview.mcpJson.mcpServers.openpets.args, ["-y", "@open-pets/mcp"]); +assert.equal(formatCommandForDisplay(defaultPreview.add), "claude mcp add --scope user openpets -- npx -y @open-pets/mcp"); + +const petPreview = buildClaudeMcpPreview("snoopy"); +assert.deepEqual(petPreview.add.args, ["mcp", "add", "--scope", "user", "openpets", "--", "npx", "-y", "@open-pets/mcp", "--pet", "snoopy"]); +assert.deepEqual(petPreview.mcpJson.mcpServers.openpets.args, ["-y", "@open-pets/mcp", "--pet", "snoopy"]); +assert.deepEqual(buildClaudeMcpGetCommand().args, ["mcp", "get", "openpets"]); + +const localPreview = buildClaudeMcpPreview("snoopy", "local"); +assert.deepEqual(localPreview.add.args, ["mcp", "add", "--scope", "user", "openpets", "--", "node", getLocalMcpEntryPath(), "--pet", "snoopy"]); +assert.equal(localPreview.mcpJson.mcpServers.openpets.command, "node"); +assert.deepEqual(localPreview.mcpJson.mcpServers.openpets.args, [getLocalMcpEntryPath(), "--pet", "snoopy"]); + +assert.throws(() => validateOpenPetsPetArg("Bad Pet")); +assert.throws(() => validateOpenPetsPetArg("bad/pet")); +assert.equal(validateOpenPetsPetArg("snoopy"), "snoopy"); + +assert.equal(parseClaudeMcpListOutput("openpets: npx -y @open-pets/mcp").present, true); +assert.equal(parseClaudeMcpListOutput("No MCP servers configured").present, false); + +const jsonGet = parseClaudeMcpGetOutput(JSON.stringify({ command: "npx", args: ["-y", "@open-pets/mcp", "--pet", "snoopy"] }), "snoopy"); +assert.equal(jsonGet.present, true); +assert.equal(jsonGet.verified, true); +assert.equal(jsonGet.matchesExpected, true); + +const localGet = parseClaudeMcpGetOutput(JSON.stringify({ command: "node", args: [getLocalMcpEntryPath(), "--pet", "snoopy"] }), "snoopy", "local"); +assert.equal(localGet.matchesExpected, true); + +const bundledPreview = buildClaudeMcpPreview("snoopy", "bundled"); +assert.deepEqual(bundledPreview.add.args, ["mcp", "add", "--scope", "user", "openpets", "--", "node", getBundledMcpEntryPath(), "--pet", "snoopy"]); +assert.equal(bundledPreview.mcpJson.mcpServers.openpets.command, "node"); +assert.deepEqual(bundledPreview.mcpJson.mcpServers.openpets.args, [getBundledMcpEntryPath(), "--pet", "snoopy"]); +const bundledGet = parseClaudeMcpGetOutput(JSON.stringify({ command: "node", args: [getBundledMcpEntryPath(), "--pet", "snoopy"] }), "snoopy", "bundled"); +assert.equal(bundledGet.matchesExpected, true); + +const spacedPath = "/Applications/OpenPets Test.app/Contents/Resources/app/node_modules/@open-pets/mcp/dist/index.js"; +assert.equal(formatCommandForDisplay({ command: "node", args: [spacedPath, "--pet", "snoopy"] }), 'node "/Applications/OpenPets Test.app/Contents/Resources/app/node_modules/@open-pets/mcp/dist/index.js" --pet snoopy'); +const spacedTextGet = parseClaudeMcpGetOutput(`openpets\nCommand: node\nArgs: "${getBundledMcpEntryPath()}" --pet snoopy`, "snoopy", "bundled"); +assert.equal(spacedTextGet.matchesExpected, true); +assert.equal(formatCommandForDisplay({ command: "node", args: ["C:\\Program Files\\OpenPets\\resources\\app\\node_modules\\@open-pets\\mcp\\dist\\index.js"] }), 'node "C:\\\\Program Files\\\\OpenPets\\\\resources\\\\app\\\\node_modules\\\\@open-pets\\\\mcp\\\\dist\\\\index.js"'); +assert.equal(mapAsarPathToUnpacked("/Applications/OpenPets.app/Contents/Resources/app.asar/node_modules/@open-pets/mcp/dist/index.js"), "/Applications/OpenPets.app/Contents/Resources/app.asar.unpacked/node_modules/@open-pets/mcp/dist/index.js"); +assert.equal(mapAsarPathToUnpacked("C:\\Program Files\\OpenPets\\resources\\app.asar\\node_modules\\@open-pets\\mcp\\dist\\index.js"), "C:\\Program Files\\OpenPets\\resources\\app.asar.unpacked\\node_modules\\@open-pets\\mcp\\dist\\index.js"); +assert.equal(mapAsarPathToUnpacked("/Applications/app.asarish/OpenPets.app/Contents/Resources/app.asar/node_modules/@open-pets/mcp/dist/index.js"), "/Applications/app.asarish/OpenPets.app/Contents/Resources/app.asar.unpacked/node_modules/@open-pets/mcp/dist/index.js"); +assert.equal(mapAsarPathToUnpacked("/tmp/app.asar.unpacked/node_modules/@open-pets/mcp/dist/index.js"), "/tmp/app.asar.unpacked/node_modules/@open-pets/mcp/dist/index.js"); + +const textGet = parseClaudeMcpGetOutput("openpets\nCommand: npx\nArgs: -y @open-pets/mcp --pet snoopy", "snoopy"); +assert.equal(textGet.present, true); +assert.equal(textGet.verified, true); +assert.equal(textGet.matchesExpected, true); + +const different = parseClaudeMcpGetOutput(JSON.stringify({ command: "node", args: ["server.js"] }), "snoopy"); +assert.equal(different.present, true); +assert.equal(different.verified, true); +assert.equal(different.matchesExpected, false); + +const unverifiable = classifyClaudeMcpStatus("openpets", "Name: openpets\nTransport: stdio", "snoopy"); +assert.equal(unverifiable.present, true); +assert.equal(unverifiable.verified, false); +assert.equal(unverifiable.matchesExpected, false); + +console.error("Claude Code setup validation passed."); diff --git a/packages/claude/src/check-claude-hooks.ts b/packages/claude/src/check-claude-hooks.ts new file mode 100644 index 00000000..8b3f371c --- /dev/null +++ b/packages/claude/src/check-claude-hooks.ts @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { addOpenPetsHooks, claudeHookEvents, createOpenPetsHookCommand, createOpenPetsHookSettingsPreview, doctorClaudeHooks, getBundledClaudeCliPath, getLocalClaudeCliPath, installClaudeHooks, openPetsHookMarker, removeOpenPetsHooks, uninstallClaudeHooks } from "./hook-settings.js"; +import { hookSpeechPools } from "./hook-messages.js"; +import { handleClaudeHookPayload, hasProjectLocalOpenPetsHook, mapClaudeHookEvent, validateHookSpeech } from "./hooks.js"; + +assert.equal(mapClaudeHookEvent({ hook_event_name: "UserPromptSubmit" })?.reaction, "thinking"); +assert.equal(mapClaudeHookEvent({ hook_event_name: "UserPromptSubmit" })?.speechCategory, undefined); +assert.equal(mapClaudeHookEvent({ hook_event_name: "PreToolUse", tool_name: "Write" })?.reaction, "editing"); +assert.equal(mapClaudeHookEvent({ hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "pnpm test" } })?.reaction, "testing"); +assert.equal(mapClaudeHookEvent({ hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "ls" } })?.reaction, undefined); +assert.equal(mapClaudeHookEvent({ hook_event_name: "PreToolUse", tool_name: "Read" })?.reaction, undefined); +assert.equal(mapClaudeHookEvent({ hook_event_name: "PermissionRequest" })?.speechCategory, "permission"); +assert.equal(mapClaudeHookEvent({ hook_event_name: "Notification" })?.reaction, undefined); +assert.equal(mapClaudeHookEvent({ hook_event_name: "Stop" })?.reaction, "success"); +assert.equal(mapClaudeHookEvent({ hook_event_name: "Stop" })?.speechCategory, undefined); +assert.equal(mapClaudeHookEvent({ hook_event_name: "StopFailure" })?.reaction, "error"); +assert.equal(mapClaudeHookEvent({ hook_event_name: "Unknown" })?.reaction, undefined); + +validateHookSpeech("Thinking it through"); +for (const [category, messages] of Object.entries(hookSpeechPools) as Array<[string, readonly string[]]>) { + for (const message of messages) { + assert.match(message, /^[A-Z]/, `${category} hook speech must start with an uppercase letter: ${message}`); + validateHookSpeech(message); + } +} +for (const unsafe of ["", "a".repeat(141), "line\nbreak", "const x = 1", "https://example.com", "/Users/alvin/project", "api_key=secret"]) { + assert.throws(() => validateHookSpeech(unsafe)); +} + +const calls: Array<{ readonly kind: string; readonly value: string; readonly leaseId?: string; readonly requestedPetId?: string }> = []; +const client = { + hello: async () => ({}), + status: async () => ({ ok: true, appRunning: true }), + listPets: async () => ({ ok: true as const, pets: [], defaultPetId: "builtin" }), + acquireLease: async (options?: { readonly requestedPetId?: string }) => { + calls.push({ kind: "lease", value: "acquire", requestedPetId: options?.requestedPetId }); + return { leaseId: "lease-fixer", requestedPetId: options?.requestedPetId, targetKind: "explicit" as const, actualTargetPetId: options?.requestedPetId ?? "builtin", actualTargetPetName: "Fixer", usingDefaultPet: false, expiresAt: Date.now() + 15_000, leaseActive: true }; + }, + heartbeatLease: async () => { throw new Error("unused"); }, + releaseLease: async () => { throw new Error("unused"); }, + react: async (reaction: string, options?: { readonly leaseId?: string }) => { calls.push({ kind: "react", value: reaction, leaseId: options?.leaseId }); }, + say: async (message: string, options?: { readonly leaseId?: string }) => { calls.push({ kind: "say", value: message, leaseId: options?.leaseId }); }, +}; +const dir = mkdtempSync(join(tmpdir(), "openpets-hooks-")); +try { +await handleClaudeHookPayload(JSON.stringify({ hook_event_name: "UserPromptSubmit", prompt: "never shown" }), { client, configuredPetId: "fixer", throttlePath: join(dir, "throttle.json"), now: () => 100_000, random: () => 0 }); +assert.deepEqual(calls[0], { kind: "lease", value: "acquire", requestedPetId: "fixer" }); +assert.deepEqual(calls[1], { kind: "react", value: "thinking", leaseId: "lease-fixer" }); +await handleClaudeHookPayload(JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "npm test -- --secret" } }), { client, throttlePath: join(dir, "throttle.json"), now: () => 101_000 }); +assert.deepEqual(calls[2], { kind: "react", value: "testing", leaseId: undefined }); +const beforeSilentBash = calls.length; +await handleClaudeHookPayload(JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "ls" } }), { client, throttlePath: join(dir, "throttle.json"), now: () => 102_000 }); +assert.equal(calls.length, beforeSilentBash); +await handleClaudeHookPayload(JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "npm test" } }), { client, throttlePath: join(dir, "throttle.json"), now: () => 102_000 }); +assert.equal(calls.length, beforeSilentBash, "duplicate testing reaction should be throttled"); +await handleClaudeHookPayload("not json", { client, throttlePath: join(dir, "throttle.json") }); + +const projectDir = join(dir, "project-with-local-hook"); +mkdirSync(join(projectDir, ".claude"), { recursive: true }); +writeFileSync(join(projectDir, ".claude", "settings.local.json"), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: "command", command: "openpets hook --openpets-managed --project-local --pet fixer" }] }] } }), "utf8"); +assert.equal(hasProjectLocalOpenPetsHook(projectDir), true); +const beforeSkippedGlobal = calls.length; +const previousProjectDir = process.env.CLAUDE_PROJECT_DIR; +process.env.CLAUDE_PROJECT_DIR = projectDir; +try { + await handleClaudeHookPayload(JSON.stringify({ hook_event_name: "PermissionRequest" }), { client, throttlePath: join(dir, "project-skip-throttle.json"), now: () => 102_000, debug: true, random: () => 0 }); + assert.equal(calls.length, beforeSkippedGlobal); + await handleClaudeHookPayload(JSON.stringify({ hook_event_name: "PermissionRequest" }), { client, projectLocal: true, configuredPetId: "fixer", throttlePath: join(dir, "throttle.json"), now: () => 103_000, random: () => 0 }); + assert.deepEqual(calls.at(-2), { kind: "lease", value: "acquire", requestedPetId: "fixer" }); + assert.deepEqual(calls.at(-1), { kind: "say", value: "Approval needed", leaseId: "lease-fixer" }); +} finally { + if (previousProjectDir === undefined) delete process.env.CLAUDE_PROJECT_DIR; + else process.env.CLAUDE_PROJECT_DIR = previousProjectDir; +} + +const preview = createOpenPetsHookSettingsPreview(); +const hooks = (preview.hooks ?? {}) as Record; +for (const event of claudeHookEvents) { + const entries = hooks[event] as Array<{ hooks: Array<{ command: string; timeout: number; async: boolean; asyncRewake: boolean }> }>; + const hook = entries[0]?.hooks[0]; + assert.ok(hook?.command.includes(openPetsHookMarker)); + assert.equal(hook.timeout, 3); + assert.equal(hook.async, true); + assert.equal(hook.asyncRewake, false); +} +const localPreview = createOpenPetsHookSettingsPreview("local"); +const localHook = (((localPreview.hooks as Record).Stop as Array<{ hooks: Array<{ command: string }> }>)[0]?.hooks[0]); +assert.ok(localHook?.command.includes(getLocalClaudeCliPath())); +assert.ok(localHook?.command.includes(openPetsHookMarker)); +const bundledPreview = createOpenPetsHookSettingsPreview("bundled"); +const bundledHook = (((bundledPreview.hooks as Record).Stop as Array<{ hooks: Array<{ command: string }> }>)[0]?.hooks[0]); +assert.ok(bundledHook?.command.includes(getBundledClaudeCliPath())); +assert.ok(bundledHook?.command.includes(openPetsHookMarker)); +assert.ok(createOpenPetsHookCommand("published", "fixer").endsWith("--openpets-managed --pet fixer")); +const petPreview = createOpenPetsHookSettingsPreview("published", "fixer"); +const petHook = (((petPreview.hooks as Record).UserPromptSubmit as Array<{ hooks: Array<{ command: string }> }>)[0]?.hooks[0]); +assert.ok(petHook?.command.includes("--pet fixer")); + +const settings = { theme: "dark", hooks: { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo safe" }] }] } }; +const installed = addOpenPetsHooks(settings); +assert.equal(doctorStatus(installed), "installed"); +const installedForFixer = addOpenPetsHooks(settings, "published", "fixer"); +assert.equal(doctorStatus(installedForFixer, "fixer"), "installed"); +assert.equal(doctorStatus(installedForFixer), "needs_update"); +assert.ok(doctorClaudeHooks(doctorStatusPath(installedForFixer), "published", "fixer").message.includes("Hook events target fixer.")); +const reinstalled = addOpenPetsHooks(removeOpenPetsHooks(installed)); +assert.deepEqual(reinstalled, installed); +const uninstalled = removeOpenPetsHooks(installed); +assert.deepEqual(uninstalled, settings); +assert.throws(() => addOpenPetsHooks({ hooks: { UserPromptSubmit: { bad: true } } })); + +const stale = addOpenPetsHooks({}); +((stale.hooks as Record).Stop as unknown[]).push({ hooks: [{ type: "command", command: "npx -y @open-pets/claude hook --openpets-managed --old" }] }); +const stalePath = join(dir, "stale-settings.json"); +writeFileSync(stalePath, JSON.stringify(stale), "utf8"); +assert.equal(doctorClaudeHooks(stalePath).status, "needs_update"); +assert.equal(installClaudeHooks(stalePath).status, "installed"); + +const settingsPath = join(dir, "settings.json"); +writeFileSync(settingsPath, JSON.stringify(settings), "utf8"); +assert.equal(doctorClaudeHooks(settingsPath).status, "not_installed"); +assert.equal(installClaudeHooks(settingsPath).status, "installed"); +assert.equal(installClaudeHooks(settingsPath).changed, false); +assert.equal(uninstallClaudeHooks(settingsPath).status, "not_installed"); +assert.equal(uninstallClaudeHooks(settingsPath).changed, false); + +writeFileSync(settingsPath, JSON.stringify({ hooks: [] }), "utf8"); +assert.equal(doctorClaudeHooks(settingsPath).status, "error"); +writeFileSync(settingsPath, JSON.stringify({ hooks: { Stop: { bad: true } } }), "utf8"); +assert.equal(doctorClaudeHooks(settingsPath).status, "error"); +const symlinkPath = join(dir, "settings-link.json"); +symlinkSync(settingsPath, symlinkPath); +assert.equal(doctorClaudeHooks(symlinkPath).status, "error"); + +process.env.OPENPETS_DISABLE_CLAUDE_ASYNC_HOOKS = "1"; +assert.throws(() => installClaudeHooks(join(dir, "async-disabled.json"))); +delete process.env.OPENPETS_DISABLE_CLAUDE_ASYNC_HOOKS; + +const isolatedEnv = { ...process.env, OPENPETS_DISCOVERY_FILE: join(dir, "missing-ipc.json") }; +const normalHook = spawnSync(process.execPath, [new URL("./cli.js", import.meta.url).pathname, "hook", "--openpets-managed"], { input: JSON.stringify({ hook_event_name: "Notification", message: "safe" }), encoding: "utf8", env: isolatedEnv }); +assert.equal(normalHook.status, 0); +assert.equal(normalHook.stdout, ""); + +const petHookRun = spawnSync(process.execPath, [new URL("./cli.js", import.meta.url).pathname, "hook", "--openpets-managed", "--pet", "fixer"], { input: JSON.stringify({ hook_event_name: "Notification", message: "safe" }), encoding: "utf8", env: isolatedEnv }); +assert.equal(petHookRun.status, 0); +assert.equal(petHookRun.stdout, ""); + +const invalidPetHook = spawnSync(process.execPath, [new URL("./cli.js", import.meta.url).pathname, "hook", "--openpets-managed", "--pet", "bad/pet"], { input: JSON.stringify({ hook_event_name: "Notification", message: "safe" }), encoding: "utf8", env: isolatedEnv }); +assert.equal(invalidPetHook.status, 1); +const missingPetHook = spawnSync(process.execPath, [new URL("./cli.js", import.meta.url).pathname, "hook", "--openpets-managed", "--pet"], { input: JSON.stringify({ hook_event_name: "Notification", message: "safe" }), encoding: "utf8", env: isolatedEnv }); +assert.equal(missingPetHook.status, 1); + +const malformedHook = spawnSync(process.execPath, [new URL("./cli.js", import.meta.url).pathname, "hook", "--openpets-managed"], { input: "not json", encoding: "utf8", env: isolatedEnv }); +assert.equal(malformedHook.status, 0); +assert.equal(malformedHook.stdout, ""); + +} finally { + rmSync(dir, { recursive: true, force: true }); +} +console.error("Claude hooks validation passed."); + +function doctorStatus(value: Record, selectedPetId?: string) { + const path = doctorStatusPath(value); + return doctorClaudeHooks(path, "published", selectedPetId).status; +} + +function doctorStatusPath(value: Record) { + const path = join(dir, `settings-${Math.random()}.json`); + writeFileSync(path, JSON.stringify(value), "utf8"); + return path; +} diff --git a/packages/claude/src/claude-code.ts b/packages/claude/src/claude-code.ts new file mode 100644 index 00000000..f9ddbef8 --- /dev/null +++ b/packages/claude/src/claude-code.ts @@ -0,0 +1,265 @@ +import { dirname, isAbsolute, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { lstatSync, realpathSync, statSync } from "node:fs"; + +export const claudeMcpServerName = "openpets"; +export const openPetsMcpPackageName = "@open-pets/mcp"; +export type OpenPetsCommandMode = "published" | "local" | "bundled"; + +export type ClaudeMcpScope = "user"; + +export interface ClaudeCommandSpec { + readonly command: string; + readonly args: readonly string[]; +} + +export interface ClaudeMcpPreview { + readonly commandMode: OpenPetsCommandMode; + readonly add: ClaudeCommandSpec; + readonly remove: ClaudeCommandSpec; + readonly mcpJson: { + readonly mcpServers: { + readonly openpets: { + readonly type: "stdio"; + readonly command: "npx" | "node"; + readonly args: readonly string[]; + }; + }; + }; + readonly displayCommand: string; +} + +export interface ParsedClaudeMcpEntry { + readonly present: boolean; + readonly command?: string; + readonly args?: readonly string[]; + readonly source: "none" | "list" | "get"; + readonly verified: boolean; + readonly matchesExpected: boolean; +} + +export function buildClaudeMcpPreview(selectedPetId?: string, commandMode: OpenPetsCommandMode = "published"): ClaudeMcpPreview { + const server = buildOpenPetsMcpServerCommand(selectedPetId, commandMode); + const addArgs = ["mcp", "add", "--scope", "user", claudeMcpServerName, "--", server.command, ...server.args] as const; + const removeArgs = ["mcp", "remove", "--scope", "user", claudeMcpServerName] as const; + const add: ClaudeCommandSpec = { command: "claude", args: addArgs }; + + return { + commandMode, + add, + remove: { command: "claude", args: removeArgs }, + mcpJson: { + mcpServers: { + openpets: { + type: "stdio", + command: server.command, + args: server.args, + }, + }, + }, + displayCommand: formatCommandForDisplay(add), + }; +} + +export function buildOpenPetsMcpArgs(selectedPetId?: string): readonly string[] { + if (selectedPetId === undefined) return ["-y", openPetsMcpPackageName]; + validateOpenPetsPetArg(selectedPetId); + return ["-y", openPetsMcpPackageName, "--pet", selectedPetId]; +} + +export function buildOpenPetsMcpServerCommand(selectedPetId?: string, commandMode: OpenPetsCommandMode = "published"): { readonly command: "npx" | "node"; readonly args: readonly string[] } { + if (commandMode === "local" || commandMode === "bundled") { + const entryPath = commandMode === "bundled" ? getBundledMcpEntryPath() : getLocalMcpEntryPath(); + commandMode === "bundled" ? assertBundledMcpEntryPath() : assertLocalMcpEntryPath(); + if (selectedPetId === undefined) return { command: "node", args: [entryPath] }; + validateOpenPetsPetArg(selectedPetId); + return { command: "node", args: [entryPath, "--pet", selectedPetId] }; + } + return { command: "npx", args: buildOpenPetsMcpArgs(selectedPetId) }; +} + +export function assertLocalMcpEntryPath(): void { + assertSafeLocalDistFile(getLocalMcpEntryPath(), "MCP entry"); +} + +export function getLocalMcpEntryPath(): string { + return getSiblingMcpEntryPath(); +} + +export function getBundledMcpEntryPath(): string { + return mapAsarPathToUnpacked(getSiblingMcpEntryPath()); +} + +export function mapAsarPathToUnpacked(path: string): string { + return path.replace(/(^|[\\/])app\.asar(?=$|[\\/])/, "$1app.asar.unpacked"); +} + +function getSiblingMcpEntryPath(): string { + const here = dirname(fileURLToPath(import.meta.url)); + return join(here, "..", "..", "mcp", "dist", "index.js"); +} + +export function assertBundledMcpEntryPath(): void { + assertSafeBundledDistFile(getBundledMcpEntryPath(), "MCP entry"); +} + +export function buildClaudeMcpGetCommand(): ClaudeCommandSpec { + return { command: "claude", args: ["mcp", "get", claudeMcpServerName] }; +} + +export function validateOpenPetsPetArg(value: string): string { + const trimmed = value.trim(); + if (trimmed !== value || trimmed.length < 1) throw new Error("Invalid OpenPets pet id."); + if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(trimmed)) throw new Error("Invalid OpenPets pet id."); + return trimmed; +} + +export function parseClaudeMcpListOutput(output: string): ParsedClaudeMcpEntry { + const normalized = output.toLowerCase(); + const present = /(^|\s|[•*-])openpets(\s|$|:|-)/m.test(normalized) || normalized.includes("openpets:"); + return { + present, + source: present ? "list" : "none", + verified: false, + matchesExpected: false, + }; +} + +export function parseClaudeMcpGetOutput(output: string, expectedPetId?: string, commandMode: OpenPetsCommandMode = "published"): ParsedClaudeMcpEntry { + const text = output.trim(); + if (!text) return { present: false, source: "none", verified: false, matchesExpected: false }; + + const parsed = tryParseJson(text); + const expected = buildOpenPetsMcpServerCommand(expectedPetId, commandMode); + const jsonEntry = parsed ? extractJsonEntry(parsed) : null; + if (jsonEntry) { + const matchesExpected = jsonEntry.command === expected.command && arraysEqual(jsonEntry.args, expected.args); + return { present: true, command: jsonEntry.command, args: jsonEntry.args, source: "get", verified: true, matchesExpected }; + } + + const command = extractTextCommand(text); + const args = extractTextArgs(text); + if (command && args) { + const matchesExpected = command === expected.command && arraysEqual(args, expected.args); + return { present: true, command, args, source: "get", verified: true, matchesExpected }; + } + + if (/openpets/i.test(text) || /@open-pets\/mcp/i.test(text)) { + return { present: true, source: "get", verified: false, matchesExpected: false }; + } + + return { present: false, source: "none", verified: false, matchesExpected: false }; +} + +export function classifyClaudeMcpStatus(listOutput: string, getOutput: string | undefined, expectedPetId?: string, commandMode: OpenPetsCommandMode = "published"): ParsedClaudeMcpEntry { + if (getOutput) { + const parsedGet = parseClaudeMcpGetOutput(getOutput, expectedPetId, commandMode); + if (parsedGet.present) return parsedGet; + } + return parseClaudeMcpListOutput(listOutput); +} + +export function formatCommandForDisplay(spec: ClaudeCommandSpec): string { + return [spec.command, ...spec.args].map(quoteArg).join(" "); +} + +function quoteArg(value: string): string { + if (/^[a-zA-Z0-9_@%+=:,./-]+$/.test(value)) return value; + if (/[\r\n"]/.test(value) || value.includes("\0")) throw new Error("Command argument contains unsupported shell characters."); + return `"${value.replaceAll("\\", "\\\\").replaceAll("$", "\\$").replaceAll("`", "\\`")}"`; +} + +function tryParseJson(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } +} + +function extractJsonEntry(value: unknown): { readonly command: string; readonly args: readonly string[] } | null { + const record = isRecord(value) ? value : null; + const maybeEntry = record && isRecord(record.mcpServers) && isRecord(record.mcpServers.openpets) + ? record.mcpServers.openpets + : record; + + if (!isRecord(maybeEntry) || typeof maybeEntry.command !== "string" || !Array.isArray(maybeEntry.args)) return null; + if (!maybeEntry.args.every((arg) => typeof arg === "string")) return null; + return { command: maybeEntry.command, args: maybeEntry.args as string[] }; +} + +function extractTextCommand(value: string): string | undefined { + const match = value.match(/(?:^|\n)\s*Command\s*:\s*([^\s\n]+)/i) ?? value.match(/(?:^|\n)\s*command\s+([^\s\n]+)/i); + return match?.[1]; +} + +function extractTextArgs(value: string): readonly string[] | undefined { + const jsonArgs = value.match(/(?:^|\n)\s*Args\s*:\s*(\[[^\n]+\])/i); + if (jsonArgs?.[1]) { + const parsed = tryParseJson(jsonArgs[1]); + if (Array.isArray(parsed) && parsed.every((arg) => typeof arg === "string")) return parsed as string[]; + } + + const textArgs = value.match(/(?:^|\n)\s*Args\s*:\s*(.+)$/im); + if (!textArgs?.[1]) return undefined; + return splitSimpleArgs(textArgs[1]); +} + +function splitSimpleArgs(value: string): readonly string[] { + const args: string[] = []; + let current = ""; + let quote: '"' | "'" | null = null; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (quote) { + if (char === quote) quote = null; + else current += char; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (/\s/.test(char)) { + if (current) { + args.push(current); + current = ""; + } + continue; + } + current += char; + } + if (current) args.push(current); + return args; +} + +function arraysEqual(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function assertSafeLocalDistFile(path: string, label: string): void { + const expectedPrefix = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); + if (!path.startsWith(expectedPrefix)) throw new Error(`Local ${label} path is outside the OpenPets workspace.`); + const stat = statSync(path); + if (!stat.isFile()) throw new Error(`Local ${label} path is not a regular file.`); +} + +function assertSafeBundledDistFile(path: string, label: string): void { + if (isTrueAsarPath(path)) throw new Error(`Bundled ${label} path must be unpacked outside app.asar.`); + if (path.includes("\n") || path.includes("\r") || path.includes("\0")) throw new Error(`Bundled ${label} path contains unsupported characters.`); + if (lstatSync(path).isSymbolicLink()) throw new Error(`Bundled ${label} path must not be a symlink.`); + const stat = statSync(path); + if (!stat.isFile()) throw new Error(`Bundled ${label} path is not a regular file.`); + const expectedRoot = realpathSync(mapAsarPathToUnpacked(join(dirname(fileURLToPath(import.meta.url)), "..", ".."))); + const realPath = realpathSync(path); + const rel = relative(expectedRoot, realPath); + if (rel.startsWith("..") || isAbsolute(rel)) throw new Error(`Bundled ${label} path is outside the packaged OpenPets resources.`); +} + +function isTrueAsarPath(path: string): boolean { + return /app\.asar(?:$|[\\/])/.test(path) && !/app\.asar\.unpacked(?:$|[\\/])/.test(path); +} diff --git a/packages/claude/src/cli.ts b/packages/claude/src/cli.ts new file mode 100644 index 00000000..8fe031e0 --- /dev/null +++ b/packages/claude/src/cli.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import { runClaudeHookFromStdin } from "./hooks.js"; +import { doctorClaudeHooks, installClaudeHooks, uninstallClaudeHooks } from "./hook-settings.js"; +import { validateOpenPetsPetArg } from "./claude-code.js"; + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2); + if (command === "hook") { + const code = await runClaudeHookFromStdin(process.stdin, { configuredPetId: readPetArg(args), projectLocal: hasProjectLocalArg(args), debug: process.env.OPENPETS_DEBUG === "1" }); + process.exitCode = code; + return; + } + if (command === "doctor-hooks") { + process.stderr.write(`${JSON.stringify(doctorClaudeHooks(readPathArg(args), undefined, readPetArg(args)), null, 2)}\n`); + return; + } + if (command === "install-hooks") { + process.stderr.write(`${JSON.stringify(installClaudeHooks(readPathArg(args), undefined, readPetArg(args)), null, 2)}\n`); + return; + } + if (command === "uninstall-hooks") { + process.stderr.write(`${JSON.stringify(uninstallClaudeHooks(readPathArg(args)), null, 2)}\n`); + return; + } + process.stderr.write("Usage: open-pets-claude [--settings ] [--pet ]\n"); + process.exitCode = 1; +} + +function readPathArg(args: readonly string[]): string | undefined { + const index = args.indexOf("--settings"); + const value = index >= 0 ? args[index + 1] : undefined; + return value && value.length > 0 ? value : undefined; +} + +function readPetArg(args: readonly string[]): string | undefined { + const equals = args.find((arg) => arg.startsWith("--pet=")); + if (equals) return validateOpenPetsPetArg(equals.slice("--pet=".length)); + const index = args.indexOf("--pet"); + const value = index >= 0 ? args[index + 1] : undefined; + if (index >= 0 && (!value || value.startsWith("--"))) throw new Error("Missing value for --pet."); + return value && value.length > 0 ? validateOpenPetsPetArg(value) : undefined; +} + +function hasProjectLocalArg(args: readonly string[]): boolean { + return args.includes("--project-local"); +} + +main().catch((error: unknown) => { + process.stderr.write(`OpenPets Claude CLI failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +}); diff --git a/packages/claude/src/hook-messages.ts b/packages/claude/src/hook-messages.ts new file mode 100644 index 00000000..8765968a --- /dev/null +++ b/packages/claude/src/hook-messages.ts @@ -0,0 +1 @@ +export { hookSpeechPools, pickHookSpeech, type HookSpeechCategory } from "@open-pets/agent-events"; diff --git a/packages/claude/src/hook-settings.ts b/packages/claude/src/hook-settings.ts new file mode 100644 index 00000000..a8f0718c --- /dev/null +++ b/packages/claude/src/hook-settings.ts @@ -0,0 +1,248 @@ +import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative } from "node:path"; +import { homedir } from "node:os"; +import { fileURLToPath } from "node:url"; + +import { mapAsarPathToUnpacked, validateOpenPetsPetArg, type OpenPetsCommandMode } from "./claude-code.js"; + +export const openPetsHookMarker = "--openpets-managed"; +export const claudeHookEvents = ["UserPromptSubmit", "PreToolUse", "PermissionRequest", "Notification", "Stop", "StopFailure"] as const; + +export type ClaudeHookInstallStatus = "not_installed" | "installed" | "needs_update" | "error"; + +export interface ClaudeHookDoctorResult { + readonly status: ClaudeHookInstallStatus; + readonly settingsPath: string; + readonly exists: boolean; + readonly valid: boolean; + readonly message: string; + readonly backupPath?: string; + readonly preview: Record; + readonly asyncSupported: boolean; +} + +export interface ClaudeHookWriteResult extends ClaudeHookDoctorResult { + readonly changed: boolean; +} + +export function getClaudeUserSettingsPath(): string { + return join(homedir(), ".claude", "settings.json"); +} + +export function createOpenPetsHookCommand(commandMode: OpenPetsCommandMode = "published", selectedPetId?: string): string { + const petArgs = selectedPetId === undefined ? "" : ` --pet ${shellQuote(validateOpenPetsPetArg(selectedPetId))}`; + if (commandMode === "local" || commandMode === "bundled") { + const cliPath = commandMode === "bundled" ? getBundledClaudeCliPath() : getLocalClaudeCliPath(); + commandMode === "bundled" ? assertBundledClaudeCliPath() : assertLocalClaudeCliPath(); + return `node ${shellQuote(cliPath)} hook ${openPetsHookMarker}${petArgs}`; + } + return `npx -y @open-pets/claude hook ${openPetsHookMarker}${petArgs}`; +} + +export function getLocalClaudeCliPath(): string { + return join(dirname(fileURLToPath(import.meta.url)), "cli.js"); +} + +export function getBundledClaudeCliPath(): string { + return mapAsarPathToUnpacked(getLocalClaudeCliPath()); +} + +export function assertLocalClaudeCliPath(): void { + const path = getLocalClaudeCliPath(); + const expectedPrefix = dirname(fileURLToPath(import.meta.url)); + if (!path.startsWith(expectedPrefix)) throw new Error("Local Claude hook path is outside the OpenPets package."); + const stat = statSync(path); + if (!stat.isFile()) throw new Error("Local Claude hook CLI path is not a regular file."); +} + +export function assertBundledClaudeCliPath(): void { + const path = getBundledClaudeCliPath(); + if (isTrueAsarPath(path)) throw new Error("Bundled Claude hook CLI path must be unpacked outside app.asar."); + if (path.includes("\n") || path.includes("\r") || path.includes("\0")) throw new Error("Bundled Claude hook CLI path contains unsupported characters."); + if (lstatSync(path).isSymbolicLink()) throw new Error("Bundled Claude hook CLI path must not be a symlink."); + const stat = statSync(path); + if (!stat.isFile()) throw new Error("Bundled Claude hook CLI path is not a regular file."); + const expectedRoot = realpathSync(mapAsarPathToUnpacked(join(dirname(fileURLToPath(import.meta.url)), "..", ".."))); + const realPath = realpathSync(path); + const rel = relative(expectedRoot, realPath); + if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Bundled Claude hook CLI path is outside the packaged OpenPets resources."); +} + +function isTrueAsarPath(path: string): boolean { + return /app\.asar(?:$|[\\/])/.test(path) && !/app\.asar\.unpacked(?:$|[\\/])/.test(path); +} + +export function createOpenPetsHookSettingsPreview(commandMode: OpenPetsCommandMode = "published", selectedPetId?: string): Record { + const hooks: Record = {}; + for (const event of claudeHookEvents) { + hooks[event] = [{ hooks: [createHookCommandEntry(commandMode, selectedPetId)] }]; + } + return { hooks }; +} + +export function doctorClaudeHooks(settingsPath = getClaudeUserSettingsPath(), commandMode: OpenPetsCommandMode = "published", selectedPetId?: string): ClaudeHookDoctorResult { + const preview = createOpenPetsHookSettingsPreview(commandMode, selectedPetId); + const asyncSupported = isClaudeHookAsyncSupported(); + try { + const settings = readClaudeSettings(settingsPath); + const status = getHookInstallStatus(settings, commandMode, selectedPetId); + return { + status, + settingsPath, + exists: existsSync(settingsPath), + valid: true, + message: `${asyncSupported ? status === "installed" ? "OpenPets Claude hooks are installed. Async hook install is enabled by OpenPets." : status === "needs_update" ? "OpenPets Claude hooks need update. Async hook install is enabled by OpenPets." : "OpenPets Claude hooks are not installed. Async hook install is enabled by OpenPets." : "Async hook install is disabled by OpenPets."} ${selectedPetId ? `Hook events target ${selectedPetId}.` : "Hook events target the default pet."}`, + preview, + asyncSupported, + }; + } catch (error) { + return { status: "error", settingsPath, exists: existsSync(settingsPath), valid: false, message: error instanceof Error ? error.message : "Claude hook settings are invalid.", preview, asyncSupported }; + } +} + +export function installClaudeHooks(settingsPath = getClaudeUserSettingsPath(), commandMode: OpenPetsCommandMode = "published", selectedPetId?: string): ClaudeHookWriteResult { + if (!isClaudeHookAsyncSupported()) throw new Error("Claude async hook support is not enabled for this OpenPets build."); + const settings = readClaudeSettings(settingsPath); + const status = getHookInstallStatus(settings, commandMode, selectedPetId); + if (status === "installed") return { ...doctorClaudeHooks(settingsPath, commandMode, selectedPetId), changed: false }; + const backupPath = backupSettings(settingsPath); + const next = addOpenPetsHooks(removeOpenPetsHooks(settings), commandMode, selectedPetId); + writeClaudeSettings(settingsPath, next); + return { ...doctorClaudeHooks(settingsPath, commandMode, selectedPetId), backupPath, changed: true }; +} + +export function uninstallClaudeHooks(settingsPath = getClaudeUserSettingsPath(), commandMode: OpenPetsCommandMode = "published"): ClaudeHookWriteResult { + const settings = readClaudeSettings(settingsPath); + const status = getHookInstallStatus(settings, commandMode); + if (status === "not_installed") return { ...doctorClaudeHooks(settingsPath, commandMode), changed: false }; + const backupPath = backupSettings(settingsPath); + const next = removeOpenPetsHooks(settings); + writeClaudeSettings(settingsPath, next); + return { ...doctorClaudeHooks(settingsPath, commandMode), backupPath, changed: true }; +} + +export function addOpenPetsHooks(settings: Record, commandMode: OpenPetsCommandMode = "published", selectedPetId?: string): Record { + const next = structuredClone(settings) as Record; + assertSelectedHookEventsAreArrays(next); + const hooks = isRecord(next.hooks) ? { ...next.hooks } : {}; + for (const event of claudeHookEvents) { + const existing = Array.isArray(hooks[event]) ? hooks[event].filter((entry) => !containsOpenPetsHook(entry)) : []; + hooks[event] = [...existing, { hooks: [createHookCommandEntry(commandMode, selectedPetId)] }]; + } + next.hooks = hooks; + return next; +} + +export function removeOpenPetsHooks(settings: Record): Record { + const next = structuredClone(settings) as Record; + if (!isRecord(next.hooks)) return next; + assertSelectedHookEventsAreArrays(next); + const hooks: Record = { ...next.hooks }; + for (const [event, entries] of Object.entries(hooks)) { + if (!Array.isArray(entries)) continue; + const cleaned = entries.map(removeOpenPetsHooksFromMatcher).filter((entry) => entry !== null); + if (cleaned.length > 0) hooks[event] = cleaned; + else delete hooks[event]; + } + if (Object.keys(hooks).length > 0) next.hooks = hooks; + else delete next.hooks; + return next; +} + +function getHookInstallStatus(settings: Record, commandMode: OpenPetsCommandMode, selectedPetId?: string): ClaudeHookInstallStatus { + if (settings.hooks !== undefined && !isRecord(settings.hooks)) throw new Error("Claude settings hooks field is not an object."); + const hooks = isRecord(settings.hooks) ? settings.hooks : {}; + let foundAny = false; + let staleManaged = false; + for (const event of claudeHookEvents) { + const entries = hooks[event]; + if (!Array.isArray(entries)) return foundAny ? "needs_update" : "not_installed"; + const currentCount = entries.filter((entry) => containsCurrentOpenPetsHook(entry, commandMode, selectedPetId)).length; + const managedCount = entries.filter((entry) => containsOpenPetsHook(entry)).length; + const hasCurrent = currentCount === 1; + if (managedCount > 0) foundAny = true; + if (managedCount !== currentCount || currentCount > 1) staleManaged = true; + if (!hasCurrent) return foundAny ? "needs_update" : "not_installed"; + foundAny = true; + } + return staleManaged ? "needs_update" : "installed"; +} + +function createHookCommandEntry(commandMode: OpenPetsCommandMode, selectedPetId?: string): Record { + return { type: "command", command: createOpenPetsHookCommand(commandMode, selectedPetId), timeout: 3, async: true, asyncRewake: false }; +} + +function containsCurrentOpenPetsHook(value: unknown, commandMode: OpenPetsCommandMode, selectedPetId?: string): boolean { + if (!isRecord(value) || !Array.isArray(value.hooks)) return false; + const command = createOpenPetsHookCommand(commandMode, selectedPetId); + return value.hooks.some((hook) => isRecord(hook) && hook.type === "command" && hook.command === command && hook.timeout === 3 && hook.async === true && hook.asyncRewake === false); +} + +function containsOpenPetsHook(value: unknown): boolean { + if (isRecord(value) && typeof value.command === "string" && value.command.includes(openPetsHookMarker)) return true; + if (isRecord(value) && Array.isArray(value.hooks)) return value.hooks.some(containsOpenPetsHook); + return false; +} + +function removeOpenPetsHooksFromMatcher(value: unknown): unknown | null { + if (!isRecord(value) || !Array.isArray(value.hooks)) return containsOpenPetsHook(value) ? null : value; + const hooks = value.hooks.filter((hook) => !containsOpenPetsHook(hook)); + if (hooks.length === 0) return null; + return { ...value, hooks }; +} + +function readClaudeSettings(path: string): Record { + assertSafeSettingsPath(path); + if (!existsSync(path)) return {}; + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (!isRecord(parsed) || Array.isArray(parsed)) throw new Error("Claude settings must be a JSON object."); + if (parsed.hooks !== undefined && !isRecord(parsed.hooks)) throw new Error("Claude settings hooks field is not an object."); + assertSelectedHookEventsAreArrays(parsed); + return parsed; +} + +function assertSelectedHookEventsAreArrays(settings: Record): void { + if (!isRecord(settings.hooks)) return; + for (const event of claudeHookEvents) { + if (settings.hooks[event] !== undefined && !Array.isArray(settings.hooks[event])) { + throw new Error(`Claude settings hooks.${event} must be an array.`); + } + } +} + +function isClaudeHookAsyncSupported(): boolean { + return process.env.OPENPETS_DISABLE_CLAUDE_ASYNC_HOOKS !== "1"; +} + +function shellQuote(value: string): string { + if (/^[a-zA-Z0-9_@%+=:,./-]+$/.test(value)) return value; + if (/[\r\n"]/.test(value) || value.includes("\0")) throw new Error("Local Claude hook path contains unsupported shell characters."); + return `"${value.replaceAll("\\", "\\\\").replaceAll("$", "\\$").replaceAll("`", "\\`")}"`; +} + +function writeClaudeSettings(path: string, settings: Record): void { + assertSafeSettingsPath(path); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const tempPath = `${path}.${process.pid}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + renameSync(tempPath, path); +} + +function backupSettings(path: string): string | undefined { + if (!existsSync(path)) return undefined; + assertSafeSettingsPath(path); + const backupPath = `${path}.openpets-backup-${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}.json`; + writeFileSync(backupPath, readFileSync(path), { mode: 0o600 }); + try { chmodSync(backupPath, 0o600); } catch { /* best effort */ } + return backupPath; +} + +function assertSafeSettingsPath(path: string): void { + if (!existsSync(path)) return; + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("Claude settings path must be a regular file."); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/claude/src/hooks.ts b/packages/claude/src/hooks.ts new file mode 100644 index 00000000..5ae960e5 --- /dev/null +++ b/packages/claude/src/hooks.ts @@ -0,0 +1,229 @@ +import { lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative } from "node:path"; +import { homedir, tmpdir, userInfo } from "node:os"; + +import { createOpenPetsClient, type OpenPetsClient, type OpenPetsReaction, OpenPetsClientError } from "@open-pets/client"; +import { validateHookSpeech as validateSharedHookSpeech } from "@open-pets/agent-events"; + +import { pickHookSpeech, type HookSpeechCategory } from "./hook-messages.js"; + +export type ClaudeHookEventName = "UserPromptSubmit" | "PreToolUse" | "PermissionRequest" | "Notification" | "Stop" | "StopFailure"; + +export interface ClaudeHookDecision { + readonly eventName?: string; + readonly reaction?: OpenPetsReaction; + readonly speechCategory?: HookSpeechCategory; +} + +export interface ClaudeHookOptions { + readonly client?: OpenPetsClient; + readonly configuredPetId?: string; + readonly projectLocal?: boolean; + readonly now?: () => number; + readonly random?: () => number; + readonly throttlePath?: string; + readonly debug?: boolean; +} + +const maxHookInputBytes = 64 * 1024; +const maxProjectLocalSettingsBytes = 256 * 1024; +const speechCooldownMs = 20_000; +const permissionCooldownMs = 3_000; +const reactionCooldownMs = 10_000; + +export async function runClaudeHookFromStdin(stdin: NodeJS.ReadStream = process.stdin, options: ClaudeHookOptions = {}): Promise { + try { + const raw = await readLimitedStdin(stdin, maxHookInputBytes); + await handleClaudeHookPayload(raw, options); + return 0; + } catch (error) { + if (options.debug || process.env.OPENPETS_DEBUG === "1") { + process.stderr.write(`OpenPets Claude hook ignored error: ${sanitizeDebugError(error)}\n`); + } + return 0; + } +} + +export async function handleClaudeHookPayload(raw: string, options: ClaudeHookOptions = {}): Promise { + let parsed: Record; + try { + parsed = parseHookPayload(raw); + } catch { + return null; + } + const decision = mapClaudeHookEvent(parsed); + if (!decision?.reaction) return decision; + if (!options.projectLocal && hasProjectLocalOpenPetsHook()) return decision; + + const shouldSpeak = decision.speechCategory ? shouldSendSpeech(decision.speechCategory, options) : false; + const shouldReact = shouldSendReaction(decision.reaction, options); + if (!shouldSpeak && !shouldReact) return decision; + + const client = options.client ?? createOpenPetsClient({ connectTimeoutMs: 500, responseTimeoutMs: 500 }); + const lease = options.configuredPetId ? await acquireHookLease(client, options.configuredPetId, options.debug) : undefined; + try { + if (decision.speechCategory && shouldSpeak) { + const message = validateHookSpeech(pickHookSpeech(decision.speechCategory, options.random)); + await client.say(message, { reaction: decision.reaction, leaseId: lease?.leaseId }); + } else { + await client.react(decision.reaction, { leaseId: lease?.leaseId }); + } + } catch (error) { + if (!(error instanceof OpenPetsClientError) && options.debug) { + process.stderr.write(`OpenPets Claude hook client error: ${sanitizeDebugError(error)}\n`); + } + } + return decision; +} + +export function hasProjectLocalOpenPetsHook(projectDir = process.env.CLAUDE_PROJECT_DIR): boolean { + if (!projectDir || /[\0\r\n]/.test(projectDir)) return false; + try { + const projectReal = realpathSync(projectDir); + const settingsPath = join(projectReal, ".claude", "settings.local.json"); + const settingsReal = realpathSync(settingsPath); + const rel = relative(projectReal, settingsReal); + if (rel.startsWith("..") || isAbsolute(rel)) return false; + const settingsLstat = lstatSync(settingsPath); + if (settingsLstat.isSymbolicLink()) return false; + const settingsStat = statSync(settingsPath); + if (!settingsStat.isFile() || settingsStat.size <= 0 || settingsStat.size > maxProjectLocalSettingsBytes) return false; + const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as unknown; + return containsProjectLocalOpenPetsHook(settings); + } catch { + return false; + } +} + +function containsProjectLocalOpenPetsHook(value: unknown): boolean { + if (typeof value === "string") return value.includes("--openpets-managed") && value.includes("--project-local"); + if (Array.isArray(value)) return value.some(containsProjectLocalOpenPetsHook); + if (isRecord(value)) return Object.values(value).some(containsProjectLocalOpenPetsHook); + return false; +} + +async function acquireHookLease(client: OpenPetsClient, requestedPetId: string, debug = false): Promise<{ readonly leaseId: string } | undefined> { + try { + return await client.acquireLease({ requestedPetId }); + } catch (error) { + if (debug) process.stderr.write(`OpenPets Claude hook lease unavailable: ${sanitizeDebugError(error)}\n`); + return undefined; + } +} + +export function parseHookPayload(raw: string): Record { + if (Buffer.byteLength(raw, "utf8") > maxHookInputBytes) throw new Error("Claude hook payload is too large."); + const parsed = JSON.parse(raw || "{}") as unknown; + return isRecord(parsed) ? parsed : {}; +} + +export function mapClaudeHookEvent(payload: Record): ClaudeHookDecision | null { + const eventName = typeof payload.hook_event_name === "string" ? payload.hook_event_name : undefined; + if (eventName === "UserPromptSubmit") return { eventName, reaction: "thinking" }; + if (eventName === "PermissionRequest") return { eventName, reaction: "waiting", speechCategory: "permission" }; + if (eventName === "Notification") return { eventName }; + if (eventName === "Stop") return { eventName, reaction: "success" }; + if (eventName === "StopFailure") return { eventName, reaction: "error", speechCategory: "error" }; + if (eventName === "PreToolUse") return { eventName, reaction: classifyToolReaction(payload) }; + return eventName ? { eventName } : null; +} + +export function validateHookSpeech(message: string): string { + return validateSharedHookSpeech(message); +} + +export function getDefaultThrottlePath(): string { + if (process.platform === "win32") { + const base = process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"); + return join(base, "OpenPets", "claude-hook-throttle.json"); + } + const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"); + if (stateHome) return join(stateHome, "openpets", "claude-hook-throttle.json"); + const uid = safeUid(); + return join(tmpdir(), `openpets-${uid}`, "claude-hook-throttle.json"); +} + +function classifyToolReaction(payload: Record): OpenPetsReaction | undefined { + const toolName = typeof payload.tool_name === "string" ? payload.tool_name : ""; + if (toolName === "Edit" || toolName === "Write" || toolName === "MultiEdit") return "editing"; + if (toolName === "Bash") { + const command = extractBashCommand(payload.tool_input); + return /\b(test|vitest|jest|pytest|npm\s+test|pnpm\s+test|yarn\s+test|cargo\s+test|go\s+test)\b/i.test(command) ? "testing" : undefined; + } + return undefined; +} + +function extractBashCommand(value: unknown): string { + return isRecord(value) && typeof value.command === "string" ? value.command.slice(0, 300) : ""; +} + +function shouldSendSpeech(category: HookSpeechCategory, options: ClaudeHookOptions): boolean { + const now = options.now?.() ?? Date.now(); + const cooldown = category === "permission" ? permissionCooldownMs : speechCooldownMs; + return shouldSendThrottleKey(category, cooldown, now, options.throttlePath ?? getDefaultThrottlePath()); +} + +function shouldSendReaction(reaction: OpenPetsReaction, options: ClaudeHookOptions): boolean { + const now = options.now?.() ?? Date.now(); + return shouldSendThrottleKey(`reaction:${reaction}`, reactionCooldownMs, now, options.throttlePath ?? getDefaultThrottlePath()); +} + +function shouldSendThrottleKey(key: string, cooldown: number, now: number, path: string): boolean { + const state = readThrottleState(path); + const previous = typeof state[key] === "number" ? state[key] : 0; + if (now - previous < cooldown) return false; + state[key] = now; + writeThrottleState(path, state); + return true; +} + +function readThrottleState(path: string): Record { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (!isRecord(parsed)) return {}; + const state: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if ((key === "thinking" || key === "success" || key === "error" || key === "permission" || key.startsWith("reaction:")) && typeof value === "number" && Number.isFinite(value)) state[key] = value; + } + return state; + } catch { + return {}; + } +} + +function writeThrottleState(path: string, state: Record): void { + try { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const tempPath = `${path}.${process.pid}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(state)}\n`, { encoding: "utf8", mode: 0o600 }); + renameSync(tempPath, path); + } catch { + // Best effort only; throttling must never break hooks. + } +} + +function readLimitedStdin(stdin: NodeJS.ReadStream, maxBytes: number): Promise { + return new Promise((resolve, reject) => { + let buffer = ""; + stdin.setEncoding("utf8"); + stdin.on("data", (chunk: string) => { + buffer += chunk; + if (Buffer.byteLength(buffer, "utf8") > maxBytes) reject(new Error("Claude hook stdin is too large.")); + }); + stdin.on("error", reject); + stdin.on("end", () => resolve(buffer)); + }); +} + +function sanitizeDebugError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/(?:[A-Za-z]:)?[\\/][^\s"']{2,}/g, "").slice(0, 200); +} + +function safeUid(): string { + try { return String(userInfo().uid); } catch { return "user"; } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/claude/src/index.ts b/packages/claude/src/index.ts new file mode 100644 index 00000000..64cd3d33 --- /dev/null +++ b/packages/claude/src/index.ts @@ -0,0 +1,6 @@ +export const claudePackageName = "@open-pets/claude"; + +export * from "./claude-code.js"; +export * from "./hooks.js"; +export * from "./hook-settings.js"; +export * from "./hook-messages.js"; diff --git a/packages/claude/tsconfig.json b/packages/claude/tsconfig.json new file mode 100644 index 00000000..762e7118 --- /dev/null +++ b/packages/claude/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..ccfeaf77 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,44 @@ +{ + "name": "@open-pets/cli", + "version": "2.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/alvinunreal/openpets.git", + "directory": "packages/cli" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "openpets": "dist/index.js" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "test": "node dist/check-cli-contract.js", + "check": "pnpm typecheck && pnpm build && pnpm test", + "typecheck": "tsc --noEmit", + "build": "tsc" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^6.0.3" + }, + "dependencies": { + "@open-pets/client": "workspace:*", + "@open-pets/claude": "workspace:*", + "@open-pets/mcp": "workspace:*", + "@open-pets/opencode": "workspace:*" + } +} diff --git a/packages/cli/src/check-cli-contract.ts b/packages/cli/src/check-cli-contract.ts new file mode 100644 index 00000000..06e90e24 --- /dev/null +++ b/packages/cli/src/check-cli-contract.ts @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; + +import { assertSafeProjectHookPath, cliPackageName, configureProject, createClaudeMcpAddJsonArgs, createLocalDevCliCommand, createVersionPinnedCliCommand, installProjectLocalHooks, parseConfigureArgs, resolveConfiguredPet, runClaudeMcpAddJson } from "./index.js"; + +const parsed = parseConfigureArgs(["--agent", "claude", "--pet", "fixer", "--cwd", "/tmp/project", "--yes"]); +assert.equal(parsed.agent, "claude"); +assert.equal(parsed.petId, "fixer"); +assert.equal(parsed.cwd, "/tmp/project"); +assert.equal(parsed.yes, true); +assert.equal(parseConfigureArgs(["--pet", "fixer", "--force"]).force, true); +assert.equal(parseConfigureArgs(["--pet", "fixer", "--replace"]).force, true); +assert.equal(parseConfigureArgs(["--pet", "fixer", "--local-dev"]).localDev, true); +assert.equal(parseConfigureArgs(["--pet=fixer"]).petId, "fixer"); +assert.equal(parseConfigureArgs(["--agent", "opencode", "--pet", "fixer"]).agent, "opencode"); +assert.throws(() => parseConfigureArgs(["--agent", "cursor"])); +assert.throws(() => parseConfigureArgs(["--pet", "bad/pet"])); + +const pinned = createVersionPinnedCliCommand("1.2.3", ["mcp", "--pet", "fixer"]); +assert.deepEqual(pinned, { command: "npx", args: ["-y", `${cliPackageName}@1.2.3`, "mcp", "--pet", "fixer"] }); +const localDev = createLocalDevCliCommand(["mcp", "--pet", "fixer"]); +assert.equal(localDev.command, process.execPath); +assert.deepEqual(localDev.args.slice(-3), ["mcp", "--pet", "fixer"]); + +let listPetsCalled = false; +const offlineExplicitPet = await resolveConfiguredPet({ + listPets: async () => { + listPetsCalled = true; + throw new Error("desktop unavailable"); + }, +}, "fixer"); +assert.deepEqual(offlineExplicitPet, { id: "fixer", displayName: "fixer" }); +assert.equal(listPetsCalled, false); + +const mcpArgs = createClaudeMcpAddJsonArgs({ type: "stdio", command: pinned.command, args: pinned.args, env: {} }); +assert.deepEqual(mcpArgs.slice(0, 3), ["mcp", "add-json", "openpets"]); +assert.equal(mcpArgs.at(-2), "--scope"); +assert.equal(mcpArgs.at(-1), "local"); +const mcpJson = JSON.parse(mcpArgs[3] ?? "{}") as { readonly command?: string; readonly args?: readonly string[] }; +assert.equal(mcpJson.command, "npx"); +assert.deepEqual(mcpJson.args, ["-y", `${cliPackageName}@1.2.3`, "mcp", "--pet", "fixer"]); + +const dir = mkdtempSync(join(tmpdir(), "openpets-cli-")); +try { + const project = join(dir, "project"); + const settingsDir = join(project, ".claude"); + mkdirSync(project); + writeFileSync(join(dir, "placeholder"), "x", "utf8"); + assert.throws(() => assertSafeProjectHookPath(join(dir, "missing"))); + installProjectLocalHooks(project, "npx -y @open-pets/cli@1.2.3 hook --openpets-managed --project-local --pet fixer"); + const settingsPath = join(settingsDir, "settings.local.json"); + const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as { readonly hooks?: Record }>> }; + assert.ok(settings.hooks?.UserPromptSubmit?.[0]?.hooks[0]?.command.includes("--project-local --pet fixer")); + + writeFileSync(settingsPath, JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: "echo keep" }] }, { hooks: [{ type: "command", command: "npx -y @open-pets/cli@old hook --openpets-managed" }] }] } }), "utf8"); + installProjectLocalHooks(project, "npx -y @open-pets/cli@1.2.3 hook --openpets-managed --project-local --pet fixer"); + const updated = JSON.parse(readFileSync(settingsPath, "utf8")) as { readonly hooks?: Record }>> }; + const stopCommands = updated.hooks?.Stop?.flatMap((entry) => entry.hooks.map((hook) => hook.command)) ?? []; + assert.ok(stopCommands.includes("echo keep")); + assert.equal(stopCommands.some((command) => command.includes("@old")), false); + assert.ok(stopCommands.some((command) => command.includes("--project-local --pet fixer"))); + assert.equal(updated.hooks?.UserPromptSubmit?.[0]?.hooks[0]?.timeout, 10); + + const badSettingsProject = join(dir, "bad-settings-project"); + mkdirSync(join(badSettingsProject, ".claude"), { recursive: true }); + mkdirSync(join(badSettingsProject, ".claude", "settings.local.json")); + assert.throws(() => assertSafeProjectHookPath(badSettingsProject)); + + const malformedHooksProject = join(dir, "malformed-hooks-project"); + mkdirSync(join(malformedHooksProject, ".claude"), { recursive: true }); + writeFileSync(join(malformedHooksProject, ".claude", "settings.local.json"), JSON.stringify({ hooks: { Stop: { bad: true } } }), "utf8"); + assert.throws(() => installProjectLocalHooks(malformedHooksProject, "npx -y @open-pets/cli@1.2.3 hook --openpets-managed --project-local --pet fixer")); + + const symlinkProject = join(dir, "symlink-project"); + const outside = join(dir, "outside-claude"); + mkdirSync(symlinkProject); + mkdirSync(outside); + symlinkSync(outside, join(symlinkProject, ".claude")); + assert.throws(() => assertSafeProjectHookPath(symlinkProject)); + + const binDir = join(dir, "bin"); + const logPath = join(dir, "claude-log.json"); + mkdirSync(binDir); + const fakeClaude = join(binDir, "claude"); + writeFileSync(fakeClaude, `#!/usr/bin/env node\nconst fs = require('fs'); let log = []; try { log = JSON.parse(fs.readFileSync(${JSON.stringify(logPath)}, 'utf8')); } catch {} log.push({ cwd: process.cwd(), argv: process.argv.slice(2) }); fs.writeFileSync(${JSON.stringify(logPath)}, JSON.stringify(log)); process.exit(0);\n`, "utf8"); + chmodSync(fakeClaude, 0o700); + const oldPath = process.env.PATH; + process.env.PATH = `${binDir}:${oldPath ?? ""}`; + try { + runClaudeMcpAddJson(project, { type: "stdio", command: "npx", args: ["-y", "@open-pets/cli@1.2.3", "mcp", "--pet", "fixer"], env: {} }, true); + } finally { + process.env.PATH = oldPath; + } + const claudeLog = JSON.parse(readFileSync(logPath, "utf8")) as Array<{ readonly cwd: string; readonly argv: readonly string[] }>; + assert.equal(claudeLog.at(-1)?.cwd, realpathSync(project)); + assert.deepEqual(claudeLog.at(-2)?.argv, ["mcp", "remove", "openpets", "--scope", "local"]); + assert.deepEqual(claudeLog.at(-1)?.argv.slice(0, 3), ["mcp", "add-json", "openpets"]); + const loggedMcpJson = JSON.parse(claudeLog.at(-1)?.argv[3] ?? "{}") as { readonly command?: string; readonly args?: readonly string[]; readonly env?: Record }; + assert.equal(loggedMcpJson.command, "npx"); + assert.deepEqual(loggedMcpJson.args, ["-y", "@open-pets/cli@1.2.3", "mcp", "--pet", "fixer"]); + assert.deepEqual(loggedMcpJson.env, {}); + assert.equal(claudeLog.at(-1)?.argv.at(-2), "--scope"); + assert.equal(claudeLog.at(-1)?.argv.at(-1), "local"); + + const opencodeProject = join(dir, "opencode-project"); + mkdirSync(opencodeProject); + await configureProject({ agent: "opencode", petId: "fixer", cwd: opencodeProject, yes: true, force: false, localDev: false }); + const opencodeConfigPath = join(opencodeProject, ".opencode", "opencode.jsonc"); + const opencodeInstructionPath = join(opencodeProject, ".opencode", "openpets.md"); + const opencodeConfig = JSON.parse(readFileSync(opencodeConfigPath, "utf8")) as { readonly mcp?: Record; readonly instructions?: readonly string[]; readonly plugin?: readonly unknown[] }; + assert.deepEqual(opencodeConfig.mcp?.openpets?.command, ["npx", "-y", "@open-pets/cli@2.0.0", "mcp", "--pet", "fixer"]); + assert.deepEqual(opencodeConfig.instructions, [".opencode/openpets.md"]); + assert.deepEqual(opencodeConfig.plugin, [["@open-pets/opencode@2.0.0", { pet: "fixer" }]]); + assert.match(readFileSync(opencodeInstructionPath, "utf8"), /OPENPETS:START/); + await configureProject({ agent: "opencode", petId: "fixer", cwd: opencodeProject, yes: true, force: false, localDev: false }); + const opencodeConfigAgain = readFileSync(opencodeConfigPath, "utf8"); + assert.equal((opencodeConfigAgain.match(/@open-pets\/opencode/g) ?? []).length, 1); + + const existingTopLevel = join(dir, "opencode-existing-top"); + mkdirSync(existingTopLevel); + writeFileSync(join(existingTopLevel, "opencode.json"), JSON.stringify({ theme: "x", mcp: { other: { type: "local", command: ["other"] } }, plugin: ["other-plugin"], instructions: ["README.md"] }, null, 2), "utf8"); + await configureProject({ agent: "opencode", petId: "fixer", cwd: existingTopLevel, yes: true, force: false, localDev: true }); + const existingConfig = JSON.parse(readFileSync(join(existingTopLevel, "opencode.json"), "utf8")) as { readonly theme?: string; readonly mcp?: Record; readonly plugin?: readonly unknown[]; readonly instructions?: readonly string[] }; + assert.equal(existingConfig.theme, "x"); + assert.deepEqual(existingConfig.mcp?.other?.command, ["other"]); + assert.equal(existingConfig.mcp?.openpets?.command?.[0], "node"); + assert.ok(existingConfig.instructions?.includes("README.md")); + assert.ok(existingConfig.instructions?.includes(".opencode/openpets.md")); + assert.ok(existingConfig.plugin?.includes("other-plugin")); + + const lowerOwnerProject = join(dir, "opencode-lower-owner"); + mkdirSync(join(lowerOwnerProject, ".opencode"), { recursive: true }); + writeFileSync(join(lowerOwnerProject, "opencode.json"), JSON.stringify({ theme: "top" }, null, 2), "utf8"); + writeFileSync(join(lowerOwnerProject, ".opencode", "opencode.jsonc"), JSON.stringify({ mcp: { openpets: { type: "local", command: ["npx", "-y", "@open-pets/cli@0.0.1", "mcp", "--pet", "helper"], enabled: true } } }, null, 2), "utf8"); + await configureProject({ agent: "opencode", petId: "fixer", cwd: lowerOwnerProject, yes: true, force: false, localDev: false }); + const lowerTop = readFileSync(join(lowerOwnerProject, "opencode.json"), "utf8"); + const lowerOwned = JSON.parse(readFileSync(join(lowerOwnerProject, ".opencode", "opencode.jsonc"), "utf8")) as { readonly mcp?: Record }; + assert.equal(lowerTop.includes("@open-pets/cli"), false); + assert.deepEqual(lowerOwned.mcp?.openpets?.command, ["npx", "-y", "@open-pets/cli@2.0.0", "mcp", "--pet", "fixer"]); + + const customProject = join(dir, "opencode-custom"); + mkdirSync(customProject); + writeFileSync(join(customProject, "opencode.json"), JSON.stringify({ mcp: { openpets: { type: "local", command: ["my-openpets-wrapper"] } } }), "utf8"); + await assert.rejects(() => configureProject({ agent: "opencode", petId: "fixer", cwd: customProject, yes: true, force: false, localDev: false })); + assert.equal(readFileSync(join(customProject, "opencode.json"), "utf8").includes("@open-pets/cli"), false); + + const instructionProject = join(dir, "opencode-instruction"); + mkdirSync(join(instructionProject, ".opencode"), { recursive: true }); + writeFileSync(join(instructionProject, ".opencode", "openpets.md"), "User text\n", "utf8"); + await configureProject({ agent: "opencode", petId: "fixer", cwd: instructionProject, yes: true, force: false, localDev: false }); + const instructionText = readFileSync(join(instructionProject, ".opencode", "openpets.md"), "utf8"); + assert.match(instructionText, /User text/); + assert.match(instructionText, /OPENPETS:START/); + + const symlinkOpenCodeProject = join(dir, "opencode-symlink"); + const outsideOpenCode = join(dir, "outside-opencode"); + mkdirSync(symlinkOpenCodeProject); + mkdirSync(outsideOpenCode); + writeFileSync(join(outsideOpenCode, "opencode.jsonc"), "{}\n", "utf8"); + writeFileSync(join(outsideOpenCode, "openpets.md"), "outside\n", "utf8"); + symlinkSync(outsideOpenCode, join(symlinkOpenCodeProject, ".opencode")); + await assert.rejects(() => configureProject({ agent: "opencode", petId: "fixer", cwd: symlinkOpenCodeProject, yes: true, force: false, localDev: false })); +} finally { + rmSync(dir, { recursive: true, force: true }); +} + +const invalidHook = spawnSync(process.execPath, [new URL("./index.js", import.meta.url).pathname, "hook", "--openpets-managed", "--pet", "bad/pet"], { input: JSON.stringify({ hook_event_name: "Notification" }), encoding: "utf8" }); +assert.equal(invalidHook.status, 1); +const missingPetHook = spawnSync(process.execPath, [new URL("./index.js", import.meta.url).pathname, "hook", "--openpets-managed", "--pet"], { input: JSON.stringify({ hook_event_name: "Notification" }), encoding: "utf8" }); +assert.equal(missingPetHook.status, 1); + +for (const args of [["--help"], ["-h"], ["configure", "--help"], ["configure", "-h"], ["mcp", "--help"], ["hook", "--help"]]) { + const help = spawnSync(process.execPath, [new URL("./index.js", import.meta.url).pathname, ...args], { encoding: "utf8" }); + assert.equal(help.status, 0); + assert.match(help.stdout, /Usage:/); +} + +console.error("CLI contract validation passed."); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 00000000..472b5f98 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,371 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from "node:child_process"; +import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { createRequire } from "node:module"; +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { fileURLToPath } from "node:url"; + +import { createOpenPetsClient, OpenPetsClientError, type OpenPetsPetListItem } from "@open-pets/client"; +import { claudeHookEvents, openPetsHookMarker, removeOpenPetsHooks, runClaudeHookFromStdin, validateOpenPetsPetArg } from "@open-pets/claude"; +import { prepareOpenCodeProjectSetup, writePreparedOpenCodeProjectSetup } from "@open-pets/opencode"; + +export const cliPackageName = "@open-pets/cli"; + +interface ConfigureOptions { + readonly agent: "claude" | "opencode"; + readonly petId?: string; + readonly cwd: string; + readonly yes: boolean; + readonly force: boolean; + readonly localDev: boolean; +} + +interface CommandSpec { + readonly command: string; + readonly args: readonly string[]; +} + +interface PreparedHooks { + readonly settingsPath: string; + readonly settings: Record; +} + +interface ConfiguredPet { + readonly id: string; + readonly displayName: string; +} + +const require = createRequire(import.meta.url); + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2); + if (!command || command === "--help" || command === "-h") { + printUsage(); + return; + } + if (command === "configure") { + if (hasHelp(args)) { + printConfigureUsage(); + return; + } + await configureProject(parseConfigureArgs(args)); + return; + } + if (command === "mcp") { + if (hasHelp(args)) { + printMcpUsage(); + return; + } + await runMcp(args); + return; + } + if (command === "hook") { + if (hasHelp(args)) { + printHookUsage(); + return; + } + const code = await runClaudeHookFromStdin(process.stdin, { configuredPetId: readPetArg(args), projectLocal: hasProjectLocalArg(args), debug: process.env.OPENPETS_DEBUG === "1" }); + process.exitCode = code; + return; + } + throw new CliError(`Unknown command: ${command}`); +} + +export async function configureProject(options: ConfigureOptions): Promise { + const projectDir = resolveProjectDir(options.cwd); + if (options.agent === "opencode") { + await configureOpenCodeProject(options, projectDir); + return; + } + assertClaudeAvailable(); + assertSafeProjectHookPath(projectDir); + const client = createOpenPetsClient(); + const selectedPet = await resolveConfiguredPet(client, options.petId); + const petId = selectedPet.id; + const packageVersion = getPackageVersion(); + const mcpCommand = options.localDev ? createLocalDevCliCommand(["mcp", "--pet", petId]) : createVersionPinnedCliCommand(packageVersion, ["mcp", "--pet", petId]); + const hookCommand = formatShellCommand(options.localDev ? createLocalDevCliCommand(["hook", openPetsHookMarker, "--project-local", "--pet", petId]) : createVersionPinnedCliCommand(packageVersion, ["hook", openPetsHookMarker, "--project-local", "--pet", petId])); + const mcpConfig = { type: "stdio", command: mcpCommand.command, args: mcpCommand.args, env: {} }; + const preparedHooks = prepareProjectLocalHooks(projectDir, hookCommand); + runClaudeMcpAddJson(projectDir, mcpConfig, options.force); + writePreparedHooks(preparedHooks); + process.stdout.write(`OpenPets configured for Claude in ${projectDir}.\nPet: ${sanitizeTerminalText(selectedPet.displayName)} (${selectedPet.id})\n`); +} + +async function configureOpenCodeProject(options: ConfigureOptions, projectDir: string): Promise { + const client = createOpenPetsClient(); + const selectedPet = await resolveConfiguredPet(client, options.petId); + const packageVersion = getPackageVersion(); + const prepared = prepareOpenCodeProjectSetup({ projectDir, petId: selectedPet.id, cliVersion: packageVersion, commandMode: options.localDev ? "local" : "published", cliEntryPath: options.localDev ? fileURLToPath(import.meta.url) : undefined }); + writePreparedOpenCodeProjectSetup(prepared); + process.stdout.write(`OpenPets configured for OpenCode in ${projectDir}.\nPet: ${sanitizeTerminalText(selectedPet.displayName)} (${selectedPet.id})\nConfig: ${prepared.configPath}\nInstructions: ${prepared.instructionPath}\nWarning: .opencode config/instructions can be committed and include the selected pet id.\nRestart OpenCode in this project to load OpenPets.\n`); +} + +export async function resolveConfiguredPet(client: Pick, "listPets">, petId?: string): Promise { + if (petId) { + const id = validateOpenPetsPetArg(petId); + return { id, displayName: id }; + } + + const petList = await getInstalledPets(client); + const id = validateOpenPetsPetArg(await pickPet(petList.pets)); + const selectedPet = petList.pets.find((pet) => pet.id === id); + if (!selectedPet || selectedPet.broken) throw new CliError(`Pet is not installed or usable: ${id}`); + return { id: selectedPet.id, displayName: selectedPet.displayName }; +} + +export function parseConfigureArgs(args: readonly string[]): ConfigureOptions { + let agent = "claude"; + let petId: string | undefined; + let cwd = process.cwd(); + let yes = false; + let force = false; + let localDev = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--yes" || arg === "-y") yes = true; + else if (arg === "--force" || arg === "--replace") force = true; + else if (arg === "--local-dev") localDev = true; + else if (arg === "--agent") { agent = readRequiredArg(args, index, "--agent"); index += 1; } + else if (arg.startsWith("--agent=")) agent = arg.slice("--agent=".length); + else if (arg === "--pet") { petId = validateOpenPetsPetArg(readRequiredArg(args, index, "--pet")); index += 1; } + else if (arg.startsWith("--pet=")) petId = validateOpenPetsPetArg(arg.slice("--pet=".length)); + else if (arg === "--cwd") { cwd = readRequiredArg(args, index, "--cwd"); index += 1; } + else if (arg.startsWith("--cwd=")) cwd = arg.slice("--cwd=".length); + else throw new CliError(`Unknown configure option: ${arg}`); + } + if (agent !== "claude" && agent !== "opencode") throw new CliError(`Unsupported agent: ${agent}. Supported agents: claude, opencode.`); + return { agent, petId, cwd, yes, force, localDev }; +} + +export function createVersionPinnedCliCommand(version: string, args: readonly string[]): CommandSpec { + return { command: "npx", args: ["-y", `${cliPackageName}@${version}`, ...args] }; +} + +export function createLocalDevCliCommand(args: readonly string[]): CommandSpec { + return { command: process.execPath, args: [fileURLToPath(import.meta.url), ...args] }; +} + +export function createClaudeMcpAddJsonArgs(config: unknown): readonly string[] { + return ["mcp", "add-json", "openpets", JSON.stringify(config), "--scope", "local"]; +} + +export function installProjectLocalHooks(projectDir: string, hookCommand: string): void { + writePreparedHooks(prepareProjectLocalHooks(projectDir, hookCommand)); +} + +export function prepareProjectLocalHooks(projectDir: string, hookCommand: string): PreparedHooks { + assertSafeProjectHookPath(projectDir); + const settingsPath = getProjectLocalSettingsPath(realpathSync(projectDir)); + const current = readJsonObject(settingsPath); + const cleaned = removeOpenPetsHooks(current); + const hooks = isRecord(cleaned.hooks) ? { ...cleaned.hooks } : {}; + for (const event of claudeHookEvents) { + if (hooks[event] !== undefined && !Array.isArray(hooks[event])) throw new CliError(`Claude local settings hooks.${event} must be an array.`); + const existing = Array.isArray(hooks[event]) ? hooks[event] : []; + hooks[event] = [...existing, { hooks: [createHookCommandEntry(hookCommand)] }]; + } + return { settingsPath, settings: { ...cleaned, hooks } }; +} + +function writePreparedHooks(prepared: PreparedHooks): void { + writeJsonFile(prepared.settingsPath, prepared.settings); +} + +function createHookCommandEntry(command: string): Record { + return { type: "command", command, timeout: 10, async: true, asyncRewake: false }; +} + +export function runClaudeMcpAddJson(projectDir: string, config: unknown, force = false): void { + if (force) runClaudeMcpRemove(projectDir); + const result = spawnSync("claude", createClaudeMcpAddJsonArgs(config), { cwd: projectDir, encoding: "utf8", shell: false, stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }); + if (result.error) throw new CliError(`Claude Code is unavailable on PATH: ${result.error.message}`); + if (result.status !== 0) throw new CliError(`Claude MCP configuration failed: ${(result.stderr || result.stdout || "unknown error").trim()}`); +} + +function runClaudeMcpRemove(projectDir: string): void { + const result = spawnSync("claude", ["mcp", "remove", "openpets", "--scope", "local"], { cwd: projectDir, encoding: "utf8", shell: false, stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }); + if (result.error) throw new CliError(`Claude Code is unavailable on PATH: ${result.error.message}`); + const output = `${result.stderr || ""}\n${result.stdout || ""}`; + if (result.status !== 0 && !/not found|does not exist|no server|unknown/i.test(output)) { + throw new CliError(`Claude MCP remove failed: ${(result.stderr || result.stdout || "unknown error").trim()}`); + } +} + +async function runMcp(args: readonly string[]): Promise { + const entry = require.resolve("@open-pets/mcp"); + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(process.execPath, [entry, ...args], { stdio: "inherit" }); + const forwardSigint = (): void => { child.kill("SIGINT"); }; + const forwardSigterm = (): void => { child.kill("SIGTERM"); }; + process.once("SIGINT", forwardSigint); + process.once("SIGTERM", forwardSigterm); + child.on("error", rejectPromise); + child.on("exit", (code, signal) => { + process.off("SIGINT", forwardSigint); + process.off("SIGTERM", forwardSigterm); + if (signal) { process.kill(process.pid, signal); return; } + process.exitCode = code ?? 1; + resolvePromise(); + }); + }); +} + +async function getInstalledPets(client: Pick, "listPets">) { + try { + return await client.listPets(); + } catch (error) { + if (error instanceof OpenPetsClientError && error.code === "unknown_method") throw new CliError("OpenPets desktop app is too old for project setup. Update/restart OpenPets and try again."); + throw new CliError("OpenPets desktop app is not running. Open OpenPets, then run this command again."); + } +} + +async function pickPet(pets: readonly OpenPetsPetListItem[]): Promise { + const usable = pets.filter((pet) => !pet.broken); + if (usable.length === 0) throw new CliError("No usable installed pets found. Open OpenPets and install a pet first."); + if (!process.stdin.isTTY) throw new CliError("Missing --pet . Non-interactive shells must pass --pet."); + process.stdout.write("Pick pet for this project:\n"); + usable.forEach((pet, index) => process.stdout.write(` ${index + 1}. ${sanitizeTerminalText(pet.displayName)} (${pet.id})\n`)); + const rl = createInterface({ input, output }); + try { + const answer = await rl.question("Pet number: "); + const index = Number(answer.trim()) - 1; + if (!Number.isInteger(index) || !usable[index]) throw new CliError("Invalid pet selection."); + return usable[index].id; + } finally { + rl.close(); + } +} + +function sanitizeTerminalText(value: string): string { + return value.replace(/[\x00-\x1F\x7F]/g, "").slice(0, 100); +} + +function resolveProjectDir(cwd: string): string { + const resolved = resolve(cwd); + const stats = lstatSync(resolved); + if (stats.isSymbolicLink()) throw new CliError("Project directory cannot be a symlink."); + if (!stats.isDirectory()) throw new CliError("Project path must be a directory."); + return realpathSync(resolved); +} + +function assertClaudeAvailable(): void { + const result = spawnSync("claude", ["--version"], { shell: false, stdio: "ignore", timeout: 5_000 }); + if (result.error || result.status !== 0) throw new CliError("Claude Code is unavailable on PATH. Install Claude Code, then try again."); +} + +export function assertSafeProjectHookPath(projectDir: string): void { + const projectReal = realpathSync(projectDir); + const claudeDir = join(projectReal, ".claude"); + if (existsSync(claudeDir)) { + const claudeStats = lstatSync(claudeDir); + if (claudeStats.isSymbolicLink()) throw new CliError("Project .claude directory cannot be a symlink."); + if (!claudeStats.isDirectory()) throw new CliError("Project .claude path must be a directory."); + const rel = relative(projectReal, realpathSync(claudeDir)); + if (rel.startsWith("..") || isAbsolute(rel)) throw new CliError("Project .claude directory escapes the project."); + } + const settingsPath = getProjectLocalSettingsPath(projectReal); + if (existsSync(settingsPath)) { + const settingsStats = lstatSync(settingsPath); + if (settingsStats.isSymbolicLink()) throw new CliError("Project Claude local settings file cannot be a symlink."); + if (!settingsStats.isFile()) throw new CliError("Project Claude local settings path must be a file."); + } + const settingsRel = relative(projectReal, resolve(settingsPath)); + if (settingsRel.startsWith("..") || isAbsolute(settingsRel)) throw new CliError("Project Claude local settings path escapes the project."); +} + +function getProjectLocalSettingsPath(projectDir: string): string { + return join(projectDir, ".claude", "settings.local.json"); +} + +function readJsonObject(path: string): Record { + if (!existsSync(path)) return {}; + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (!isRecord(parsed) || Array.isArray(parsed)) throw new CliError("Claude local settings must be a JSON object."); + if (parsed.hooks !== undefined && !isRecord(parsed.hooks)) throw new CliError("Claude local settings hooks field must be an object."); + return parsed; +} + +function writeJsonFile(path: string, value: Record): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const parentStats = lstatSync(dirname(path)); + if (parentStats.isSymbolicLink() || !parentStats.isDirectory()) throw new CliError("Project .claude directory is unsafe after creation."); + const tempPath = `${path}.${process.pid}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + renameSync(tempPath, path); + try { chmodSync(path, 0o600); } catch { /* best effort */ } +} + +function readPetArg(args: readonly string[]): string | undefined { + const equals = args.find((arg) => arg.startsWith("--pet=")); + if (equals) return validateOpenPetsPetArg(equals.slice("--pet=".length)); + const index = args.indexOf("--pet"); + const value = index >= 0 ? args[index + 1] : undefined; + if (index >= 0 && (!value || value.startsWith("--"))) throw new CliError("Missing value for --pet."); + return value && value.length > 0 ? validateOpenPetsPetArg(value) : undefined; +} + +function hasProjectLocalArg(args: readonly string[]): boolean { + return args.includes("--project-local"); +} + +function readRequiredArg(args: readonly string[], index: number, flag: string): string { + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new CliError(`Missing value for ${flag}.`); + return value; +} + +function formatShellCommand(command: CommandSpec): string { + return [command.command, ...command.args].map(shellQuote).join(" "); +} + +function shellQuote(value: string): string { + if (/^[a-zA-Z0-9_@%+=:,./-]+$/.test(value)) return value; + if (/[\r\n"]/.test(value) || value.includes("\0")) throw new CliError("Command argument contains unsupported shell characters."); + return `"${value.replaceAll("\\", "\\\\").replaceAll("$", "\\$").replaceAll("`", "\\`")}"`; +} + +function getPackageVersion(): string { + const parsed = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as unknown; + if (!isRecord(parsed) || typeof parsed.version !== "string") throw new CliError("Cannot read OpenPets CLI package version."); + return parsed.version; +} + +function printUsage(): void { + process.stdout.write("Usage:\n openpets configure [--agent claude|opencode] [--pet ] [--cwd ] [--yes] [--force]\n openpets mcp [--pet ]\n openpets hook --openpets-managed [--pet ]\n\nRun `openpets --help` for command options.\n"); +} + +function printConfigureUsage(): void { + process.stdout.write("Usage:\n openpets configure [--agent claude|opencode] [--pet ] [--cwd ] [--yes] [--force]\n\nOptions:\n --pet Pet id to use for this project. If omitted, prompts with installed pets.\n --agent Agent to configure: claude or opencode. Defaults to claude.\n --cwd Project directory to configure. Defaults to current directory.\n --yes, -y Accepted for scripts; no confirmation prompt is shown.\n --force Replace supported managed entries where applicable.\n --replace Alias for --force.\n --local-dev Use local development command paths where supported.\n -h, --help Show this help.\n"); +} + +function printMcpUsage(): void { + process.stdout.write("Usage:\n openpets mcp [--pet ]\n\nStarts the OpenPets MCP server wrapper. This command is written into Claude MCP config by `openpets configure`.\n"); +} + +function printHookUsage(): void { + process.stdout.write("Usage:\n openpets hook --openpets-managed [--pet ]\n\nRuns one Claude hook event from stdin. This command is written into Claude project hooks by `openpets configure`.\n"); +} + +function hasHelp(args: readonly string[]): boolean { + return args.includes("--help") || args.includes("-h"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +class CliError extends Error {} + +if (isMainModule()) { + main().catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} + +function isMainModule(): boolean { + return process.argv[1] ? resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false; +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000..762e7118 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 00000000..a3102657 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,40 @@ +{ + "name": "@open-pets/client", + "version": "2.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/alvinunreal/openpets.git", + "directory": "packages/client" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "test": "node dist/check-client-protocol.js", + "check": "pnpm typecheck && pnpm build && pnpm test", + "typecheck": "tsc --noEmit", + "build": "tsc", + "smoke": "pnpm build && node dist/smoke.js", + "smoke:status": "pnpm smoke status", + "smoke:react": "pnpm smoke react", + "smoke:say": "pnpm smoke say", + "smoke:invalid-token": "pnpm smoke invalid-token" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^6.0.3" + } +} diff --git a/packages/client/src/check-client-protocol.ts b/packages/client/src/check-client-protocol.ts new file mode 100644 index 00000000..d6b64a7b --- /dev/null +++ b/packages/client/src/check-client-protocol.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; + +import { validateDiscovery } from "./discovery.js"; +import { parsePetListResult } from "./index.js"; +import { OpenPetsClientError, parseIpcResponse, validateReaction } from "./protocol.js"; + +const baseDiscovery = { + protocolVersion: 1, + protocol: "openpets-ipc", + endpoint: process.platform === "win32" ? "\\\\.\\pipe\\openpets-abc-123" : "/tmp/openpets-501/openpets-123.sock", + token: "x".repeat(32), + appVersion: "0.0.0", + pid: 123, + platform: process.platform, +}; + +validateDiscovery(baseDiscovery); +assertRejects(() => validateDiscovery({ ...baseDiscovery, protocol: "http" })); +assertRejects(() => validateDiscovery({ ...baseDiscovery, protocolVersion: 2 })); +assertRejects(() => validateDiscovery({ ...baseDiscovery, endpoint: "127.0.0.1:1234" })); +assertRejects(() => validateDiscovery({ ...baseDiscovery, platform: "freebsd" })); +assertRejects(() => validateReaction("bad")); +assert.equal(validateReaction("waving"), "waving"); + +const ok = parseIpcResponse<{ value: number }>({ id: "1", ok: true, result: { value: 1 } }); +if (!ok.ok || ok.result.value !== 1) throw new Error("Failed to parse ok response."); + +const err = parseIpcResponse({ id: "1", ok: false, error: { code: "invalid_token", message: "Invalid" } }); +if (err.ok || err.error.code !== "invalid_token") throw new Error("Failed to parse error response."); + +assertRejects(() => parseIpcResponse({ ok: true })); +assert.deepEqual(parsePetListResult({ ok: true, defaultPetId: "builtin", pets: [{ id: "fixer", displayName: "Fixer", builtIn: false, broken: false }] }), { ok: true, defaultPetId: "builtin", pets: [{ id: "fixer", displayName: "Fixer", builtIn: false, broken: false }] }); +assertRejects(() => parsePetListResult({ ok: true, pets: [{ id: "fixer" }], defaultPetId: "builtin" })); + +console.log("Client protocol validation passed."); + +function assertRejects(callback: () => unknown): void { + try { + callback(); + } catch (error) { + if (error instanceof OpenPetsClientError || error instanceof Error) return; + } + throw new Error("Expected validation to reject."); +} diff --git a/packages/client/src/discovery.ts b/packages/client/src/discovery.ts new file mode 100644 index 00000000..48803b40 --- /dev/null +++ b/packages/client/src/discovery.ts @@ -0,0 +1,125 @@ +import { existsSync, lstatSync, readFileSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join } from "node:path"; + +import { isRecord, maxIpcMessageBytes, openPetsIpcProtocol, openPetsIpcVersion, OpenPetsClientError } from "./protocol.js"; + +export interface OpenPetsDiscoveryFile { + readonly protocolVersion: 1; + readonly protocol: "openpets-ipc"; + readonly endpoint: string; + readonly token: string; + readonly appVersion: string; + readonly pid: number; + readonly platform: NodeJS.Platform; +} + +export function getDiscoveryFilePath(): string { + if (process.env.OPENPETS_DISCOVERY_FILE) { + return process.env.OPENPETS_DISCOVERY_FILE; + } + + if (process.platform === "darwin") { + return join(homedir(), "Library", "Application Support", "OpenPets", "runtime", "ipc.json"); + } + + if (process.platform === "win32") { + return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "OpenPets", "runtime", "ipc.json"); + } + + const xdg = getSecureXdgRuntimeDir(); + if (xdg) { + return join(xdg, "openpets", "ipc.json"); + } + + return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "OpenPets", "runtime", "ipc.json"); +} + +export function readDiscoveryFile(path = getDiscoveryFilePath()): OpenPetsDiscoveryFile { + let raw: string; + try { + const stat = statSync(path); + if (!stat.isFile()) throw new OpenPetsClientError("invalid_discovery", "OpenPets discovery path is not a file."); + if (stat.size > maxIpcMessageBytes) throw new OpenPetsClientError("invalid_discovery", "OpenPets discovery file is too large."); + raw = readFileSync(path, "utf8"); + } catch (error) { + if (error instanceof OpenPetsClientError) throw error; + throw new OpenPetsClientError("unavailable", `OpenPets discovery file is unavailable: ${error instanceof Error ? error.message : "unknown error"}`); + } + + if (Buffer.byteLength(raw, "utf8") > maxIpcMessageBytes) { + throw new OpenPetsClientError("invalid_discovery", "OpenPets discovery file is too large."); + } + + try { + return validateDiscovery(JSON.parse(raw) as unknown); + } catch (error) { + if (error instanceof OpenPetsClientError) throw error; + throw new OpenPetsClientError("invalid_discovery", "OpenPets discovery file is malformed JSON."); + } +} + +export function validateDiscovery(value: unknown): OpenPetsDiscoveryFile { + if (!isRecord(value)) throw new OpenPetsClientError("invalid_discovery", "Discovery must be an object."); + if (value.protocol !== openPetsIpcProtocol) throw new OpenPetsClientError("invalid_discovery", "Discovery protocol is invalid."); + if (value.protocolVersion !== openPetsIpcVersion) throw new OpenPetsClientError("invalid_discovery", "Discovery protocol version is invalid."); + if (value.platform !== process.platform) throw new OpenPetsClientError("invalid_discovery", "Discovery platform does not match this client."); + if (typeof value.endpoint !== "string") throw new OpenPetsClientError("invalid_discovery", "Discovery endpoint is invalid."); + if (typeof value.token !== "string" || value.token.length < 16 || value.token.length > 256) throw new OpenPetsClientError("invalid_discovery", "Discovery token is invalid."); + if (typeof value.appVersion !== "string") throw new OpenPetsClientError("invalid_discovery", "Discovery app version is invalid."); + if (typeof value.pid !== "number" || !Number.isInteger(value.pid) || value.pid <= 0) throw new OpenPetsClientError("invalid_discovery", "Discovery pid is invalid."); + + validateEndpoint(value.endpoint); + + return { + protocolVersion: openPetsIpcVersion, + protocol: openPetsIpcProtocol, + endpoint: value.endpoint, + token: value.token, + appVersion: value.appVersion, + pid: value.pid, + platform: value.platform as NodeJS.Platform, + }; +} + +export function validateEndpoint(endpoint: string): void { + if (endpoint.length < 1 || endpoint.length > 240) throw new OpenPetsClientError("invalid_discovery", "Discovery endpoint length is invalid."); + if (endpoint.includes("\0")) throw new OpenPetsClientError("invalid_discovery", "Discovery endpoint contains NUL."); + + if (process.platform === "win32") { + if (!endpoint.startsWith("\\\\.\\pipe\\openpets-") || endpoint.includes("/")) { + throw new OpenPetsClientError("invalid_discovery", "Discovery endpoint is not an OpenPets named pipe."); + } + return; + } + + if (!endpoint.startsWith("/") || endpoint.includes("://") || endpoint.includes("..")) { + throw new OpenPetsClientError("invalid_discovery", "Discovery endpoint is not an absolute Unix socket path."); + } + + if (!basename(endpoint).startsWith("openpets-") || !basename(endpoint).endsWith(".sock")) { + throw new OpenPetsClientError("invalid_discovery", "Discovery endpoint filename is not an OpenPets socket."); + } + + const parent = dirname(endpoint); + const parentName = basename(parent); + const isTmpRuntime = parent.startsWith("/tmp/") && parentName.startsWith("openpets-"); + const isXdgRuntime = parentName === "openpets"; + if (!isTmpRuntime && !isXdgRuntime) { + throw new OpenPetsClientError("invalid_discovery", "Discovery endpoint is outside an expected OpenPets runtime directory."); + } +} + +function getSecureXdgRuntimeDir(): string | null { + const dir = process.env.XDG_RUNTIME_DIR; + if (!dir || !existsSync(dir)) return null; + try { + const stat = lstatSync(dir); + if (!stat.isDirectory() || stat.isSymbolicLink()) return null; + if (typeof process.getuid === "function" && stat.uid !== process.getuid()) return null; + if ((stat.mode & 0o777) !== 0o700) return null; + return dir; + } catch { + return null; + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 00000000..2c86b5db --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,169 @@ +import net from "node:net"; +import { randomUUID } from "node:crypto"; + +import { readDiscoveryFile, type OpenPetsDiscoveryFile } from "./discovery.js"; +import { connectTimeoutMs, maxIpcMessageBytes, openPetsIpcVersion, parseIpcResponse, responseTimeoutMs, validateReaction, OpenPetsClientError, type OpenPetsIpcMethod, type OpenPetsIpcRequest, type OpenPetsReaction } from "./protocol.js"; + +export { getDiscoveryFilePath, readDiscoveryFile, validateDiscovery, validateEndpoint, type OpenPetsDiscoveryFile } from "./discovery.js"; +export { allowedReactions, OpenPetsClientError, type OpenPetsReaction } from "./protocol.js"; + +export interface OpenPetsClientOptions { + readonly discoveryPath?: string; + readonly connectTimeoutMs?: number; + readonly responseTimeoutMs?: number; +} + +export interface OpenPetsStatusResult { + readonly ok: boolean; + readonly appRunning: boolean; + readonly unavailableReason?: string; + readonly [key: string]: unknown; +} + +export interface OpenPetsLeaseResult { + readonly leaseId: string; + readonly requestedPetId?: string; + readonly targetKind: "default" | "explicit"; + readonly actualTargetPetId: string; + readonly actualTargetPetName: string; + readonly usingDefaultPet: boolean; + readonly fallbackReason?: string; + readonly expiresAt: number; + readonly leaseActive: boolean; +} + +export interface OpenPetsPetListResult { + readonly ok: true; + readonly pets: readonly OpenPetsPetListItem[]; + readonly defaultPetId: string; +} + +export interface OpenPetsPetListItem { + readonly id: string; + readonly displayName: string; + readonly builtIn: boolean; + readonly broken: boolean; +} + +export interface OpenPetsClient { + hello(): Promise; + status(options?: { readonly leaseId?: string }): Promise; + listPets(): Promise; + acquireLease(options?: { readonly requestedPetId?: string }): Promise; + heartbeatLease(leaseId: string): Promise<{ readonly leaseId: string; readonly expiresAt: number }>; + releaseLease(leaseId: string): Promise<{ readonly released: boolean }>; + react(reaction: OpenPetsReaction, options?: { readonly leaseId?: string }): Promise; + say(message: string, options?: { readonly reaction?: OpenPetsReaction; readonly leaseId?: string }): Promise; +} + +export function createOpenPetsClient(options: OpenPetsClientOptions = {}): OpenPetsClient { + return { + hello: () => sendDiscoveredRequest("hello", {}, options), + status: async (statusOptions) => { + try { + return await sendDiscoveredRequest("status", { leaseId: statusOptions?.leaseId }, options); + } catch (error) { + return { + ok: false, + appRunning: false, + unavailableReason: error instanceof Error ? error.message : "OpenPets is unavailable.", + }; + } + }, + listPets: async () => parsePetListResult(await sendDiscoveredRequest("pets.list", {}, options)), + acquireLease: (leaseOptions) => sendDiscoveredRequest("lease.acquire", { requestedPetId: leaseOptions?.requestedPetId }, options), + heartbeatLease: (leaseId) => sendDiscoveredRequest("lease.heartbeat", { leaseId }, options), + releaseLease: (leaseId) => sendDiscoveredRequest("lease.release", { leaseId }, options), + react: (reaction, reactOptions) => sendDiscoveredRequest("pet.react", { reaction: validateReaction(reaction), leaseId: reactOptions?.leaseId }, options), + say: (message, sayOptions) => sendDiscoveredRequest("pet.say", { message, reaction: sayOptions?.reaction, leaseId: sayOptions?.leaseId }, options), + }; +} + +export function parsePetListResult(value: unknown): OpenPetsPetListResult { + if (!isRecord(value) || value.ok !== true || !Array.isArray(value.pets) || typeof value.defaultPetId !== "string") { + throw new OpenPetsClientError("invalid_response", "OpenPets pet list response is invalid."); + } + return { + ok: true, + defaultPetId: value.defaultPetId, + pets: value.pets.map(parsePetListItem), + }; +} + +function parsePetListItem(value: unknown): OpenPetsPetListItem { + if (!isRecord(value) || typeof value.id !== "string" || typeof value.displayName !== "string" || typeof value.builtIn !== "boolean" || typeof value.broken !== "boolean") { + throw new OpenPetsClientError("invalid_response", "OpenPets pet list item is invalid."); + } + return { id: value.id, displayName: value.displayName, builtIn: value.builtIn, broken: value.broken }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +async function sendDiscoveredRequest(method: OpenPetsIpcMethod, params: unknown, options: OpenPetsClientOptions): Promise { + const discovery = readDiscoveryFile(options.discoveryPath); + return sendRequest(discovery, method, params, options); +} + +export function sendRequest(discovery: OpenPetsDiscoveryFile, method: OpenPetsIpcMethod, params: unknown, options: OpenPetsClientOptions = {}): Promise { + const request: OpenPetsIpcRequest = { + id: randomUUID(), + version: openPetsIpcVersion, + token: discovery.token, + method, + params, + }; + + const requestLine = `${JSON.stringify(request)}\n`; + if (Buffer.byteLength(requestLine, "utf8") > maxIpcMessageBytes) { + return Promise.reject(new OpenPetsClientError("request_too_large", "OpenPets IPC request is too large.")); + } + + return new Promise((resolve, reject) => { + const socket = net.createConnection(discovery.endpoint); + let buffer = ""; + let settled = false; + + const connectTimer = setTimeout(() => finish(new OpenPetsClientError("connect_timeout", "Timed out connecting to OpenPets.")), options.connectTimeoutMs ?? connectTimeoutMs); + const responseTimer = setTimeout(() => finish(new OpenPetsClientError("response_timeout", "Timed out waiting for OpenPets response.")), options.responseTimeoutMs ?? responseTimeoutMs); + + const finish = (error?: unknown, result?: T): void => { + if (settled) return; + settled = true; + clearTimeout(connectTimer); + clearTimeout(responseTimer); + socket.destroy(); + if (error) reject(error); + else resolve(result as T); + }; + + socket.setEncoding("utf8"); + socket.once("connect", () => { + clearTimeout(connectTimer); + socket.write(requestLine); + }); + socket.on("data", (chunk) => { + buffer += chunk; + if (Buffer.byteLength(buffer, "utf8") > maxIpcMessageBytes) { + finish(new OpenPetsClientError("response_too_large", "OpenPets IPC response is too large.")); + return; + } + + const newline = buffer.indexOf("\n"); + if (newline === -1) return; + + try { + const parsed = parseIpcResponse(JSON.parse(buffer.slice(0, newline)) as unknown); + if (parsed.ok) finish(undefined, parsed.result); + else finish(new OpenPetsClientError(parsed.error.code, parsed.error.message)); + } catch (error) { + finish(error); + } + }); + socket.once("error", (error) => finish(new OpenPetsClientError("unavailable", error.message))); + socket.once("end", () => { + if (!settled) finish(new OpenPetsClientError("connection_closed", "OpenPets closed the IPC connection before responding.")); + }); + }); +} diff --git a/packages/client/src/protocol.ts b/packages/client/src/protocol.ts new file mode 100644 index 00000000..6cbdd9b6 --- /dev/null +++ b/packages/client/src/protocol.ts @@ -0,0 +1,79 @@ +export const openPetsIpcProtocol = "openpets-ipc"; +export const openPetsIpcVersion = 1; +export const maxIpcMessageBytes = 16 * 1024; +export const connectTimeoutMs = 2_000; +export const responseTimeoutMs = 3_000; + +export const allowedReactions = [ + "idle", + "thinking", + "working", + "editing", + "running", + "testing", + "waiting", + "waving", + "success", + "error", + "celebrating", +] as const; + +export type OpenPetsReaction = typeof allowedReactions[number]; +export type OpenPetsIpcMethod = "hello" | "status" | "pets.list" | "lease.acquire" | "lease.heartbeat" | "lease.release" | "pet.react" | "pet.say"; + +export interface OpenPetsIpcRequest { + readonly id: string; + readonly version: 1; + readonly token: string; + readonly method: OpenPetsIpcMethod; + readonly params?: unknown; +} + +export interface OpenPetsIpcOkResponse { + readonly id: string | null; + readonly ok: true; + readonly result: T; +} + +export interface OpenPetsIpcErrorResponse { + readonly id: string | null; + readonly ok: false; + readonly error: { + readonly code: string; + readonly message: string; + }; +} + +export type OpenPetsIpcResponse = OpenPetsIpcOkResponse | OpenPetsIpcErrorResponse; + +export function parseIpcResponse(value: unknown): OpenPetsIpcResponse { + if (!isRecord(value)) throw new OpenPetsClientError("invalid_response", "IPC response must be an object."); + if (typeof value.id !== "string" && value.id !== null) throw new OpenPetsClientError("invalid_response", "IPC response id is invalid."); + + if (value.ok === true) { + return { id: value.id, ok: true, result: value.result as T }; + } + + if (value.ok === false && isRecord(value.error) && typeof value.error.code === "string" && typeof value.error.message === "string") { + return { id: value.id, ok: false, error: { code: value.error.code, message: value.error.message } }; + } + + throw new OpenPetsClientError("invalid_response", "IPC response shape is invalid."); +} + +export function validateReaction(value: string): OpenPetsReaction { + if (!allowedReactions.includes(value as OpenPetsReaction)) { + throw new OpenPetsClientError("invalid_reaction", "Invalid OpenPets reaction."); + } + return value as OpenPetsReaction; +} + +export class OpenPetsClientError extends Error { + constructor(readonly code: string, message: string) { + super(message); + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/client/src/smoke.ts b/packages/client/src/smoke.ts new file mode 100644 index 00000000..4dcd4acb --- /dev/null +++ b/packages/client/src/smoke.ts @@ -0,0 +1,30 @@ +import { createOpenPetsClient } from "./index.js"; +import { validateReaction, type OpenPetsReaction } from "./protocol.js"; + +const client = createOpenPetsClient(); +const [command = "status", first, second] = process.argv.slice(2); + +try { + const result = command === "hello" + ? await client.hello() + : command === "status" + ? await client.status() + : command === "react" + ? await client.react(validateReaction(first ?? "idle")) + : command === "say" + ? await client.say(first ?? "Working on it", second ? { reaction: validateReaction(second) as OpenPetsReaction } : undefined) + : command === "invalid-token" + ? await runInvalidTokenCheck() + : await client.status(); + + console.log(JSON.stringify(result, null, 2)); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} + +async function runInvalidTokenCheck(): Promise { + const { readDiscoveryFile, sendRequest } = await import("./index.js"); + const discovery = readDiscoveryFile(); + return sendRequest({ ...discovery, token: "invalid-token-value" }, "hello", {}); +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 00000000..762e7118 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 00000000..bd420018 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,43 @@ +{ + "name": "@open-pets/mcp", + "version": "2.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/alvinunreal/openpets.git", + "directory": "packages/mcp" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "bin": { + "open-pets-mcp": "./dist/index.js" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "test": "node dist/check-mcp-contract.js", + "check": "pnpm --filter @open-pets/client build && pnpm typecheck && pnpm build && pnpm test", + "typecheck": "tsc --noEmit", + "build": "tsc && node dist/ensure-executable.js" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^6.0.3" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "@open-pets/client": "workspace:*", + "zod": "^4.4.3" + } +} diff --git a/packages/mcp/src/args.ts b/packages/mcp/src/args.ts new file mode 100644 index 00000000..814db3eb --- /dev/null +++ b/packages/mcp/src/args.ts @@ -0,0 +1,57 @@ +export interface McpCliOptions { + readonly petId?: string; + readonly help: boolean; + readonly version: boolean; +} + +export function parseMcpArgs(argv: readonly string[]): McpCliOptions { + let petId: string | undefined; + let help = false; + let version = false; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") { + help = true; + continue; + } + if (arg === "--version" || arg === "-v") { + version = true; + continue; + } + if (arg === "--pet") { + const next = argv[index + 1]; + if (!next) throw new Error("--pet requires a pet id."); + petId = validateRawPetArg(next); + index += 1; + continue; + } + if (arg.startsWith("--pet=")) { + petId = validateRawPetArg(arg.slice("--pet=".length)); + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + return { petId, help, version }; +} + +export function validatePetId(value: string): string { + if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value) || value === "builtin") { + throw new Error(`Invalid pet id: ${value}`); + } + return value; +} + +export function validateRawPetArg(value: string): string { + const trimmed = value.trim(); + if (trimmed.length < 1) throw new Error("--pet requires a non-empty pet id."); + if (Buffer.byteLength(trimmed, "utf8") > 128 || /[\x00-\x1F\x7F/\\]/.test(trimmed)) { + throw new Error("--pet value is outside OpenPets CLI bounds."); + } + return trimmed; +} + +export function createHelpText(): string { + return `OpenPets MCP server\n\nUsage:\n open-pets-mcp [--pet ]\n\nOptions:\n --pet Request an installed OpenPets pet for this MCP process; missing pets fall back to default.\n --help Show this help.\n --version Show package version.\n`; +} diff --git a/packages/mcp/src/check-mcp-contract.ts b/packages/mcp/src/check-mcp-contract.ts new file mode 100644 index 00000000..0c116b12 --- /dev/null +++ b/packages/mcp/src/check-mcp-contract.ts @@ -0,0 +1,129 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; + +import { parseMcpArgs } from "./args.js"; +import { createOpenPetsMcpServer } from "./server.js"; +import { createMcpStatus, sanitizeUnavailableReason, type OpenPetsMcpStatus } from "./tools.js"; + +parseMcpArgs(["--pet", "snoopy"]); +parseMcpArgs(["--pet=snoopy"]); +parseMcpArgs(["--pet", "Bad Pet"]); +parseMcpArgs(["--help"]); +assertRejects(() => parseMcpArgs(["--pet", "bad/pet"])); +assertRejects(() => parseMcpArgs(["--agent", "claude"])); + +const unavailableStatus = createMcpStatus({ ok: false, appRunning: false, unavailableReason: "/Users/alvin/.config/OpenPets/runtime/ipc.json ENOENT" }, "snoopy"); +if (unavailableStatus.routingImplemented !== true || unavailableStatus.configuredPetId !== "snoopy") { + throw new Error("MCP status did not preserve configured pet during degraded status."); +} +if (unavailableStatus.unavailableReason?.includes("/Users/")) { + throw new Error("Unavailable reason leaked a local path."); +} +if (sanitizeUnavailableReason("/tmp/openpets-501/openpets-1.sock ENOENT")?.includes("/tmp")) { + throw new Error("Sanitizer leaked socket path."); +} + +await checkMcpServerContract(); +await checkStdioServerContract(); +const builtEntrypoint = readFileSync(join("dist", "index.js"), "utf8"); +if (!builtEntrypoint.startsWith("#!/usr/bin/env node")) { + throw new Error("Built MCP entrypoint is missing a Node shebang."); +} + +console.error("MCP contract validation passed."); + +async function checkMcpServerContract(): Promise { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const fakeClient = { + status: async () => ({ ok: true, appRunning: true, defaultPet: { id: "snoopy", displayName: "Snoopy" } }), + listPets: async () => ({ ok: true as const, pets: [], defaultPetId: "builtin" }), + acquireLease: async () => ({ leaseId: "lease-1", requestedPetId: "snoopy", targetKind: "explicit" as const, actualTargetPetId: "snoopy", actualTargetPetName: "Snoopy", usingDefaultPet: false, expiresAt: Date.now() + 15_000, leaseActive: true }), + heartbeatLease: async (leaseId: string) => ({ leaseId, expiresAt: Date.now() + 15_000 }), + releaseLease: async () => ({ released: true }), + react: async (reaction: string, options?: { readonly leaseId?: string }) => ({ ok: true, reaction, leaseId: options?.leaseId }), + say: async (message: string, options?: { readonly leaseId?: string }) => ({ ok: true, message, leaseId: options?.leaseId }), + hello: async () => ({ ok: true }), + }; + const server = createOpenPetsMcpServer({ configuredPetId: "snoopy", client: fakeClient, lease: { lease: await fakeClient.acquireLease() }, leaseReady: Promise.resolve() }); + const client = new Client({ name: "openpets-contract", version: "0.0.0" }); + + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + try { + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name).sort(); + if (names.join(",") !== "openpets_react,openpets_say,openpets_status") { + throw new Error(`Unexpected MCP tool list: ${names.join(",")}`); + } + + const status = await client.callTool({ name: "openpets_status", arguments: {} }, CallToolResultSchema); + const structured = status.structuredContent as unknown as OpenPetsMcpStatus; + if (!structured.ok || structured.configuredPetId !== "snoopy" || structured.routingImplemented !== true || structured.actualTargetPetId !== "snoopy") { + throw new Error("Status tool returned unexpected structured content."); + } + + const react = await client.callTool({ name: "openpets_react", arguments: { reaction: "waving" } }, CallToolResultSchema); + if (react.isError) throw new Error("Valid reaction unexpectedly failed."); + const reactStructured = react.structuredContent as { readonly result?: { readonly leaseId?: string } } | undefined; + if (reactStructured?.result?.leaseId !== "lease-1") throw new Error("Reaction did not pass lease id to client."); + + const invalidReact = await client.callTool({ name: "openpets_react", arguments: { reaction: "bad" } }, CallToolResultSchema); + if (!invalidReact.isError) throw new Error("Invalid reaction was not rejected."); + + const invalidSay = await client.callTool({ name: "openpets_say", arguments: { message: "const secret = 1" } }, CallToolResultSchema); + if (!invalidSay.isError) throw new Error("Unsafe say message was not rejected."); + + const stale = createMcpStatus({ ok: false, appRunning: true, leaseId: "missing", leaseActive: false, staleReason: "unknown_lease" }, "snoopy", undefined, "missing", "missing"); + if (stale.leaseActive !== false || stale.staleReason !== "unknown_lease" || stale.ok !== false) { + throw new Error("Stale MCP lease status was not preserved."); + } + } finally { + await client.close(); + await server.close(); + } +} + +async function checkStdioServerContract(): Promise { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [join("dist", "index.js"), "--pet", "snoopy"], + env: { ...process.env, OPENPETS_DISCOVERY_FILE: join(process.cwd(), ".missing-openpets-discovery.json") }, + stderr: "pipe", + }); + const client = new Client({ name: "openpets-stdio-contract", version: "0.0.0" }); + await client.connect(transport); + try { + const tools = await client.listTools(); + const names = tools.tools.map((tool) => tool.name).sort(); + if (names.join(",") !== "openpets_react,openpets_say,openpets_status") { + throw new Error(`Unexpected stdio MCP tool list: ${names.join(",")}`); + } + + const status = await client.callTool({ name: "openpets_status", arguments: {} }, CallToolResultSchema); + const content = Array.isArray(status.content) ? status.content : []; + const first = content[0] as { readonly type?: unknown; readonly text?: unknown } | undefined; + const text = first?.type === "text" && typeof first.text === "string" ? first.text : ""; + if (!text.includes("Configured --pet snoopy") || !text.includes("actual target is unavailable")) { + throw new Error("Unavailable stdio status did not explain configured pet and unavailable target."); + } + const structured = status.structuredContent as unknown as OpenPetsMcpStatus; + if (structured.appRunning !== false || structured.configuredPetId !== "snoopy" || structured.routingImplemented !== true) { + throw new Error("Unavailable stdio status returned unexpected structured content."); + } + } finally { + await client.close(); + } +} + +function assertRejects(callback: () => unknown): void { + try { + callback(); + } catch { + return; + } + throw new Error("Expected validation to reject."); +} diff --git a/packages/mcp/src/ensure-executable.ts b/packages/mcp/src/ensure-executable.ts new file mode 100644 index 00000000..bae027cd --- /dev/null +++ b/packages/mcp/src/ensure-executable.ts @@ -0,0 +1,9 @@ +import { chmodSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const entrypoint = join(dirname(fileURLToPath(import.meta.url)), "index.js"); + +if (process.platform !== "win32") { + chmodSync(entrypoint, 0o755); +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 00000000..82897f09 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { createHelpText, parseMcpArgs } from "./args.js"; +import { createOpenPetsMcpServer } from "./server.js"; +import { createToolContext, type LeaseContext } from "./tools.js"; + +async function main(): Promise { + const options = parseMcpArgs(process.argv.slice(2)); + + if (options.help) { + process.stdout.write(createHelpText()); + return; + } + + if (options.version) { + process.stdout.write(`${readPackageVersion()}\n`); + return; + } + + const lease: LeaseContext = {}; + const context = createToolContext(options.petId); + const leaseReady = acquireStartupLease(context.client, lease, options.petId); + const server = createOpenPetsMcpServer({ ...context, lease, leaseReady }); + let heartbeatTimer: NodeJS.Timeout | null = null; + let closing = false; + leaseReady.then(() => { + if (!lease.lease) return; + heartbeatTimer = setInterval(() => { + if (!lease.lease) return; + void context.client.heartbeatLease(lease.lease.leaseId).catch((error: unknown) => { + lease.staleLeaseId = lease.lease?.leaseId; + lease.degradedReason = sanitizeMcpRuntimeError(error); + lease.lease = undefined; + if (heartbeatTimer) clearInterval(heartbeatTimer); + heartbeatTimer = null; + }); + }, 5_000); + heartbeatTimer.unref?.(); + }).catch(() => {}); + const transport = new StdioServerTransport(); + const close = async (): Promise => { + if (closing) return; + closing = true; + if (heartbeatTimer) clearInterval(heartbeatTimer); + heartbeatTimer = null; + const leaseId = lease.lease?.leaseId; + lease.lease = undefined; + if (leaseId) { + try { await context.client.releaseLease(leaseId); } catch { /* best effort */ } + } + try { await server.close(); } catch { /* ignore shutdown errors */ } + }; + transport.onclose = () => { void close(); }; + + process.on("SIGINT", () => { void close().finally(() => process.exit(0)); }); + process.on("SIGTERM", () => { void close().finally(() => process.exit(0)); }); + + await server.connect(transport); +} + +async function acquireStartupLease(client: ReturnType["client"], lease: LeaseContext, requestedPetId: string | undefined): Promise { + try { + lease.lease = await client.acquireLease({ requestedPetId }); + lease.staleLeaseId = undefined; + lease.degradedReason = undefined; + } catch (error) { + lease.lease = undefined; + lease.staleLeaseId = undefined; + lease.degradedReason = sanitizeMcpRuntimeError(error); + } +} + +function sanitizeMcpRuntimeError(error: unknown): string { + const message = error instanceof Error ? error.message : "OpenPets lease operation failed."; + if (/\/|\\|\.sock|pipe|token|ipc\.json|ENOENT|ECONNREFUSED|EACCES/i.test(message)) { + return "OpenPets desktop app or local IPC is unavailable."; + } + return message.slice(0, 160); +} + +main().catch((error: unknown) => { + process.stderr.write(`OpenPets MCP server failed: ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); + +function readPackageVersion(): string { + try { + const here = dirname(fileURLToPath(import.meta.url)); + const packageJson = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as { version?: unknown }; + return typeof packageJson.version === "string" ? packageJson.version : "0.0.0"; + } catch { + return "0.0.0"; + } +} diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts new file mode 100644 index 00000000..919a97d8 --- /dev/null +++ b/packages/mcp/src/server.ts @@ -0,0 +1,32 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import { handleReact, handleSay, handleStatus, reactSchema, saySchema, type ToolContext } from "./tools.js"; + +export function createOpenPetsMcpServer(context: ToolContext): McpServer { + const server = new McpServer({ name: "open-pets", version: "0.0.0" }, { + instructions: "Interact with the user's OpenPets desktop companion. Use openpets_status first. Use openpets_say only for short status/personality messages, never code, logs, secrets, URLs, or file paths.", + }); + + server.registerTool("openpets_status", { + title: "OpenPets Status", + description: "Check whether OpenPets is reachable and which pet MCP events currently target.", + inputSchema: {}, + annotations: { readOnlyHint: true, idempotentHint: true }, + }, async () => handleStatus(context)); + + server.registerTool("openpets_react", { + title: "OpenPets React", + description: "Set a short coding-oriented reaction on the OpenPets desktop pet.", + inputSchema: reactSchema, + annotations: { readOnlyHint: false, idempotentHint: false }, + }, async (input) => handleReact(input, context)); + + server.registerTool("openpets_say", { + title: "OpenPets Say", + description: "Show a short safe message on the OpenPets desktop pet. Do not send code, logs, secrets, URLs, or file paths.", + inputSchema: saySchema, + annotations: { readOnlyHint: false, idempotentHint: false }, + }, async (input) => handleSay(input, context)); + + return server; +} diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts new file mode 100644 index 00000000..6bc8465c --- /dev/null +++ b/packages/mcp/src/tools.ts @@ -0,0 +1,181 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { allowedReactions, createOpenPetsClient, OpenPetsClientError, type OpenPetsClient, type OpenPetsLeaseResult, type OpenPetsReaction, type OpenPetsStatusResult } from "@open-pets/client"; +import { z } from "zod"; + +export const reactionSchema = z.enum(allowedReactions); + +export const saySchema = z.object({ + message: z.string().trim().min(1).max(140) + .refine((value) => !/[\r\n]/.test(value), "Message must be single-line.") + .refine((value) => !/```||\b(class|import|export|const|let|var)\b/.test(value), "Message looks like code.") + .refine((value) => !/https?:\/\/|www\.|\/[\w.-]+\/[\w./-]+|[A-Za-z]:\\/.test(value), "Message contains URL or path-like content.") + .refine((value) => !/(api[_-]?key|secret|token|password|passwd|BEGIN [A-Z ]+PRIVATE KEY)/i.test(value), "Message looks secret-like."), + reaction: reactionSchema.optional(), +}); + +export const reactSchema = z.object({ reaction: reactionSchema }); + +export interface OpenPetsMcpStatus { + readonly [key: string]: unknown; + ok: boolean; + appRunning: boolean; + configuredPetId?: string; + actualTargetPetId?: string; + actualTargetPetName?: string; + usingDefaultPet: boolean; + routingImplemented: boolean; + unavailableReason?: string; + fallbackReason?: string; +} + +export interface LeaseContext { + lease?: OpenPetsLeaseResult; + staleLeaseId?: string; + degradedReason?: string; +} + +export interface ToolContext { + readonly configuredPetId?: string; + readonly client?: OpenPetsClient; + readonly lease?: LeaseContext; + readonly leaseReady?: Promise; +} + +export function createToolContext(configuredPetId?: string): ToolContext & { readonly client: OpenPetsClient } { + return { + configuredPetId, + client: createOpenPetsClient(), + }; +} + +export async function handleStatus(context: ToolContext): Promise { + await context.leaseReady; + const client = context.client ?? createOpenPetsClient(); + const leaseId = context.lease?.lease?.leaseId ?? context.lease?.staleLeaseId; + const status = await client.status({ leaseId }); + const structured = createMcpStatus(status, context.configuredPetId, context.lease?.lease, context.lease?.degradedReason, context.lease?.staleLeaseId); + const configuredText = context.configuredPetId + ? `Configured --pet ${context.configuredPetId}; actual target is ${structured.actualTargetPetId ?? "unavailable"}.` + : "No --pet configured; actual target is the desktop default pet."; + + if (!structured.appRunning) { + return { + content: [{ type: "text", text: `OpenPets is unavailable. ${configuredText} ${structured.unavailableReason ?? "Open the OpenPets desktop app and try again."}` }], + structuredContent: structured, + }; + } + + return { + content: [{ type: "text", text: `OpenPets is running. ${configuredText}` }], + structuredContent: structured, + }; +} + +export async function handleReact(input: unknown, context: ToolContext): Promise { + await context.leaseReady; + const parsed = reactSchema.safeParse(input); + if (!parsed.success) return toolError("Invalid reaction. Use one of: " + allowedReactions.join(", ")); + if (!context.lease?.lease) return toolError(`OpenPets lease is unavailable. ${sanitizeUnavailableReason(context.lease?.degradedReason) ?? "Open OpenPets and try again."}`); + + try { + const client = context.client ?? createOpenPetsClient(); + const result = await client.react(parsed.data.reaction, { leaseId: context.lease.lease.leaseId }); + return { + content: [{ type: "text", text: `OpenPets reaction sent: ${parsed.data.reaction}` }], + structuredContent: { ok: true, reaction: parsed.data.reaction, result }, + }; + } catch (error) { + return toolError(`OpenPets desktop app is not running or local IPC is unavailable. ${sanitizeError(error)}`); + } +} + +export async function handleSay(input: unknown, context: ToolContext): Promise { + await context.leaseReady; + const parsed = saySchema.safeParse(input); + if (!parsed.success) return toolError("Invalid message. Keep it short, single-line, and avoid code, secrets, URLs, and file paths."); + if (!context.lease?.lease) return toolError(`OpenPets lease is unavailable. ${sanitizeUnavailableReason(context.lease?.degradedReason) ?? "Open OpenPets and try again."}`); + + try { + const client = context.client ?? createOpenPetsClient(); + const result = await client.say(parsed.data.message, { reaction: parsed.data.reaction, leaseId: context.lease.lease.leaseId }); + return { + content: [{ type: "text", text: "OpenPets message sent." }], + structuredContent: { ok: true, result }, + }; + } catch (error) { + return toolError(`OpenPets desktop app is not running or local IPC is unavailable. ${sanitizeError(error)}`); + } +} + +export function createMcpStatus(status: OpenPetsStatusResult, configuredPetId?: string, lease?: OpenPetsLeaseResult, degradedReason?: string, staleLeaseId?: string): OpenPetsMcpStatus { + if (status.leaseActive === false || staleLeaseId) { + return { + ok: false, + appRunning: status.appRunning === true, + configuredPetId, + usingDefaultPet: true, + routingImplemented: true, + unavailableReason: sanitizeUnavailableReason(degradedReason ?? status.unavailableReason ?? status.staleReason), + leaseId: typeof status.leaseId === "string" ? status.leaseId : staleLeaseId, + leaseActive: false, + staleReason: typeof status.staleReason === "string" ? status.staleReason : "unknown_lease", + } as OpenPetsMcpStatus; + } + if (lease) { + const statusTargetPetId = typeof status.actualTargetPetId === "string" ? status.actualTargetPetId : undefined; + const statusTargetPetName = typeof status.actualTargetPetName === "string" ? status.actualTargetPetName : undefined; + const statusUsingDefault = typeof status.usingDefaultPet === "boolean" ? status.usingDefaultPet : undefined; + const statusFallbackReason = typeof status.fallbackReason === "string" ? status.fallbackReason : undefined; + return { + ok: status.appRunning === true && status.ok !== false, + appRunning: status.appRunning === true, + configuredPetId, + actualTargetPetId: statusTargetPetId ?? lease.actualTargetPetId, + actualTargetPetName: statusTargetPetName ?? lease.actualTargetPetName, + usingDefaultPet: statusUsingDefault ?? lease.usingDefaultPet, + routingImplemented: true, + fallbackReason: statusFallbackReason ?? lease.fallbackReason, + leaseId: lease.leaseId, + leaseActive: lease.leaseActive, + }; + } + const defaultPet = isRecord(status.defaultPet) ? status.defaultPet : undefined; + const actualTargetPetId = typeof defaultPet?.id === "string" ? defaultPet.id : undefined; + const actualTargetPetName = typeof defaultPet?.displayName === "string" ? defaultPet.displayName : undefined; + const appRunning = status.appRunning === true; + + return { + ok: appRunning && status.ok !== false, + appRunning, + configuredPetId, + actualTargetPetId, + actualTargetPetName, + usingDefaultPet: true, + routingImplemented: true, + unavailableReason: appRunning ? undefined : sanitizeUnavailableReason(degradedReason ?? status.unavailableReason), + fallbackReason: undefined, + }; +} + +export function toolError(message: string): CallToolResult { + return { content: [{ type: "text", text: message }], isError: true }; +} + +export function sanitizeUnavailableReason(value: unknown): string | undefined { + if (typeof value !== "string" || value.length === 0) return "OpenPets desktop app is unavailable."; + if (/\/|\\|\.sock|pipe|token|ipc\.json|ENOENT|ECONNREFUSED|EACCES/i.test(value)) { + return "OpenPets desktop app or local IPC is unavailable."; + } + return value.slice(0, 160); +} + +function sanitizeError(error: unknown): string { + if (error instanceof OpenPetsClientError) return sanitizeUnavailableReason(error.message) ?? "OpenPets is unavailable."; + return "Open OpenPets and try again."; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export type { OpenPetsReaction }; diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 00000000..762e7118 --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json new file mode 100644 index 00000000..6a24ce7e --- /dev/null +++ b/packages/opencode/package.json @@ -0,0 +1,44 @@ +{ + "name": "@open-pets/opencode", + "version": "2.0.0", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/alvinunreal/openpets.git", + "directory": "packages/opencode" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./server": { + "types": "./dist/plugin.d.ts", + "default": "./dist/plugin.js" + } + }, + "scripts": { + "test": "node dist/check-opencode-foundation.js && node dist/check-opencode-plugin.js", + "check": "pnpm typecheck && pnpm build && pnpm test", + "typecheck": "tsc --noEmit", + "build": "tsc" + }, + "dependencies": { + "@open-pets/agent-events": "workspace:*", + "@open-pets/client": "workspace:*", + "jsonc-parser": "^3.3.1" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "typescript": "^6.0.3" + } +} diff --git a/packages/opencode/src/check-opencode-foundation.ts b/packages/opencode/src/check-opencode-foundation.ts new file mode 100644 index 00000000..ac7a120e --- /dev/null +++ b/packages/opencode/src/check-opencode-foundation.ts @@ -0,0 +1,268 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { hookSpeechPools, validateHookSpeech } from "@open-pets/agent-events"; + +import { createOpenCodeExecutableDetection, executePlannedWrite, getGlobalOpenCodeConfigDir, getGlobalOpenCodeConfigPaths, getProjectOpenCodeConfigPaths, parseOpenCodeConfig, planOpenCodeConfigWrite, selectProjectOpenCodeConfigPath, updateOpenCodeConfigText } from "./opencode-config.js"; +import { buildOpenCodeInstructionPath, buildOpenCodeMcpEntry, buildOpenCodePluginPreview, formatOpenCodeMcpConfig } from "./opencode-previews.js"; +import { doctorOpenCodeGlobalSetup, prepareOpenCodeGlobalRemove, prepareOpenCodeGlobalSetup, writePreparedOpenCodeGlobalRemove, writePreparedOpenCodeGlobalSetup } from "./opencode-global-setup.js"; +import { classifyOpenCodeInstructionsStatus, classifyOpenCodeMcpStatus, classifyOpenCodePluginStatus } from "./opencode-status.js"; + +const root = mkdtempSync(join(tmpdir(), "openpets-opencode-")); +try { + const project = join(root, "project"); + mkdirSync(project); + const paths = getProjectOpenCodeConfigPaths(project); + assert.deepEqual(paths.candidates.map((path) => path.slice(project.length + 1)), ["opencode.json", "opencode.jsonc", ".opencode/opencode.json", ".opencode/opencode.jsonc"]); + assert.equal(selectProjectOpenCodeConfigPath(project), join(project, ".opencode", "opencode.jsonc")); + mkdirSync(join(project, ".opencode")); + writeFileSync(join(project, ".opencode", "opencode.jsonc"), "{}\n"); + assert.equal(selectProjectOpenCodeConfigPath(project), join(project, ".opencode", "opencode.jsonc")); + writeFileSync(join(project, "opencode.json"), "{}\n"); + assert.equal(selectProjectOpenCodeConfigPath(project), join(project, "opencode.json")); + + assert.equal(getGlobalOpenCodeConfigDir({ OPENCODE_CONFIG_DIR: join(root, "custom") }, root, "linux"), join(root, "custom")); + assert.equal(getGlobalOpenCodeConfigDir({ XDG_CONFIG_HOME: join(root, "xdg") }, root, "linux"), join(root, "xdg", "opencode")); + assert.equal(getGlobalOpenCodeConfigDir({ APPDATA: join(root, "appdata") }, root, "win32"), join(root, "appdata", "opencode")); + assert.deepEqual(getGlobalOpenCodeConfigPaths({ OPENCODE_CONFIG_DIR: join(root, "global") }, root, "linux").candidates.map((path) => path.slice(join(root, "global").length + 1)), ["config.json", "opencode.json", "opencode.jsonc"]); + assert.deepEqual(createOpenCodeExecutableDetection({ platform: "win32" }).command, "opencode.cmd"); + assert.deepEqual(createOpenCodeExecutableDetection({ platform: "darwin" }).command, "opencode"); + + assert.deepEqual(formatOpenCodeMcpConfig({ cliVersion: "0.0.0", petId: "fixer" }), { mcp: { openpets: { type: "local", command: ["npx", "-y", "@open-pets/cli@0.0.0", "mcp", "--pet", "fixer"], enabled: true } } }); + assert.deepEqual(buildOpenCodeMcpEntry({ cliVersion: "0.0.0" }), { type: "local", command: ["npx", "-y", "@open-pets/cli@0.0.0", "mcp"], enabled: true }); + assert.deepEqual(buildOpenCodeMcpEntry({ cliVersion: "0.0.0", commandMode: "local", cliEntryPath: join(root, "cli.js"), petId: "fixer" }), { type: "local", command: ["node", join(root, "cli.js"), "mcp", "--pet", "fixer"], enabled: true }); + assert.throws(() => buildOpenCodeMcpEntry({ cliVersion: "0.0.0", commandMode: "local", cliEntryPath: "relative.js" })); + assert.throws(() => buildOpenCodeMcpEntry({ cliVersion: "0.0.0", petId: "bad/pet" })); + assert.equal(buildOpenCodeInstructionPath("project"), ".opencode/openpets.md"); + assert.equal(buildOpenCodeInstructionPath("global", join(root, "global")), join(root, "global", "openpets.md")); + assert.deepEqual(buildOpenCodePluginPreview("fixer"), ["@open-pets/opencode", { pet: "fixer" }]); + assert.deepEqual(buildOpenCodePluginPreview("fixer", "0.0.0"), ["@open-pets/opencode@0.0.0", { pet: "fixer" }]); + + const jsonc = `{ + // keep this comment + "theme": "dark", + "mcp": { "other": { "type": "local", "command": ["x"] } }, + }`; + const parsed = parseOpenCodeConfig(jsonc); + assert.equal(parsed.ok, true); + const updated = updateOpenCodeConfigText(jsonc, [{ path: ["mcp", "openpets"], value: buildOpenCodeMcpEntry({ cliVersion: "0.0.0", petId: "fixer" }) }]); + assert.equal(typeof updated, "string"); + assert.match(String(updated), /keep this comment/); + assert.match(String(updated), /"openpets"/); + assert.match(String(updated), /"other"/); + assert.equal(parseOpenCodeConfig("{").ok, false); + assert.equal(parseOpenCodeConfig("[]").ok, false); + assert.equal(parseOpenCodeConfig(JSON.stringify({ mcp: [] })).ok, false); + assert.equal(parseOpenCodeConfig(JSON.stringify({ instructions: "x" })).ok, false); + assert.equal(parseOpenCodeConfig(JSON.stringify({ plugin: {} })).ok, false); + assert.equal(parseOpenCodeConfig(JSON.stringify({ instructions: [1] })).ok, false); + assert.equal(parseOpenCodeConfig(`{"x":"${"a".repeat(1024 * 1024)}"}`).ok, false); + + const expected = { cliVersion: "0.0.0", petId: "fixer" }; + assert.equal(classifyOpenCodeMcpStatus([], expected).status, "not_installed"); + assert.equal(classifyOpenCodeMcpStatus([{ mcp: { openpets: buildOpenCodeMcpEntry(expected) } }], expected).status, "installed"); + assert.equal(classifyOpenCodeMcpStatus([{ mcp: { openpets: { command: ["npx", "-y", "@open-pets/cli@0.0.0", "mcp", "--pet", "fixer"], enabled: true, type: "local" } } }], expected).status, "installed"); + assert.equal(classifyOpenCodeMcpStatus([{ mcp: { openpets: buildOpenCodeMcpEntry({ cliVersion: "0.0.0", petId: "helper" }) } }], expected).status, "needs_update"); + assert.equal(classifyOpenCodeMcpStatus([{ mcp: { openpets: buildOpenCodeMcpEntry({ cliVersion: "0.0.0", commandMode: "local", cliEntryPath: join(root, "cli.js"), petId: "helper" }) } }], { cliVersion: "0.0.0", commandMode: "local", cliEntryPath: join(root, "cli.js"), petId: "fixer" }).status, "needs_update"); + assert.equal(classifyOpenCodeMcpStatus([{ mcp: { openpets: { type: "local", command: ["npx", "-y", "@open-pets/cli@0.0.0", "mcp", "--pet", "fixer"], enabled: false } } }], expected).status, "custom"); + assert.equal(classifyOpenCodeMcpStatus([{ mcp: { openpets: { type: "remote", command: ["npx", "-y", "@open-pets/cli@0.0.0", "mcp", "--pet", "fixer"], enabled: true } } }], expected).status, "custom"); + assert.equal(classifyOpenCodeMcpStatus([{ mcp: { openpets: { type: "local", command: ["npx", "-y", "@open-pets/cli@file:../cli", "mcp", "--pet", "fixer"], enabled: true } } }], expected).status, "custom"); + assert.equal(classifyOpenCodeMcpStatus([{ mcp: { openpets: { type: "local", command: ["npx", "-y", "@open-pets/cli@workspace:*", "mcp", "--pet", "fixer"], enabled: true } } }], expected).status, "custom"); + assert.equal(classifyOpenCodeMcpStatus([{ mcp: { openpets: { type: "local", command: ["npx", "-y", "@open-pets/cli@0.0.0", "mcp", "--pet", "fixer"], enabled: true, timeout: 10 } } }], expected).status, "custom"); + assert.equal(classifyOpenCodeMcpStatus([{ mcp: { openpets: { type: "local", command: ["my-openpets-wrapper"] } } }], expected).status, "custom"); + assert.equal(classifyOpenCodeMcpStatus([{ mcp: { openpets: buildOpenCodeMcpEntry(expected) } }, { mcp: { openpets: buildOpenCodeMcpEntry({ cliVersion: "0.0.0", petId: "helper" }) } }], expected).status, "conflict"); + assert.equal(classifyOpenCodeInstructionsStatus([{ instructions: [".opencode/openpets.md"] }], "project", undefined, { ".opencode/openpets.md": "\nHi\n\n" }).status, "installed"); + assert.equal(classifyOpenCodeInstructionsStatus([{ instructions: [".opencode/openpets.md"] }], "project").status, "needs_update"); + assert.equal(classifyOpenCodeInstructionsStatus([{ instructions: [".opencode/openpets.md"] }, { instructions: ["old-openpets.md"] }], "project", undefined, { ".opencode/openpets.md": "\nHi\n\n" }).status, "conflict"); + assert.equal(classifyOpenCodeInstructionsStatus([{ instructions: ["old-openpets.md"] }], "project").status, "custom"); + assert.equal(classifyOpenCodePluginStatus([{ plugin: [["@open-pets/opencode", { pet: "fixer" }]] }], "fixer").status, "installed"); + assert.equal(classifyOpenCodePluginStatus([{ plugin: [["@open-pets/opencode@0.0.0", { pet: "fixer" }]] }], "fixer", "0.0.0").status, "installed"); + assert.equal(classifyOpenCodePluginStatus([{ plugin: ["@open-pets/opencode"] }], "fixer").status, "needs_update"); + assert.equal(classifyOpenCodePluginStatus([{ plugin: [["@open-pets/opencode@old", { pet: "helper" }], "./openpets-custom-plugin.js"] }], "fixer", "0.0.0").status, "conflict"); + assert.equal(classifyOpenCodePluginStatus([{ plugin: [["@open-pets/opencode@0.0.0"]] }], "fixer", "0.0.0").status, "custom"); + assert.equal(classifyOpenCodePluginStatus([{ plugin: [["@open-pets/opencode@0.0.0", {}]] }], "fixer", "0.0.0").status, "custom"); + assert.equal(classifyOpenCodePluginStatus([{ plugin: [["@open-pets/opencode@0.0.0", { pet: "fixer" }, "extra"]] }], "fixer", "0.0.0").status, "custom"); + assert.equal(classifyOpenCodePluginStatus([{ plugin: [["@open-pets/opencode@0.0.0", { pet: "fixer", extra: true }]] }], "fixer", "0.0.0").status, "custom"); + assert.equal(classifyOpenCodePluginStatus([{ plugin: ["./openpets-custom-plugin.js"] }], "fixer").status, "custom"); + assert.equal(classifyOpenCodePluginStatus([{ plugin: [["@open-pets/opencode", { pet: "fixer" }], "./openpets-custom-plugin.js"] }], "fixer").status, "conflict"); + + const writeTarget = join(root, "write", "opencode.jsonc"); + const writePlan = planOpenCodeConfigWrite(root, writeTarget, "{\"mcp\":{}}\n"); + if ("targetPath" in writePlan) { + executePlannedWrite(writePlan); + assert.equal(existsSync(writeTarget), true); + const second = planOpenCodeConfigWrite(root, writeTarget, "{\"mcp\":{}}\n"); + assert.equal("backupPath" in second && Boolean(second.backupPath), true); + if ("targetPath" in second && second.backupPath) { + writeFileSync(second.backupPath, "already exists"); + assert.throws(() => executePlannedWrite(second)); + } + assert.throws(() => executePlannedWrite({ ...writePlan, rootPath: join(root, "missing-root") })); + assert.throws(() => executePlannedWrite({ ...writePlan, tempPath: join(tmpdir(), "openpets-unsafe.tmp") })); + assert.throws(() => executePlannedWrite({ ...writePlan, backupPath: join(tmpdir(), "openpets-unsafe.backup") })); + } + const outsidePlan = planOpenCodeConfigWrite(root, join(tmpdir(), "outside-opencode.jsonc"), "{}\n"); + assert.equal("ok" in outsidePlan ? outsidePlan.ok : true, false); + const linkTarget = join(root, "link-target"); + mkdirSync(linkTarget); + symlinkSync(linkTarget, join(root, "link-parent")); + const linkParentPlan = planOpenCodeConfigWrite(root, join(root, "link-parent", "opencode.jsonc"), "{}\n"); + assert.equal("ok" in linkParentPlan ? linkParentPlan.ok : true, false); + const linkedFile = join(root, "linked-file.jsonc"); + writeFileSync(join(root, "real-file.jsonc"), "{}\n"); + symlinkSync(join(root, "real-file.jsonc"), linkedFile); + const linkedFilePlan = planOpenCodeConfigWrite(root, linkedFile, "{}\n"); + assert.equal("ok" in linkedFilePlan ? linkedFilePlan.ok : true, false); + symlinkSync(project, join(root, "project-link")); + assert.throws(() => getProjectOpenCodeConfigPaths(join(root, "project-link"))); + + const globalDir = join(root, "global-missing"); + const globalPrepared = prepareOpenCodeGlobalSetup({ configDir: globalDir, petId: "fixer", cliVersion: "0.0.0" }); + writePreparedOpenCodeGlobalSetup(globalPrepared); + assert.equal(existsSync(join(globalDir, "opencode.jsonc")), true); + assert.equal(doctorOpenCodeGlobalSetup(globalDir).status, "installed"); + const globalConfig = readFileSync(join(globalDir, "opencode.jsonc"), "utf8"); + assert.match(globalConfig, /@open-pets\/opencode@0\.0\.0/); + assert.match(readFileSync(join(globalDir, "openpets.md"), "utf8"), /OPENPETS:START/); + const globalRemove = prepareOpenCodeGlobalRemove(globalDir); + writePreparedOpenCodeGlobalRemove(globalRemove); + assert.equal(doctorOpenCodeGlobalSetup(globalDir).status, "not_installed"); + + const globalLower = join(root, "global-lower"); + mkdirSync(globalLower); + writeFileSync(join(globalLower, "config.json"), JSON.stringify({ theme: "keep" }), "utf8"); + writeFileSync(join(globalLower, "opencode.jsonc"), JSON.stringify({ plugin: [["@open-pets/opencode@old", { pet: "helper" }]] }), "utf8"); + writePreparedOpenCodeGlobalSetup(prepareOpenCodeGlobalSetup({ configDir: globalLower, petId: "fixer", cliVersion: "0.0.0" })); + assert.equal(readFileSync(join(globalLower, "config.json"), "utf8").includes("@open-pets/opencode"), false); + assert.match(readFileSync(join(globalLower, "opencode.jsonc"), "utf8"), /@open-pets\/opencode@0\.0\.0/); + + const globalExistingJson = join(root, "global-existing-json"); + mkdirSync(globalExistingJson); + writeFileSync(join(globalExistingJson, "opencode.json"), JSON.stringify({ plugin: ["user-plugin"], instructions: ["USER.md"] }, null, 2), "utf8"); + const existingJsonPrepared = prepareOpenCodeGlobalSetup({ configDir: globalExistingJson, petId: "fixer", cliVersion: "0.0.0" }); + assert.equal(existingJsonPrepared.configPath, join(globalExistingJson, "opencode.json")); + writePreparedOpenCodeGlobalSetup(existingJsonPrepared); + assert.equal(existsSync(join(globalExistingJson, "opencode.jsonc")), false, "desktop global setup must not create a higher-precedence opencode.jsonc over an existing opencode.json"); + const existingJsonConfig = JSON.parse(readFileSync(join(globalExistingJson, "opencode.json"), "utf8")) as { readonly plugin?: readonly unknown[]; readonly instructions?: readonly string[] }; + assert.deepEqual(existingJsonConfig.plugin?.[0], "user-plugin"); + assert.ok(existingJsonConfig.instructions?.includes("USER.md")); + + const globalExistingMultiple = join(root, "global-existing-multiple"); + mkdirSync(globalExistingMultiple); + writeFileSync(join(globalExistingMultiple, "config.json"), JSON.stringify({ theme: "base" }, null, 2), "utf8"); + writeFileSync(join(globalExistingMultiple, "opencode.json"), JSON.stringify({ plugin: ["user-plugin"] }, null, 2), "utf8"); + const existingMultiplePrepared = prepareOpenCodeGlobalSetup({ configDir: globalExistingMultiple, petId: "fixer", cliVersion: "0.0.0" }); + assert.equal(existingMultiplePrepared.configPath, join(globalExistingMultiple, "opencode.json")); + assert.equal(readFileSync(join(globalExistingMultiple, "config.json"), "utf8").includes("openpets"), false); + + const globalLowerPluginOwner = join(root, "global-lower-plugin-owner"); + mkdirSync(globalLowerPluginOwner); + writeFileSync(join(globalLowerPluginOwner, "config.json"), JSON.stringify({ plugin: ["user-plugin"] }, null, 2), "utf8"); + writeFileSync(join(globalLowerPluginOwner, "opencode.json"), JSON.stringify({ theme: "dark" }, null, 2), "utf8"); + const lowerPluginPrepared = prepareOpenCodeGlobalSetup({ configDir: globalLowerPluginOwner, petId: "fixer", cliVersion: "0.0.0" }); + assert.equal(lowerPluginPrepared.configPath, join(globalLowerPluginOwner, "config.json")); + writePreparedOpenCodeGlobalSetup(lowerPluginPrepared); + const lowerPluginConfig = JSON.parse(readFileSync(join(globalLowerPluginOwner, "config.json"), "utf8")) as { readonly plugin?: readonly unknown[] }; + assert.deepEqual(lowerPluginConfig.plugin?.[0], "user-plugin"); + assert.equal(readFileSync(join(globalLowerPluginOwner, "opencode.json"), "utf8").includes("openpets"), false); + + const globalSplitArrayOwners = join(root, "global-split-array-owners"); + mkdirSync(globalSplitArrayOwners); + writeFileSync(join(globalSplitArrayOwners, "config.json"), JSON.stringify({ plugin: ["user-plugin"] }, null, 2), "utf8"); + writeFileSync(join(globalSplitArrayOwners, "opencode.json"), JSON.stringify({ instructions: ["USER.md"] }, null, 2), "utf8"); + assert.throws(() => prepareOpenCodeGlobalSetup({ configDir: globalSplitArrayOwners, petId: "fixer", cliVersion: "0.0.0" }), /different config files/); + + const globalEmptyPluginShadow = join(root, "global-empty-plugin-shadow"); + mkdirSync(globalEmptyPluginShadow); + writeFileSync(join(globalEmptyPluginShadow, "config.json"), JSON.stringify({ plugin: ["user-plugin"] }, null, 2), "utf8"); + writeFileSync(join(globalEmptyPluginShadow, "opencode.json"), JSON.stringify({ plugin: [] }, null, 2), "utf8"); + assert.throws(() => prepareOpenCodeGlobalSetup({ configDir: globalEmptyPluginShadow, petId: "fixer", cliVersion: "0.0.0" }), /higher-precedence config shadows user plugin/); + + const globalEmptyInstructionShadow = join(root, "global-empty-instruction-shadow"); + mkdirSync(globalEmptyInstructionShadow); + writeFileSync(join(globalEmptyInstructionShadow, "config.json"), JSON.stringify({ instructions: ["USER.md"] }, null, 2), "utf8"); + writeFileSync(join(globalEmptyInstructionShadow, "opencode.json"), JSON.stringify({ instructions: [] }, null, 2), "utf8"); + assert.throws(() => prepareOpenCodeGlobalSetup({ configDir: globalEmptyInstructionShadow, petId: "fixer", cliVersion: "0.0.0" }), /higher-precedence config shadows user instructions/); + + const globalEmptyArrayOwner = join(root, "global-empty-array-owner"); + mkdirSync(globalEmptyArrayOwner); + writeFileSync(join(globalEmptyArrayOwner, "opencode.json"), JSON.stringify({ plugin: [] }, null, 2), "utf8"); + const emptyArrayOwnerPrepared = prepareOpenCodeGlobalSetup({ configDir: globalEmptyArrayOwner, petId: "fixer", cliVersion: "0.0.0" }); + assert.equal(emptyArrayOwnerPrepared.configPath, join(globalEmptyArrayOwner, "opencode.json")); + + const globalStaleOverlay = join(root, "global-stale-overlay"); + mkdirSync(globalStaleOverlay); + writeFileSync(join(globalStaleOverlay, "opencode.json"), JSON.stringify({ plugin: ["user-plugin"], instructions: ["USER.md"] }, null, 2), "utf8"); + writeFileSync(join(globalStaleOverlay, "opencode.jsonc"), JSON.stringify({ plugin: [["@open-pets/opencode@0.0.0", { pet: "helper" }]], instructions: [buildOpenCodeInstructionPath("global", globalStaleOverlay)] }, null, 2), "utf8"); + const stalePrepared = prepareOpenCodeGlobalSetup({ configDir: globalStaleOverlay, petId: "fixer", cliVersion: "0.0.1" }); + assert.equal(stalePrepared.configPath, join(globalStaleOverlay, "opencode.json")); + assert.equal(stalePrepared.cleanupConfigWrites.length, 1); + writePreparedOpenCodeGlobalSetup(stalePrepared); + const staleOwnerConfig = JSON.parse(readFileSync(join(globalStaleOverlay, "opencode.json"), "utf8")) as { readonly plugin?: readonly unknown[]; readonly instructions?: readonly string[] }; + assert.deepEqual(staleOwnerConfig.plugin?.[0], "user-plugin"); + assert.ok(staleOwnerConfig.instructions?.includes("USER.md")); + const staleOverlayText = readFileSync(join(globalStaleOverlay, "opencode.jsonc"), "utf8"); + assert.doesNotMatch(staleOverlayText, /plugin/); + assert.doesNotMatch(staleOverlayText, /instructions/); + + const globalStaleRemove = join(root, "global-stale-remove"); + mkdirSync(globalStaleRemove); + writeFileSync(join(globalStaleRemove, "opencode.json"), JSON.stringify({ plugin: ["user-plugin"] }, null, 2), "utf8"); + writeFileSync(join(globalStaleRemove, "opencode.jsonc"), JSON.stringify({ plugin: [["@open-pets/opencode@0.0.0", { pet: "fixer" }]] }, null, 2), "utf8"); + writePreparedOpenCodeGlobalRemove(prepareOpenCodeGlobalRemove(globalStaleRemove)); + assert.doesNotMatch(readFileSync(join(globalStaleRemove, "opencode.jsonc"), "utf8"), /plugin/); + assert.match(readFileSync(join(globalStaleRemove, "opencode.json"), "utf8"), /user-plugin/); + + const globalPublishedToBundled = join(root, "global-published-to-bundled"); + mkdirSync(globalPublishedToBundled); + writeFileSync(join(globalPublishedToBundled, "opencode.jsonc"), JSON.stringify({ mcp: { openpets: buildOpenCodeMcpEntry({ cliVersion: "0.0.0", petId: "helper" }) } }), "utf8"); + const bundledCli = join(root, "app.asar.unpacked", "node_modules", "@open-pets", "cli", "dist", "index.js"); + const migrated = prepareOpenCodeGlobalSetup({ configDir: globalPublishedToBundled, petId: "fixer", cliVersion: "0.0.1", pluginVersion: "0.0.2", commandMode: "bundled", cliEntryPath: bundledCli }); + assert.equal(migrated.configPath, join(globalPublishedToBundled, "opencode.jsonc")); + assert.match(migrated.configWrite.content, /app\.asar\.unpacked/); + assert.doesNotMatch(migrated.configWrite.content, /app\.asar(?!\.unpacked)/); + assert.match(migrated.configWrite.content, /@open-pets\/opencode@0\.0\.2/); + + const globalNoInstructionMarkers = join(root, "global-no-instruction-markers"); + mkdirSync(globalNoInstructionMarkers); + writeFileSync(join(globalNoInstructionMarkers, "opencode.jsonc"), JSON.stringify({ instructions: [buildOpenCodeInstructionPath("global", globalNoInstructionMarkers)] }), "utf8"); + writeFileSync(join(globalNoInstructionMarkers, "openpets.md"), "user owned\n", "utf8"); + const noMarkerRemove = prepareOpenCodeGlobalRemove(globalNoInstructionMarkers); + assert.equal(noMarkerRemove.instructionWrite, undefined); + + const globalCustomPluginOptions = join(root, "global-custom-plugin-options"); + mkdirSync(globalCustomPluginOptions); + writeFileSync(join(globalCustomPluginOptions, "opencode.jsonc"), JSON.stringify({ plugin: [["@open-pets/opencode@0.0.0", { pet: "fixer", extra: true }]] }), "utf8"); + assert.throws(() => prepareOpenCodeGlobalSetup({ configDir: globalCustomPluginOptions, petId: "fixer", cliVersion: "0.0.0" })); + + const globalCustom = join(root, "global-custom"); + mkdirSync(globalCustom); + writeFileSync(join(globalCustom, "opencode.jsonc"), JSON.stringify({ mcp: { openpets: { type: "local", command: ["custom", "mcp"] } } }), "utf8"); + assert.throws(() => prepareOpenCodeGlobalSetup({ configDir: globalCustom, petId: "fixer", cliVersion: "0.0.0" })); + assert.throws(() => prepareOpenCodeGlobalRemove(globalCustom)); + + const globalCustomMcpFields = join(root, "global-custom-mcp-fields"); + mkdirSync(globalCustomMcpFields); + writeFileSync(join(globalCustomMcpFields, "opencode.jsonc"), JSON.stringify({ mcp: { openpets: { type: "local", command: ["npx", "-y", "@open-pets/cli@0.0.0", "mcp", "--pet", "fixer"], enabled: true, environment: { OPENPETS_DEBUG: "1" } } } }), "utf8"); + assert.throws(() => prepareOpenCodeGlobalSetup({ configDir: globalCustomMcpFields, petId: "fixer", cliVersion: "0.0.0" })); + + const globalSymlink = join(root, "global-symlink"); + const globalOutside = join(root, "global-outside"); + mkdirSync(globalOutside); + writeFileSync(join(globalOutside, "opencode.jsonc"), "{}\n", "utf8"); + symlinkSync(globalOutside, globalSymlink); + assert.equal(doctorOpenCodeGlobalSetup(globalSymlink).status, "error"); + + for (const [category, messages] of Object.entries(hookSpeechPools) as Array<[string, readonly string[]]>) { + for (const message of messages) { + assert.match(message, /^[A-Z]/, `${category} hook speech must start uppercase`); + validateHookSpeech(message); + } + } +} finally { + rmSync(root, { recursive: true, force: true }); +} + +console.error("OpenCode foundation validation passed."); diff --git a/packages/opencode/src/check-opencode-plugin.ts b/packages/opencode/src/check-opencode-plugin.ts new file mode 100644 index 00000000..188b1709 --- /dev/null +++ b/packages/opencode/src/check-opencode-plugin.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import type { OpenPetsClient, OpenPetsReaction } from "@open-pets/client"; + +import plugin, { openPetsOpenCodePluginId } from "./plugin.js"; +import { classifyOpenCodeBusEvent, classifyOpenCodeToolReaction, createOpenPetsOpenCodeHooks, getDefaultOpenCodeThrottlePath, shouldIgnoreOpenPetsTool } from "./opencode-plugin-runtime.js"; + +assert.equal(plugin.id, openPetsOpenCodePluginId); +assert.equal(typeof plugin.server, "function"); +const packagePlugin = await import("@open-pets/opencode/server"); +assert.equal(packagePlugin.default.id, openPetsOpenCodePluginId); +assert.equal(typeof packagePlugin.default.server, "function"); + +assert.equal(classifyOpenCodeToolReaction("edit", {}), "editing"); +assert.equal(classifyOpenCodeToolReaction("apply_patch", {}), "editing"); +assert.equal(classifyOpenCodeToolReaction("bash", { command: "pnpm test" }), "testing"); +assert.equal(classifyOpenCodeToolReaction("shell", { command: "ls" }), undefined); +assert.equal(classifyOpenCodeToolReaction("read", {}), undefined); +assert.equal(shouldIgnoreOpenPetsTool("openpets_openpets_status"), true); +assert.equal(shouldIgnoreOpenPetsTool("openpets_openpets_say"), true); +assert.equal(shouldIgnoreOpenPetsTool("openpets_openpets_react"), true); +assert.equal(shouldIgnoreOpenPetsTool("openpets_status"), true); +assert.deepEqual(classifyOpenCodeBusEvent({ type: "permission.asked" }), { reaction: "waiting", speechCategory: "permission" }); +assert.equal(classifyOpenCodeBusEvent({ type: "permission.asked", properties: { permission: "openpets_openpets_say" } }), undefined); +assert.equal(classifyOpenCodeBusEvent({ payload: { type: "permission.asked", properties: { patterns: ["openpets_openpets_react"] } } }), undefined); +assert.deepEqual(classifyOpenCodeBusEvent({ type: "session.error" }), { reaction: "error", speechCategory: "error" }); +assert.deepEqual(classifyOpenCodeBusEvent({ type: "session.status", properties: { status: { type: "idle" } } }), { reaction: "success" }); +assert.ok(getDefaultOpenCodeThrottlePath().includes("opencode-hook-throttle.json")); + +assert.throws(() => createOpenPetsOpenCodeHooks({ pet: "bad/pet" })); + +const dir = mkdtempSync(join(tmpdir(), "openpets-opencode-plugin-")); +try { + const calls: Array<{ readonly kind: string; readonly value: string; readonly leaseId?: string; readonly requestedPetId?: string }> = []; + let releaseBlockedReact: (() => void) | undefined; + const blocked = new Promise((resolve) => { releaseBlockedReact = resolve; }); + const client: OpenPetsClient = { + hello: async () => ({}), + status: async () => ({ ok: true, appRunning: true }), + listPets: async () => ({ ok: true, pets: [], defaultPetId: "builtin" }), + acquireLease: async (options?: { readonly requestedPetId?: string }) => { + calls.push({ kind: "lease", value: "acquire", requestedPetId: options?.requestedPetId }); + return { leaseId: "lease-fixer", requestedPetId: options?.requestedPetId, targetKind: "explicit", actualTargetPetId: options?.requestedPetId ?? "builtin", actualTargetPetName: "Fixer", usingDefaultPet: false, expiresAt: Date.now() + 15_000, leaseActive: true }; + }, + heartbeatLease: async () => ({ leaseId: "lease-fixer", expiresAt: Date.now() + 15_000 }), + releaseLease: async () => ({ released: true }), + react: async (reaction: OpenPetsReaction, options?: { readonly leaseId?: string }) => { + calls.push({ kind: "react", value: reaction, leaseId: options?.leaseId }); + await blocked; + }, + say: async (message: string, options?: { readonly leaseId?: string }) => { + calls.push({ kind: "say", value: message, leaseId: options?.leaseId }); + }, + }; + + const scheduled: Array<() => Promise> = []; + const hooks = createOpenPetsOpenCodeHooks({ pet: "fixer", clientFactory: () => client, schedule: (work) => { scheduled.push(work); }, throttlePath: join(dir, "opencode-hook-throttle.json"), now: () => 100_000, random: () => 0 }); + hooks["chat.message"]({}, { message: { text: "do not use this prompt" } }); + assert.equal(scheduled.length, 1); + const thinkingWork = scheduled.shift(); + const thinkingPromise = thinkingWork?.(); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(calls[0], { kind: "lease", value: "acquire", requestedPetId: "fixer" }); + assert.deepEqual(calls[1], { kind: "react", value: "thinking", leaseId: "lease-fixer" }); + releaseBlockedReact?.(); + await thinkingPromise; + releaseBlockedReact = undefined; + + hooks["tool.execute.before"]({ tool: "bash" }, { args: { command: "pnpm test -- --secret" } }); + assert.equal(scheduled.length, 1); + const work = scheduled.shift(); + const promise = work?.(); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(calls.at(-1), { kind: "react", value: "testing", leaseId: "lease-fixer" }); + await promise; + + hooks["tool.execute.before"]({ tool: "shell" }, { args: { command: "ls" } }); + assert.equal(scheduled.length, 0); + hooks["tool.execute.before"]({ tool: "bash" }, { args: { command: "pnpm test" } }); + assert.equal(scheduled.length, 1); + const beforeDuplicateTesting = calls.length; + await scheduled.shift()?.(); + assert.equal(calls.length, beforeDuplicateTesting, "duplicate testing reaction should be throttled without lease/client work"); + + const beforeIgnored = scheduled.length; + hooks["tool.execute.before"]({ tool: "openpets_openpets_say" }, { args: {} }); + assert.equal(scheduled.length, beforeIgnored); + + hooks.event({ event: { type: "permission.asked", properties: { prompt: "never speak this" } } }); + assert.equal(scheduled.length, 1); + await scheduled.shift()?.(); + assert.deepEqual(calls.at(-1), { kind: "say", value: "Approval needed", leaseId: "lease-fixer" }); + + hooks.event({ event: { type: "session.status", properties: { status: { type: "idle" } } } }); + assert.equal(scheduled.length, 1); + await scheduled.shift()?.(); + assert.deepEqual(calls.at(-1), { kind: "react", value: "success", leaseId: "lease-fixer" }); + + const errors: string[] = []; + const failingHooks = createOpenPetsOpenCodeHooks({ clientFactory: () => { throw new Error("api_key=secret /tmp/path"); }, schedule: (work) => { void work(); }, debug: true, debugLog: (message) => errors.push(message), throttlePath: join(dir, "fail-throttle.json"), now: () => 200_000 }); + assert.doesNotThrow(() => failingHooks.event({ event: { type: "session.error" } })); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.match(errors.join("\n"), /|/); + + const throwingSchedule = createOpenPetsOpenCodeHooks({ schedule: () => { throw new Error("schedule failed"); }, debug: true, debugLog: (message) => errors.push(message) }); + assert.doesNotThrow(() => throwingSchedule.event({ event: { type: "session.error" } })); +} finally { + rmSync(dir, { recursive: true, force: true }); +} + +const loaded = await plugin.server({}, { pet: "fixer" }); +assert.equal(typeof loaded.event, "function"); +assert.equal(typeof loaded["chat.message"], "function"); +assert.equal(typeof loaded["tool.execute.before"], "function"); + +console.error("OpenCode plugin validation passed."); diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts new file mode 100644 index 00000000..0de757cc --- /dev/null +++ b/packages/opencode/src/index.ts @@ -0,0 +1,6 @@ +export * from "./opencode-config.js"; +export * from "./opencode-global-setup.js"; +export * from "./opencode-project-setup.js"; +export * from "./opencode-plugin-runtime.js"; +export * from "./opencode-previews.js"; +export * from "./opencode-status.js"; diff --git a/packages/opencode/src/opencode-config.ts b/packages/opencode/src/opencode-config.ts new file mode 100644 index 00000000..2fc82e68 --- /dev/null +++ b/packages/opencode/src/opencode-config.ts @@ -0,0 +1,221 @@ +import { randomUUID } from "node:crypto"; +import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative } from "node:path"; +import { homedir } from "node:os"; + +import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"; + +export interface OpenCodeConfigPaths { + readonly candidates: readonly string[]; + readonly defaultCreatePath: string; +} + +export interface ParsedOpenCodeConfig { + readonly ok: true; + readonly value: Record; +} + +export interface OpenCodeConfigError { + readonly ok: false; + readonly message: string; +} + +export interface PlannedWrite { + readonly rootPath: string; + readonly targetPath: string; + readonly backupPath?: string; + readonly tempPath: string; + readonly content: string; +} + +export interface OpenCodeExecutableDetection { + readonly command: "opencode" | "opencode.cmd"; + readonly platform: NodeJS.Platform | string; + readonly available: boolean; + readonly version?: string; + readonly error?: string; +} + +export const maxOpenCodeConfigBytes = 1024 * 1024; + +export function getProjectOpenCodeConfigPaths(projectDir: string): OpenCodeConfigPaths { + const root = assertSafeProjectRoot(projectDir); + return { + candidates: [join(root, "opencode.json"), join(root, "opencode.jsonc"), join(root, ".opencode", "opencode.json"), join(root, ".opencode", "opencode.jsonc")], + defaultCreatePath: join(root, ".opencode", "opencode.jsonc"), + }; +} + +export function selectProjectOpenCodeConfigPath(projectDir: string): string { + const paths = getProjectOpenCodeConfigPaths(projectDir); + return paths.candidates.find((candidate) => existsSync(candidate)) ?? paths.defaultCreatePath; +} + +export function getGlobalOpenCodeConfigDir(env: NodeJS.ProcessEnv = process.env, homeDir = homedir(), platform = process.platform): string { + if (env.OPENCODE_CONFIG_DIR) return env.OPENCODE_CONFIG_DIR; + if (platform === "win32") return join(env.APPDATA || join(homeDir, "AppData", "Roaming"), "opencode"); + return join(env.XDG_CONFIG_HOME || join(homeDir, ".config"), "opencode"); +} + +export function getGlobalOpenCodeConfigPaths(env: NodeJS.ProcessEnv = process.env, homeDir = homedir(), platform = process.platform): OpenCodeConfigPaths { + const configDir = getGlobalOpenCodeConfigDir(env, homeDir, platform); + return { + candidates: [join(configDir, "config.json"), join(configDir, "opencode.json"), join(configDir, "opencode.jsonc")], + defaultCreatePath: join(configDir, "opencode.jsonc"), + }; +} + +export function createOpenCodeExecutableDetection(input: Partial & { readonly platform?: NodeJS.Platform | string } = {}): OpenCodeExecutableDetection { + const platform = input.platform ?? process.platform; + return { + command: input.command ?? (platform === "win32" ? "opencode.cmd" : "opencode"), + platform, + available: input.available ?? false, + version: input.version, + error: input.error, + }; +} + +export function readOpenCodeConfigFile(path: string): ParsedOpenCodeConfig | OpenCodeConfigError { + const safety = assertSafeExistingConfigFile(path); + if (!safety.ok) return safety; + return parseOpenCodeConfig(readFileSync(path, "utf8")); +} + +export function parseOpenCodeConfig(text: string): ParsedOpenCodeConfig | OpenCodeConfigError { + if (Buffer.byteLength(text, "utf8") > maxOpenCodeConfigBytes) return { ok: false, message: "OpenCode config is too large." }; + const errors: ParseError[] = []; + const parsed = parse(text || "{}", errors, { allowTrailingComma: true, disallowComments: false }) as unknown; + if (errors.length > 0) return { ok: false, message: "OpenCode config JSONC is invalid." }; + if (!isRecord(parsed) || Array.isArray(parsed)) return { ok: false, message: "OpenCode config must be a JSON object." }; + const fields = validateKnownFieldTypes(parsed); + if (!fields.ok) return fields; + return { ok: true, value: parsed }; +} + +export function updateOpenCodeConfigText(text: string, updates: readonly { readonly path: readonly (string | number)[]; readonly value: unknown }[]): string | OpenCodeConfigError { + const parsed = parseOpenCodeConfig(text); + if (!parsed.ok) return parsed; + let next = text.trim() ? text : "{}\n"; + for (const update of updates) { + const edits = modify(next, [...update.path], update.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }); + next = applyEdits(next, edits); + } + return next.endsWith("\n") ? next : `${next}\n`; +} + +export function planOpenCodeConfigWrite(rootPath: string, targetPath: string, content: string): PlannedWrite | OpenCodeConfigError { + const root = assertSafeProjectRoot(rootPath); + const rel = relative(root, targetPath); + if (rel.startsWith("..") || isAbsolute(rel)) return { ok: false, message: "OpenCode config target must stay inside the validated root." }; + const parent = dirname(targetPath); + const parentSafety = assertSafeParentDirectory(parent); + if (!parentSafety.ok) return parentSafety; + const existing = assertSafeExistingConfigFile(targetPath, true); + if (!existing.ok) return existing; + const parsed = parseOpenCodeConfig(content); + if (!parsed.ok) return parsed; + const stamp = `${process.pid}-${Date.now()}-${randomUUID()}`; + return { + rootPath: root, + targetPath, + backupPath: existsSync(targetPath) ? uniquePath(`${targetPath}.openpets-backup-${stamp}.json`) : undefined, + tempPath: uniquePath(join(parent, `.openpets-${stamp}.tmp`)), + content, + }; +} + +export function executePlannedWrite(plan: PlannedWrite): void { + const root = assertSafeProjectRoot(plan.rootPath); + const rel = relative(root, plan.targetPath); + if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("OpenCode write target escaped validated root."); + for (const path of [plan.backupPath, plan.tempPath].filter((value): value is string => typeof value === "string")) { + const pathRel = relative(root, path); + if (pathRel.startsWith("..") || isAbsolute(pathRel)) throw new Error("OpenCode write support path escaped validated root."); + if (dirname(path) !== dirname(plan.targetPath)) throw new Error("OpenCode write support path must stay next to target."); + } + const parentSafety = assertSafeParentDirectory(dirname(plan.targetPath)); + if (!parentSafety.ok) throw new Error(parentSafety.message); + const targetSafety = assertSafeExistingConfigFile(plan.targetPath, true); + if (!targetSafety.ok) throw new Error(targetSafety.message); + const parsed = parseOpenCodeConfig(plan.content); + if (!parsed.ok) throw new Error(parsed.message); + mkdirSync(dirname(plan.targetPath), { recursive: true, mode: 0o700 }); + if (plan.backupPath && existsSync(plan.targetPath)) { + const backupFd = openSync(plan.backupPath, "wx", 0o600); + try { + writeFileSync(backupFd, readFileSync(plan.targetPath)); + } finally { + closeSync(backupFd); + } + } + const fd = openSync(plan.tempPath, "wx", 0o600); + try { + writeFileSync(fd, plan.content, "utf8"); + } finally { + closeSync(fd); + } + renameSync(plan.tempPath, plan.targetPath); + try { chmodSync(plan.targetPath, 0o600); } catch { /* best effort */ } +} + +export function assertSafeProjectRoot(projectDir: string): string { + if (!isAbsolute(projectDir)) throw new Error("OpenCode project path must be absolute."); + if (!existsSync(projectDir)) throw new Error("OpenCode project path does not exist."); + const stat = lstatSync(projectDir); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("OpenCode project path must be a safe directory."); + return projectDir; +} + +function assertSafeExistingConfigFile(path: string, allowMissing = false): OpenCodeConfigError | { readonly ok: true } { + if (!existsSync(path)) return allowMissing ? { ok: true } : { ok: false, message: "OpenCode config does not exist." }; + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile()) return { ok: false, message: "OpenCode config path must be a regular file." }; + if (stat.size > maxOpenCodeConfigBytes) return { ok: false, message: "OpenCode config is too large." }; + return { ok: true }; +} + +function assertSafeParentDirectory(path: string): OpenCodeConfigError | { readonly ok: true } { + const existing = nearestExistingParent(path); + const rel = relative(existing, path); + if (rel.startsWith("..") || isAbsolute(rel)) return { ok: false, message: "OpenCode config parent escapes target directory." }; + let current = existing; + while (current !== dirname(current)) { + if (existsSync(current) && lstatSync(current).isSymbolicLink()) return { ok: false, message: "OpenCode config parent must not be a symlink." }; + if (current === path) break; + current = dirname(current); + } + if (existsSync(path)) { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isDirectory()) return { ok: false, message: "OpenCode config parent must be a safe directory." }; + } + return { ok: true }; +} + +function nearestExistingParent(path: string): string { + let current = path; + while (!existsSync(current)) current = dirname(current); + if (!statSync(current).isDirectory()) current = dirname(current); + return current; +} + +function validateKnownFieldTypes(config: Record): OpenCodeConfigError | { readonly ok: true } { + if (config.mcp !== undefined && !isRecord(config.mcp)) return { ok: false, message: "OpenCode config mcp field must be an object." }; + if (config.instructions !== undefined && !Array.isArray(config.instructions)) return { ok: false, message: "OpenCode config instructions field must be an array." }; + if (Array.isArray(config.instructions) && !config.instructions.every((entry) => typeof entry === "string")) return { ok: false, message: "OpenCode config instructions entries must be strings." }; + if (config.plugin !== undefined && !Array.isArray(config.plugin)) return { ok: false, message: "OpenCode config plugin field must be an array." }; + return { ok: true }; +} + +function uniquePath(path: string): string { + if (!existsSync(path)) return path; + for (let index = 1; index < 1000; index += 1) { + const candidate = `${path}.${index}`; + if (!existsSync(candidate)) return candidate; + } + throw new Error("Unable to allocate unique OpenCode temp path."); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/opencode/src/opencode-global-setup.ts b/packages/opencode/src/opencode-global-setup.ts new file mode 100644 index 00000000..6cdeded0 --- /dev/null +++ b/packages/opencode/src/opencode-global-setup.ts @@ -0,0 +1,354 @@ +import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative } from "node:path"; +import { randomUUID } from "node:crypto"; + +import { parseOpenCodeConfig, readOpenCodeConfigFile, updateOpenCodeConfigText, type OpenCodeConfigPaths, type PlannedWrite } from "./opencode-config.js"; +import { buildOpenCodeInstructionPath, buildOpenCodeMcpEntry, buildOpenCodePluginPreview, validateOpenPetsPetArg, type OpenCodeCommandMode } from "./opencode-previews.js"; +import { classifyOpenCodeInstructionsStatus, classifyOpenCodeMcpStatus, classifyOpenCodePluginStatus, isManagedOpenPetsMcpEntry, isManagedOpenPetsPluginEntry, isOpenPetsLikePluginEntry } from "./opencode-status.js"; +import { createOpenPetsInstructionBlock } from "./opencode-project-setup.js"; + +export interface PrepareOpenCodeGlobalSetupOptions { + readonly configDir: string; + readonly petId?: string; + readonly cliVersion: string; + readonly pluginVersion?: string; + readonly commandMode?: OpenCodeCommandMode; + readonly cliEntryPath?: string; +} + +export interface PreparedOpenCodeGlobalSetup { + readonly configDir: string; + readonly petId?: string; + readonly configPath: string; + readonly instructionPath: string; + readonly configWrite: PlannedWrite; + readonly cleanupConfigWrites: readonly PlannedWrite[]; + readonly instructionWrite: GlobalPlannedTextWrite; +} + +export interface GlobalPlannedTextWrite { + readonly targetPath: string; + readonly backupPath?: string; + readonly tempPath: string; + readonly content: string; +} + +export interface OpenCodeGlobalState { + readonly status: "not_installed" | "installed" | "custom" | "conflict" | "error"; + readonly message: string; +} + +const maxInstructionBytes = 1024 * 1024; +const openPetsStart = ""; +const openPetsEnd = ""; + +export function getExplicitGlobalOpenCodeConfigPaths(configDir: string): OpenCodeConfigPaths { + assertSafeDirectoryRoot(configDir, true); + return { candidates: [join(configDir, "config.json"), join(configDir, "opencode.json"), join(configDir, "opencode.jsonc")], defaultCreatePath: join(configDir, "opencode.jsonc") }; +} + +export function prepareOpenCodeGlobalSetup(options: PrepareOpenCodeGlobalSetupOptions): PreparedOpenCodeGlobalSetup { + const petId = options.petId === undefined ? undefined : validateOpenPetsPetArg(options.petId); + const paths = getExplicitGlobalOpenCodeConfigPaths(options.configDir); + const existingConfigs = readExistingGlobalConfigs(options.configDir, paths.candidates); + const configs = existingConfigs.map((entry) => entry.config); + const instructionPath = buildOpenCodeInstructionPath("global", options.configDir); + assertSafeGlobalPath(options.configDir, instructionPath, "OpenCode instruction"); + const instructionContent = existsSync(instructionPath) ? readSafeInstructionFile(instructionPath) : ""; + const mcpStatus = classifyOpenCodeMcpStatus(configs, { cliVersion: options.cliVersion, petId, commandMode: options.commandMode, cliEntryPath: options.cliEntryPath }); + const instructionStatus = classifyOpenCodeInstructionsStatus(configs, "global", options.configDir, { [instructionPath]: instructionContent }); + const pluginStatus = classifyOpenCodePluginStatus(configs, petId, options.pluginVersion ?? options.cliVersion); + for (const status of [mcpStatus, instructionStatus, pluginStatus]) { + if (status.status === "custom" || status.status === "conflict" || status.status === "error") throw new Error(`${status.message} Edit or remove the custom OpenPets OpenCode entry, then rerun setup.`); + } + const selectedPath = selectWriteTarget(options.configDir, paths.candidates, existingConfigs, paths.defaultCreatePath); + const selectedText = existsSync(selectedPath) ? readFileSync(selectedPath, "utf8") : "{}\n"; + const parsed = parseOpenCodeConfig(selectedText); + if (!parsed.ok) throw new Error(parsed.message); + const nextConfig = buildNextGlobalConfig(parsed.value, petId, options); + const nextText = updateOpenCodeConfigText(selectedText, [ + { path: ["mcp"], value: nextConfig.mcp }, + { path: ["instructions"], value: nextConfig.instructions }, + { path: ["plugin"], value: nextConfig.plugin }, + ]); + if (typeof nextText !== "string") throw new Error(nextText.message); + const configWrite = planGlobalConfigWrite(options.configDir, selectedPath, nextText); + const cleanupConfigWrites = planSetupCleanupWrites(options.configDir, selectedPath, existingConfigs); + const instructionWrite = planTextWrite(options.configDir, instructionPath, upsertOpenPetsBlock(instructionContent)); + return { configDir: options.configDir, petId, configPath: selectedPath, instructionPath, configWrite, cleanupConfigWrites, instructionWrite }; +} + +export function writePreparedOpenCodeGlobalSetup(prepared: PreparedOpenCodeGlobalSetup): void { + executeTextWrite(prepared.instructionWrite); + for (const write of prepared.cleanupConfigWrites) executeGlobalConfigWrite(write); + executeGlobalConfigWrite(prepared.configWrite); +} + +export function prepareOpenCodeGlobalRemove(configDir: string): { readonly configWrites: readonly PlannedWrite[]; readonly instructionWrite?: GlobalPlannedTextWrite } { + const paths = getExplicitGlobalOpenCodeConfigPaths(configDir); + const existingConfigs = readExistingGlobalConfigs(configDir, paths.candidates); + const state = classifyGlobalState(configDir, existingConfigs); + if (state.status === "custom" || state.status === "conflict" || state.status === "error") throw new Error(state.message); + const owners = existingConfigs.filter((entry) => hasManagedOpenPetsEntry(configDir, entry.config)); + if (owners.length === 0) return { configWrites: [] }; + if (owners.length > 1) throw new Error("OpenCode has OpenPets entries in multiple global config files. Remove duplicates manually."); + const owner = owners[0]; + if (!owner) return { configWrites: [] }; + const text = readFileSync(owner.path, "utf8"); + const next = removeManagedConfig(configDir, owner.config); + const nextText = updateOpenCodeConfigText(text, [ + { path: ["mcp"], value: next.mcp }, + { path: ["instructions"], value: next.instructions }, + { path: ["plugin"], value: next.plugin }, + ]); + if (typeof nextText !== "string") throw new Error(nextText.message); + const configWrite = planGlobalConfigWrite(configDir, owner.path, nextText); + const instructionPath = buildOpenCodeInstructionPath("global", configDir); + const instructionContent = existsSync(instructionPath) ? readSafeInstructionFile(instructionPath) : ""; + const instructionWrite = hasManagedInstructionBlock(instructionContent) ? planTextWrite(configDir, instructionPath, removeOpenPetsBlock(instructionContent)) : undefined; + return { configWrites: [configWrite], instructionWrite }; +} + +export function writePreparedOpenCodeGlobalRemove(prepared: { readonly configWrites: readonly PlannedWrite[]; readonly instructionWrite?: GlobalPlannedTextWrite }): void { + for (const write of prepared.configWrites) executeGlobalConfigWrite(write); + if (prepared.instructionWrite) executeTextWrite(prepared.instructionWrite); +} + +export function doctorOpenCodeGlobalSetup(configDir: string): OpenCodeGlobalState { + try { + const paths = getExplicitGlobalOpenCodeConfigPaths(configDir); + return classifyGlobalState(configDir, readExistingGlobalConfigs(configDir, paths.candidates)); + } catch (error) { + return { status: "error", message: error instanceof Error ? error.message : "OpenCode global setup status is unavailable." }; + } +} + +function readExistingGlobalConfigs(configDir: string, candidates: readonly string[]): Array<{ readonly path: string; readonly config: Record }> { + return candidates.flatMap((path) => { + if (!existsSync(path)) return []; + assertSafeGlobalPath(configDir, path, "OpenCode config"); + const parsed = readOpenCodeConfigFile(path); + if (!parsed.ok) throw new Error(parsed.message); + return [{ path, config: parsed.value }]; + }); +} + +function buildNextGlobalConfig(config: Record, petId: string | undefined, options: PrepareOpenCodeGlobalSetupOptions): { readonly mcp: Record; readonly instructions: readonly string[]; readonly plugin: readonly unknown[] } { + const mcp = isRecord(config.mcp) ? { ...config.mcp } : {}; + mcp.openpets = buildOpenCodeMcpEntry({ cliVersion: options.cliVersion, petId, commandMode: options.commandMode, cliEntryPath: options.cliEntryPath }); + const instructionPath = buildOpenCodeInstructionPath("global", options.configDir); + const instructions = [...new Set([...(Array.isArray(config.instructions) ? config.instructions.filter((entry): entry is string => typeof entry === "string") : []), instructionPath])]; + const plugin = [...(Array.isArray(config.plugin) ? config.plugin.filter((entry) => !isManagedOpenPetsPluginEntry(entry)) : []), buildOpenCodePluginPreview(petId, options.pluginVersion ?? options.cliVersion)]; + return { mcp, instructions, plugin }; +} + +function removeManagedConfig(configDir: string, config: Record): { readonly mcp?: Record; readonly instructions?: readonly string[]; readonly plugin?: readonly unknown[] } { + const mcp = isRecord(config.mcp) ? { ...config.mcp } : {}; + if (isManagedOpenPetsMcpEntry(mcp.openpets)) delete mcp.openpets; + const instructionPath = buildOpenCodeInstructionPath("global", configDir); + const instructions = Array.isArray(config.instructions) ? config.instructions.filter((entry) => typeof entry === "string" && entry !== instructionPath) : []; + const plugin = Array.isArray(config.plugin) ? config.plugin.filter((entry) => !isManagedOpenPetsPluginEntry(entry)) : []; + return { mcp: Object.keys(mcp).length > 0 ? mcp : undefined, instructions: instructions.length > 0 ? instructions : undefined, plugin: plugin.length > 0 ? plugin : undefined }; +} + +function selectWriteTarget(configDir: string, candidates: readonly string[], existing: readonly { readonly path: string; readonly config: Record }[], fallback: string): string { + const owners = existing.filter((entry) => hasManagedOpenPetsEntry(configDir, entry.config)).map((entry) => entry.path); + const uniqueOwners = [...new Set(owners)]; + if (uniqueOwners.length > 1) throw new Error("OpenCode has OpenPets entries in multiple global config files. Remove duplicates manually."); + const arrayOwner = selectArrayFieldOwner(configDir, candidates, existing); + if (arrayOwner) return arrayOwner; + if (uniqueOwners.length === 1) return uniqueOwners[0] ?? fallback; + const highestPrecedenceExisting = [...candidates].reverse().find((candidate) => existing.some((entry) => entry.path === candidate)); + if (highestPrecedenceExisting) return highestPrecedenceExisting; + return fallback; +} + +function planSetupCleanupWrites(configDir: string, selectedPath: string, existing: readonly { readonly path: string; readonly config: Record }[]): readonly PlannedWrite[] { + return existing.flatMap((entry) => { + if (entry.path === selectedPath || !hasManagedOpenPetsEntry(configDir, entry.config)) return []; + const source = readFileSync(entry.path, "utf8"); + const next = removeManagedConfig(configDir, entry.config); + const nextText = updateOpenCodeConfigText(source, [ + { path: ["mcp"], value: next.mcp }, + { path: ["instructions"], value: next.instructions }, + { path: ["plugin"], value: next.plugin }, + ]); + if (typeof nextText !== "string") throw new Error(nextText.message); + return [planGlobalConfigWrite(configDir, entry.path, nextText)]; + }); +} + +function selectArrayFieldOwner(configDir: string, candidates: readonly string[], existing: readonly { readonly path: string; readonly config: Record }[]): string | undefined { + const pluginOwner = findEffectiveArrayOwner(candidates, existing, "plugin", (entry) => !isManagedOpenPetsPluginEntry(entry), isManagedOpenPetsPluginEntry); + const instructionPath = buildOpenCodeInstructionPath("global", configDir); + const instructionOwner = findEffectiveArrayOwner(candidates, existing, "instructions", (entry) => typeof entry === "string" && entry !== instructionPath, (entry) => entry === instructionPath); + const owners = [...new Set([pluginOwner, instructionOwner].filter((value): value is string => typeof value === "string"))]; + if (owners.length > 1) throw new Error("OpenCode global plugin and instruction arrays live in different config files. Consolidate them before installing OpenPets."); + return owners[0]; +} + +function findEffectiveArrayOwner(candidates: readonly string[], existing: readonly { readonly path: string; readonly config: Record }[], field: "plugin" | "instructions", isUserEntry: (entry: unknown) => boolean, isManagedEntry: (entry: unknown) => boolean): string | undefined { + const entries = [...candidates].reverse().flatMap((candidate) => { + const entry = existing.find((item) => item.path === candidate); + const value = entry?.config[field]; + return entry && Array.isArray(value) ? [{ path: entry.path, values: value as readonly unknown[] }] : []; + }); + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (!entry) continue; + if (entry.values.some(isUserEntry)) return entry.path; + const lowerUserOwner = entries.slice(index + 1).find((item) => item.values.some(isUserEntry))?.path; + if (entry.values.some(isManagedEntry)) return lowerUserOwner ?? entry.path; + if (lowerUserOwner) throw new Error(`OpenCode global ${field} array in a higher-precedence config shadows user ${field} entries in a lower-precedence config. Consolidate them before installing OpenPets.`); + return entry.path; + } + return undefined; +} + +function hasManagedOpenPetsEntry(configDir: string, config: Record): boolean { + if (isRecord(config.mcp) && isManagedOpenPetsMcpEntry(config.mcp.openpets)) return true; + if (Array.isArray(config.instructions) && config.instructions.some((entry) => entry === buildOpenCodeInstructionPath("global", configDir))) return true; + if (Array.isArray(config.plugin) && config.plugin.some(isManagedOpenPetsPluginEntry)) return true; + return false; +} + +function hasCustomOpenPetsEntry(configDir: string, config: Record): boolean { + if (isRecord(config.mcp) && config.mcp.openpets !== undefined && !isManagedOpenPetsMcpEntry(config.mcp.openpets)) return true; + if (Array.isArray(config.instructions) && config.instructions.some((entry) => typeof entry === "string" && /openpets\.md$/i.test(entry) && entry !== buildOpenCodeInstructionPath("global", configDir))) return true; + if (Array.isArray(config.plugin) && config.plugin.some((entry) => isOpenPetsLikePluginEntry(entry) && !isManagedOpenPetsPluginEntry(entry))) return true; + return false; +} + +function classifyGlobalState(configDir: string, existing: readonly { readonly path: string; readonly config: Record }[]): OpenCodeGlobalState { + if (existing.some((entry) => hasCustomOpenPetsEntry(configDir, entry.config))) return { status: "custom", message: "OpenCode has custom OpenPets-like global entries. Edit or remove them manually." }; + const owners = existing.filter((entry) => hasManagedOpenPetsEntry(configDir, entry.config)); + if (owners.length > 1) return { status: "conflict", message: "OpenCode has OpenPets entries in multiple global config files. Remove duplicates manually." }; + if (owners.length === 1) return { status: "installed", message: "OpenCode global OpenPets setup is installed." }; + return { status: "not_installed", message: "OpenCode global OpenPets setup is not installed." }; +} + +function planTextWrite(root: string, targetPath: string, content: string): GlobalPlannedTextWrite { + assertSafeGlobalPath(root, targetPath, "OpenCode instruction"); + if (existsSync(targetPath)) { + const stat = lstatSync(targetPath); + if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("OpenCode instruction path must be a safe regular file."); + if (stat.size > maxInstructionBytes) throw new Error("OpenCode instruction file is too large."); + } + const stamp = `${process.pid}-${Date.now()}-${randomUUID()}`; + return { targetPath, backupPath: existsSync(targetPath) ? `${targetPath}.openpets-backup-${stamp}.md` : undefined, tempPath: join(dirname(targetPath), `.openpets-${stamp}.tmp`), content }; +} + +function executeTextWrite(plan: GlobalPlannedTextWrite): void { + const parent = dirname(plan.targetPath); + if (existsSync(parent)) { + const parentStat = lstatSync(parent); + if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) throw new Error("OpenCode instruction directory is unsafe."); + } + if (existsSync(plan.targetPath)) { + const targetStat = lstatSync(plan.targetPath); + if (targetStat.isSymbolicLink() || !targetStat.isFile()) throw new Error("OpenCode instruction path must be a safe regular file."); + if (targetStat.size > maxInstructionBytes) throw new Error("OpenCode instruction file is too large."); + } + if (plan.backupPath && dirname(plan.backupPath) !== parent) throw new Error("OpenCode instruction backup path is unsafe."); + if (dirname(plan.tempPath) !== parent) throw new Error("OpenCode instruction temp path is unsafe."); + mkdirSync(dirname(plan.targetPath), { recursive: true, mode: 0o700 }); + if (plan.backupPath && existsSync(plan.targetPath)) { + const backup = openSync(plan.backupPath, "wx", 0o600); + try { writeFileSync(backup, readFileSync(plan.targetPath)); } finally { closeSync(backup); } + } + const fd = openSync(plan.tempPath, "wx", 0o600); + try { writeFileSync(fd, plan.content, "utf8"); } finally { closeSync(fd); } + renameSync(plan.tempPath, plan.targetPath); + try { chmodSync(plan.targetPath, 0o600); } catch { /* best effort */ } +} + +function planGlobalConfigWrite(rootPath: string, targetPath: string, content: string): PlannedWrite { + assertSafeGlobalPath(rootPath, targetPath, "OpenCode config"); + const parsed = parseOpenCodeConfig(content); + if (!parsed.ok) throw new Error(parsed.message); + const stamp = `${process.pid}-${Date.now()}-${randomUUID()}`; + return { rootPath, targetPath, backupPath: existsSync(targetPath) ? `${targetPath}.openpets-backup-${stamp}.json` : undefined, tempPath: join(dirname(targetPath), `.openpets-${stamp}.tmp`), content }; +} + +function executeGlobalConfigWrite(plan: PlannedWrite): void { + const parent = dirname(plan.targetPath); + assertSafeGlobalPath(plan.rootPath, plan.targetPath, "OpenCode config"); + if (existsSync(plan.targetPath)) { + const targetStat = lstatSync(plan.targetPath); + if (targetStat.isSymbolicLink() || !targetStat.isFile()) throw new Error("OpenCode config path must be a safe regular file."); + } + if (plan.backupPath && (dirname(plan.backupPath) !== parent || existsSync(plan.backupPath))) throw new Error("OpenCode config backup path is unsafe."); + if (dirname(plan.tempPath) !== parent || existsSync(plan.tempPath)) throw new Error("OpenCode config temp path is unsafe."); + const parsed = parseOpenCodeConfig(plan.content); + if (!parsed.ok) throw new Error(parsed.message); + mkdirSync(parent, { recursive: true, mode: 0o700 }); + if (plan.backupPath && existsSync(plan.targetPath)) { + const backup = openSync(plan.backupPath, "wx", 0o600); + try { writeFileSync(backup, readFileSync(plan.targetPath)); } finally { closeSync(backup); } + } + const fd = openSync(plan.tempPath, "wx", 0o600); + try { writeFileSync(fd, plan.content, "utf8"); } finally { closeSync(fd); } + renameSync(plan.tempPath, plan.targetPath); + try { chmodSync(plan.targetPath, 0o600); } catch { /* best effort */ } +} + +function readSafeInstructionFile(path: string): string { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("OpenCode instruction path must be a safe regular file."); + if (stat.size > maxInstructionBytes) throw new Error("OpenCode instruction file is too large."); + return readFileSync(path, "utf8"); +} + +function upsertOpenPetsBlock(source: string): string { + const withoutBlock = source.replace(new RegExp(`${escapeRegExp(openPetsStart)}[\\s\\S]*?${escapeRegExp(openPetsEnd)}\\n?`, "g"), "").replace(/\n{3,}/g, "\n\n").replace(/\s*$/u, ""); + const block = createOpenPetsInstructionBlock(); + return withoutBlock ? `${withoutBlock}\n\n${block}` : block; +} + +function removeOpenPetsBlock(source: string): string { + return source.replace(new RegExp(`${escapeRegExp(openPetsStart)}[\\s\\S]*?${escapeRegExp(openPetsEnd)}\\n?`, "g"), "").replace(/\n{3,}/g, "\n\n").replace(/\s*$/u, (match) => (match.includes("\n") ? "\n" : "")); +} + +function hasManagedInstructionBlock(value: string): boolean { + return new RegExp(`${escapeRegExp(openPetsStart)}[\\s\\S]*?${escapeRegExp(openPetsEnd)}`).test(value); +} + +function assertSafeDirectoryRoot(root: string, allowMissing: boolean): void { + if (!isAbsolute(root)) throw new Error("OpenCode global config directory must be absolute."); + if (!existsSync(root)) { + if (allowMissing) return; + throw new Error("OpenCode global config directory does not exist."); + } + const stat = lstatSync(root); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("OpenCode global config directory is unsafe."); +} + +function assertSafeGlobalPath(root: string, targetPath: string, label: string): void { + assertSafeNearestExistingRoot(root); + const rel = relative(root, targetPath); + if (rel.startsWith("..") || isAbsolute(rel)) throw new Error(`${label} path escapes global config directory.`); + let current = root; + for (const part of rel.split(/[\\/]+/).filter(Boolean).slice(0, -1)) { + current = join(current, part); + if (!existsSync(current)) continue; + const stat = lstatSync(current); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${label} parent directory is unsafe.`); + } +} + +function assertSafeNearestExistingRoot(root: string): void { + if (!isAbsolute(root)) throw new Error("OpenCode global config directory must be absolute."); + let current = root; + while (!existsSync(current)) current = dirname(current); + const stat = statSync(current); + if (!stat.isDirectory()) throw new Error("OpenCode global config parent is unsafe."); + if (lstatSync(current).isSymbolicLink()) throw new Error("OpenCode global config parent must not be a symlink."); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/opencode/src/opencode-plugin-runtime.ts b/packages/opencode/src/opencode-plugin-runtime.ts new file mode 100644 index 00000000..18a68656 --- /dev/null +++ b/packages/opencode/src/opencode-plugin-runtime.ts @@ -0,0 +1,229 @@ +import { homedir, tmpdir, userInfo } from "node:os"; +import { dirname, join } from "node:path"; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; + +import { createOpenPetsClient, type OpenPetsClient, type OpenPetsReaction } from "@open-pets/client"; +import { pickHookSpeech, type HookSpeechCategory, validateHookSpeech } from "@open-pets/agent-events"; + +import { validateOpenPetsPetArg } from "./opencode-previews.js"; + +export interface OpenCodePluginOptions { + readonly pet?: string; + readonly debug?: boolean; +} + +export interface OpenCodePluginRuntimeOptions extends OpenCodePluginOptions { + readonly clientFactory?: () => OpenPetsClient; + readonly schedule?: (work: () => Promise) => void; + readonly now?: () => number; + readonly random?: () => number; + readonly throttlePath?: string; + readonly debugLog?: (message: string) => void; +} + +export interface OpenCodePluginDecision { + readonly reaction?: OpenPetsReaction; + readonly speechCategory?: HookSpeechCategory; +} + +export type OpenCodeHooks = { + readonly event: (input: { readonly event: unknown }) => void; + readonly "chat.message": (input: unknown, output: unknown) => void; + readonly "tool.execute.before": (input: { readonly tool?: string }, output: { readonly args?: unknown }) => void; + readonly "tool.execute.after": (input: { readonly tool?: string }, output: unknown) => void; +}; + +const speechCooldownMs = 20_000; +const permissionCooldownMs = 3_000; +const reactionCooldownMs = 10_000; + +export function createOpenPetsOpenCodeHooks(options: OpenCodePluginRuntimeOptions = {}): OpenCodeHooks { + const pet = options.pet === undefined ? undefined : validateOpenPetsPetArg(options.pet); + const clientFactory = options.clientFactory ?? (() => createOpenPetsClient({ connectTimeoutMs: 500, responseTimeoutMs: 500 })); + const schedule = options.schedule ?? defaultSchedule; + const debug = options.debug === true || process.env.OPENPETS_DEBUG === "1"; + const debugLog = options.debugLog ?? ((message) => { if (debug) process.stderr.write(`${message}\n`); }); + let client: OpenPetsClient | undefined; + let lease: { readonly leaseId: string; readonly expiresAt?: number } | undefined; + + const run = (decision: OpenCodePluginDecision | undefined): void => { + if (!decision?.reaction) return; + const reaction = decision.reaction; + try { + schedule(async () => { + try { + const shouldSpeak = decision.speechCategory ? shouldSendSpeech(decision.speechCategory, options) : false; + const shouldReact = shouldSendReaction(reaction, options); + if (!shouldSpeak && !shouldReact) return; + + client ??= clientFactory(); + const leaseId = pet ? await getLeaseId(client, pet) : undefined; + if (decision.speechCategory && shouldSpeak) { + await client.say(validateHookSpeech(pickHookSpeech(decision.speechCategory, options.random)), { reaction, leaseId }); + return; + } + await client.react(reaction, { leaseId }); + } catch (error) { + debugLog(`OpenPets OpenCode plugin ignored error: ${sanitizeDebugError(error)}`); + } + }); + } catch (error) { + debugLog(`OpenPets OpenCode plugin scheduling ignored error: ${sanitizeDebugError(error)}`); + } + }; + + const getLeaseId = async (hit: OpenPetsClient, requestedPetId: string): Promise => { + if (lease && (!lease.expiresAt || lease.expiresAt - Date.now() > 2_000)) return lease.leaseId; + try { + const next = await hit.acquireLease({ requestedPetId }); + lease = { leaseId: next.leaseId, expiresAt: next.expiresAt }; + return next.leaseId; + } catch (error) { + debugLog(`OpenPets OpenCode lease unavailable: ${sanitizeDebugError(error)}`); + return undefined; + } + }; + + return { + event(input) { + try { + run(classifyOpenCodeBusEvent(input.event)); + } catch (error) { + debugLog(`OpenPets OpenCode event ignored error: ${sanitizeDebugError(error)}`); + } + }, + "chat.message"() { + run({ reaction: "thinking" }); + }, + "tool.execute.before"(input, output) { + const tool = typeof input.tool === "string" ? input.tool : ""; + if (shouldIgnoreOpenPetsTool(tool)) return; + run({ reaction: classifyOpenCodeToolReaction(tool, output.args) }); + }, + "tool.execute.after"() { + // Intentionally quiet for now; session.error/session.status events provide less noisy completion signals. + }, + }; +} + +export function classifyOpenCodeToolReaction(toolName: string, args?: unknown): OpenPetsReaction | undefined { + const normalized = toolName.toLowerCase(); + if (/edit|write|patch|apply_patch/.test(normalized)) return "editing"; + if (/bash|shell|terminal/.test(normalized)) return isTestLikeToolArgs(args) ? "testing" : undefined; + return undefined; +} + +export function classifyOpenCodeBusEvent(event: unknown): OpenCodePluginDecision | undefined { + const type = getEventType(event); + if (type === "permission.asked") return shouldIgnoreOpenPetsTool(getEventPermission(event) ?? "") ? undefined : { reaction: "waiting", speechCategory: "permission" }; + if (type === "session.error") return { reaction: "error", speechCategory: "error" }; + if (type === "session.status" && getEventStatusType(event) === "idle") return { reaction: "success" }; + return undefined; +} + +export function shouldIgnoreOpenPetsTool(toolName: string): boolean { + const normalized = toolName.toLowerCase().replace(/[^a-z0-9_:-]+/g, "_"); + return /(?:^|[_:-])openpets_(?:openpets_)?(?:status|say|react)$/.test(normalized) || /^openpets_(?:status|say|react)$/.test(normalized); +} + +export function getDefaultOpenCodeThrottlePath(): string { + if (process.platform === "win32") return join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "OpenPets", "opencode-hook-throttle.json"); + const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"); + if (stateHome) return join(stateHome, "openpets", "opencode-hook-throttle.json"); + return join(tmpdir(), `openpets-${safeUid()}`, "opencode-hook-throttle.json"); +} + +function shouldSendSpeech(category: HookSpeechCategory, options: OpenCodePluginRuntimeOptions): boolean { + const now = options.now?.() ?? Date.now(); + const cooldown = category === "permission" ? permissionCooldownMs : speechCooldownMs; + return shouldSendThrottleKey(category, cooldown, now, options.throttlePath ?? getDefaultOpenCodeThrottlePath()); +} + +function shouldSendReaction(reaction: OpenPetsReaction, options: OpenCodePluginRuntimeOptions): boolean { + const now = options.now?.() ?? Date.now(); + return shouldSendThrottleKey(`reaction:${reaction}`, reactionCooldownMs, now, options.throttlePath ?? getDefaultOpenCodeThrottlePath()); +} + +function shouldSendThrottleKey(key: string, cooldown: number, now: number, path: string): boolean { + const state = readThrottleState(path); + const previous = typeof state[key] === "number" ? state[key] : 0; + if (now - previous < cooldown) return false; + state[key] = now; + writeThrottleState(path, state); + return true; +} + +function isTestLikeToolArgs(args: unknown): boolean { + const command = isRecord(args) && typeof args.command === "string" ? args.command.slice(0, 300) : ""; + return /\b(test|vitest|jest|pytest|npm\s+test|pnpm\s+test|yarn\s+test|cargo\s+test|go\s+test)\b/i.test(command); +} + +function getEventType(event: unknown): string | undefined { + if (!isRecord(event)) return undefined; + if (typeof event.type === "string") return event.type; + if (isRecord(event.payload) && typeof event.payload.type === "string") return event.payload.type; + return undefined; +} + +function getEventStatusType(event: unknown): string | undefined { + if (!isRecord(event)) return undefined; + const properties = isRecord(event.properties) ? event.properties : isRecord(event.payload) && isRecord(event.payload.properties) ? event.payload.properties : undefined; + const status = isRecord(properties?.status) ? properties.status : undefined; + return typeof status?.type === "string" ? status.type : undefined; +} + +function getEventPermission(event: unknown): string | undefined { + if (!isRecord(event)) return undefined; + const properties = isRecord(event.properties) ? event.properties : isRecord(event.payload) && isRecord(event.payload.properties) ? event.payload.properties : undefined; + if (typeof properties?.permission === "string") return properties.permission; + if (Array.isArray(properties?.patterns)) { + const hit = properties.patterns.find((pattern) => typeof pattern === "string" && shouldIgnoreOpenPetsTool(pattern)); + if (typeof hit === "string") return hit; + } + return undefined; +} + +function readThrottleState(path: string): Record { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + if (!isRecord(parsed)) return {}; + const state: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if ((key === "thinking" || key === "success" || key === "error" || key === "permission" || key.startsWith("reaction:")) && typeof value === "number" && Number.isFinite(value)) state[key] = value; + } + return state; + } catch { + return {}; + } +} + +function writeThrottleState(path: string, state: Record): void { + try { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const tempPath = `${path}.${process.pid}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(state)}\n`, { encoding: "utf8", mode: 0o600 }); + renameSync(tempPath, path); + } catch { + // Best effort only; throttling must never break hooks. + } +} + +function defaultSchedule(work: () => Promise): void { + queueMicrotask(() => { void work(); }); +} + +function sanitizeDebugError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message + .replace(/(?:[A-Za-z]:)?[\\/][^\s"']{2,}/g, "") + .replace(/\b(api[_-]?key|secret|password|token)\s*[:=]\s*\S+/gi, "$1=") + .slice(0, 200); +} + +function safeUid(): string { + try { return String(userInfo().uid); } catch { return "user"; } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/opencode/src/opencode-previews.ts b/packages/opencode/src/opencode-previews.ts new file mode 100644 index 00000000..65d42ddd --- /dev/null +++ b/packages/opencode/src/opencode-previews.ts @@ -0,0 +1,52 @@ +import { isAbsolute, join } from "node:path"; + +export const openCodeMcpServerName = "openpets"; +export const openPetsCliPackageName = "@open-pets/cli"; +export type OpenCodeCommandMode = "published" | "local" | "bundled"; + +export interface OpenCodeMcpEntry { + readonly type: "local"; + readonly command: readonly string[]; + readonly enabled: true; +} + +export interface OpenCodePreviewOptions { + readonly cliVersion: string; + readonly petId?: string; + readonly commandMode?: OpenCodeCommandMode; + readonly cliEntryPath?: string; +} + +export function validateOpenPetsPetArg(value: string): string { + const trimmed = value.trim(); + if (trimmed !== value || trimmed.length < 1) throw new Error("Invalid OpenPets pet id."); + if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(trimmed)) throw new Error("Invalid OpenPets pet id."); + return trimmed; +} + +export function buildOpenCodeMcpEntry(options: OpenCodePreviewOptions): OpenCodeMcpEntry { + const petArgs = options.petId === undefined ? [] : ["--pet", validateOpenPetsPetArg(options.petId)]; + const mode = options.commandMode ?? "published"; + if (mode === "local" || mode === "bundled") { + if (!options.cliEntryPath || !isAbsolute(options.cliEntryPath)) throw new Error("OpenCode local MCP preview requires an absolute CLI entry path."); + return { type: "local", command: ["node", options.cliEntryPath, "mcp", ...petArgs], enabled: true }; + } + return { type: "local", command: ["npx", "-y", `${openPetsCliPackageName}@${options.cliVersion}`, "mcp", ...petArgs], enabled: true }; +} + +export function buildOpenCodeInstructionPath(scope: "project" | "global", configDir?: string): string { + if (scope === "project") return ".opencode/openpets.md"; + if (!configDir) throw new Error("Global OpenCode instruction path requires config directory."); + return join(configDir, "openpets.md"); +} + +export type OpenCodePluginSpec = string | readonly [string, { readonly pet?: string }]; + +export function buildOpenCodePluginPreview(petId?: string, packageVersion?: string): OpenCodePluginSpec { + const spec = packageVersion ? `@open-pets/opencode@${packageVersion}` : "@open-pets/opencode"; + return petId === undefined ? spec : [spec, { pet: validateOpenPetsPetArg(petId) }]; +} + +export function formatOpenCodeMcpConfig(options: OpenCodePreviewOptions): Record { + return { mcp: { [openCodeMcpServerName]: buildOpenCodeMcpEntry(options) } }; +} diff --git a/packages/opencode/src/opencode-project-setup.ts b/packages/opencode/src/opencode-project-setup.ts new file mode 100644 index 00000000..e822bf7b --- /dev/null +++ b/packages/opencode/src/opencode-project-setup.ts @@ -0,0 +1,182 @@ +import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative } from "node:path"; +import { randomUUID } from "node:crypto"; + +import { executePlannedWrite, getProjectOpenCodeConfigPaths, parseOpenCodeConfig, planOpenCodeConfigWrite, readOpenCodeConfigFile, updateOpenCodeConfigText, type PlannedWrite } from "./opencode-config.js"; +import { buildOpenCodeInstructionPath, buildOpenCodeMcpEntry, buildOpenCodePluginPreview, validateOpenPetsPetArg, type OpenCodeCommandMode } from "./opencode-previews.js"; +import { classifyOpenCodeInstructionsStatus, classifyOpenCodeMcpStatus, classifyOpenCodePluginStatus, isManagedOpenPetsMcpEntry, isManagedOpenPetsPluginEntry } from "./opencode-status.js"; + +export interface PrepareOpenCodeProjectSetupOptions { + readonly projectDir: string; + readonly petId: string; + readonly cliVersion: string; + readonly commandMode?: OpenCodeCommandMode; + readonly cliEntryPath?: string; +} + +export interface PreparedOpenCodeProjectSetup { + readonly projectDir: string; + readonly petId: string; + readonly configPath: string; + readonly instructionPath: string; + readonly configWrite: PlannedWrite; + readonly instructionWrite: PlannedTextWrite; +} + +export interface PlannedTextWrite { + readonly targetPath: string; + readonly backupPath?: string; + readonly tempPath: string; + readonly content: string; +} + +const maxInstructionBytes = 1024 * 1024; +const openPetsStart = ""; +const openPetsEnd = ""; + +export function prepareOpenCodeProjectSetup(options: PrepareOpenCodeProjectSetupOptions): PreparedOpenCodeProjectSetup { + const petId = validateOpenPetsPetArg(options.petId); + const paths = getProjectOpenCodeConfigPaths(options.projectDir); + const existingConfigs = paths.candidates.flatMap((path) => { + if (!existsSync(path)) return []; + assertSafeProjectLocalPath(options.projectDir, path, "OpenCode config"); + const parsed = readOpenCodeConfigFile(path); + if (!parsed.ok) throw new Error(parsed.message); + return [{ path, config: parsed.value }]; + }); + const configs = existingConfigs.map((entry) => entry.config); + const instructionRelPath = buildOpenCodeInstructionPath("project"); + const instructionPath = join(options.projectDir, instructionRelPath); + assertSafeProjectLocalPath(options.projectDir, instructionPath, "OpenCode instruction"); + const instructionContent = existsSync(instructionPath) ? readSafeInstructionFile(instructionPath) : ""; + const mcpStatus = classifyOpenCodeMcpStatus(configs, { cliVersion: options.cliVersion, petId, commandMode: options.commandMode, cliEntryPath: options.cliEntryPath }); + const instructionStatus = classifyOpenCodeInstructionsStatus(configs, "project", undefined, { [instructionRelPath]: instructionContent }); + const pluginStatus = classifyOpenCodePluginStatus(configs, petId, options.cliVersion); + for (const status of [mcpStatus, instructionStatus, pluginStatus]) { + if (status.status === "custom" || status.status === "conflict" || status.status === "error") throw new Error(`${status.message} Edit or remove the custom OpenPets OpenCode entry, then rerun setup.`); + } + + const selectedPath = selectWriteTarget(paths.candidates, existingConfigs, paths.defaultCreatePath); + const selectedText = existsSync(selectedPath) ? readFileSync(selectedPath, "utf8") : "{}\n"; + const parsedSelected = parseOpenCodeConfig(selectedText); + if (!parsedSelected.ok) throw new Error(parsedSelected.message); + const nextConfig = buildNextConfig(parsedSelected.value, petId, options); + const nextText = updateOpenCodeConfigText(selectedText, [ + { path: ["mcp"], value: nextConfig.mcp }, + { path: ["instructions"], value: nextConfig.instructions }, + { path: ["plugin"], value: nextConfig.plugin }, + ]); + if (typeof nextText !== "string") throw new Error(nextText.message); + const configWrite = planOpenCodeConfigWrite(options.projectDir, selectedPath, nextText); + if ("ok" in configWrite) throw new Error(configWrite.message); + const instructionWrite = planInstructionWrite(options.projectDir, instructionPath, upsertOpenPetsInstructionBlock(instructionContent)); + return { projectDir: options.projectDir, petId, configPath: selectedPath, instructionPath, configWrite, instructionWrite }; +} + +export function writePreparedOpenCodeProjectSetup(prepared: PreparedOpenCodeProjectSetup): void { + executeTextWrite(prepared.instructionWrite); + executePlannedWrite(prepared.configWrite); +} + +export function createOpenPetsInstructionBlock(): string { + return `${openPetsStart}\n## OpenPets\n\nOpenPets MCP tools may be available.\n\nUse OpenPets as a short visible status channel for meaningful coding progress:\n- Use \`openpets_say\` when starting, completing, blocking, or needing review on non-trivial work.\n- Keep messages brief, user-facing, and non-sensitive.\n- Do not include code, logs, secrets, URLs, or file paths.\n- Use \`openpets_react\` for small visual or emotional feedback.\n- Use \`openpets_status\` only when checking availability or the targeted pet.\n- Do not spam every internal step.\n${openPetsEnd}\n`; +} + +function buildNextConfig(config: Record, petId: string, options: PrepareOpenCodeProjectSetupOptions): { readonly mcp: Record; readonly instructions: readonly string[]; readonly plugin: readonly unknown[] } { + const mcp = isRecord(config.mcp) ? { ...config.mcp } : {}; + mcp.openpets = buildOpenCodeMcpEntry({ cliVersion: options.cliVersion, petId, commandMode: options.commandMode, cliEntryPath: options.cliEntryPath }); + const instructionPath = buildOpenCodeInstructionPath("project"); + const instructions = [...new Set([...(Array.isArray(config.instructions) ? config.instructions.filter((entry): entry is string => typeof entry === "string") : []), instructionPath])]; + const pluginSpec = buildOpenCodePluginPreview(petId, options.cliVersion); + const plugin = [...(Array.isArray(config.plugin) ? config.plugin.filter((entry) => !isManagedOpenPetsPluginEntry(entry)) : []), pluginSpec]; + return { mcp, instructions, plugin }; +} + +function selectWriteTarget(candidates: readonly string[], existing: readonly { readonly path: string; readonly config: Record }[], fallback: string): string { + const owners = existing.filter((entry) => hasManagedOpenPetsEntry(entry.config)).map((entry) => entry.path); + const uniqueOwners = [...new Set(owners)]; + if (uniqueOwners.length > 1) throw new Error("OpenCode has OpenPets entries in multiple config files. Remove duplicates, then rerun setup."); + if (uniqueOwners.length === 1) return uniqueOwners[0] ?? fallback; + return candidates.find((candidate) => existing.some((entry) => entry.path === candidate)) ?? fallback; +} + +function planInstructionWrite(projectDir: string, targetPath: string, content: string): PlannedTextWrite { + assertSafeProjectLocalPath(projectDir, targetPath, "OpenCode instruction"); + if (existsSync(targetPath)) { + const stat = lstatSync(targetPath); + if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("OpenCode instruction path must be a safe regular file."); + if (stat.size > maxInstructionBytes) throw new Error("OpenCode instruction file is too large."); + } + const parent = dirname(targetPath); + if (existsSync(parent)) { + const stat = lstatSync(parent); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("OpenCode instruction directory is unsafe."); + } + const stamp = `${process.pid}-${Date.now()}-${randomUUID()}`; + return { targetPath, backupPath: existsSync(targetPath) ? `${targetPath}.openpets-backup-${stamp}.md` : undefined, tempPath: join(parent, `.openpets-${stamp}.tmp`), content }; +} + +function executeTextWrite(plan: PlannedTextWrite): void { + const parent = dirname(plan.targetPath); + if (existsSync(parent)) { + const parentStat = lstatSync(parent); + if (parentStat.isSymbolicLink() || !parentStat.isDirectory()) throw new Error("OpenCode instruction directory is unsafe."); + } + if (existsSync(plan.targetPath)) { + const targetStat = lstatSync(plan.targetPath); + if (targetStat.isSymbolicLink() || !targetStat.isFile()) throw new Error("OpenCode instruction path must be a safe regular file."); + if (targetStat.size > maxInstructionBytes) throw new Error("OpenCode instruction file is too large."); + } + if (plan.backupPath && dirname(plan.backupPath) !== parent) throw new Error("OpenCode instruction backup path is unsafe."); + if (dirname(plan.tempPath) !== parent) throw new Error("OpenCode instruction temp path is unsafe."); + mkdirSync(dirname(plan.targetPath), { recursive: true, mode: 0o700 }); + if (plan.backupPath && existsSync(plan.targetPath)) { + const backup = openSync(plan.backupPath, "wx", 0o600); + try { writeFileSync(backup, readFileSync(plan.targetPath)); } finally { closeSync(backup); } + } + const fd = openSync(plan.tempPath, "wx", 0o600); + try { writeFileSync(fd, plan.content, "utf8"); } finally { closeSync(fd); } + renameSync(plan.tempPath, plan.targetPath); + try { chmodSync(plan.targetPath, 0o600); } catch { /* best effort */ } +} + +function readSafeInstructionFile(path: string): string { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("OpenCode instruction path must be a safe regular file."); + if (stat.size > maxInstructionBytes) throw new Error("OpenCode instruction file is too large."); + return readFileSync(path, "utf8"); +} + +function assertSafeProjectLocalPath(projectDir: string, targetPath: string, label: string): void { + const rel = relative(projectDir, targetPath); + if (rel.startsWith("..") || isAbsolute(rel)) throw new Error(`${label} path escapes the project.`); + const parts = rel.split(/[\\/]+/).filter(Boolean); + let current = projectDir; + for (let index = 0; index < parts.length - 1; index += 1) { + current = join(current, parts[index] ?? ""); + if (!existsSync(current)) continue; + const stat = lstatSync(current); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`${label} parent directory is unsafe.`); + } +} + +function upsertOpenPetsInstructionBlock(source: string): string { + const withoutBlock = source.replace(new RegExp(`${escapeRegExp(openPetsStart)}[\\s\\S]*?${escapeRegExp(openPetsEnd)}\\n?`, "g"), "").replace(/\n{3,}/g, "\n\n").replace(/\s*$/u, ""); + const block = createOpenPetsInstructionBlock(); + return withoutBlock ? `${withoutBlock}\n\n${block}` : block; +} + +function hasManagedOpenPetsEntry(config: Record): boolean { + if (isRecord(config.mcp) && isManagedOpenPetsMcpEntry(config.mcp.openpets)) return true; + if (Array.isArray(config.instructions) && config.instructions.some((entry) => entry === buildOpenCodeInstructionPath("project"))) return true; + if (Array.isArray(config.plugin) && config.plugin.some(isManagedOpenPetsPluginEntry)) return true; + return false; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/opencode/src/opencode-status.ts b/packages/opencode/src/opencode-status.ts new file mode 100644 index 00000000..1672d48c --- /dev/null +++ b/packages/opencode/src/opencode-status.ts @@ -0,0 +1,136 @@ +import { buildOpenCodeInstructionPath, buildOpenCodeMcpEntry, buildOpenCodePluginPreview, openCodeMcpServerName, type OpenCodePreviewOptions } from "./opencode-previews.js"; + +export type OpenCodeEntryStatus = "not_installed" | "installed" | "needs_update" | "custom" | "conflict" | "error"; + +export interface OpenCodeStatusResult { + readonly status: OpenCodeEntryStatus; + readonly message: string; + readonly matches: readonly string[]; +} + +export function classifyOpenCodeMcpStatus(configs: readonly Record[], expected: OpenCodePreviewOptions): OpenCodeStatusResult { + const entries = configs.flatMap((config, index) => { + const mcp = isRecord(config.mcp) ? config.mcp : undefined; + const entry = mcp?.[openCodeMcpServerName]; + return entry === undefined ? [] : [{ source: String(index), entry }]; + }); + if (entries.length === 0) return { status: "not_installed", message: "OpenCode OpenPets MCP is not installed.", matches: [] }; + const expectedEntry = buildOpenCodeMcpEntry(expected); + const current = entries.filter(({ entry }) => isSameMcpEntry(entry, expectedEntry)); + const managed = entries.filter(({ entry }) => isManagedOpenPetsMcpEntry(entry, expectedEntry.command)); + if (current.length === 1 && entries.length === 1) return { status: "installed", message: "OpenCode OpenPets MCP is installed.", matches: [entries[0]?.source ?? "0"] }; + if (current.length > 0 || managed.length > 0) return { status: entries.length > 1 ? "conflict" : "needs_update", message: "OpenCode OpenPets MCP needs update.", matches: entries.map((entry) => entry.source) }; + return { status: "custom", message: "OpenCode has a custom openpets MCP entry.", matches: entries.map((entry) => entry.source) }; +} + +export function classifyOpenCodeInstructionsStatus(configs: readonly Record[], scope: "project" | "global", configDir?: string, instructionFiles: Record = {}): OpenCodeStatusResult { + const expected = buildOpenCodeInstructionPath(scope, configDir); + const allEntries = configs.flatMap((config, index) => Array.isArray(config.instructions) ? config.instructions.filter((entry): entry is string => typeof entry === "string" && isOpenPetsLikeInstruction(entry)).map((entry) => ({ source: String(index), entry })) : []); + const managedEntries = allEntries.filter(({ entry }) => entry === expected); + const customEntries = allEntries.filter(({ entry }) => entry !== expected); + if (allEntries.length === 0) return { status: "not_installed", message: "OpenCode OpenPets instructions are not installed.", matches: [] }; + if (managedEntries.length > 1 || (managedEntries.length > 0 && customEntries.length > 0)) return { status: "conflict", message: "OpenCode has conflicting OpenPets instruction entries.", matches: allEntries.map((entry) => entry.source) }; + if (managedEntries.length === 1 && hasManagedInstructionBlock(instructionFiles[expected])) return { status: "installed", message: "OpenCode OpenPets instructions are installed.", matches: managedEntries.map((entry) => entry.source) }; + if (managedEntries.length === 1) return { status: "needs_update", message: "OpenCode OpenPets instruction file needs managed block.", matches: managedEntries.map((entry) => entry.source) }; + return { status: "custom", message: "OpenCode has custom OpenPets-like instruction entries.", matches: customEntries.map((entry) => entry.source) }; +} + +export function classifyOpenCodePluginStatus(configs: readonly Record[], petId?: string, packageVersion?: string): OpenCodeStatusResult { + const expected = buildOpenCodePluginPreview(petId, packageVersion); + const pluginEntries = configs.flatMap((config, index) => Array.isArray(config.plugin) ? config.plugin.map((entry) => ({ source: String(index), entry })) : []); + const current = pluginEntries.filter(({ entry }) => isExpectedPlugin(entry, expected)); + const recognizable = pluginEntries.filter(({ entry }) => isManagedOpenPetsPluginEntry(entry)); + const custom = pluginEntries.filter(({ entry }) => !isManagedOpenPetsPluginEntry(entry) && isOpenPetsLikePluginEntry(entry)); + if (current.length === 1 && recognizable.length === 1 && custom.length === 0) return { status: "installed", message: "OpenCode OpenPets plugin is installed.", matches: current.map((entry) => entry.source) }; + if (recognizable.length > 0 && custom.length > 0) return { status: "conflict", message: "OpenCode has conflicting OpenPets plugin entries.", matches: [...recognizable, ...custom].map((entry) => entry.source) }; + if (recognizable.length > 0) return { status: recognizable.length > 1 ? "conflict" : "needs_update", message: "OpenCode OpenPets plugin needs update.", matches: recognizable.map((entry) => entry.source) }; + if (custom.length > 0) return { status: "custom", message: "OpenCode has custom OpenPets-like plugin entries.", matches: custom.map((entry) => entry.source) }; + return { status: "not_installed", message: "OpenCode OpenPets plugin is not installed.", matches: [] }; +} + +export function isManagedOpenPetsMcpEntry(value: unknown, expectedCommand?: readonly string[]): boolean { + if (!isRecord(value) || value.type !== "local" || value.enabled !== true || !Array.isArray(value.command)) return false; + const keys = Object.keys(value).sort(); + if (keys.length !== 3 || keys[0] !== "command" || keys[1] !== "enabled" || keys[2] !== "type") return false; + return isManagedOpenPetsMcpCommand(value.command, expectedCommand); +} + +function isManagedOpenPetsMcpCommand(command: readonly unknown[], expectedCommand?: readonly string[]): boolean { + if (!command.every((part) => typeof part === "string")) return false; + const parts = command as readonly string[]; + if (expectedCommand && isSameCommand(parts, expectedCommand)) return true; + if (expectedCommand && isExpectedNodeOpenPetsMcpCommand(parts, expectedCommand)) return true; + return isPublishedOpenPetsMcpCommand(parts) || isNodeOpenPetsMcpCommand(parts); +} + +function isExpectedNodeOpenPetsMcpCommand(command: readonly string[], expected: readonly string[]): boolean { + return expected[0] === "node" && command.length >= 3 && command[0] === "node" && command[1] === expected[1] && command[2] === "mcp" && hasValidPetArgs(command.slice(3)); +} + +function isPublishedOpenPetsMcpCommand(command: readonly string[]): boolean { + return command.length >= 4 && command[0] === "npx" && command[1] === "-y" && /^@open-pets\/cli@\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?$/.test(command[2] ?? "") && command[3] === "mcp" && hasValidPetArgs(command.slice(4)); +} + +function isNodeOpenPetsMcpCommand(command: readonly string[]): boolean { + return command.length >= 3 && command[0] === "node" && isOpenPetsCliEntryPath(command[1] ?? "") && command[2] === "mcp" && hasValidPetArgs(command.slice(3)); +} + +function isOpenPetsCliEntryPath(path: string): boolean { + return /(?:^|[\\/])node_modules[\\/]@open-pets[\\/]cli[\\/]dist[\\/]index\.js$/u.test(path) || /(?:^|[\\/])packages[\\/]cli[\\/]dist[\\/]index\.js$/u.test(path); +} + +function hasValidPetArgs(args: readonly string[]): boolean { + if (args.length === 0) return true; + return args.length === 2 && args[0] === "--pet" && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(args[1] ?? ""); +} + +function isSameCommand(command: readonly string[], expected: readonly string[]): boolean { + return command.length === expected.length && command.every((part, index) => part === expected[index]); +} + +function isExpectedPlugin(value: unknown, expected: string | readonly [string, { readonly pet?: string }]): boolean { + if (typeof expected === "string") return value === expected; + return Array.isArray(value) && value.length === 2 && value[0] === expected[0] && isSamePluginOptions(value[1], expected[1]); +} + +export function isManagedOpenPetsPluginEntry(value: unknown): boolean { + if (typeof value === "string") return /^@open-pets\/opencode(?:@[^/]+)?$/.test(value); + return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && /^@open-pets\/opencode(?:@[^/]+)?$/.test(value[0]) && isPetPluginOptions(value[1]); +} + +export function isOpenPetsLikePluginEntry(value: unknown): boolean { + if (typeof value === "string") return /openpets|open-pets/i.test(value); + if (Array.isArray(value)) return value.some(isOpenPetsLikePluginEntry); + return false; +} + +function isPetPluginOptions(value: unknown): boolean { + if (!isRecord(value)) return false; + const keys = Object.keys(value); + return keys.length === 1 && keys[0] === "pet" && typeof value.pet === "string" && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(value.pet); +} + +function isSameMcpEntry(value: unknown, expected: { readonly type: "local"; readonly command: readonly string[]; readonly enabled: true }): boolean { + if (!isRecord(value) || value.type !== expected.type || value.enabled !== expected.enabled || !Array.isArray(value.command)) return false; + const keys = Object.keys(value).sort(); + if (keys.length !== 3 || keys[0] !== "command" || keys[1] !== "enabled" || keys[2] !== "type") return false; + return value.command.length === expected.command.length && value.command.every((part, index) => part === expected.command[index]); +} + +function isSamePluginOptions(value: unknown, expected: { readonly pet?: string }): boolean { + if (!isRecord(value)) return Object.keys(expected).length === 0; + const keys = Object.keys(value); + return keys.length === Object.keys(expected).length && value.pet === expected.pet; +} + +function isOpenPetsLikeInstruction(value: string): boolean { + return /openpets\.md$/i.test(value) || /@open-pets\/opencode/i.test(value); +} + +function hasManagedInstructionBlock(value: string | undefined): boolean { + return typeof value === "string" && /[\s\S]*?/.test(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/opencode/src/plugin.ts b/packages/opencode/src/plugin.ts new file mode 100644 index 00000000..fcd13154 --- /dev/null +++ b/packages/opencode/src/plugin.ts @@ -0,0 +1,10 @@ +import { createOpenPetsOpenCodeHooks, type OpenCodePluginOptions } from "./opencode-plugin-runtime.js"; + +export const openPetsOpenCodePluginId = "open-pets-opencode"; + +const plugin = { + id: openPetsOpenCodePluginId, + server: async (_input: unknown, options?: OpenCodePluginOptions) => createOpenPetsOpenCodeHooks(options ?? {}), +}; + +export default plugin; diff --git a/packages/opencode/tsconfig.json b/packages/opencode/tsconfig.json new file mode 100644 index 00000000..762e7118 --- /dev/null +++ b/packages/opencode/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/pet-format/package.json b/packages/pet-format/package.json new file mode 100644 index 00000000..69afd25e --- /dev/null +++ b/packages/pet-format/package.json @@ -0,0 +1,28 @@ +{ + "name": "@open-pets/pet-format", + "version": "2.0.0", + "private": true, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/alvinunreal/openpets.git", + "directory": "packages/pet-format" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "check": "pnpm typecheck", + "typecheck": "tsc --noEmit", + "build": "tsc" + }, + "devDependencies": { + "typescript": "^6.0.3" + } +} diff --git a/packages/pet-format/src/index.ts b/packages/pet-format/src/index.ts new file mode 100644 index 00000000..36dda072 --- /dev/null +++ b/packages/pet-format/src/index.ts @@ -0,0 +1,5 @@ +export interface PetFormatPackageMarker { + readonly packageName: "@open-pets/pet-format"; +} + +export const petFormatPackageName = "@open-pets/pet-format"; diff --git a/packages/pet-format/tsconfig.json b/packages/pet-format/tsconfig.json new file mode 100644 index 00000000..df59da57 --- /dev/null +++ b/packages/pet-format/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..61479fa8 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2983 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + apps/desktop: + dependencies: + '@open-pets/agent-events': + specifier: workspace:* + version: link:../../packages/agent-events + '@open-pets/claude': + specifier: workspace:* + version: link:../../packages/claude + '@open-pets/cli': + specifier: workspace:* + version: link:../../packages/cli + '@open-pets/mcp': + specifier: workspace:* + version: link:../../packages/mcp + '@open-pets/opencode': + specifier: workspace:* + version: link:../../packages/opencode + yauzl: + specifier: 3.3.0 + version: 3.3.0 + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + '@types/yauzl': + specifier: 2.10.3 + version: 2.10.3 + electron: + specifier: ^42.0.0 + version: 42.0.0 + electron-builder: + specifier: ^26.8.1 + version: 26.8.1(electron-builder-squirrel-windows@26.8.1) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + packages/agent-events: + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + packages/claude: + dependencies: + '@open-pets/agent-events': + specifier: workspace:* + version: link:../agent-events + '@open-pets/client': + specifier: workspace:* + version: link:../client + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + packages/cli: + dependencies: + '@open-pets/claude': + specifier: workspace:* + version: link:../claude + '@open-pets/client': + specifier: workspace:* + version: link:../client + '@open-pets/mcp': + specifier: workspace:* + version: link:../mcp + '@open-pets/opencode': + specifier: workspace:* + version: link:../opencode + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + packages/client: + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + packages/mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) + '@open-pets/client': + specifier: workspace:* + version: link:../client + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + packages/opencode: + dependencies: + '@open-pets/agent-events': + specifier: workspace:* + version: link:../agent-events + '@open-pets/client': + specifier: workspace:* + version: link:../client + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + packages/pet-format: + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + +packages: + + 7zip-bin@5.2.0: + resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} + + '@develar/schema-utils@2.6.5': + resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} + engines: {node: '>= 8.9.0'} + + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@electron/fuses@1.8.0': + resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} + hasBin: true + + '@electron/get@3.1.0': + resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} + engines: {node: '>=14'} + + '@electron/get@5.0.0': + resolution: {integrity: sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==} + engines: {node: '>=22.12.0'} + + '@electron/notarize@2.5.0': + resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} + engines: {node: '>= 10.0.0'} + + '@electron/osx-sign@1.3.3': + resolution: {integrity: sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==} + engines: {node: '>=12.0.0'} + hasBin: true + + '@electron/rebuild@4.0.4': + resolution: {integrity: sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@electron/universal@2.0.3': + resolution: {integrity: sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==} + engines: {node: '>=16.4'} + + '@electron/windows-sign@1.2.2': + resolution: {integrity: sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==} + engines: {node: '>=14.14'} + hasBin: true + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@malept/cross-spawn-promise@2.0.0': + resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} + engines: {node: '>= 12.13.0'} + + '@malept/flatpak-bundler@0.4.0': + resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} + engines: {node: '>= 10.0.0'} + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@24.12.2': + resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} + + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + + '@types/plist@3.0.5': + resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + + '@types/verror@1.10.11': + resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + app-builder-bin@5.0.0-alpha.12: + resolution: {integrity: sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==} + + app-builder-lib@26.8.1: + resolution: {integrity: sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==} + engines: {node: '>=14.0.0'} + peerDependencies: + dmg-builder: 26.8.1 + electron-builder-squirrel-windows: 26.8.1 + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builder-util-runtime@9.5.1: + resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} + engines: {node: '>=12.0.0'} + + builder-util@26.8.1: + resolution: {integrity: sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + chromium-pickle-js@0.2.0: + resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} + + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cli-truncate@2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + compare-version@0.1.2: + resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} + engines: {node: '>=0.10.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + crc@3.8.0: + resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} + + cross-dirname@0.1.0: + resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + dir-compare@4.2.0: + resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + + dmg-builder@26.8.1: + resolution: {integrity: sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==} + + dmg-license@1.0.11: + resolution: {integrity: sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==} + engines: {node: '>=8'} + os: [darwin] + hasBin: true + + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-builder-squirrel-windows@26.8.1: + resolution: {integrity: sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==} + + electron-builder@26.8.1: + resolution: {integrity: sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==} + engines: {node: '>=14.0.0'} + hasBin: true + + electron-publish@26.8.1: + resolution: {integrity: sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==} + + electron-winstaller@5.4.0: + resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} + engines: {node: '>=8.0.0'} + + electron@42.0.0: + resolution: {integrity: sha512-in5jnW/Ywy3Rh3FPr4MR80exPEkywKYAmDJRZ/gIKlr8VXEi3zXgiAZbf0Si7KRccHTF2y8euVMRz7M6HqTjMA==} + engines: {node: '>= 22.12.0'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + express-rate-limit@8.5.1: + resolution: {integrity: sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + extsprintf@1.4.1: + resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} + engines: {'0': node >=0.6.0} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + engines: {node: '>=14.14'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hono@4.12.18: + resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==} + engines: {node: '>=16.9.0'} + + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-corefoundation@1.1.7: + resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} + engines: {node: ^8.11.2 || >=10} + os: [darwin] + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isbinaryfile@5.0.7: + resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} + engines: {node: '>= 18.0.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + lazy-val@1.0.5: + resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-abi@4.31.0: + resolution: {integrity: sha512-Erq5w/t3syw3s4sDsUaX4QttIdBPsGKTT1DTRsCkTonGggczhlDKm/wDX3o+HPJpQ41EjXCbcmXf0tgr5YZJXw==} + engines: {node: '>=22.12.0'} + + node-addon-api@1.7.2: + resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + + node-api-version@0.2.1: + resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + + node-gyp@12.3.0: + resolution: {integrity: sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pe-library@0.4.1: + resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} + engines: {node: '>=12', npm: '>=6'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + plist@3.1.0: + resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} + engines: {node: '>=10.4.0'} + + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + read-binary-file-arch@1.0.6: + resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resedit@1.7.2: + resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} + engines: {node: '>=12', npm: '>=6'} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + slice-ansi@3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + stat-mode@1.0.0: + resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} + engines: {node: '>= 6'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tar@7.5.15: + resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} + engines: {node: '>=18'} + + temp-file@3.4.0: + resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} + + temp@0.9.4: + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} + + tiny-async-pool@1.3.0: + resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + engines: {node: '>=14.14'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + undici@6.25.0: + resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} + engines: {node: '>=18.17'} + + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + verror@1.10.1: + resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} + engines: {node: '>=0.6.0'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yauzl@3.3.0: + resolution: {integrity: sha512-PtGEvEP30p7sbIBJKUBjUnqgTVOyMURc4dLo9iNyAJnNIEz9pm88cCXF21w94Kg3k6RXkeZh5DHOGS0qEONvNQ==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + 7zip-bin@5.2.0: {} + + '@develar/schema-utils@2.6.5': + dependencies: + ajv: 6.15.0 + ajv-keywords: 3.5.2(ajv@6.15.0) + + '@electron/asar@3.4.1': + dependencies: + commander: 5.1.0 + glob: 7.2.3 + minimatch: 3.1.5 + + '@electron/fuses@1.8.0': + dependencies: + chalk: 4.1.2 + fs-extra: 9.1.0 + minimist: 1.2.8 + + '@electron/get@3.1.0': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/get@5.0.0': + dependencies: + debug: 4.4.3 + env-paths: 3.0.0 + graceful-fs: 4.2.11 + progress: 2.0.3 + semver: 7.7.4 + sumchecker: 3.0.1 + optionalDependencies: + undici: 7.25.0 + transitivePeerDependencies: + - supports-color + + '@electron/notarize@2.5.0': + dependencies: + debug: 4.4.3 + fs-extra: 9.1.0 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@electron/osx-sign@1.3.3': + dependencies: + compare-version: 0.1.2 + debug: 4.4.3 + fs-extra: 10.1.0 + isbinaryfile: 4.0.10 + minimist: 1.2.8 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/rebuild@4.0.4': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + node-abi: 4.31.0 + node-api-version: 0.2.1 + node-gyp: 12.3.0 + read-binary-file-arch: 1.0.6 + transitivePeerDependencies: + - supports-color + + '@electron/universal@2.0.3': + dependencies: + '@electron/asar': 3.4.1 + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + dir-compare: 4.2.0 + fs-extra: 11.3.5 + minimatch: 9.0.9 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/windows-sign@1.2.2': + dependencies: + cross-dirname: 0.1.0 + debug: 4.4.3 + fs-extra: 11.3.5 + minimist: 1.2.8 + postject: 1.0.0-alpha.6 + transitivePeerDependencies: + - supports-color + optional: true + + '@hono/node-server@1.19.14(hono@4.12.18)': + dependencies: + hono: 4.12.18 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@malept/cross-spawn-promise@2.0.0': + dependencies: + cross-spawn: 7.0.6 + + '@malept/flatpak-bundler@0.4.0': + dependencies: + debug: 4.4.3 + fs-extra: 9.1.0 + lodash: 4.18.1 + tmp-promise: 3.0.3 + transitivePeerDependencies: + - supports-color + + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.18) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.5.1(express@5.2.1) + hono: 4.12.18 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + '@sindresorhus/is@4.6.0': {} + + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 25.6.2 + '@types/responselike': 1.0.3 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 25.6.2 + + '@types/http-cache-semantics@4.2.0': {} + + '@types/keyv@3.1.4': + dependencies: + '@types/node': 25.6.2 + + '@types/ms@2.1.0': {} + + '@types/node@24.12.2': + dependencies: + undici-types: 7.16.0 + + '@types/node@25.6.2': + dependencies: + undici-types: 7.19.2 + + '@types/plist@3.0.5': + dependencies: + '@types/node': 25.6.2 + xmlbuilder: 15.1.1 + optional: true + + '@types/responselike@1.0.3': + dependencies: + '@types/node': 25.6.2 + + '@types/verror@1.10.11': + optional: true + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 24.12.2 + + '@xmldom/xmldom@0.8.13': {} + + '@xmldom/xmldom@0.9.10': + optional: true + + abbrev@4.0.0: {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-keywords@3.5.2(ajv@6.15.0): + dependencies: + ajv: 6.15.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + app-builder-bin@5.0.0-alpha.12: {} + + app-builder-lib@26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1): + dependencies: + '@develar/schema-utils': 2.6.5 + '@electron/asar': 3.4.1 + '@electron/fuses': 1.8.0 + '@electron/get': 3.1.0 + '@electron/notarize': 2.5.0 + '@electron/osx-sign': 1.3.3 + '@electron/rebuild': 4.0.4 + '@electron/universal': 2.0.3 + '@malept/flatpak-bundler': 0.4.0 + '@types/fs-extra': 9.0.13 + async-exit-hook: 2.0.1 + builder-util: 26.8.1 + builder-util-runtime: 9.5.1 + chromium-pickle-js: 0.2.0 + ci-info: 4.3.1 + debug: 4.4.3 + dmg-builder: 26.8.1(electron-builder-squirrel-windows@26.8.1) + dotenv: 16.6.1 + dotenv-expand: 11.0.7 + ejs: 3.1.10 + electron-builder-squirrel-windows: 26.8.1(dmg-builder@26.8.1) + electron-publish: 26.8.1 + fs-extra: 10.1.0 + hosted-git-info: 4.1.0 + isbinaryfile: 5.0.7 + jiti: 2.7.0 + js-yaml: 4.1.1 + json5: 2.2.3 + lazy-val: 1.0.5 + minimatch: 10.2.5 + plist: 3.1.0 + proper-lockfile: 4.1.2 + resedit: 1.7.2 + semver: 7.7.4 + tar: 7.5.15 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + which: 5.0.0 + transitivePeerDependencies: + - supports-color + + argparse@2.0.1: {} + + assert-plus@1.0.0: + optional: true + + astral-regex@2.0.0: + optional: true + + async-exit-hook@2.0.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + boolean@3.2.0: + optional: true + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + buffer-crc32@0.2.13: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + optional: true + + builder-util-runtime@9.5.1: + dependencies: + debug: 4.4.3 + sax: 1.6.0 + transitivePeerDependencies: + - supports-color + + builder-util@26.8.1: + dependencies: + 7zip-bin: 5.2.0 + '@types/debug': 4.1.13 + app-builder-bin: 5.0.0-alpha.12 + builder-util-runtime: 9.5.1 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + fs-extra: 10.1.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + js-yaml: 4.1.1 + sanitize-filename: 1.6.4 + source-map-support: 0.5.21 + stat-mode: 1.0.0 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chownr@3.0.0: {} + + chromium-pickle-js@0.2.0: {} + + ci-info@4.3.1: {} + + ci-info@4.4.0: {} + + cli-truncate@2.1.0: + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + optional: true + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@5.1.0: {} + + commander@9.5.0: + optional: true + + compare-version@0.1.2: {} + + concat-map@0.0.1: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + core-util-is@1.0.2: + optional: true + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + crc@3.8.0: + dependencies: + buffer: 5.7.1 + optional: true + + cross-dirname@0.1.0: + optional: true + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + optional: true + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + detect-node@2.1.0: + optional: true + + dir-compare@4.2.0: + dependencies: + minimatch: 3.1.5 + p-limit: 3.1.0 + + dmg-builder@26.8.1(electron-builder-squirrel-windows@26.8.1): + dependencies: + app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) + builder-util: 26.8.1 + fs-extra: 10.1.0 + iconv-lite: 0.6.3 + js-yaml: 4.1.1 + optionalDependencies: + dmg-license: 1.0.11 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + + dmg-license@1.0.11: + dependencies: + '@types/plist': 3.0.5 + '@types/verror': 1.10.11 + ajv: 6.15.0 + crc: 3.8.0 + iconv-corefoundation: 1.1.7 + plist: 3.1.1 + smart-buffer: 4.2.0 + verror: 1.10.1 + optional: true + + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-builder-squirrel-windows@26.8.1(dmg-builder@26.8.1): + dependencies: + app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) + builder-util: 26.8.1 + electron-winstaller: 5.4.0 + transitivePeerDependencies: + - dmg-builder + - supports-color + + electron-builder@26.8.1(electron-builder-squirrel-windows@26.8.1): + dependencies: + app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) + builder-util: 26.8.1 + builder-util-runtime: 9.5.1 + chalk: 4.1.2 + ci-info: 4.4.0 + dmg-builder: 26.8.1(electron-builder-squirrel-windows@26.8.1) + fs-extra: 10.1.0 + lazy-val: 1.0.5 + simple-update-notifier: 2.0.0 + yargs: 17.7.2 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + + electron-publish@26.8.1: + dependencies: + '@types/fs-extra': 9.0.13 + builder-util: 26.8.1 + builder-util-runtime: 9.5.1 + chalk: 4.1.2 + form-data: 4.0.5 + fs-extra: 10.1.0 + lazy-val: 1.0.5 + mime: 2.6.0 + transitivePeerDependencies: + - supports-color + + electron-winstaller@5.4.0: + dependencies: + '@electron/asar': 3.4.1 + debug: 4.4.3 + fs-extra: 7.0.1 + lodash: 4.18.1 + temp: 0.9.4 + optionalDependencies: + '@electron/windows-sign': 1.2.2 + transitivePeerDependencies: + - supports-color + + electron@42.0.0: + dependencies: + '@electron/get': 5.0.0 + '@types/node': 24.12.2 + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + env-paths@2.2.1: {} + + env-paths@3.0.0: {} + + err-code@2.0.3: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es6-error@4.1.1: + optional: true + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: + optional: true + + etag@1.8.1: {} + + eventsource-parser@3.0.8: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.8 + + exponential-backoff@3.1.3: {} + + express-rate-limit@8.5.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + extsprintf@1.4.1: + optional: true + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-uri@3.1.2: {} + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@11.3.5: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs.realpath@1.0.0: {} + + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.4 + serialize-error: 7.0.1 + optional: true + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + optional: true + + gopd@1.2.0: {} + + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + optional: true + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hono@4.12.18: {} + + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + http-cache-semantics@4.2.0: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-corefoundation@1.1.7: + dependencies: + cli-truncate: 2.1.0 + node-addon-api: 1.7.2 + optional: true + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: + optional: true + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-promise@4.0.0: {} + + isbinaryfile@4.0.10: {} + + isbinaryfile@5.0.7: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + isexe@4.0.0: {} + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + jiti@2.7.0: {} + + jose@6.2.3: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-stringify-safe@5.0.1: + optional: true + + json5@2.2.3: {} + + jsonc-parser@3.3.1: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + lazy-val@1.0.5: {} + + lodash@4.18.1: {} + + lowercase-keys@2.0.0: {} + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@2.6.0: {} + + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + ms@2.1.3: {} + + negotiator@1.0.0: {} + + node-abi@4.31.0: + dependencies: + semver: 7.7.4 + + node-addon-api@1.7.2: + optional: true + + node-api-version@0.2.1: + dependencies: + semver: 7.7.4 + + node-gyp@12.3.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + nopt: 9.0.0 + proc-log: 6.1.0 + semver: 7.7.4 + tar: 7.5.15 + tinyglobby: 0.2.16 + undici: 6.25.0 + which: 6.0.1 + + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + + normalize-url@6.1.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: + optional: true + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + p-cancelable@2.1.1: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + parseurl@1.3.3: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + pe-library@0.4.1: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pkce-challenge@5.0.1: {} + + plist@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.13 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.10 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + optional: true + + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + optional: true + + proc-log@6.1.0: {} + + progress@2.0.3: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + + quick-lru@5.1.1: {} + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + read-binary-file-arch@1.0.6: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resedit@1.7.2: + dependencies: + pe-library: 0.4.1 + + resolve-alpn@1.2.1: {} + + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + retry@0.12.0: {} + + rimraf@2.6.3: + dependencies: + glob: 7.2.3 + + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + + sax@1.6.0: {} + + semver-compare@1.0.0: + optional: true + + semver@5.7.2: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.4 + + slice-ansi@3.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + optional: true + + smart-buffer@4.2.0: + optional: true + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sprintf-js@1.1.3: + optional: true + + stat-mode@1.0.0: {} + + statuses@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tar@7.5.15: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + temp-file@3.4.0: + dependencies: + async-exit-hook: 2.0.1 + fs-extra: 10.1.0 + + temp@0.9.4: + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.3 + + tiny-async-pool@1.3.0: + dependencies: + semver: 5.7.2 + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.5 + + tmp@0.2.5: {} + + toidentifier@1.0.1: {} + + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + + type-fest@0.13.1: + optional: true + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@6.0.3: {} + + undici-types@7.16.0: {} + + undici-types@7.19.2: {} + + undici@6.25.0: {} + + undici@7.25.0: + optional: true + + universalify@0.1.2: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + utf8-byte-length@1.0.5: {} + + vary@1.1.2: {} + + verror@1.10.1: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.4.1 + optional: true + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@5.0.0: + dependencies: + isexe: 3.1.5 + + which@6.0.1: + dependencies: + isexe: 4.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + xmlbuilder@15.1.1: {} + + y18n@5.0.8: {} + + yallist@4.0.0: {} + + yallist@5.0.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yauzl@3.3.0: + dependencies: + buffer-crc32: 0.2.13 + pend: 1.2.0 + + yocto-queue@0.1.0: {} + + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..85c017f6 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - "apps/*" + - "packages/*" +allowBuilds: + electron-winstaller: false diff --git a/task.txt b/task.txt new file mode 100644 index 00000000..23a1edee --- /dev/null +++ b/task.txt @@ -0,0 +1,1190 @@ +Look, I'm working in this folder on project OpenPets 2.0 +It's electron based, cross platform app, which is only showed in tray. +To simplify, idea is this: you can install pets, and connect coding agents suchs as Claude, +Opencode, Vscode Copilot, Windsurf, Cursor, Antigravity to those pets. + + +Here are few featus I'd like it to have: +- Want to be able to launch multiple pets in parallel and connect coding agent to its own pen. +- When launching pet, coding agent should be able to choose it's pet. +- I want it to work well with npx, nodejs - shouldn't reuqire bun! +- I want it to be cross platform. + + +I have here in v1 folder: +- v1/openpet/ contains old v1 version of openpet; so pets itself dragin stuff works fine, but has many issues like confifugirn ai agnest is broke, etc, don't blindly assume sutff there are true; putting here just for idea collection, feature collection, reference; But wee need to always think solution ourselves, not relay to code there; +- v1/claude-pets/ - is like separate repo i build which is special plugin for claude to integrate with openpets +- v1/chatty-claude-pet - is like someone sent me this repo and told me made claude more responsive, chatty pets for claude, we need to take lots of learnings from this, incorporate in our v2 claude integration, claude is one of the critical tool we want to do extra effort it's integrated really really well, people are pleased etc - thats why I included this here too. + +# Write below here our discussion notes + +## Working process + +For each topic/problem we discuss: +- Start from the problem description and desired outcome. +- Scout the current codebase to understand what already exists. +- Check relevant up-to-date library/tool documentation via librarian when APIs, Electron/Node behavior, agent integrations, or platform details matter. +- Compare current code, v1 references, and latest docs; do not blindly copy v1 assumptions. +- Come up with the final implementation approach. +- Write the agreed problem description, reasoning, and final solution plan back into this task file so the decision history stays clear. + +## High-level user flow + +- User installs OpenPets and opens it. +- User sees an onboarding wizard where they can choose which coding agents to auto-configure OpenPets for. +- User sees a grid/catalog of pets and can install any pet. +- Once configured, the app is ready to use. +- When a coding agent is launched, for example Claude, that agent has its selected pet running. +- The coding agent communicates with its pet and the pet provides visible status updates / feedback. + +## Current initial focus + +The first target is to make the core experience rock solid before expanding scope. Current focus areas: + +- User onboarding flow. +- Multiple pets management. +- Claude integration. +- Installing and managing pets. + +These are the priority areas for initial design, code scouting, documentation research, implementation planning, and validation. + +## Agreed app product surface + +OpenPets 2.0 should be tray-first, not dashboard-first. + +The app should feel like a lightweight companion app that lives in the tray/menu bar with floating pets, not like a heavy full desktop dashboard. + +Initial product surface: + +- Tray/menu bar app. +- Floating pet windows. +- First-run onboarding window. +- Pet manager window. +- Agent setup/configuration window. +- Small settings window. + +Do not build a full dashboard as the main v2 experience. + +The main persistent UI is: + +```text +Tray/menu bar + floating pets +``` + +Task-specific windows are opened when needed: + +```text +Onboarding +Pet Manager +Agent Setup +Settings +``` + +Suggested tray menu items: + +```text +OpenPets +Default Pet: +Manage Pets +Configure Agents +Show/Hide Default Pet +Pause All Pets +Settings +Quit +``` + +Suggested first-run onboarding shape: + +```text +Welcome +Optional pet catalog installs +Agent setup +Done +``` + +Because OpenPets ships with a bundled default pet, onboarding does not need to force pet installation before the app is usable. + +## Agreed agent setup/onboarding behavior + +Agent setup should be detection-first and confirmation-based. + +Onboarding should show supported coding agents as cards/rows, for example: + +```text +Claude Code +OpenCode +Cursor +VS Code Copilot +Windsurf +Antigravity +``` + +Each agent should show clear status: + +```text +Detected +Not detected +Configured +Needs setup +Error / needs attention +``` + +Core principle: + +```text +Auto-detect where possible, but never silently edit agent config without user confirmation. +``` + +Agent setup flow: + +1. Detect supported coding agents where possible. +2. Show what was found and what is already configured. +3. Let user choose which agents to configure. +4. Explain what OpenPets will change. +5. Show config diff/preview when practical. +6. Apply configuration only after confirmation. +7. Make backups before editing user config files. +8. Verify setup with a doctor/check step. +9. Let user skip any agent and configure later from the Agent Setup window. + +Initial per-agent behavior: + +- Claude Code: configure MCP + install global OpenPets Claude hooks for enhanced status/speech. +- Other MCP-capable agents: configure MCP first. +- Agent-specific enhanced integrations can come later if useful. + +Agent setup actions: + +```text +Configure +Skip +Doctor / Check +Uninstall / Remove integration +``` + +For Claude specifically: + +```text +Configure MCP +Install global Claude hooks +Doctor +Uninstall hooks +``` + +Per-project pet selection still happens through project MCP config using `--pet`. The global onboarding/config flow gives users a working default integration first, and advanced per-project pet choices can be documented and supported after the core flow is solid. + +## Agreed initial pet window behavior + +Initial v2 pet windows should prioritize stability, predictability, and cross-platform behavior over complex desktop physics. + +Pet window behavior: + +- Pets render in small transparent frameless windows. +- Pet windows should be always-on-top by default. +- Pet windows should not steal focus from the user's editor/terminal. +- Pets should be draggable by the user. +- Pets should remember their last position. +- The default initial position should be near the bottom-right of the screen. +- Multiple pets should auto-offset from each other so they do not fully overlap. +- Tray/menu should allow showing/hiding pets. +- Tray/menu should allow pausing all pets. + +Initial v2 should avoid overcomplicated behavior: + +- No complex physics requirement. +- No elaborate desktop-edge/running behavior requirement. +- No advanced multi-monitor behavior beyond basic safe positioning. +- No fragile platform-specific hacks unless truly necessary. + +The goal is that pets feel alive and useful without interfering with coding work. + +## Agreed bundled default pet behavior + +OpenPets 2.0 should ship with one built-in default pet. + +Bundled default pet rules: + +- The built-in pet is available immediately after install, without network access. +- The built-in pet is the initial default pet. +- The built-in pet should use the same pet manifest/spritesheet format as downloaded pets so rendering/install logic stays unified. +- The built-in pet acts as the fallback pet when a requested `--pet` is missing or not installed. +- User can set any installed pet as the default later. +- User should not be able to fully uninstall/delete the built-in fallback pet from the app. +- User can stop using it by choosing another default pet, hiding pets, disabling open-default-pet-on-launch, or pausing pets. + +This ensures OpenPets always has a working pet even before catalog fetch/install succeeds. + +## Agreed persisted state + +OpenPets should persist user preferences and installed pets, but not transient agent session state. + +Persist across app restarts: + +- Installed pets. +- Default pet id. +- Default pet last position. +- Open-default-pet-on-launch setting. +- Pet scale / size preference. +- Speech enabled/disabled preference. +- Onboarding completed state. +- Agent configuration status/check results where useful. +- Last fetched pet catalog cache where useful. + +Do not persist as durable state: + +- Active MCP leases. +- Temporary non-default agent pets after their owning agents exit. +- Transient reactions/states. +- Speech bubbles. +- In-progress agent session status. + +Restart rule: + +- On app restart, OpenPets starts clean from persisted user preferences. +- Default pet opens if open-default-pet-on-launch is enabled. +- Active MCP leases are not restored from disk. +- Coding agents reconnect and create fresh leases when they launch their MCP servers again. + +## Agreed privacy/safety rules for speech and messages + +`openpets_say` and automatic hook speech are for short visible status/personality messages, not for code, logs, secrets, or large content. + +Initial speech/message rules: + +- Keep messages short. +- Enforce a max message length, around 140 characters. +- Reject or truncate multiline content. +- Reject obvious code blocks. +- Reject obvious secret-looking content where practical. +- Avoid URLs and long file paths in speech bubbles by default. +- Rate limit speech so pets do not spam the screen. +- Provide a global setting to disable speech bubbles. +- Claude hook speech should use local message pools/templates, not model-generated long content. + +Product rule: + +```text +Pets can be playful and informative, but they must not become a place where private code, secrets, or noisy logs are displayed. +``` + +## Agreed initial v2 MVP scope + +The initial OpenPets 2.0 target is a rock-solid core experience, not every possible feature. + +Initial v2 should include: + +1. Tray-first Electron app. +2. Floating pet windows. +3. One bundled built-in default pet. +4. Default pet opens on app launch by default. +5. Pet manager window: + - Browse v2 pet catalog. + - Search pets. + - Install pets. + - Show installed state. + - Remove installed pets. + - Set default pet. +6. New v2 pet catalog endpoint: + - `https://openpets.dev/pets/catalog.v2.json` +7. MCP integration through `@open-pets/mcp`: + - Node/npm/npx based. + - No Bun runtime requirement. + - Auto-launch OpenPets app if needed. + - Support `--pet`. + - Fallback to default pet if requested pet is missing. + - Immediate lease/pet open on MCP startup. + - Automatic cleanup on MCP shutdown. + - Short TTL/heartbeat fallback for crashed MCP servers. + - Minimal tools: `openpets_status`, `openpets_say`, `openpets_react`. +8. Claude integration through `@open-pets/claude`: + - Global hooks install by default. + - Hook-driven automatic reactions. + - Hook-driven short speech message pools. + - Safe install/uninstall/doctor. +9. `@open-pets/client` local IPC client package. +10. `@open-pets/cli` for website/manual commands such as: + - `npx -y @open-pets/cli install snoopy` +11. Detection-first onboarding: + - Welcome. + - Optional extra pet installs from catalog. + - Agent detection/configuration. + - Done. +12. Cross-platform support: + - macOS. + - Windows. + - Linux. + +Explicitly not initial v2 scope: + +- Full dashboard as main app experience. +- Categories/tags/favorites for pet browsing. +- Advanced sorting/ratings/popularity UI. +- Pet update system. +- Remote HTTP MCP server. +- Plugin marketplace. +- Advanced pet physics. +- Elaborate desktop-edge/running behavior. +- Complex multi-monitor behavior beyond safe positioning. +- Agent-specific enhanced integrations beyond Claude hooks. +- Letting agents install/remove/change pets directly without user confirmation. +- Analytics-heavy flows. + +Guiding MVP principle: + +```text +Make onboarding, pet management, MCP integration, Claude integration, and default-pet behavior reliable before expanding scope. +``` + +## Agreed local IPC protocol/security direction + +OpenPets local IPC is the private control plane between the Electron app and adapters such as `@open-pets/mcp`, `@open-pets/claude`, and `@open-pets/cli`. + +IPC direction: + +- Use local-only per-user IPC. +- macOS/Linux: Unix domain socket. +- Windows: named pipe. +- Do not expose TCP/HTTP by default. +- IPC is internal/private, not a remote API. + +Discovery: + +- When the OpenPets app is running, it writes a small user-owned discovery file. +- `@open-pets/client` reads this file to find the current IPC endpoint. +- Discovery file contains endpoint, protocol version, app version, and a random startup token. + +Example discovery shape: + +```json +{ + "version": 1, + "protocol": "openpets-ipc", + "endpoint": "/run/user/1000/openpets/openpets.sock", + "token": "random-session-token", + "appVersion": "2.0.0" +} +``` + +Windows endpoint example: + +```json +{ + "version": 1, + "protocol": "openpets-ipc", + "endpoint": "\\\\.\\pipe\\openpets-abc123", + "token": "random-session-token", + "appVersion": "2.0.0" +} +``` + +Security: + +- The app generates a random IPC token on startup. +- Every IPC request includes protocol version and token. +- Invalid token/version requests are rejected. +- Use restrictive per-user runtime directory/file permissions where the platform supports it. + +IPC message style: + +```json +{ + "id": "uuid", + "version": 1, + "token": "random-session-token", + "method": "pet.say", + "params": { + "petId": "doggo", + "message": "On it!" + } +} +``` + +Initial internal IPC methods: + +```text +hello +status +lease.acquire +lease.heartbeat +lease.release +pet.say +pet.react +``` + +Later internal IPC methods can cover pet install/remove/default/catalog actions, but initial integration work should keep IPC focused on app status, leases, speech, and reactions. + +## Agreed initial integration architecture + +For OpenPets 2.0, the agreed direction is: + +- MCP is the primary public integration path for coding agents. +- Local IPC is the private/internal bridge between adapters and the Electron tray app. +- Claude hooks are an enhanced Claude-specific layer for richer automatic status and speech updates. +- Pet selection is configured simply through MCP arguments, mainly `--pet`; no need for extra `--agent` or `--workspace` arguments. +- If no pet is configured, the integration uses the user's default pet. +- Multiple agents/projects are allowed to point at the same default pet; the pet should simply display/say the events it receives. +- npm packages should use the existing organization name `@open-pets`, not `@openpets`. +- OpenPets must work with Node/npm/npx and must not require Bun at runtime. + +Universal routing decision: + +- Do not depend on agent-specific hidden metadata for core MCP routing. +- Across agents, stdio MCP does not reliably provide chat session id, agent id, project id, or stable workspace metadata. +- Universal OpenPets routing is MCP-process-based. +- Each `@open-pets/mcp` process has exactly one target pet: + - explicit `--pet`, or + - the user's default pet if `--pet` is omitted. +- All MCP tools from that process affect that process's target pet. +- This applies consistently across Claude, Cursor, OpenCode, VS Code/Copilot, Windsurf, Antigravity, and other MCP-capable agents. +- No universal session/project/agent routing is required for MCP. +- Claude hooks are the only Claude-specific enhanced side channel; they can best-effort route to active Claude MCP leases using Claude hook metadata, but if no reliable match exists they fall back to the default pet. +- Keep user-facing MCP config simple: `--pet` is the main routing control. +- Do not add user-facing `--agent` or `--workspace` unless a later real need appears. + +Proposed package shape: + +```bash +@open-pets/client +@open-pets/mcp +@open-pets/claude +@open-pets/cli +``` + +Example per-project MCP config: + +```json +{ + "mcpServers": { + "openpets": { + "command": "npx", + "args": ["-y", "@open-pets/mcp", "--pet", "doggo"] + } + } +} +``` + +Behavior: + +- If `--pet doggo` is passed, that MCP server/session targets the installed pet with id `doggo`. +- If `--pet` is omitted, OpenPets uses the configured default pet. +- If the requested pet is missing or not installed, OpenPets should fall back to the default pet, keep the MCP server usable, notify the user in the app, and report the fallback in `openpets_status`. +- The project/workspace context naturally comes from where the MCP config lives, so we do not need to pass a workspace argument. + +Architecture shape: + +```text +Claude / Cursor / Copilot / VS Code-style agent + ↓ + MCP server: @open-pets/mcp + ↓ + @open-pets/client + ↓ + Local OpenPets IPC bridge + ↓ + Electron tray app / pet manager +``` + +For Claude enhanced integration: + +```text +Claude Code hooks + ↓ + @open-pets/claude + ↓ + @open-pets/client + ↓ + Local OpenPets IPC bridge + ↓ + Electron tray app / pet manager +``` + +The Electron app owns the real state and product logic: + +- Installed pets. +- Default pet. +- Pet assignment and fallback behavior. +- Active sessions / leases. +- State arbitration when multiple agents send events to the same pet. +- Tray UI. +- Onboarding. +- Pet installation, update, and removal. + +The MCP server should stay thin. It should expose tools and forward requests/events to the running OpenPets app, but it should not own pet installation, pet lifecycle, UI state, or long-term configuration. + +## Agreed MCP session lifecycle and tools + +The MCP server should manage the pet session lifecycle automatically. The model/agent should not need to call a release tool for normal cleanup. + +Desired flow: + +```text +User launches Claude/Cursor/etc with OpenPets MCP configured + ↓ +Agent starts `@open-pets/mcp` as a stdio MCP server + ↓ +If OpenPets app is not already running, `@open-pets/mcp` attempts to auto-launch it + ↓ +`@open-pets/mcp` connects to the OpenPets app through local IPC + ↓ +OpenPets creates/acquires a temporary lease for the configured pet + ↓ +The pet appears and reacts to events/tool calls + ↓ +User closes the agent / MCP process exits / stdin closes / parent dies + ↓ +`@open-pets/mcp` automatically releases its lease through IPC + ↓ +OpenPets closes that pet if it was only opened for that lease and no other lease/user pin needs it +``` + +Important lifecycle rules: + +- `@open-pets/mcp --pet doggo` means this MCP server owns a temporary lease for `doggo`. +- If `--pet` is omitted, the lease uses the default pet. +- If multiple agents point to the same pet, the pet remains open until all active leases are gone. +- When `@open-pets/mcp` starts, it immediately acquires a lease and opens/shows the configured pet. Do not wait for the first tool call. +- If `--pet` is missing, it immediately connects to the default pet. +- If the requested `--pet` is not installed, it immediately falls back to the default pet, notifies the user, and reports the fallback in `openpets_status`. +- If the OpenPets desktop app is not running, `@open-pets/mcp` should auto-launch it on startup, wait briefly for IPC to become available, then connect/open the configured/default pet. +- If auto-launch or IPC connection fails, the MCP server should remain alive in degraded mode and `openpets_status` should report the failure clearly. +- Cleanup should be driven by adapter/process lifecycle, not by the model deciding to call a tool. +- `openpets_release` should not be part of the initial public MCP tool set. + +Automatic cleanup mechanisms for `@open-pets/mcp`: + +- Listen for stdio `end` / `close`. +- Listen for process signals such as `SIGTERM` and `SIGINT` where available. +- Watch the parent process as a fallback, because coding agents may not shut down MCP servers gracefully. +- Send lease heartbeat while alive. +- Use short lease TTL fallback in the OpenPets app so crashed/orphaned MCP processes are cleaned up automatically. + +Keep pet lifetime rules simple for v2: + +- OpenPets has one default pet. +- By default, the default pet opens when the OpenPets app launches and keeps running. +- User can later disable "open default pet on app launch" if they do not want a persistent default pet. +- If an agent starts without `--pet`, it uses the already-running default pet. +- Agent shutdown never auto-closes the default pet. +- If an agent starts with an explicit non-default `--pet`, OpenPets opens that pet as a temporary agent pet. +- Explicit non-default agent pets close when the last active lease using them ends. +- If multiple agents use the same explicit non-default pet, that pet stays open until all those agents are gone. +- If an explicit `--pet` is missing and OpenPets falls back to the default pet, that fallback default pet is treated like the normal persistent default and does not close when the agent exits. + +Simplified close rule: + +```text +Default pet: persistent; never auto-close due to agent lifecycle. +Explicit non-default pet: temporary; close after last active lease ends. +``` + +Initial MCP tools should stay intentionally minimal. Final agreed initial tool set: + +```text +openpets_status +openpets_say +openpets_react +``` + +Tool meanings: + +- `openpets_status`: checks that OpenPets is reachable and reports the active configured/default pet session. This combines health/start-style usefulness without needing separate `health`, `start`, or `list_pets` tools. +- `openpets_say`: shows a short speech bubble, optionally with a related state/reaction. This is the clearest agent-facing value. +- `openpets_react`: silently changes the pet reaction/state, such as thinking, working, testing, waiting, success, error, or celebrating. + +`openpets_status` should report degraded startup/connection state, for example: + +```json +{ + "ok": false, + "appRunning": false, + "attemptedLaunch": true, + "message": "OpenPets is not running or could not be launched." +} +``` + +Initial public `openpets_react` reactions: + +```text +idle +thinking +working +editing +running +testing +waiting +success +error +celebrating +``` + +Initial input shape: + +```json +{ + "reaction": "testing" +} +``` + +OpenPets can map these public reactions to internal animation/state names. Keep the public reaction list simple and coding-agent-oriented. + +Tools intentionally excluded from initial MCP: + +- `openpets_release`: cleanup is automatic through MCP process lifecycle and lease TTL fallback. +- `openpets_start`: starting/acquiring the pet session happens when the MCP server starts. +- `openpets_list_pets`: pet browsing/management belongs in the OpenPets app UI, not the initial MCP tool surface. +- Pet install/remove/default-setting tools: agents should not install/remove/change pets without explicit user confirmation in the app. + +## Claude enhanced status/speech lessons + +From comparing `v1/claude-pets` and `v1/chatty-claude-pet`, the useful lesson is that Claude integration should not only change pet animation state; it should also support automatic short speech bubbles triggered by Claude hook events. + +The valuable part of `chatty-claude-pet` is the product behavior: + +- Zero-token automatic speech from hooks. +- Message pools per event. +- Random short messages to avoid repetition. +- Mood-specific messages for thinking, success, error, etc. +- No speech on very noisy events like every tool use. +- Clear, fixed functional messages for permission/approval requests. + +The implementation details from `chatty-claude-pet` should not be copied directly: + +- No shell-script-based core integration. +- No `nc -U` Unix-socket-only implementation. +- No hardcoded `/tmp` socket paths. +- No manual JSON socket writes. +- No Unix-only assumptions. + +Instead, Claude hooks should call a Node-based `@open-pets/claude` package, which uses `@open-pets/client` and the official local IPC bridge. + +Claude hook installation scope: + +- `@open-pets/claude` should install hooks globally by default. +- Global install means configuring the user's Claude Code settings so OpenPets enhanced status/speech works across projects. +- Project-specific MCP config can still choose different pets with `--pet`; global Claude hooks provide the passive lifecycle/speech layer. +- The installer must be safe: show/understand existing config, make backups, avoid blindly overwriting user settings, support doctor/uninstall. + +Desired Claude hook mapping: + +```text +UserPromptSubmit → thinking + short speech from message pool +PreToolUse → working/editing/testing animation, usually no speech +PermissionRequest → waiting/attention + clear approval message +Notification → attention/waiting reaction, no speech by default +Stop → success + short speech from message pool +StopFailure → error + short speech from message pool +``` + +Important product rule: + +- Animation/state updates can happen often. +- Speech must be throttled/selective so the pet feels alive but not annoying. +- Claude `Notification` hooks should not create speech by default. They can trigger attention/waiting animation only. +- Claude `PermissionRequest` is the important interruption case and should use a clear fixed approval message. + +## Integration principles + +- Build agent-neutral first through MCP. +- Make Claude feel extra polished through hooks, but do not make the core architecture Claude-only. +- Keep local IPC as the stable internal control plane for all adapters. +- Keep package/runtime requirements simple: Node/npm/npx, cross-platform. +- Avoid Bun runtime dependency. +- Avoid brittle shell scripts as the foundation. +- Avoid putting core pet management inside MCP. +- Avoid agents installing arbitrary pets without user confirmation. + +## Agreed pet browsing and installation direction + +The existing `web/` app already acts like a pet catalog/registry for `openpets.dev`. + +Current useful files: + +```text +web/public/pets/manifest.json +web/public/pets/install.json +web/app/lib/pets.generated.js +web/scripts/sync-pets.js +``` + +Current catalog shape: + +- `manifest.json` contains richer browsing metadata: `petId`, `installId`, `slug`, `displayName`, `description`, `spritesheetPath`, `zipPath`, `sourceUrl`, `xHandle`, `createdAt`, etc. +- `install.json` is a smaller install index with `installId`, `displayName`, and `zipPath`. +- Pet zip files are served from URLs like: + +```text +https://zip.openpets.dev/pets/{slug}/{petId}.zip +``` + +Example: + +```text +https://zip.openpets.dev/pets/snoopy-23e05847/snoopy.zip +``` + +Current web install command is: + +```bash +bunx install-pet snoopy +``` + +For OpenPets 2.0, the app onboarding should not depend on `bunx install-pet`. The better native app flow is: + +```text +OpenPets app → fetch pet catalog → show pet grid → user selects pets → download zip files → validate → install locally → optionally set default pet +``` + +Onboarding pet install flow: + +1. OpenPets ships with a bundled default pet. +2. The bundled default pet is available immediately and can run without downloading anything. +3. Fetch the remote pet catalog from `openpets.dev`. +4. Show a nice native grid of pets with preview sprites, names, and descriptions. +5. Each catalog pet has an easy Install button. +6. User may install zero, one, or many extra pets. +7. User can continue without installing any extra pets. +8. Download selected pet zips from `zipPath`. +9. Validate each zip structure before installing. +10. Install selected pets into the local OpenPets pet directory. +11. Continue to coding-agent setup. + +The onboarding flow should not force the user to choose or install a default pet. The bundled default pet remains the default unless the user changes it later in pet management. + +The app should also have a pet management area after onboarding: + +- Browse available pets. +- Search pets. +- See installed pets. +- Install new pets. +- Remove pets. +- Set default pet. +- Later: categories, tags, sorting, favorites, updates. + +Important: keep an easy command-line install option for website users. Users browsing `openpets.dev` should still be able to copy one command and install a pet they like. + +For v2, use the official `@open-pets/cli` package for website/manual install commands: + +```bash +npx -y @open-pets/cli install snoopy +``` + +The rule is: + +- App onboarding installs pets directly from catalog/zip. +- Website/manual install remains available through an easy `npx` command. +- Runtime pet installation should not require Bun. +- Do not create a separate `@open-pets/install-pet` package initially; keep install/doctor/list/uninstall commands under `@open-pets/cli`. + +V2 catalog decision: + +Create a new app-friendly catalog for OpenPets 2.0. The desktop app should fetch the new v2 catalog instead of depending on the old `manifest.json` or minimal `install.json` shape. + +Target URL: + +```text +https://openpets.dev/pets/catalog.v2.json +``` + +Existing `manifest.json` / `install.json` can remain for the current website/current install flow, but the v2 desktop app should use the clean v2 catalog contract so old structures do not drag the new app design. + +Initial v2 catalog shape: + +```json +{ + "version": 2, + "generatedAt": "...", + "pets": [ + { + "id": "snoopy", + "displayName": "Snoopy", + "description": "...", + "preview": "https://openpets.dev/pets/snoopy-23e05847/spritesheet.webp", + "zip": "https://zip.openpets.dev/pets/snoopy-23e05847/snoopy.zip", + "format": { + "type": "spritesheet", + "columns": 8, + "rows": 9, + "frameWidth": 192, + "frameHeight": 208 + } + } + ] +} +``` + +Do not add categories/tags to the initial v2 catalog yet. Keep it minimal until the product needs those fields. + +Initial v2 pet browsing UI: + +- Grid of available pets. +- Search. +- Preview sprite. +- Install button. +- Installed state. +- Remove installed pet. +- Set installed pet as default. +- Basic loading/error states. + +Not initial scope: + +- Categories. +- Tags. +- Favorites. +- Advanced sorting. +- Ratings/popularity UI. +- Pet update system. + +Do not prioritize backward compatibility over a better v2 flow, but understand and reuse what already exists where useful: the catalog metadata, sprite previews, zip URLs, and install IDs are already enough to make native onboarding pet browsing technically possible. + +## Agreed first-run user experience + +OpenPets onboarding should optimize for emotional clarity first and technical configuration second. + +Problem: + +A new user may not know what MCP is, what Claude hooks are, what pet leases are, or why coding-agent setup matters. The first-run experience should make OpenPets feel immediately understandable, alive, and useful instead of feeling like a configuration chore. + +Desired first 5 minutes: + +- User launches OpenPets. +- User quickly understands that OpenPets lives in the tray/menu bar. +- User sees that a default pet is already available. +- User understands that pets can react to coding agents. +- User understands that agent setup is optional but recommended. +- User understands that Claude Code gets the most polished enhanced integration. +- User sees that OpenPets will not edit agent configuration without confirmation. + +Recommended first-run flow: + +1. User launches OpenPets. +2. The bundled default pet appears early, before agent setup. +3. Welcome explains the product in one short sentence: + + ```text + OpenPets gives your coding agents tiny desktop companions. + ``` + +4. User can optionally install more pets from the catalog. +5. User can configure coding agents. +6. Claude Code is highlighted as the best-supported integration. +7. User finishes onboarding with one clear success state: + + ```text + OpenPets is ready. Your pet will react when your coding agent connects. + ``` + +Decision: + +The default pet should be shown early in onboarding, before agent configuration. + +Reasoning: + +- It proves the app works immediately. +- It makes the product feel alive before any technical setup. +- Agent setup becomes easier to understand: make this visible pet react to your coding tools. +- Users can skip agent setup and still understand what OpenPets is. +- The product should feel like a companion app first, not an MCP configuration utility. + +## Agreed tray/menu behavior + +OpenPets should use a small tray/menu bar menu focused on quick control, not deep management. + +Initial tray menu: + +```text +OpenPets +──────────────── +Default Pet: +Show Default Pet / Hide Default Pet +Pause All Pets / Resume All Pets +──────────────── +Manage Pets... +Configure Agents... +Settings... +──────────────── +Quit OpenPets +``` + +Behavior: + +- `OpenPets` is a disabled title row. +- `Default Pet: ` opens Pet Manager. +- `Show Default Pet / Hide Default Pet` controls only the persistent default pet. +- Temporary explicit agent pets are not hidden by the default-pet show/hide item. +- `Pause All Pets / Resume All Pets` is a global quiet mode. +- Pausing all pets does not close pet windows. +- When paused, pets should stay visible but become quiet: no speech bubbles, reduced reactions, and idle/static or subtle animation. +- `Manage Pets...` opens Pet Manager. +- `Configure Agents...` opens Agent Setup. +- `Settings...` opens a small settings window. +- `Quit OpenPets` fully exits the app and closes all pet windows. + +Decision: + +Keep the tray menu simple. Pet catalog browsing, install/remove, default changes, and agent setup belong in dedicated windows, not directly in the tray menu. + +## Agreed app windows and responsibilities + +OpenPets should have a small set of single-purpose windows. Do not let the app turn into a full dashboard. + +Initial app windows: + +```text +1. Onboarding +2. Pet Manager +3. Agent Setup +4. Settings +5. Floating Pet windows +``` + +### Onboarding window + +Appears on first run. It may also be reopened later from Settings if useful. + +Purpose: + +- Welcome the user. +- Show the default pet early. +- Offer optional pet installs. +- Offer agent setup. +- End with a clear done/ready state. + +Not for: + +- Full catalog management. +- Advanced settings. +- Deep troubleshooting. + +### Pet Manager window + +The main place for pet ownership and catalog browsing. + +Purpose: + +- Browse the pet catalog. +- Search pets. +- Install pets. +- Remove installed pets. +- Set the default pet. +- Show installed/default state. + +Not for: + +- Agent configuration. +- App-wide settings. +- Turning into a general dashboard. + +### Agent Setup window + +The main place for coding-agent integrations. + +Purpose: + +- Detect supported agents. +- Show configured / not configured / needs attention / error states. +- Configure selected agents. +- Preview config changes where practical. +- Run doctor/check. +- Uninstall integrations. + +Not for: + +- Pet browsing. +- Pet installation. +- Detailed per-project pet routing UI beyond basic guidance. + +### Settings window + +A small preferences window for app-level behavior. + +Purpose: + +- Open default pet on app launch. +- Enable/disable speech bubbles. +- Pet size/scale. +- Start OpenPets on login. +- Reset pet position. +- Optionally reopen onboarding. + +Not for: + +- Pet catalog management. +- Agent installation details. + +### Floating Pet windows + +Tiny transparent always-on-top companion windows. + +Purpose: + +- Render pets. +- Show reactions. +- Show short speech bubbles. +- Allow dragging and repositioning. + +Not for: + +- Menus. +- Forms. +- Configuration. +- Management flows. + +Decision: + +Keep each window single-purpose. The product should remain tray-first with task-specific windows, not dashboard-first. + +## Agreed local-first/account-free behavior + +OpenPets 2.0 should not require an account. + +The app should work as a local-first desktop companion: + +- No login required. +- Bundled default pet works offline. +- Pet windows work offline. +- MCP and Claude integrations work locally. +- Installed pets and user preferences are stored locally. +- Remote catalog fetch is optional for discovering/installing extra pets. +- If catalog fetch fails, the app remains usable with the bundled default pet and already-installed pets. +- No cloud sync in the initial v2 scope. +- No paid/account-gated features in the initial v2 scope. + +Decision: + +OpenPets v2 is local-first and account-free. + +## Deferred start-at-login behavior + +Start-at-login behavior is not important for the initial planning focus. + +Decision: + +- Do not prioritize start-at-login for the initial MVP. +- Do not make auto-start a core onboarding decision yet. +- This can be added later as a Settings option if users want OpenPets to launch automatically. + +## Agreed desktop app missing behavior for MCP + +If `@open-pets/mcp` runs while the OpenPets desktop app is not running, it may try to auto-launch the installed desktop app. + +If the desktop app is not installed or cannot be found, `@open-pets/mcp` should not try to install it automatically. + +Behavior: + +- If OpenPets is installed but not running, MCP tries to launch it. +- If OpenPets is not installed or cannot be found, MCP stays alive in degraded mode. +- `openpets_status` explains the problem clearly. + +Example status message: + +```text +OpenPets desktop app is not installed or could not be found. Install and open OpenPets to enable pet reactions. +``` + +Reasoning: + +- Hidden app installers are risky and annoying. +- MCP should not make system-level desktop changes. +- Agent integration should remain predictable. +- User remains in control of installing the desktop app. + +Decision: + +MCP can auto-launch an installed OpenPets desktop app, but it cannot auto-install the app. + +## Agreed agent control boundaries + +Coding agents should not be able to install, remove, change, or manage pets directly through the initial MCP integration. + +Agents may interact with their assigned/current pet through the minimal public MCP tool set: + +- `openpets_status` +- `openpets_say` +- `openpets_react` + +Agents should not be able to: + +- Install pets. +- Remove pets. +- Set the default pet. +- Change user preferences. +- Browse the full pet catalog through MCP. +- Silently switch themselves to another pet. + +Reasoning: + +- Pet ownership should stay user-controlled. +- OpenPets should avoid surprising or annoying changes caused by agents. +- The MCP surface should stay small and predictable. +- Avoid confirmation flows inside coding agents for initial v2. +- Pet management belongs in the OpenPets app UI or explicit user-run CLI commands. + +Important nuance: + +Manual CLI commands are allowed because the user explicitly runs them, for example: + +```bash +npx -y @open-pets/cli install snoopy +``` + +Decision: + +Agents can express status/personality through pets, but pet ownership and configuration stay user-controlled. + +## Agreed MVP done definition + +The initial OpenPets 2.0 MVP should be judged by a polished end-to-end Claude Code experience plus the core tray/pet management experience. + +MVP is not just a partial MCP demo. MVP should include the full Claude integration designed in this plan. + +A successful MVP demo is: + +1. User installs/opens OpenPets. +2. OpenPets runs as a tray/menu bar app. +3. Bundled default pet appears early and works immediately. +4. First-run onboarding completes. +5. User can browse/install pets from the v2 catalog. +6. User can manage installed pets and change the default pet. +7. Claude Code integration is configured with user confirmation. +8. Claude Code MCP integration works through `@open-pets/mcp`. +9. Claude enhanced hooks are installed through `@open-pets/claude`. +10. A real Claude Code session can start with OpenPets connected. +11. The pet reacts when Claude thinks, works, edits, tests, waits, succeeds, errors, or celebrates. +12. The pet shows short, safe, throttled speech from Claude hooks/message pools. +13. Speech/privacy rules are enforced so code, secrets, large logs, and noisy content are not displayed. +14. Temporary explicit non-default agent pets open/close according to MCP lease lifecycle. +15. The default pet remains persistent and does not close because an agent exits. +16. Quitting Claude or the MCP process cleans up temporary leases reliably. +17. Quitting OpenPets closes all pet windows and exits cleanly. + +Decision: + +Initial MVP success should be measured by the complete Claude Code experience plus reliable core pet management, not by equal polish for every supported coding agent on day one. + +Other MCP-capable agents should still work through the agent-neutral `@open-pets/mcp` path where possible, but the highest-polish MVP integration target is Claude Code. + +## Agreed implementation process + +Implementation should happen in meaningful, manually verifiable phases with strong developer experience. + +The detailed implementation process lives in: + +```text +docs/implementation-process.md +``` + +Core process rules: + +- Each phase has a clear goal and manual acceptance criteria. +- Each phase plan is reviewed by Oracle before implementation. +- Oracle feedback is fixed or explicitly dispositioned. +- Implementation happens after the reviewed plan is accepted. +- The implemented diff is reviewed by Oracle before user confirmation. +- Actionable Oracle feedback is fixed before asking the user to verify. +- The user gets exact commands/actions for manual verification. +- Do not proceed to the next numbered phase, or any meaningful user-visible sub-phase, until the user confirms the current checkpoint is acceptable. +- If a phase becomes too large, stop and split it instead of silently expanding scope. + +Decision: + +OpenPets 2.0 should be built iteratively with Oracle-reviewed phase plans, Oracle-reviewed implementations, fixes, and then user confirmation at each meaningful checkpoint. diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..6f8f7da0 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true, + "declarationMap": true, + "rootDir": "src", + "outDir": "dist" + } +}